Uh oh!
There was an error while loading. Please reload this page.
Add the dispatch job: idempotent task generation, backfill-capable - #43
Merged
Conversation
os-warren
marked this pull request as ready for review
September 1, 2026 05:06
Uh oh!
There was an error while loading. Please reload this page.
This was referenced Sep 1, 2026
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 freeto 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#2
The spine. Active recurring duties become tasks, once per period, forever, without a lock.
Gates green at
fe7941a(the merged head,origin/mainmerged in for #36/#38):pnpm validate✓ ·pnpm typecheck✓ ·pnpm test243 passed (7 files) ·pnpm build✓.Metadata-first: what is declarative, and what could not be
Declarative: the schedule, its timezone, the retry policy, the timeout, the duty-selection filter, and the identity constraint the whole design rests on (
duly_task_dispatch_identity, already on the object).Imperative: the period arithmetic — calendar maths in a per-row IANA zone, which no filter language expresses — and the insert loop. Two files, so the boundary is a file boundary:
src/jobs/dispatch.plan.tssrc/jobs/dispatch.job.tsdefineJobmetadata, plus ~40 lines that talk to the engine.A scheduled flow was the alternative and was measured, not assumed. It is expressible (
get_record→script→loop→create_record) and it was rejected on three findings:try_catchandfaultedges swallow every failure identically, because thecreate_recordexecutor collapses the engine error to a string with no code before either can see it. For the one job the product cannot afford to fail quietly, "the run succeeded and created nothing" is the wrong failure to make easy.ScheduleTriggerhands the flow{ event, params: { jobId, flowName, schedule } }— no input channel.IJobService.trigger(name, data)forwardsdatato a job handler, which is how{ from, to }gets in.ScheduleTriggercallsjobService.schedule(name, schedule, handler)with no options;defineJobthreads both.The product docs settle the shape independently (
data-model.md: "the dispatcher can be a plain idempotent job";duty.object.tsnames this file), so this is not a re-litigation of that decision — it is the evidence for why it still holds.Idempotency: attempt the insert, then ask the data
The happy path is a bare
insert. No read-then-write guard — that is a race two overlapping runs lose, and it costs a query on every task every night for a collision that is rare.The interesting part is the failure path, and it does not classify the error. Measured on 17.2.0, ObjectQL rethrows the raw driver error, so a duplicate arrives as
code: 'SQLITE_CONSTRAINT_UNIQUE'here and would be23505on Postgres; the platform's dialect-independent predicateisUniqueViolationErrorlives in@objectstack/types, which an application cannot resolve. Hard-coding one dialect or matching a message are both a consumer growing tolerance for a producer that will not answer.So on failure it asks the data, which every driver answers the same way: is the row there now?
existing. A run that inserts nothing because everything already exists is a successful run.The gap this could not close: a job handler has no data reach
Measured: a
defineJobhandler is invoked with exactly{ jobId, data, bundle }— no engine, no service registry, no logger. So the one metadata shape the platform offers for scheduled work cannot, by itself, write a record.Not worked around silently.
runDispatch(engine, …)takes its engine as an argument;bindDispatchEngineis the one named seam; and until a host calls itdulyDispatchrefuses loudly, naming the wiring, rather than reporting a clean night on which nothing was dispatched.defineJobhandler is invoked with{ jobId, data, bundle }and no data reach, so the platform's only scheduled-work metadata shape cannot read or write a record objectstack#14094 — the handler-context gap (measured probe, suggested one-line direction, additive).isUniqueViolationErroris not reachable from an app objectstack#14095 — an app cannot recognise a uniqueness violation.onEnable— one line, in the file #4 currently owns #42 (this repo) — the one-lineonEnablewire. It lands insrc/actions/register-handlers.ts, which One-click completion with undo, and skip-with-reason #4 owns and is editing now, so it is split out withBlocked-by: #4rather than taken here. Until Wire the dispatch job's engine atonEnable— one line, in the file #4 currently owns #42 lands the job does not dispatch at runtime — loudly, by design.Semantics worth reviewing
2026-08-31T20:00ZanAsia/Shanghaiduty gets2026-09and anAmerica/Los_Angelesduty gets2026-08, on the same run.visible_fromgoverns when a task shows up, not whether it exists — plus any later period whose lead window has already opened. The look-ahead is bounded attoday + lead_days, which is exact rather than generous.due_date, always. That second one is what stops a backfill inventing obligations that predate the duty, and it is why a backfill of a duty whose window has closed still works.effective_from/effective_toare calendar days in the duty's own zone, and "today" is a different day in Auckland and Los Angeles at one instant, so no single predicate is right for every row it matches. The query filters only the two zone-independent facts; the window test lives where the zone is known.last_dispatched_periodadvances, never regresses. Keys of one frequency are fixed-width and zero-padded, so a lexical compare is chronological. A run that created nothing writes nothing — which is also why a second identical run issues zero writes of any kind.duly_dutyand neverduly_task. Asserted directly: a spy engine records everyupdatetarget across two runs at two clocks andduly_taskmust not appear.toon its own is refused — it could only mean "since the duty began", andeffective_fromis nullable, so that window has no floor.Tests — and why they run on sqlite
test/dispatch.test.ts, 55 tests, three layers: wiring, the pure planner, and idempotency against a real booted engine.The engine is sqlite, not memory, and that is the load-bearing choice. Measured:
InMemoryDriver.createis atable.push()that stores no constraints, so two identicalduly_taskinserts both succeed and the table ends with two rows. A dispatcher suite on the memory driver reports idempotency passing while the index providing it was never consulted. One test asserts the index is genuinely enforced, so the suite cannot go green by absence.Two ablations, each committed first, each confirmed on disk, each restored by a
trap:1 → 0, injected-literal count1,git diff --statnon-emptyduly_taskrow1 → 0, injectedengine.update('duly_task'count1Both restored clean (
git statusempty after each). Independent evidence that the suite bites: it caught a real bug during the first run — withlead_days: 0the look-ahead passed the run's instant asfromand midnight-of-today asto, soperiodsBetweenreturned[]and every zero-lead duty silently dispatched nothing. Fixed, and the comment says why.Acceptance criteria, each with a test: two runs → zero second-pass inserts · two concurrent runs → one task · Shanghai vs Los Angeles on one instant · standing produces nothing in any run · paused produces nothing and un-pausing produces only the current period, not the gap ·
last_dispatched_periodadvances and never regresses · a 13-month backfill produces exactly the expected key set and a second inserts nothing.Files, and one note on scope
src/jobs/dispatch.job.ts,src/jobs/dispatch.plan.ts,src/jobs/index.ts,test/dispatch.test.ts— all inside the declared surface.One addition outside it, named here rather than buried:
src/functions/index.ts, to register the handler indulyFunctions. A job needs two registrations — the schedule indulyJobsand the handler in the functions map — andAppPluginresolves the second ascollectBundleFunctions(bundle)[job.handler], loggingjob handler not found in bundle.functions — skippingon a miss. Without that entry the job is registered, listed, validated, and never executed, with no author-time gate. The file is held by no other round-2 claim, and a test performs the same lookup the runtime performs.objectstack.config.tswas not touched; the barrels were sufficient.Generated by Claude Code