Skip to content

docs(service-job): state the scheduler leader-election guarantee with its window - #14903

Queued
os-sales wants to merge 1 commit into
mainfrom
claude/issue-14619-scheduler-lease-window-docs
Queued

docs(service-job): state the scheduler leader-election guarantee with its window#14903
os-sales wants to merge 1 commit into
mainfrom
claude/issue-14619-scheduler-lease-window-docs

Conversation

@os-sales

@os-sales os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator

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, leaseMs and the adapters' behaviour are all untouched (confirmed mechanically below).

CronJobAdapter.runScheduled() holds its cluster lock for the duration of the fire (acquired, released in finally) — 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. once is 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() docblock

Before (lines 216–221, unchanged from the card's quote):

/**
 * Run a SCHEDULED fire of `name` under cluster leader-election: only the node
 * that acquires the per-job lock runs the handler; peers skip. No cluster /
 * in-memory driver => lock always granted => single-node unchanged. Manual
 * `trigger()` bypasses this.
 */

After (lines 216–230), added paragraph at 221–229:

 *
 * State the guarantee WITH its window: the lock below is held for the
 * duration of the fire (acquired here, released in `finally`), so it
 * de-duplicates *concurrent* fires — its mutual-exclusion window is the
 * handler's runtime, not the scheduling deadline. Exactly-once per deadline
 * holds only when replica clocks agree to within that window (the normal
 * case on an NTP-synced deployment). A replica whose clock lags past the
 * window finds the lock already released and reruns the job; `once` has no
 * later tick during which a business-level de-duplication marker could win.
 */

2. packages/services/service-job/src/db-job-adapter.ts, DbJobAdapter.schedule() routing docblock

Before (lines 122–170): the routing docblock explains WHICH schedule types go through CronJobAdapter (hence get leader election) vs inner, and separately states once is "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:

 * **State the guarantee WITH its window.** The lock `CronJobAdapter.runScheduled()`
 * takes is held for the duration of the fire, not for the deadline — its
 * mutual-exclusion window is the handler's runtime. So the routing above
 * de-duplicates *concurrent* fires: exactly-once per deadline holds only
 * when replica clocks agree to within that window (NTP-synced deployments,
 * the normal case). A replica whose clock lags past the window finds the
 * lock already released and reruns the job, and `once` is the sharpest case
 * because there is no later tick to self-correct that away.

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 existing OS_CLUSTER_DRIVER guidance (lines 405–412):

**Scheduled jobs' leader election has a window, not a deadline.** `cron` /
`interval` / `once` schedules dedupe across replicas by holding a lock for
the duration of each fire — the mutual-exclusion window is the handler's
runtime, not the scheduling deadline. Exactly-once per deadline holds only
when replica clocks agree to within that window, which an NTP-synced
deployment gives you. A replica whose clock lags past the window finds the
lock already released and reruns the job; `once` schedules are the sharpest
case, since a one-shot has no later tick to self-correct on.

Clause-②: no (verified, not assumed)

Comment-stripped dist/index.d.ts before 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 eslint on both touched .ts files is clean, pnpm --filter @objectstack/service-job typecheck is clean, and all 106 existing tests pass unchanged (including db-job-adapter.interval-leader.test.ts and db-job-adapter.once-leader.test.ts).

Changeset: owed, and it's included

skip-changeset applies only when no published .d.ts prose moves — measured, not assumed: both edited docblocks (runScheduled is private, but TS still emits private members' docblocks into .d.ts) show up verbatim in packages/services/service-job/dist/index.d.ts after a rebuild. .changeset/service-job-lease-window-docblock.md (patch) is included, following the authz-brand-structured-clone-doc.md precedent for a "docblock is a published byte" patch.

Verification (head d7b622f2f)

  • pnpm --filter '@objectstack/service-job^...' build then pnpm --filter '@objectstack/service-job' build — clean; .d.ts diff 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-config on both touched .ts files — 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:
    • 51 green.
    • pnpm check:dual-build-cjs-loads and node scripts/check-test-completeness.mjsNOT MEASURED (exit 3, "PREREQUISITE NOT MET" in their own verdict text): both require a full monorepo build / a saved turbo run test log, 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 under half-state-patrol.yml) — not run: confirmed that workflow's pull_request trigger is path-filtered to scripts/pm/check-half-states.mjs and .github/workflows/half-state-patrol.yml only, 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-examples failed once on a fresh worktree because packages/client-react/dist had no prior build (unrelated to this diff's content — the mdx addition has no fenced code example); built @objectstack/client-react and it's green.

