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
- a long-running scheduled pipeline does not delay an unrelated schedule;
- it does not delay materialization deliveries;
- a saturated resource pool does not block the scheduler thread;
- the measurement above (fast schedule alone vs. beside a slow one) shows no material difference.
What happens
duckle serveruns 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:
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.rsunless noted.One thread, one loop.
spawn_schedulerspawns a single thread (:4264) whose body isloop { 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:4392and the interval path at:4439. It reachesrun_scheduled(:3583), which callsexecute_one(:3591) and waits for the pipeline to finish.Admission blocks.
Gates::acquire(crates/duckdb-engine/src/pools.rs:227) delegates toGate::acquire(pools.rs:172), an untimed condvar wait: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):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: queuecannot 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, andreconcile(retry.rs:417-427) deliberately turns a queued receipt intointerruptedon 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: queueandreplaceat 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