Skip to content

Commit 45f4967

Browse files
xuyushun441-sysos-zhuangclaude
authored
feat(service-job): leader-elect scheduled cron/interval jobs across the cluster (#2219)
CronJobAdapter fired every scheduled tick on EVERY replica, so a cron job ran N times in an N-node cluster (duplicate side effects). Scheduled fires now acquire a per-job cluster lock (fail-fast) before running — only the node that wins runs the handler; peers skip. With no cluster wired or the in-memory driver (single process) the lock always succeeds, so single-node behaviour is unchanged. Manual trigger() bypasses the gate (explicit, node-local). JobServicePlugin injects the cluster service into the adapter. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent f941058 commit 45f4967

3 files changed

Lines changed: 98 additions & 5 deletions

File tree

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { describe, it, expect, vi } from 'vitest';
2+
import { CronJobAdapter } from './cron-job-adapter.js';
3+
4+
const HOUR = 3_600_000;
5+
const grants = () => ({ acquire: vi.fn(async () => ({ release: vi.fn(async () => {}) })) });
6+
const denies = () => ({ acquire: vi.fn(async () => null) });
7+
8+
describe('CronJobAdapter — scheduler leader-election', () => {
9+
it('runs the handler when the cluster lock is acquired (leader)', async () => {
10+
const handler = vi.fn(async () => {});
11+
const adapter = new CronJobAdapter({ cluster: { lock: grants() } });
12+
await adapter.schedule('j', { type: 'interval', intervalMs: HOUR }, handler);
13+
await (adapter as any).runScheduled('j');
14+
expect(handler).toHaveBeenCalledTimes(1);
15+
await adapter.destroy();
16+
});
17+
it('skips the handler when the lock is held by another node', async () => {
18+
const handler = vi.fn(async () => {});
19+
const adapter = new CronJobAdapter({ cluster: { lock: denies() } });
20+
await adapter.schedule('j', { type: 'interval', intervalMs: HOUR }, handler);
21+
await (adapter as any).runScheduled('j');
22+
expect(handler).not.toHaveBeenCalled();
23+
await adapter.destroy();
24+
});
25+
it('runs without a cluster (single-node, unchanged behaviour)', async () => {
26+
const handler = vi.fn(async () => {});
27+
const adapter = new CronJobAdapter();
28+
await adapter.schedule('j', { type: 'interval', intervalMs: HOUR }, handler);
29+
await (adapter as any).runScheduled('j');
30+
expect(handler).toHaveBeenCalledTimes(1);
31+
await adapter.destroy();
32+
});
33+
it('manual trigger() bypasses leader-election and always runs', async () => {
34+
const handler = vi.fn(async () => {});
35+
const adapter = new CronJobAdapter({ cluster: { lock: denies() } });
36+
await adapter.schedule('j', { type: 'interval', intervalMs: HOUR }, handler);
37+
await adapter.trigger('j');
38+
expect(handler).toHaveBeenCalledTimes(1);
39+
await adapter.destroy();
40+
});
41+
it('releases the lock after the scheduled run', async () => {
42+
const release = vi.fn(async () => {});
43+
const acquire = vi.fn(async () => ({ release }));
44+
const adapter = new CronJobAdapter({ cluster: { lock: { acquire } } });
45+
await adapter.schedule('j', { type: 'interval', intervalMs: HOUR }, vi.fn(async () => {}));
46+
await (adapter as any).runScheduled('j');
47+
expect(acquire).toHaveBeenCalledWith('job:j', { ttlMs: 60000, waitMs: 0 });
48+
expect(release).toHaveBeenCalledTimes(1);
49+
await adapter.destroy();
50+
});
51+
});

packages/services/service-job/src/cron-job-adapter.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,13 @@ import type {
88
JobExecution,
99
} from '@objectstack/spec/contracts';
1010

11+
/** Minimal cluster lock surface for scheduler leader-election (structural — no hard dep on the cluster contract). */
12+
interface SchedulerCluster {
13+
lock?: {
14+
acquire(key: string, opts?: { ttlMs?: number; waitMs?: number }): Promise<{ release(): Promise<void> } | null>;
15+
};
16+
}
17+
1118
/**
1219
* Configuration for the cron-based job adapter.
1320
*/
@@ -16,6 +23,12 @@ export interface CronJobAdapterOptions {
1623
timezone?: string;
1724
/** Maximum execution history per job (default: 100) */
1825
maxExecutions?: number;
26+
/** Cluster service for scheduler leader-election. With a remote driver only ONE
27+
* node fires each scheduled job; with the in-memory driver the lock always
28+
* succeeds so single-node behaviour is unchanged. */
29+
cluster?: SchedulerCluster;
30+
/** Lease TTL (ms) held while a scheduled fire runs. Default 60000. */
31+
leaseMs?: number;
1932
}
2033

2134
interface CronJobRecord {
@@ -37,10 +50,14 @@ export class CronJobAdapter implements IJobService {
3750
private readonly defaultTimezone: string;
3851
private readonly maxExecutions: number;
3952
private readonly jobs = new Map<string, CronJobRecord>();
53+
private readonly cluster?: SchedulerCluster;
54+
private readonly leaseMs: number;
4055

4156
constructor(options: CronJobAdapterOptions = {}) {
4257
this.defaultTimezone = options.timezone ?? 'UTC';
4358
this.maxExecutions = options.maxExecutions ?? 100;
59+
this.cluster = options.cluster;
60+
this.leaseMs = options.leaseMs ?? 60_000;
4461
}
4562

4663
async schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void> {
@@ -55,18 +72,18 @@ export class CronJobAdapter implements IJobService {
5572
const task = new Cron(
5673
schedule.expression,
5774
{ timezone: schedule.timezone ?? this.defaultTimezone, name },
58-
async () => { await this.execute(record); },
75+
async () => { await this.runScheduled(name); },
5976
);
6077
record.task = task;
6178
} else if (schedule.type === 'interval' && schedule.intervalMs) {
62-
const handle = setInterval(() => { void this.execute(record); }, schedule.intervalMs);
79+
const handle = setInterval(() => { void this.runScheduled(name); }, schedule.intervalMs);
6380
(handle as any)?.unref?.();
6481
// Use a sentinel Cron-like shape with stop() for cancel()
6582
record.task = { stop: () => clearInterval(handle) } as unknown as Cron;
6683
} else if (schedule.type === 'once' && schedule.at) {
6784
const delay = new Date(schedule.at).getTime() - Date.now();
6885
if (delay > 0) {
69-
const handle = setTimeout(() => { void this.execute(record); }, delay);
86+
const handle = setTimeout(() => { void this.runScheduled(name); }, delay);
7087
(handle as any)?.unref?.();
7188
record.task = { stop: () => clearTimeout(handle) } as unknown as Cron;
7289
}
@@ -107,6 +124,26 @@ export class CronJobAdapter implements IJobService {
107124
this.jobs.clear();
108125
}
109126

127+
/**
128+
* Run a SCHEDULED fire of `name` under cluster leader-election: only the node
129+
* that acquires the per-job lock runs the handler; peers skip. No cluster /
130+
* in-memory driver => lock always granted => single-node unchanged. Manual
131+
* `trigger()` bypasses this.
132+
*/
133+
private async runScheduled(name: string): Promise<void> {
134+
const record = this.jobs.get(name);
135+
if (!record) return;
136+
const lock = this.cluster?.lock;
137+
if (!lock) { await this.execute(record); return; }
138+
const handle = await lock.acquire(`job:${name}`, { ttlMs: this.leaseMs, waitMs: 0 });
139+
if (!handle) return; // another node is the leader for this fire
140+
try {
141+
await this.execute(record);
142+
} finally {
143+
try { await handle.release(); } catch { /* ignore */ }
144+
}
145+
}
146+
110147
private async execute(record: CronJobRecord, data?: unknown): Promise<void> {
111148
const execution: JobExecution = {
112149
jobId: record.name,

packages/services/service-job/src/job-service-plugin.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,11 @@ import {
1616
/**
1717
* Configuration options for the JobServicePlugin.
1818
*/
19+
/** Resolve the cluster service if present; undefined on single-node. */
20+
function getClusterSafe(ctx: any): any {
21+
try { return ctx.getService('cluster'); } catch { return undefined; }
22+
}
23+
1924
export interface JobServicePluginOptions {
2025
/**
2126
* Job adapter type.
@@ -99,7 +104,7 @@ export class JobServicePlugin implements Plugin {
99104
}
100105

101106
if (choice === 'cron') {
102-
const cron = new CronJobAdapter({ timezone: 'UTC' });
107+
const cron = new CronJobAdapter({ timezone: 'UTC', cluster: getClusterSafe(ctx) });
103108
ctx.registerService('job', cron);
104109
ctx.logger.info('JobServicePlugin: registered CronJobAdapter');
105110
return;
@@ -129,7 +134,7 @@ export class JobServicePlugin implements Plugin {
129134
let cron: CronJobAdapter | undefined;
130135
if (this.options.enableCron !== false) {
131136
try {
132-
cron = new CronJobAdapter({ timezone: 'UTC' });
137+
cron = new CronJobAdapter({ timezone: 'UTC', cluster: getClusterSafe(ctx) });
133138
} catch (err) {
134139
ctx.logger.warn('JobServicePlugin: cron adapter init failed; cron jobs will not auto-run', err as any);
135140
}

0 commit comments

Comments
 (0)