Scope

In scope only: the three sites above. Not touched: packages/spec, content/docs/releases/**.


Generated by Claude Code

… 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
@github-actions github-actions Bot added the size/s label Sep 3, 2026
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

2 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 3 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bdpackageMentionDocs.

Which tree this was computed on

This run read content/docs from 022bf71fe0f005acec64d3320850af1c3fad5ae6 — the merge of head d7b622f2fc81da3d5e8c1b814f3050195e231ea9 into base 2b9f5810b777ee9d0211d9d6273d854bd8e2d5bd, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

os-sales commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author

✅ PM ACCEPT — verified against the diff, not against the report

domain:services seat, session session_01AUF1NoViznQK32gqpK8wS8, R22. Card #14619, ruling A on decision batch #17 item 4 (maintainer, verbatim 「同意」, recorded at 14619#issuecomment-5522738007).

"No mechanism change" — verified directly from the patch

The two .ts files add 9 lines each, and every added line begins with * inside a /** */ block. Zero non-comment source lines, zero deletions across the whole PR. That is the ruling's hard constraint, and it is readable off the diff rather than taken from the report.

Four files, all of them comment / prose / changeset:

file + what
packages/services/service-job/src/cron-job-adapter.ts 9 runScheduled docblock
packages/services/service-job/src/db-job-adapter.ts 9 schedule routing docblock
content/docs/deployment/self-hosting.mdx 9 "Scaling beyond one node" prose
.changeset/service-job-lease-window-docblock.md 33 added

All three sites the ruling named are covered, and DbJobAdapter.schedule got the same sentence rather than a paraphrase, as the ruling required.

The changeset question the ruling asked to measure was measured

The ruling said skip-changeset if no published .d.ts prose moves — and asked for that to be measured, not assumed. The answer is in the changeset body itself:

It ships as a patch because the docblocks are published bytes: tsup's declaration rollup carries CronJobAdapter.runScheduled's docblock and DbJobAdapter.schedule's routing docblock into dist/index.d.ts / dist/index.d.cts (measured: with all comments stripped, the before/after declaration files are byte-identical — no exported symbol moved, no signature changed).

So published prose does move ⇒ skip-changeset does not apply and a changeset is owed; no symbol moves ⇒ patch. That is the right reading of the ruling's condition, and the comment-stripped declaration diff is also what settles Clause-② no — the same measurement answers both questions from opposite sides.

⚠️ One note for my own record rather than the dev's: the JSON report put this reasoning in the changeset artefact rather than in its changeset field, and I briefly read the report as having skipped the measurement. It had not — I had.

The premise held, and the dev said so precisely

The 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.

Gates

54 derived gate commands, exit codes captured by redirect-then-read: 51 green. Two exited 3 with PREREQUISITE NOT MET in their own verdict text (check:dual-build-cjs-loads needs a full monorepo build; check-test-completeness needs a saved turbo test log) — read as NOT MEASURED per each gate's own words, not as red. check:skill-examples failed once on a fresh worktree for an unbuilt @objectstack/client-react and went green after building it — reported rather than hidden.

Tests 10 files / 106 tests pass, including both leader-election pins unchanged; typecheck and eslint clean.

Landing: CI is still running on d7b622f2; 12 checks green or skipped so far with zero failures. ⛔ Not undrafted yet — this seat reads job-level conclusions, not an aggregate, so it waits for the shards to finish.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/s tooling

Projects

None yet

2 participants