Add ctx.reschedule for poll-until-done pattern#5
Conversation
- New `Store.rescheduleJob(id, version, scheduledFor)` mirrors `retryJob`'s `running → pending` shape but resets `attempt` to 0, clears `last_error` / `failure_reason`, and clears `scheduler_ref` so the result handler can materialize a fresh wake at the new `scheduledFor`. Also clears the missing-handler horizon state (`defer_attempts`, `deferred_since`) since this protocol is distinct from the failure-mode defer. - Bumps `version` to introduce a new delivery identity. Stale wakes or held snapshots from the prior run cycle (e.g., an in-flight Posthook delivery whose hookId predates the reschedule) lose CAS rather than acting on the rescheduled row. - Returns the updated `Job` on success or `null` on CAS loss (concurrent `markCompleted` / `markFailed` / `cancelJob` / `replaceJob`). - Memory, Postgres, and SQLite implementations. - Backend testsuite covers field mutations, version bump, stale-CAS loss after reschedule, CAS loss on wrong version, CAS loss on non-running status, and clearing of prior-attempt error state. - Postgres-concurrency test races `rescheduleJob` against `markCompleted` over 50 trials — exactly one wins, the row satisfies invariants either way.
- New `ctx.reschedule({ delay, at })` lets a handler indicate "not
done yet, come back at T" — the row transitions `running → pending`
with the new `scheduledFor` instead of `running → completed` after
the handler returns successfully. Backs the poll-until-done idiom
for Replicate / OpenAI batch / Mux-style polling without rotating
keys.
- Synchronous void method on `HandlerContext`. Last call within a
run wins. Throwing from the handler discards the intent and falls
through to normal retry/failure logic.
- `attempt` resets to `0` on each rescheduled delivery — the
just-finished run is a checkpoint, not a consumed retry.
- Currently scoped to `kind: "once"` jobs. Calling from a debounce
or throttle handler throws synchronously; pattern handlers requeue
automatically via their wait/maxWait window.
- Validates exactly one of `delay` or `at`, with the same constraints
as `dk.schedule` (parsable duration string, valid Date, capped at
10 years in the future).
- New `JobRescheduledEvent` (`job:rescheduled`) carries the
post-reschedule `job`, the new `scheduledFor`, and `durationMs`
for the just-finished run. Distinct from `job:scheduled` so
observers counting fresh schedules don't overcount poll cycles.
- New `handler_rescheduled` `ExecutionResult` variant; result-handler
routes through `Store.rescheduleJob` and materializes a fresh wake.
CAS loss (concurrent cancel/replace/etc.) is a silent no-op.
- `dk.schedule(...)` now returns `{ created: true; job }` or
`{ created: false; job; skippedReason }` where `skippedReason` is
`"pending"`, `"running"`, or `"race_lost"`. Existing destructuring
keeps working — both arms have `job` and `created` — so callers
that don't care about the discriminator are unaffected.
- The new field surfaces the silent self-rescheduling footgun: a
developer who calls `dk.schedule("x", { key })` from inside the
handler for `("x", key)` now sees `skippedReason: "running"`
instead of an unexplained `created: false`. JSDoc on the variant
points at `ctx.reschedule({ delay, at })` as the right tool for
that case.
- `dk.schedule` JSDoc spells out the idempotence contract and the
ctx.reschedule alternative.
- Exported `ScheduleResult` and `SkippedReason` types.
- Tests cover each `skippedReason` case (`"pending"` from default
skip, `"running"` from both `skip` and `replace`, `"race_lost"`
from a forced `replaceJob` CAS loss).
📝 WalkthroughWalkthroughA handler-initiated job rescheduling feature is added, enabling ChangesHandler-Initiated Job Rescheduling
Sequence DiagramsequenceDiagram
participant Handler
participant Executor
participant Store
participant ResultHandler
participant Scheduler
Handler->>Executor: handler function executing
Handler->>Executor: ctx.reschedule({ delay: "1h" })
Executor->>Executor: resolveRescheduleAt (validate options)
Executor->>Executor: record rescheduleIntent
Handler->>Handler: continues execution
Handler-->>Executor: returns (no throw)
Executor->>Executor: detect rescheduleIntent set
Executor-->>ResultHandler: ExecutionResult { status: "handler_rescheduled" }
ResultHandler->>Store: rescheduleJob(id, version, scheduledFor)
Store->>Store: running → pending, version++, attempt=0
Store-->>ResultHandler: updated Job
ResultHandler->>ResultHandler: emit job:rescheduled event
ResultHandler->>Scheduler: materializeWake (new pending schedule)
Scheduler->>Scheduler: schedule next poll at new time
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@docs/INVARIANTS.md`:
- Line 169: Update the docs to include the missing rescheduleJob entry: add
rescheduleJob to the CAS-guarded transition list in Invariant 7 alongside
markCompleted, retryJob, etc. (mention that it uses WHERE status = 'running' AND
version = ${version}). Also add rescheduleJob to the method list in Section 9
DB-first flow alongside rescheduleDueAt() and requeueForNextWindow(), noting it
follows the same row-first-then-wake pattern.
In `@src/executor.ts`:
- Around line 206-207: The delay-based reschedule currently returns new
Date(Date.now() + parseDuration(options.delay as string)) without enforcing the
SCHEDULE_MAX_FUTURE_MS ceiling; update the reschedule logic (the branch that
reads options.delay and calls parseDuration) to clamp the computed future
timestamp to Date.now() + SCHEDULE_MAX_FUTURE_MS (or throw if it exceeds) so it
applies the same 10-year max as the `at` path; use the existing
SCHEDULE_MAX_FUTURE_MS constant and keep parseDuration(options.delay) and the
returned Date semantics intact while enforcing the bound.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 0600a064-e4f6-478e-81a8-2562db3594a5
📒 Files selected for processing (16)
CHANGELOG.mdREADME.mddocs/INVARIANTS.mddocs/OPERATIONS.mdsrc/delaykit.tssrc/executor.tssrc/index.tssrc/result-handler.tssrc/stores/memory.tssrc/stores/postgres.tssrc/stores/sqlite.tssrc/types.tstest/delaykit.test.tstest/postgres-concurrency.test.tstest/reschedule.test.tstest/store-contract.ts
- Add `noteMissingHandler` and `rescheduleJob` to the CAS-guarded
transitions list in INVARIANTS Invariant 7. Add `rescheduleJob` to
the DB-first-flow method list in Section 9.
- Apply the same `SCHEDULE_MAX_FUTURE_MS` ceiling to
`ctx.reschedule({ delay })` that the `at` path already enforces.
A delay greater than 10 years now throws synchronously.
Self-contained smoke test for `ctx.reschedule` against a real
PosthookScheduler. Wires a single "check-job" handler that polls a
fake external API; the API returns "pending" twice, then "succeeded".
Each pending response calls `ctx.reschedule({ delay: "10s" })`; the
succeeded response returns normally and the row marks completed.
Uses Node's built-in `http` module (no Express dep) and converts
between IncomingMessage and Web Request/Response so dk.createHandler()
can verify Posthook signatures and route to the registered handler.
Run instructions, env vars, and Posthook-project setup notes are in
the file header. Linked from docs/OPERATIONS.md "Poll-until-done".
Verified end-to-end against a real Posthook test project: three
signed deliveries, two reschedule cycles, terminal completion.
Summary
Adds
ctx.reschedule({ delay, at })for handler-initiated continuation — the poll-until-done pattern (Replicate predictions, OpenAI batch jobs, Mux transcodes, etc.). Inside a handler, callingreschedule(...)records intent for after the run; on successful return the row transitionsrunning → pendingwith the newscheduledForinstead ofrunning → completed.What changed
Store.rescheduleJob(id, version, scheduledFor)— new store primitive backing the API.running → pendingCAS, attempt resets to 0, clears prior-run error state andscheduler_ref, bumps version so stale wakes lose CAS. Memory + Postgres + SQLite + Postgres concurrency tests.ctx.reschedule({ delay | at })— synchronous void method onHandlerContext. Last call wins; throwing discards intent. Validates the same way asdk.schedule(parsable duration, valid Date, capped at 10 years). Currently scoped tokind="once"jobs — pattern handlers throw (their requeue happens via wait/maxWait). Newhandler_rescheduledexecution result + result-handler branch.job:rescheduledevent — fires per rescheduled cycle withscheduledForanddurationMs. Distinct fromjob:scheduledso observers counting fresh schedules don't overcount poll cycles.dk.schedulereturns a discriminated union —{ created: true; job }or{ created: false; job; skippedReason }whereskippedReasonis"pending","running", or"race_lost". Existing destructuring ({ job, created }) keeps working. The new field nudges callers towardctx.reschedulewhen they hit the"running"case (the silent self-rescheduling footgun). Exported asScheduleResultandSkippedReason.Why
The website's poll-until-done pattern showed
dk.schedule(...)being called from inside a handler, butdk.scheduleis idempotent — it skips when an active row exists, including the row currently being executed by the handler. So the pattern was silently broken. Addingctx.rescheduleis the right primitive (the row is being rescheduled, not duplicated), and the discriminated-union return ondk.schedulesurfaces the footgun in the moment.Test plan
npm run typechecknpm test— 604 passed (16 new intest/reschedule.test.ts, 4 new intest/delaykit.test.ts)npm run test:postgres— 156 passed (1 new concurrency test, 4 new contract tests)poll-until-donepattern indelaykit-website/lib/patterns.ts:582to usectx.reschedule({ delay: "2m" })after this lands in npmNotes
Store.retryJobhas the same shape asrescheduleJob(running → pendingCAS) but does not bump version. By the same argument used here forrescheduleJob(stale wakes from the prior run cycle should lose CAS),retryJobarguably should bump too. Not changed in this PR — separate concern.🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
ctx.reschedule({ delay, at })schedule()method returns reason when duplicate jobs are skipped ("pending","running", or"race_lost")job:rescheduledevent for observability when handlers trigger reschedulesDocumentation