Skip to content

service-job: type: 'once' schedules on DbJobAdapter get no leader election either — same routing limb as #13686, deliberately left out of its scope #13918

Description

@os-steve

Found while implementing #13686 (interval leader election). Filed rather than fixed there: #13686's scope, its field evidence and #2219's declared capability are all about cron/interval, and widening the routing to a third schedule type is a behaviour change the reporter's cluster cannot confirm.

The gap

DbJobAdapter.schedule() decides which adapter owns a scheduled fire, and only the adapter it picks decides whether that fire is leader-elected:

So on a multi-replica deployment a one-shot job registered with { type: 'once', at } runs once per replica, not once per cluster. Same failure shape as #13686 and, for a one-shot, arguably a worse one: there is no later tick during which a de-duplication marker could win, so every replica's copy lands in the same short window.

The fix is already sitting there

CronJobAdapter.schedule() handles type: 'once' itself (else if (schedule.type === 'once' && schedule.at)) and arms it with setTimeout(() => { void this.runScheduled(name); }, delay) — the same leader-elected fire path its cron and interval limbs use. So the repair is the one #13686 applied to the interval limb, one branch over: delegate once to this.cron when one is assembled, and register it on inner via IntervalJobAdapter.register() (stores without arming a timer) so trigger() / replay() / getExecutions() / listJobs() are unaffected.

What needs deciding before someone writes that

Not mechanical, which is why this is an issue and not a follow-up commit:

  1. Is there a real consumer? feat(service-job): leader-elect scheduled cron/interval jobs across the cluster #2219 declared cron/interval. Whether any shipped or app-level code registers type: 'once' against a multi-replica assembly was not measured — worth measuring before spending the change, per the startup-scope-discipline axis.
  2. Crash semantics differ for a one-shot. For interval, a leader that dies mid-fire costs one tick and the next tick re-elects. For once there is no next tick: the lease expires with the work undone and nothing re-arms it, so "exactly once per cluster" becomes "at most once per cluster". That may be fine, or it may want the lock to be released only on success — a decision, not a detail.

Repro sketch

Same instrument as packages/services/service-job/src/db-job-adapter.interval-leader.test.ts: two DbJobAdapter stacks over one fake engine and one shared lock, a { type: 'once', at: <now + tick> } registration on each, one advanceTimersByTimeAsync. Today that executes the handler twice and writes two sys_job_run rows.

Landing site: packages/services/service-job/src/db-job-adapter.ts (schedule()).

Activity

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

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions