Skip to content

cron: catch up runs that came due while the machine was paused - #536

Merged
pufit merged 9 commits into
mainfrom
pufit/cron-resume-catchup
Oct 21, 2026
Merged

pufit merged 9 commits into
mainfrom
pufit/cron-resume-catchup

Conversation

@pufit

@pufit pufit commented Oct 21, 2026

Copy link
Copy Markdown
Member

cron: catch up runs that came due while the machine was paused

APScheduler arms its next fire on the monotonic clock, which does not advance while a machine sleeps, is suspended, or is restored from a snapshot. After such a pause a job armed "in 3 hours" fired 3 hours after the resume and was then dropped as missed. Nerve now detects the pause and recovers.

  • A watcher samples the wall clock against the monotonic clock every 5 s, and the scheduler checks the same thing before it looks for due jobs. A forward step of 5 s or more is a pause.
  • On a pause: every job whose next run came due during it is re-armed to its next fire time after the pause (so the scheduler neither runs it twice nor reports it missed); catchup: true jobs and source runners then run once, concurrently, after a short settle delay for the network; catchup: false jobs are recorded as missed; cleanup catches up once. Jobs that came due within the scheduler's own 30 s grace are left to it.
  • Catch-ups never overlap a run of the same job (a shared per-job in-flight set), re-resolve the job after waiting for the lock (a reload may have removed or edited it), and are queued behind a running catch-up. stop() stops the watcher and waits with a bound.
  • Documented in docs/cron.md ("After a pause").

Tests: 31 new in tests/test_cron_resume.py (fake clocks against a real scheduler and SQLite: a 10-min crontab job over a 3 h step runs once, interval jobs keep their grid, catchup: false not run, lock respected, a 2 s step ignored, DST-backward steps ignored, overlap and stale-catch-up cases). Full suite 6,667 passed, 39 skipped; ruff unchanged.

Generated by Nerve

🤖 Generated with Claude Code

pufit added 9 commits October 21, 2026 01:16
The scheduler waits on the event loop's monotonic timer but decides what
is due by the wall clock. Across host sleep, suspend or a VM snapshot
restore the monotonic clock stands still while the wall clock is stepped
forward, so a run that came due during the pause either waited out a
timer armed before it or was dropped as missed once it started more than
the misfire grace late.

The cron service now samples both clocks every 5 seconds, and again
right before the scheduler looks for due jobs, so whichever runs first
after a resume sees the step. On a forward step of 5 seconds or more,
every job it scheduled whose next run came due during the pause moves to
its next fire time after the pause (interval jobs stay on their anchored
grid), and the jobs with catchup plus the source runners run once
through the normal wrappers, so gates, lock and the run log apply. Jobs
with catchup: false, cleanup and the wakeup sweep only move on. A
catch-up waits for one still running, including the startup catch-up.
The clock watcher awaited the whole catch-up, so it stopped sampling
while the caught-up runs went on, and a second pause in that time was
noticed only when the scheduler next looked. The watcher and the
scheduler's hook now share one check that never waits: the triggers
move at once and the catch-up runs as a tracked task.

Moving the triggers also takes one job at a time: a job that cannot be
moved is logged and left to the scheduler, and the jobs after it still
move instead of being dropped as late runs.
The scheduler starts a job at most once at a time, but it only counts
the runs it started itself. A catch-up after a pause ran through the
run wrapper directly, so a run still going across the pause got a
concurrent second run, and a catch-up still going when the job's next
fire came got a scheduled twin; without lock that is two agent runs
working the same queue.

Both run wrappers now record every run in progress, by job id and by
how it was started (schedule, catch-up or manual). A catch-up is
skipped while any run of the job is going, as the scheduler skips a
fire that finds the previous run going, and a scheduled fire is skipped
while a catch-up of the job is going. A manual run is never skipped.
Each skip is logged at INFO. The set of runs in progress is kept apart
from the wrappers so other ways of starting a run can use the same
check.
A catch-up kept the job objects it found when the pause was noticed,
and could wait a long time for an earlier catch-up to finish. By then
a reload might have removed or changed a job, or the scheduler might
have run it on its moved trigger, and the catch-up still ran the old
job, again; a job due in two queued pauses ran twice.

A pause now queues job ids, once each, with the number of runs each
job had started. When the catch-up gets its turn it looks every id up
on the scheduler: a job that is gone is skipped, a changed one runs as
it is now, and one that has started a run since it was queued is not
caught up on top of it. Each skip is logged.
Moving every trigger that came due during a pause also moved one due a
few seconds before the resume. The scheduler used to start such a run
late, inside its 30 second misfire grace; now a job with catchup: false
skipped it without a trace, although the docs said it still ran. A
machine woken just in time for a job, and resumed a few seconds after
it came due, skipped the very job it was woken for.

A trigger now moves only when its run came due more than the grace
before the resume; anything later is left to the scheduler, which runs
it as it always did. A job with catchup: false that is moved gets a
missed row in cron_logs for the run it skipped, as a late run does.
The daily cleanup catches up once like the jobs that want it: on a
machine that is mostly paused it may otherwise never run.
The catch-up runs started within seconds of a resume. On a laptop the
network is often not back yet at that point (Wi-Fi rejoining, DNS), so
a source runner or an agent turn started then failed, was logged as an
error and waited for its next slot.

The runs now start 15 seconds after the pause is noticed. Moving the
triggers past the pause still happens at once, so the scheduler never
sees the old fire times, and a run that comes due on its moved trigger
within those seconds runs normally and is not caught up again. The log
line for a pause says when its catch-up will start.
stop() cancelled the clock watcher and the catch-ups and waited for
them with no limit, while the scheduler kept running: it could start
runs, and its clock check could start new catch-ups that nobody would
cancel. A task that did not wind down kept the daemon from exiting.

stop() now marks the service as stopping and pauses the scheduler
first. From then on the clock check, the watcher and a catch-up still
waiting for its turn do nothing. The cancelled tasks get as long as the
gateway gives its own background tasks to finish; whatever is left is
logged and the scheduler shuts down anyway. A second stop() returns at
once.
A jump of exactly five seconds counts as a pause and one just under it
does not, read off clocks the test controls alone, so a changed
comparison no longer goes unnoticed.

The service tests all ran in UTC. New York now covers both 2026 DST
changes: the local clock jumping an hour either way is not a pause, and
neither is a wall clock set back an hour as daylight saving time ends.
A pause across the spring change moves an hourly, a nightly and a
four-hourly job each to its right next run.
The overview of the pause handling still said every trigger that came
due is moved; one that the scheduler would still run late stays where
it is.
@alex-clickhouse
alex-clickhouse added this pull request to stack #540 October 21, 2026 14:46
@pufit
pufit merged commit 2f63104 into main Oct 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants