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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
45 changes: 45 additions & 0 deletions .changeset/job-interval-leader-election.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
---
"@objectstack/service-job": patch
---

fix(service-job): leader-elect `interval` schedules on multi-replica deployments (#13686)

`DbJobAdapter` — the adapter a production assembly upgrades to — routed a
`type: 'cron'` schedule to `CronJobAdapter`, which takes a per-fire cluster lock
(`job:<name>`, `waitMs: 0`) before running the handler, and routed a
`type: 'interval'` schedule to `IntervalJobAdapter`, which has no lock at all.
So on a 3-replica cluster every replica armed its own `setInterval` and every
tick executed three times. #2219 declared the capability as leader-electing
scheduled **cron/interval** jobs across the cluster; only the cron half enforced
it.

Reported from a live 3-replica deployment (traefik → 3 app replicas, shared
postgres + redis, `OS_CLUSTER_DRIVER=redis`) with the fence counter
`os:fence:job:ts:*` measured flat for 100 s while eight 60 s interval jobs ran —
where the same jobs on cron expressions increment it once per replica per tick.
Duplicate *business effects* were mostly masked by per-handler de-duplication
and staggered container start times; the writes de-duplication did not cover
were not — one SLA escalation delivered its notifications twice to each of three
recipients, six inserts inside a 54 ms window.

**What changed.** `DbJobAdapter.schedule()` now delegates `interval` to the same
`cron` adapter it already delegates `cron` to — `CronJobAdapter` has always
handled `type: 'interval'` itself and fires it through the same leader-elected
`runScheduled()` — so an interval fire acquires the `job:` lock and the replicas
that lose it skip that tick. Both delegated types are still registered on the
inner adapter through the new `IntervalJobAdapter.register()`, which stores a
registration **without arming a timer**, so `trigger()`, `replay()`,
`getExecutions()` and `listJobs()` are unchanged and one process never holds an
elected timer beside an unelected one.

**Unchanged on purpose.** Cron routing and cron behaviour; manual `trigger()`,
which is deliberately not leader-elected (an operator is asking *this* node to
run the job now); `once` schedules; the `sys_job` / `sys_job_run` writes. With no
cluster driver configured the lock is always granted, so single-node scheduling
is byte-for-byte what it was. With no cron adapter assembled at all
(`enableCron: false`, or its construction threw) an interval job still fires on
the inner timer exactly as before — unelected, and now saying so in a warning
rather than surfacing only as duplicate rows.

No new configuration key: #2219 declared this as the behaviour, and putting it
behind a switch would re-open the same declared-≠-enforced gap on the switch.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,298 @@
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.

/**
* #13686 — an `interval` schedule registered on `DbJobAdapter` must reach the
* SAME leader-elected fire path a `cron` schedule reaches.
*
* ⚠️ What this file can and cannot measure. The defect is a concurrency defect
* across OS processes and this harness is one process, so nothing here is a
* cluster test. What IS pinned deterministically: the ROUTING (which adapter
* received the registration, and that exactly one timer exists per job in one
* process), and the LOCK SEMANTICS at the adapter seam (two adapter instances
* contending for one fire against one shared lock ⇒ one execution, the loser
* SKIPPING rather than throwing, waiting or retrying). Whether a redis fence
* behaves that way across three real replicas is measurable only on a real
* multi-replica deployment.
*/

import { describe, it, expect, vi, afterEach } from 'vitest';
import type { IJobService, JobSchedule } from '@objectstack/spec/contracts';
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
import { DbJobAdapter } from './db-job-adapter.js';
import { CronJobAdapter } from './cron-job-adapter.js';

const TICK = 60_000;
const IV: JobSchedule = { type: 'interval', intervalMs: TICK };

function makeFakeEngine() {
const tables = new Map<string, any[]>();
return {
tables,
rows(table: string) { return tables.get(table) ?? []; },
async find(table: string, opts: any = {}) {
const t = tables.get(table) ?? [];
const matched = opts.where
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => {
// REFUSE what this double does not implement, rather than reading a
// combinator as if it were a column name.
if (k.startsWith('$')) throw new Error(`fake engine: unsupported operator ${k}`);
return r[k] === v;
}))
: [...t];
// The caller's bound, applied AFTER the filter and by PRESENCE: a `limit`
// of zero is a bound of zero rows, not an absent bound.
return typeof opts?.limit === 'number' ? matched.slice(0, opts.limit) : matched;
},
async insert(table: string, data: any) {
const t = tables.get(table) ?? [];
t.push({ ...data });
tables.set(table, t);
return { id: data.id };
},
async update(table: string, data: any, options?: Record<string, unknown>) {
// Hold this double to ObjectQL.update's own dispatch rule, so it cannot be
// looser than the engine `DbJobAdapter` really writes through.
assertEngineUpdateDispatch(data, options);
const r = (tables.get(table) ?? []).find((x) => x.id === data.id);
if (r) Object.assign(r, data);
return r;
},
};
}

/**
* A cron adapter that RECORDS what it was handed and owns no clock of its own.
* It is the routing probe: with it in place, anything that still fires came
* from a timer `DbJobAdapter` armed somewhere else.
*/
function recordingCron() {
const calls: Array<{ name: string; schedule: JobSchedule }> = [];
const svc: IJobService & { calls: typeof calls } = {
calls,
async schedule(name: string, schedule: JobSchedule) { calls.push({ name, schedule }); },
async cancel() {},
async trigger() {},
async getExecutions() { return []; },
async listJobs() { return []; },
};
return svc;
}

/** One lock shared by every simulated replica — the redis fence's stand-in. */
function sharedLock() {
const held = new Set<string>();
const acquire = vi.fn(async (key: string) => {
if (held.has(key)) return null; // another node is the leader for this fire
held.add(key);
return { release: vi.fn(async () => { held.delete(key); }) };
});
return { lock: { acquire }, acquire, held };
}

const denies = () => ({ acquire: vi.fn(async () => null) });

const built: Array<{ destroy(): Promise<void> }> = [];
function track<T extends { destroy(): Promise<void> }>(a: T): T { built.push(a); return a; }

afterEach(async () => {
while (built.length) await built.pop()!.destroy();
vi.useRealTimers();
});

describe('DbJobAdapter — interval schedules are leader-elected (#13686)', () => {
it('routes an interval registration to the cron (leader-electing) adapter, and arms no timer of its own', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = recordingCron();
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('heartbeat', IV, handler);

// The routing itself — asserted at the seam, not against the wall clock.
expect(cron.calls).toEqual([{ name: 'heartbeat', schedule: IV }]);

// …and NOTHING else armed a timer. The probe owns no clock, so ten ticks
// must produce zero runs; a second, unelected `setInterval` inside `inner`
// would show up here as ten.
await vi.advanceTimersByTimeAsync(TICK * 10);
expect(handler).not.toHaveBeenCalled();

// The registration is still reachable through the adapter's own surface.
expect(await db.listJobs()).toEqual(['heartbeat']);
});

it('one process holds exactly ONE timer for a delegated interval job: a tick runs the handler once', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const acquire = vi.fn(async () => ({ release: vi.fn(async () => {}) }));
const cron = new CronJobAdapter({ cluster: { lock: { acquire } } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);

await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(TICK);
expect(handler).toHaveBeenCalledTimes(2);
// One lock acquire per fire — the fence counter the field report watched
// stand still for 100 seconds.
expect(acquire).toHaveBeenCalledTimes(2);
expect(acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });
});

it('two simulated replicas, ONE tick: exactly one execution and ONE run row', async () => {
// One engine and one lock, two adapter stacks — the shared postgres + redis
// of a 3-replica deployment, minus the process boundary this harness has no
// way to cross.
vi.useFakeTimers();
const engine = makeFakeEngine();
const fence = sharedLock();
// The winner HOLDS its lease until this opens, so the loser's acquire is
// guaranteed to land while the lock is held rather than after it is
// released — which is what makes the count below a fact about the lock and
// not about how many microtasks the timer flush happened to run.
let open!: () => void;
const lease = new Promise<void>((resolve) => { open = resolve; });
const handler = vi.fn(async () => { await lease; });

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// One tick of the wall clock reaches BOTH replicas.
await vi.advanceTimersByTimeAsync(TICK);

expect(handler, 'one tick must execute the job once across the cluster, not once per replica').toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
// waitMs:0 is the "skip", spelled structurally — a waiting acquire would let
// the loser run the same tick a moment later.
expect(fence.acquire).toHaveBeenCalledWith('job:sla_escalation', { ttlMs: 60000, waitMs: 0 });

open();
await vi.advanceTimersByTimeAsync(0);

// The durable record agrees: one tick, ONE run row, run_count +1. Unrouted,
// this shared engine took one row per replica — the shape the field report
// caught as six notification inserts inside 54 ms.
const runs = engine.rows('sys_job_run').filter((r) => r.job_name === 'sla_escalation');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('success');
expect(engine.rows('sys_job')[0].run_count).toBe(1);
});

it('the replica that loses the lock SKIPS: it resolves, it does not throw and it does not retry', async () => {
const engine = makeFakeEngine();
const fence = sharedLock();
const handler = vi.fn(async () => {});

const replica = () => {
const cron = new CronJobAdapter({ cluster: { lock: fence.lock } });
return { cron, db: track(new DbJobAdapter({ engine, cron })) };
};
const a = replica();
const b = replica();
await a.db.schedule('sla_escalation', IV, handler);
await b.db.schedule('sla_escalation', IV, handler);

// Driven at the fire seam so both promises are observable: `b` calls acquire
// while `a` still holds the lease.
const fireA = (a.cron as any).runScheduled('sla_escalation');
const fireB = (b.cron as any).runScheduled('sla_escalation');
await expect(Promise.all([fireA, fireB])).resolves.toHaveLength(2);

expect(handler).toHaveBeenCalledTimes(1);
expect(fence.acquire).toHaveBeenCalledTimes(2);
expect(fence.held.has('job:sla_escalation'), 'the winner must release its lease when the fire ends').toBe(false);
});

it('single-replica, no cluster driver: the interval job still fires (the regression that would be worse than the defect)', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const cron = new CronJobAdapter(); // no `cluster` — nothing to elect against
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 3);
expect(handler).toHaveBeenCalledTimes(3);
});

it('no cron adapter assembled: the interval job still fires on the inner timer, and says it is unelected', async () => {
vi.useFakeTimers();
const engine = makeFakeEngine();
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, logger }));

await db.schedule('nightly_sweep', IV, handler);

await vi.advanceTimersByTimeAsync(TICK * 2);
expect(handler).toHaveBeenCalledTimes(2);
expect(
logger.warn.mock.calls.some((c) => String(c[0]).includes('NO leader election')),
'a cron-less assembly cannot elect anything — that has to be said, not inferred from duplicate rows',
).toBe(true);
});

it('manual trigger() still runs on THIS node while another replica holds the lock', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
// Reaches `inner`, which still holds the registration: a delegated job that
// vanished from `inner` would throw `Job "…" not found` right here.
await db.trigger('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
});

it('replay() and getExecutions() still work for a delegated interval job', async () => {
const engine = makeFakeEngine();
const cron = new CronJobAdapter({ cluster: { lock: denies() } });
const handler = vi.fn(async () => {});
const db = track(new DbJobAdapter({ engine, cron }));

await db.schedule('sla_escalation', IV, handler);
await db.replay('sla_escalation');

expect(handler).toHaveBeenCalledTimes(1);
expect((await db.getExecutions('sla_escalation')).map((e) => e.status)).toEqual(['success']);
expect(engine.rows('sys_job_run').some((r) => r.trigger === 'replay')).toBe(true);
});

it('still upserts the sys_job row for a delegated interval schedule', async () => {
const engine = makeFakeEngine();
const db = track(new DbJobAdapter({ engine, cron: recordingCron() }));

await db.schedule('heartbeat', IV, async () => {});

expect(engine.rows('sys_job')[0]).toMatchObject({
name: 'heartbeat',
schedule_type: 'interval',
schedule_expression: String(TICK),
active: true,
});
});

it('DECLARED CONTROL — cron routing is unchanged: delegated to the cron adapter, still registered for trigger()', async () => {
const engine = makeFakeEngine();
const cron = recordingCron();
const db = track(new DbJobAdapter({ engine, cron }));
const schedule: JobSchedule = { type: 'cron', expression: '0 0 30 2 *' };

await db.schedule('nightly_report', schedule, async () => {});

expect(cron.calls).toEqual([{ name: 'nightly_report', schedule }]);
expect(await db.listJobs()).toEqual(['nightly_report']);
});
});
Loading
Loading