From d7b622f2fc81da3d5e8c1b814f3050195e231ea9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 08:48:17 +0000 Subject: [PATCH] docs(service-job): state the scheduler leader-election guarantee with 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 Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --- .../service-job-lease-window-docblock.md | 33 +++++++++++++++++++ content/docs/deployment/self-hosting.mdx | 9 +++++ .../service-job/src/cron-job-adapter.ts | 9 +++++ .../service-job/src/db-job-adapter.ts | 9 +++++ 4 files changed, 60 insertions(+) create mode 100644 .changeset/service-job-lease-window-docblock.md diff --git a/.changeset/service-job-lease-window-docblock.md b/.changeset/service-job-lease-window-docblock.md new file mode 100644 index 0000000000..569ab6ec2f --- /dev/null +++ b/.changeset/service-job-lease-window-docblock.md @@ -0,0 +1,33 @@ +--- +'@objectstack/service-job': patch +--- + +docs(service-job): state the scheduler leader-election guarantee with its window (#14619) + +Documentation only — no runtime change, no type change, no accept/reject +behaviour moves. 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). + +`CronJobAdapter.runScheduled()` holds its cluster lock for the duration of a +scheduled fire (acquired, then released in `finally`), not for the scheduling +deadline. The two docblocks stated the guarantee — "only the node that +acquires the per-job lock runs the handler" — without that window, which reads +as exactly-once per deadline. It is exactly-once only when replica clocks +agree to within the handler's runtime (the normal case on an NTP-synced +deployment); a replica whose clock lags past that window finds the lock +already released and reruns the job. `once` schedules are the sharpest case, +since a one-shot has no later tick during which a business-level +de-duplication marker could self-correct that away. The multi-node section of +[Self-Hosted Deployment](/docs/deployment/self-hosting) states the same +caveat. + +⛔ The mechanism is deliberately unchanged: holding the lease keyed to the +deadline (plus takeover semantics for a leader that dies mid-fire) is a +distributed-design item with zero measured pull and no measured +skew-to-runtime ratio — this is bookkeeping, not closure. If a real +duplicate-fire incident is measured on a `once` schedule, that remedy returns +as its own card. diff --git a/content/docs/deployment/self-hosting.mdx b/content/docs/deployment/self-hosting.mdx index afd8043600..63385f611f 100644 --- a/content/docs/deployment/self-hosting.mdx +++ b/content/docs/deployment/self-hosting.mdx @@ -402,6 +402,15 @@ decrypt each other's secrets. All replicas must share the same `OS_SECRET_KEY`, `OS_AUTH_SECRET`, and database. See [Cluster](/docs/kernel/cluster). +**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. + ## First boot: create the admin How the first administrator is created depends on the deployment's diff --git a/packages/services/service-job/src/cron-job-adapter.ts b/packages/services/service-job/src/cron-job-adapter.ts index acb6ea1ba8..49e9070e33 100644 --- a/packages/services/service-job/src/cron-job-adapter.ts +++ b/packages/services/service-job/src/cron-job-adapter.ts @@ -218,6 +218,15 @@ export class CronJobAdapter implements IJobService { * 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. + * + * 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. */ private async runScheduled(name: string): Promise { const record = this.jobs.get(name); diff --git a/packages/services/service-job/src/db-job-adapter.ts b/packages/services/service-job/src/db-job-adapter.ts index 4d58eb1fa6..d8ee435c7f 100644 --- a/packages/services/service-job/src/db-job-adapter.ts +++ b/packages/services/service-job/src/db-job-adapter.ts @@ -138,6 +138,15 @@ export class DbJobAdapter implements IJobService { * later tick during which a business-level de-duplication marker could win, so * every replica's copy lands inside the same short window. * + * **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. + * * **`once` is AT-MOST-ONCE per cluster, and deliberately so** (maintainer * ruling 2026-09-01). Election decides *who* fires, never *that* the fire * survives: there is no second deadline, so a leader that dies mid-fire loses