docs(service-job): state the scheduler leader-election guarantee with its window - #14903
docs(service-job): state the scheduler leader-election guarantee with its window#14903os-sales wants to merge 1 commit into
Conversation
… its window (#14619) `CronJobAdapter.runScheduled()` holds its cluster lock for the duration of a scheduled fire, not for the scheduling deadline. The docblocks on `runScheduled` and on `DbJobAdapter.schedule()`'s routing decision stated the guarantee without that window, reading as exactly-once per deadline; it is exactly-once only when replica clocks agree to within the handler's runtime (NTP-synced deployments). The multi-node section of the self-hosting guide gets the same caveat. Docs-only, per the maintainer's ruling on #14619 (adopting recommendation A): the mechanism stays as-is (holding the lease keyed to the deadline plus takeover semantics is a distributed-design item with zero measured pull). Changeset included because the docblock prose is a published byte (tsup's declaration rollup carries it into dist/index.d.ts); measured with all comments stripped, the before/after declaration files are byte-identical, so no exported symbol moved. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift Check2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 022bf71fe0f005acec64d3320850af1c3fad5ae6 && git checkout 022bf71fe0f005acec64d3320850af1c3fad5ae6
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd d7b622f2fc81da3d5e8c1b814f3050195e231ea9 && git checkout -B drift-repro 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd && git merge --no-ff d7b622f2fc81da3d5e8c1b814f3050195e231ea9
node scripts/docs-audit/affected-docs.mjs --json 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd |
✅ PM ACCEPT — verified against the diff, not against the report
"No mechanism change" — verified directly from the patchThe two Four files, all of them comment / prose / changeset:
All three sites the ruling named are covered, and The changeset question the ruling asked to measure was measuredThe ruling said
So published prose does move ⇒ The premise held, and the dev said so preciselyThe card's quoted docblock matched the current tree exactly; only the line numbers had drifted by one from an earlier commit's citation. So no mechanism change was needed to make the sentence true, and the ruling's stop-and-report escape hatch was correctly not taken. Gates54 derived gate commands, exit codes captured by redirect-then-read: 51 green. Two exited 3 with Tests Landing: CI is still running on Generated by Claude Code |
Fixes #14619
What this does
Docs-only, per the maintainer's ruling on #14619 (
issuecomment-5522738007, adopting recommendation A, verbatim 「同意」): writes down the clock-agreement assumption behind the scheduler's leader election, in the three sites the ruling names. ⛔ No mechanism change —runScheduled, the lease,leaseMsand the adapters' behaviour are all untouched (confirmed mechanically below).CronJobAdapter.runScheduled()holds its cluster lock for the duration of the fire (acquired, released infinally) — the mutual-exclusion window is the handler's runtime, not the scheduling deadline. The existing docblocks stated the guarantee without that window ("only the node that acquires the per-job lock runs the handler; peers skip"), which reads as exactly-once per deadline. It's exactly-once only when replica clocks agree to within that window (the normal case on an NTP-synced deployment); a replica whose clock lags past it finds the lock already released and reruns the job.onceis the sharpest case — a one-shot has no later tick to self-correct on.Option B (hold the lease keyed to the deadline, plus takeover semantics for a leader that dies mid-fire) is explicitly not built here — zero measured pull, no measured skew-to-runtime ratio, a distributed-design item. This is bookkeeping, not closure: the docblocks now state what the mechanism actually guarantees.
The three sites (before → after, measured on this branch)
1.
packages/services/service-job/src/cron-job-adapter.ts,runScheduled()docblockBefore (lines 216–221, unchanged from the card's quote):
After (lines 216–230), added paragraph at 221–229:
2.
packages/services/service-job/src/db-job-adapter.ts,DbJobAdapter.schedule()routing docblockBefore (lines 122–170): the routing docblock explains WHICH schedule types go through
CronJobAdapter(hence get leader election) vsinner, and separately statesonceis "AT-MOST-ONCE per cluster, deliberately so" for the leader-dies-mid-fire case — but nowhere states that the concurrent-fires dedup itself has a clock-agreement precondition.After: new paragraph inserted at lines 141–148, between the routing explanation and the AT-MOST-ONCE paragraph:
3.
content/docs/deployment/self-hosting.mdx, "## Scaling beyond one node"This is the only multi-node section under
content/docs/deployment/that discusses coordination for "locks, queues, schedules" (verified:grep -rn -i "leader\|scheduled job" content/docs/deployment/finds no other candidate section). Added a paragraph after the existingOS_CLUSTER_DRIVERguidance (lines 405–412):Clause-②: no (verified, not assumed)
Comment-stripped
dist/index.d.tsbefore vs. after this change (all/** */and//comments stripped from both) is byte-identical — no exported symbol added/moved/renamed, no signature changed, no new key on any published payload.pnpm exec eslinton both touched.tsfiles is clean,pnpm --filter @objectstack/service-job typecheckis clean, and all 106 existing tests pass unchanged (includingdb-job-adapter.interval-leader.test.tsanddb-job-adapter.once-leader.test.ts).Changeset: owed, and it's included
skip-changesetapplies only when no published.d.tsprose moves — measured, not assumed: both edited docblocks (runScheduledisprivate, but TS still emits private members' docblocks into.d.ts) show up verbatim inpackages/services/service-job/dist/index.d.tsafter a rebuild..changeset/service-job-lease-window-docblock.md(patch) is included, following theauthz-brand-structured-clone-doc.mdprecedent for a "docblock is a published byte" patch.Verification (head
d7b622f2f)pnpm --filter '@objectstack/service-job^...' buildthenpnpm --filter '@objectstack/service-job' build— clean;.d.tsdiff reviewed above.pnpm --filter '@objectstack/service-job' typecheck— clean.pnpm --filter '@objectstack/service-job' exec vitest run --maxWorkers=2— 10 files, 106 tests, all pass.pnpm exec eslint --no-inline-configon both touched.tsfiles — clean.node scripts/check-nul-bytes.mjs— clean.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack, re-derived after every content change (including after adding the changeset) — 54 matched gate commands total, all run:pnpm check:dual-build-cjs-loadsandnode scripts/check-test-completeness.mjs— NOT MEASURED (exit 3, "PREREQUISITE NOT MET" in their own verdict text): both require a full monorepo build / a savedturbo run testlog, disproportionate for an S-sized docs-only diff; not a red, nothing here to fix.node scripts/pm/check-half-states.mjs(bare, the live-sweep invocation underhalf-state-patrol.yml) — not run: confirmed that workflow'spull_requesttrigger is path-filtered toscripts/pm/check-half-states.mjsand.github/workflows/half-state-patrol.ymlonly, neither of which this diff touches, so it does not actually run in this PR's CI.pnpm check:pm-half-states(the offline self-test variant,lint.yml) did run and is green.check:skill-examplesfailed once on a fresh worktree becausepackages/client-react/disthad no prior build (unrelated to this diff's content — the mdx addition has no fenced code example); built@objectstack/client-reactand it's green.Scope
In scope only: the three sites above. Not touched:
packages/spec,content/docs/releases/**.Generated by Claude Code