Repository navigation
fix(cron): run dynamic schedules with their payload and keep one chain per schedule - #102
Merged
Merged
Conversation
…n per schedule Successors of a cron occurrence now copy its payload and priority, and occurrences of a dynamic schedule run as pgconductor.invoke events with their payload, which the worker validates like an invocation. ctx.schedule() replaces a schedule in _private_schedule(), which holds the queue row lock like _private_unschedule(). A running occurrence of the replaced schedule inserts no successor once a later occurrence of its schedule is live, so only the new chain survives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes follow-ups 1, 12 and 19 from the handover (originally noted in #89 and #90).
What
Dynamic schedule payloads (fix(scheduling): overnight windows, unschedule races, maintenance jitter #89, fix(retries): make retried writes from running executions replay-safe #90).
ctx.schedule(task, name, { cron }, { userId })lost its payload in two ways:_private_schedule_next_occurrence()inserted successors withoutpayloadorpriority, so from the second occurrence on the payload wasnulland the priority was0.taskEventForgave cron executions no payload at all, so even the first occurrence's payload never reached the handler. A dynamic occurrence arrived as{ name: "<schedule name>" }, which matches none of the handler's event types.Successors now copy
payloadandpriority, as they already didgroupandmetadata. Occurrences of a dynamic schedule (dynamic::dedupe keys) now run as{ name: "pgconductor.invoke", payload }. That matches the typectx.schedulechecks the payload against, and the worker validates the payload against the task's schema, as it does for every invocation. Static cron triggers are unchanged:{ name: "<trigger name>" }, no payload.Two schedule chains (fix(scheduling): overnight windows, unschedule races, maintenance jitter #89). If
ctx.schedule()replaced a schedule while a running occurrence of the old schedule was inserting its successor, both chains survived. There were two ways this happened:The fix also has two parts:
_private_schedule(), a plpgsql function next to_private_unschedule(). It takes the queue row lockfor update, then deletes and upserts in later statements. The successor path already takes that lockfor share, so the two are now ordered._private_schedule_next_occurrence()inserts no successor while a live occurrence of the same schedule with a later scheduled time exists. It stores that occurrence's id as the step instead. It compares the dedupe key's timestamp, notrun_at, because retries and sleeps moverun_at.Docs (fix(retries): make retried writes from running executions replay-safe #90).
testing.mdsaid the next occurrence is scheduled after each run. It is scheduled when an occurrence starts, before its handler.cron.mdnow describes the event a dynamic occurrence receives and what replacing a running schedule does.AGENTS.md"Cron Schedules" is updated.Behaviour change: handlers that told dynamic occurrences apart by
event.name === "<schedule name>"now see"pgconductor.invoke". That shape was never in the handler's type, and the payload was never delivered.Verification
Test first. All three new tests in
cron-scheduling.test.tsfailed onmain:{ name: "user-u1", payload: undefined }, and the successor had no payload or priority.The existing test cron task with failure still reschedules next execution caught a first version that compared
run_at; the dedupe key timestamp fixes that.just lint(oxlint plus typecheck) andjust formatare clean.pnpm test: 614 of 615 pass. The one failure is inwait-deadlines.test.ts(… settled after the deadline > times out), which fails the same way onmainat9f450dawhen run alone (2–3 of its 11 tests). See the follow-ups.Follow-ups (out of scope)
wait-deadlines.test.tsis flaky onmain. Since fix(testing): keep fake time running so event waits match under a set test clock #98, fake time keeps running, so the waiter's 1 s fake deadline (12:00:00 → 12:00:01) can pass during setup before the test sets the clock forward. The test then sees a completion instead of a timeout. This fits F13 (test suite).trace_context, so each occurrence starts a new trace. I left that unchanged on purpose.🤖 Generated with Claude Code