Skip to content

Commit ca48cf3

Browse files
os-salesclaude
andauthored
fix(service-job): leader-elect type: 'once' schedules on DbJobAdapter (#14618)
* fix(service-job): leader-elect `type: 'once'` schedules on DbJobAdapter `DbJobAdapter.schedule()` routed `once` registrations to `inner` (`IntervalJobAdapter`), a bare `setTimeout` with no cluster lock anywhere in that file, so a one-shot job ran once per replica instead of once per cluster — the last limb left after #13686 did the same for `interval`, and the worst-shaped of the three: a one-shot has no later tick during which a business-level de-duplication marker could win. Route `once` to `this.cron` (`CronJobAdapter`, whose own `once` branch already fires through the leader-electing `runScheduled()`) when a cron adapter is assembled, and keep the registration in `inner` via `register()` so `trigger()`, `replay()`, `getExecutions()` and `listJobs()` are unaffected. No cron adapter assembled => unchanged: `inner.schedule()`, as before. Crash semantics are at-most-once per cluster, per the maintainer ruling of 2026-09-01, and stated in the docblock: no re-arm and no persistence is added. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * chore(service-job): changeset for the `once` leader-election fix Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * chore(scripts): record the new service-job engine double in the pinned ledger `node scripts/check-engine-double-contract.mjs --write` — 1 row added, 0 lost: the `update` double in the new `db-job-adapter.once-leader.test.ts`, which is already routed through `assertEngineUpdateDispatch`. Coverage growth only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 * test(service-job): let the #13918 card pin report all of today's evidence The pin exists to state the card's repro: two replicas, one deadline, two `sys_job_run` rows today and one after. A hard throw on the execution count stops the run before the fence count and the row count are ever reported, so the ablation that proves the pin can fail printed only the first of the three. The three pre-row assertions are now soft; the row assertions stay hard. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 10220a7 commit ca48cf3

4 files changed

Lines changed: 442 additions & 15 deletions

File tree

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
"@objectstack/service-job": patch
3+
---
4+
5+
fix(service-job): leader-elect `type: 'once'` schedules on `DbJobAdapter` (#13918)
6+
7+
`DbJobAdapter.schedule()` decides which adapter owns a scheduled fire, and only
8+
`CronJobAdapter` takes the cluster lock (`runScheduled()` -> `lock.acquire('job:'
9+
+ name, { waitMs: 0 })`). `cron` schedules were routed there from the start and
10+
`interval` schedules since #13686 — but `once` schedules still went to the inner
11+
`IntervalJobAdapter`, a bare `setTimeout` with no lock. On a multi-replica
12+
deployment a one-shot job therefore ran **once per replica**, not once per
13+
cluster, and a one-shot is the worst-shaped of the three: there is no later tick
14+
during which a business-level de-duplication marker could win, so every replica's
15+
copy lands inside the same short window.
16+
17+
`once` now takes the same leader-elected path as `cron` and `interval` whenever a
18+
cron adapter is assembled, and stays registered on the inner adapter via
19+
`register()` (stored, not armed), so `trigger()`, `replay()`, `getExecutions()`
20+
and `listJobs()` answer for it exactly as before. Affected paths in this repo:
21+
the automation wait-node's timer resume and its cold-boot re-arm
22+
(`@objectstack/service-automation`), schedule-triggered flows with an `at`
23+
(`@objectstack/trigger-schedule`), and app-declared jobs with a `once` schedule
24+
(`@objectstack/runtime`).
25+
26+
**Behaviour change, on multi-replica assemblies only.** A `once` job now fires on
27+
one replica per cluster instead of on every replica. Single-node behaviour is
28+
unchanged in both assemblies: with a cron adapter and no cluster driver the lock
29+
is always granted, and with no cron adapter at all the job still fires on the
30+
inner timer exactly as before. The semantics are **at-most-once per cluster**
31+
(maintainer ruling 2026-09-01): election decides who fires, not that the fire
32+
survives — a leader that dies mid-fire loses it, and nothing re-arms it. That
33+
takes nothing away, because the previous unelected `setTimeout` was not persisted
34+
either and the same crash lost it on every replica at once. No re-arm, retry or
35+
persistence mechanism is added.

0 commit comments

Comments
 (0)