Skip to content

fix(cron): run dynamic schedules with their payload and keep one chain per schedule - #102

Merged
psteinroe merged 1 commit into
mainfrom
t3/fix-cron-payload-priority
Oct 21, 2026
Merged

psteinroe merged 1 commit into
mainfrom
t3/fix-cron-payload-priority

Conversation

@psteinroe

Copy link
Copy Markdown
Owner

Fixes follow-ups 1, 12 and 19 from the handover (originally noted in #89 and #90).

What

  1. 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 without payload or priority, so from the second occurrence on the payload was null and the priority was 0.
    • taskEventFor gave 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 payload and priority, as they already did group and metadata. Occurrences of a dynamic schedule (dynamic:: dedupe keys) now run as { name: "pgconductor.invoke", payload }. That matches the type ctx.schedule checks 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.

  2. 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:

    • Sequential: the replacement commits between the occurrence's claim and its successor insert, and the successor is inserted anyway with the old cron expression.
    • Concurrent: neither statement sees the other's uncommitted row.

    The fix also has two parts:

    • The scheduling statement moved into _private_schedule(), a plpgsql function next to _private_unschedule(). It takes the queue row lock for update, then deletes and upserts in later statements. The successor path already takes that lock for 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, not run_at, because retries and sleeps move run_at.
  3. Docs (fix(retries): make retried writes from running executions replay-safe #90). testing.md said the next occurrence is scheduled after each run. It is scheduled when an occurrence starts, before its handler. cron.md now 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.ts failed on main:

  • every occurrence of a dynamic schedule gets its payload and priority: fake clock, two drained occurrences. Got { name: "user-u1", payload: undefined }, and the successor had no payload or priority.
  • a successor scheduled after the replacement is not inserted: left two pending occurrences.
  • a replacement concurrent with the successor removes it: holds the successor's transaction open and waits until the replacement either finishes or blocks on a lock. Left two pending occurrences.

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) and just format are clean. pnpm test: 614 of 615 pass. The one failure is in wait-deadlines.test.ts (… settled after the deadline > times out), which fails the same way on main at 9f450da when run alone (2–3 of its 11 tests). See the follow-ups.

Follow-ups (out of scope)

  • wait-deadlines.test.ts is flaky on main. 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).
  • Cron successors still don't carry trace_context, so each occurrence starts a new trace. I left that unchanged on purpose.

🤖 Generated with Claude Code

…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>
@psteinroe
psteinroe merged commit 78d7a9e into main Oct 21, 2026
11 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.

1 participant