Skip to content

Scheduler: a single slow run stalls every other schedule and the delivery pump #329

Description

@SouravRoy-ETL

What happens

duckle serve runs its scheduler on one thread, and a scheduled run is executed inline on it. Any run that takes a while therefore stops every other schedule, and the subscription delivery pump, until it finishes.

Measured on a real server, tick interval 2s, both schedules on a 5-second interval:

fast schedule alone, 60s window                  -> fired 9 times
same window, one slow schedule beside it         -> fired 2 times
                                                    (the slow one ran once)

One run cost a five-second schedule 78% of its occurrences in the same minute. The slow pipeline here is a single ~25s DuckDB aggregate; a real extract that takes an hour stops everything for an hour.

Why

Three things combine, all in crates/duckle-runner/src/serve.rs unless noted.

One thread, one loop. spawn_scheduler spawns a single thread (:4264) whose body is loop { sleep(tick_interval); ... } (:4279-4280).

Runs are inline. fire_schedule(&state, id, cfg, &pipes) is a plain synchronous call in that loop - the cron path at :4392 and the interval path at :4439. It reaches run_scheduled (:3583), which calls execute_one (:3591) and waits for the pipeline to finish.

Admission blocks. Gates::acquire (crates/duckdb-engine/src/pools.rs:227) delegates to Gate::acquire (pools.rs:172), an untimed condvar wait:

    pub fn acquire(&self) -> Permit<'_> {
        let mut free = self.free.lock().unwrap_or_else(|p| p.into_inner());
        while *free == 0 {
            free = self.ready.wait(free).unwrap_or_else(|p| p.into_inner());
        }

There is no try-variant and no timeout, so a saturated pool holds the scheduler thread as effectively as a long run does.

What else stops

pump_deliveries(&state) is called at the top of the same loop (:4286). So a slow scheduled run also delays every materialization-triggered run (#325) - and the comment right there says why that matters: "the point of a data trigger is that the consumer runs when the data lands, not up to a minute later."

The hazard is already recognised, one line away

Freshness evaluation in the same loop was deliberately moved off the thread (:4288-4295):

                // Off the scheduler's thread, so a slow evaluation delays no
                // schedule, and guarded so two can never overlap on a workspace
                // where it takes longer than the interval.
                std::thread::spawn(move || { ... sla::check_and_alert(...) });

The same argument applies with more force to the runs themselves, which are unboundedly slower than an SLA sweep.

Why this is not #296

#296 is about policy - what should happen when occurrences overlap or are missed. This is liveness: a schedule that has no overlap at all, and is not overdue, simply does not fire on time because an unrelated pipeline is running. It is present regardless of which overlap or misfire policy is chosen.

It does interact with #296 in one direction worth noting: overlapPolicy: queue cannot be built on the current shape, because "queued" today means a blocked thread. retry::enqueue (crates/duckdb-engine/src/retry.rs:385) only stamps the run's own receipt - there is no durable queue, nothing reads a queued receipt back, and reconcile (retry.rs:417-427) deliberately turns a queued receipt into interrupted on restart.

Direction

Not prescribing one, but the shape that seems to follow: the tick thread should decide and hand off, never execute. That is the same split #296 settled on for occurrences - the scheduler decides what should run, the durable run path decides how - and it is what a durable queue would need anyway.

A bounded worker pool reading a durable queue would fix this and unblock overlapPolicy: queue and replace at the same time, since both need runs that are startable by something other than the thread that decided them.

Scope

Single server, as #296 is. No leader election, no distributed queue.

Acceptance criteria

  1. a long-running scheduled pipeline does not delay an unrelated schedule;
  2. it does not delay materialization deliveries;
  3. a saturated resource pool does not block the scheduler thread;
  4. the measurement above (fast schedule alone vs. beside a slow one) shows no material difference.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

bugSomething isn't working

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions