Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .changeset/service-job-lease-window-docblock.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions content/docs/deployment/self-hosting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions packages/services/service-job/src/cron-job-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
const record = this.jobs.get(name);
Expand Down
9 changes: 9 additions & 0 deletions packages/services/service-job/src/db-job-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading