Skip to content

Commit a0fab65

Browse files
os-zhuangclaude
andcommitted
feat(job): honor authored retryPolicy/timeout in the job scheduler (#3494)
JobSchema.retryPolicy and JobSchema.timeout were parsed-but-ignored (the 2026-06 liveness audit's aspirational-config cluster). Built rather than pruned: - spec: IJobService.schedule gains an optional 4th options argument (JobScheduleOptions with retryPolicy/timeout + JobRetryPolicy type); backward compatible. JobSchema describe strings updated (EXPERIMENTAL markers removed). - service-job: new runWithPolicy helper (+ JobTimeoutError) wraps handler invocation in CronJobAdapter and IntervalJobAdapter; DbJobAdapter threads options through. Failed attempts (incl. timeouts) retry with exponential backoff up to maxRetries; over-limit attempts record execution status 'timeout'. No options = legacy single-attempt behavior (guarded by test). - runtime: AppPlugin declarative-jobs registration forwards the authored retryPolicy/timeout. Tests were written first and verified red on the old adapters (handler called once, no timeout status), then green on the implementation. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 083c414 commit a0fab65

13 files changed

Lines changed: 274 additions & 18 deletions

File tree

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/service-job": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
feat(job): honor the authored `retryPolicy` / `timeout` in the job scheduler (#3494)
8+
9+
`JobSchema.retryPolicy` and `JobSchema.timeout` used to be parsed-but-ignored
10+
(the 2026-06 liveness audit's aspirational-config cluster). They are now
11+
enforced end to end — built rather than pruned, since retry/backoff and
12+
per-run time limits are semantics job authors reasonably expect:
13+
14+
- **spec**: `IJobService.schedule` gains an optional 4th `options` argument
15+
(`JobScheduleOptions` with `retryPolicy` / `timeout`, mirroring the
16+
authorable schema); new `JobRetryPolicy` type. Backward compatible —
17+
existing 3-arg implementations and callers are unaffected.
18+
- **service-job**: new `runWithPolicy` helper (exported, with
19+
`JobTimeoutError`) wraps every handler invocation in `CronJobAdapter` and
20+
`IntervalJobAdapter`; `DbJobAdapter` threads options through to its inner
21+
adapters. Failed attempts (including timeouts) retry with exponential
22+
backoff `backoffMs * backoffMultiplier^(retry-1)` up to `maxRetries`;
23+
an attempt exceeding `timeout` is recorded with execution status
24+
`'timeout'`. No `options` → exactly the legacy single-attempt behavior.
25+
- **runtime**: declarative-jobs registration in AppPlugin forwards the
26+
authored `retryPolicy` / `timeout` to the scheduler.
27+
28+
Note: JavaScript cannot forcibly cancel an in-flight handler — a timed-out
29+
attempt is abandoned, not killed. The retry delay caps only via the
30+
multiplier arithmetic (no maxDelay knob yet).
31+
32+
Refs #3494, #1878, #1893.

content/docs/references/system/job.mdx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ const result = CronSchedule.parse(data);
6262
| **description** | `string` | optional | Job description / purpose |
6363
| **schedule** | `{ type: 'cron'; expression: string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source?: string; ast?: any; meta?: object }; timezone?: string } \| { type: 'interval'; intervalMs: integer } \| { type: 'once'; at: string }` || Job schedule configuration |
6464
| **handler** | `string` || Handler function name (must match a key in `defineStack({ functions })`) |
65-
| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number }` | optional | Retry policy configuration. [EXPERIMENTAL — not enforced] The service-job scheduler does not yet read retryPolicy; failed runs are not retried per this config (liveness audit #1878/#1893). |
66-
| **timeout** | `integer` | optional | Timeout in milliseconds. [EXPERIMENTAL — not enforced] The service-job scheduler does not yet enforce a per-run timeout (liveness audit #1878/#1893). |
65+
| **retryPolicy** | `{ maxRetries?: integer; backoffMs?: integer; backoffMultiplier?: number }` | optional | Retry policy: failed runs (including timeouts) are retried with exponential backoff (delay = backoffMs * backoffMultiplier^(retry-1)) up to maxRetries retries after the initial attempt (#3494). Omit for the legacy single-attempt behavior. |
66+
| **timeout** | `integer` | optional | Per-attempt time limit in milliseconds; an over-limit run is recorded with execution status "timeout" (#3494). The in-flight handler is abandoned, not forcibly cancelled. Omit for no time limit. |
6767
| **enabled** | `boolean` | optional | Whether the job is enabled |
6868

6969

packages/runtime/src/app-plugin.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -674,6 +674,10 @@ export class AppPlugin implements Plugin {
674674
async (jobCtx: any) => {
675675
await handler({ ...jobCtx, jobId: jobName, bundle: this.bundle });
676676
},
677+
// #3494: thread the authored retryPolicy/timeout to the adapter
678+
(job.retryPolicy || job.timeout)
679+
? { retryPolicy: job.retryPolicy, timeout: job.timeout }
680+
: undefined,
677681
);
678682
ok++;
679683
} catch (err: any) {

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

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,70 @@ describe('CronJobAdapter', () => {
6565
expect(execs[0].error).toBe('boom');
6666
});
6767
});
68+
69+
describe('CronJobAdapter retryPolicy / timeout (#3494)', () => {
70+
let adapter: CronJobAdapter;
71+
afterEach(async () => { await adapter?.destroy(); });
72+
73+
it('retries a failing handler per retryPolicy and succeeds', async () => {
74+
adapter = new CronJobAdapter();
75+
let calls = 0;
76+
await adapter.schedule(
77+
'flaky',
78+
{ type: 'cron', expression: '* * * * *' },
79+
async () => {
80+
calls++;
81+
if (calls < 3) throw new Error(`attempt ${calls} boom`);
82+
},
83+
{ retryPolicy: { maxRetries: 3, backoffMs: 1, backoffMultiplier: 1 } },
84+
);
85+
await adapter.trigger('flaky');
86+
expect(calls).toBe(3);
87+
const execs = await adapter.getExecutions('flaky');
88+
expect(execs).toHaveLength(1);
89+
expect(execs[0].status).toBe('success');
90+
});
91+
92+
it('exhausts retries and records the last failure', async () => {
93+
adapter = new CronJobAdapter();
94+
let calls = 0;
95+
await adapter.schedule(
96+
'doomed',
97+
{ type: 'cron', expression: '* * * * *' },
98+
async () => { calls++; throw new Error('always boom'); },
99+
{ retryPolicy: { maxRetries: 2, backoffMs: 1 } },
100+
);
101+
await adapter.trigger('doomed');
102+
expect(calls).toBe(3); // initial + 2 retries
103+
const execs = await adapter.getExecutions('doomed');
104+
expect(execs[0].status).toBe('failed');
105+
expect(execs[0].error).toBe('always boom');
106+
});
107+
108+
it('does not retry when no retryPolicy is given (legacy behavior)', async () => {
109+
adapter = new CronJobAdapter();
110+
let calls = 0;
111+
await adapter.schedule('legacy', { type: 'cron', expression: '* * * * *' }, async () => {
112+
calls++;
113+
throw new Error('boom');
114+
});
115+
await adapter.trigger('legacy');
116+
expect(calls).toBe(1);
117+
const execs = await adapter.getExecutions('legacy');
118+
expect(execs[0].status).toBe('failed');
119+
});
120+
121+
it('enforces a per-attempt timeout and records status "timeout"', async () => {
122+
adapter = new CronJobAdapter();
123+
await adapter.schedule(
124+
'slow',
125+
{ type: 'cron', expression: '* * * * *' },
126+
async () => { await new Promise((r) => setTimeout(r, 150)); },
127+
{ timeout: 25 },
128+
);
129+
await adapter.trigger('slow');
130+
const execs = await adapter.getExecutions('slow');
131+
expect(execs[0].status).toBe('timeout');
132+
expect(execs[0].error).toMatch(/timed out after 25ms/);
133+
});
134+
});

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,9 @@ import type {
66
JobSchedule,
77
JobHandler,
88
JobExecution,
9+
JobScheduleOptions,
910
} from '@objectstack/spec/contracts';
11+
import { runWithPolicy, JobTimeoutError } from './run-with-policy';
1012

1113
/** Minimal cluster lock surface for scheduler leader-election (structural — no hard dep on the cluster contract). */
1214
interface SchedulerCluster {
@@ -35,6 +37,7 @@ interface CronJobRecord {
3537
name: string;
3638
schedule: JobSchedule;
3739
handler: JobHandler;
40+
options?: JobScheduleOptions;
3841
task?: Cron;
3942
executions: JobExecution[];
4043
}
@@ -60,10 +63,10 @@ export class CronJobAdapter implements IJobService {
6063
this.leaseMs = options.leaseMs ?? 60_000;
6164
}
6265

63-
async schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void> {
66+
async schedule(name: string, schedule: JobSchedule, handler: JobHandler, options?: JobScheduleOptions): Promise<void> {
6467
await this.cancel(name);
6568

66-
const record: CronJobRecord = { name, schedule, handler, executions: [] };
69+
const record: CronJobRecord = { name, schedule, handler, options, executions: [] };
6770

6871
if (schedule.type === 'cron') {
6972
if (!schedule.expression) {
@@ -152,10 +155,10 @@ export class CronJobAdapter implements IJobService {
152155
};
153156
const startMs = Date.now();
154157
try {
155-
await record.handler({ jobId: record.name, data });
158+
await runWithPolicy(record.name, () => record.handler({ jobId: record.name, data }), record.options);
156159
execution.status = 'success';
157160
} catch (err) {
158-
execution.status = 'failed';
161+
execution.status = err instanceof JobTimeoutError ? 'timeout' : 'failed';
159162
execution.error = err instanceof Error ? err.message : String(err);
160163
} finally {
161164
execution.completedAt = new Date().toISOString();

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

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type {
55
JobSchedule,
66
JobHandler,
77
JobExecution,
8+
JobScheduleOptions,
89
} from '@objectstack/spec/contracts';
910
import { IntervalJobAdapter } from './interval-job-adapter.js';
1011

@@ -76,18 +77,18 @@ export class DbJobAdapter implements IJobService {
7677

7778
// ── IJobService ──────────────────────────────────────────────────
7879

79-
async schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void> {
80+
async schedule(name: string, schedule: JobSchedule, handler: JobHandler, options?: JobScheduleOptions): Promise<void> {
8081
const wrapped = this.wrap(name, handler, 'schedule');
8182

8283
if (schedule.type === 'cron') {
83-
if (this.cron) await this.cron.schedule(name, schedule, wrapped);
84+
if (this.cron) await this.cron.schedule(name, schedule, wrapped, options);
8485
else this.logger?.warn?.(
8586
`DbJobAdapter: cron schedule registered for "${name}" without CronJobAdapter — job will only run via manual trigger`,
8687
);
8788
// Still record in inner so trigger() works
88-
await this.inner.schedule(name, schedule, wrapped);
89+
await this.inner.schedule(name, schedule, wrapped, options);
8990
} else {
90-
await this.inner.schedule(name, schedule, wrapped);
91+
await this.inner.schedule(name, schedule, wrapped, options);
9192
}
9293

9394
await this.upsertJobRow(name, schedule, true);

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,4 @@ export { DbJobAdapter } from './db-job-adapter.js';
1010
export type { DbJobAdapterOptions, JobEngineLike, JobLoggerLike } from './db-job-adapter.js';
1111
// JobRunRetention was retired (ADR-0057): sys_job_run declares a `lifecycle`
1212
// window and the platform LifecycleService is the one sweeper.
13+
export { runWithPolicy, JobTimeoutError } from './run-with-policy.js';

packages/services/service-job/src/interval-job-adapter.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,41 @@ describe('IntervalJobAdapter', () => {
118118
expect(await adapter.listJobs()).toEqual([]);
119119
});
120120
});
121+
122+
describe('IntervalJobAdapter retryPolicy / timeout (#3494)', () => {
123+
let adapter: IntervalJobAdapter;
124+
afterEach(async () => { await adapter?.destroy(); });
125+
126+
it('retries a failing handler per retryPolicy and succeeds', async () => {
127+
adapter = new IntervalJobAdapter();
128+
let calls = 0;
129+
await adapter.schedule(
130+
'flaky',
131+
{ type: 'interval', intervalMs: 100000 },
132+
async () => {
133+
calls++;
134+
if (calls < 2) throw new Error('boom');
135+
},
136+
{ retryPolicy: { maxRetries: 2, backoffMs: 1 } },
137+
);
138+
await adapter.trigger('flaky');
139+
expect(calls).toBe(2);
140+
const execs = await adapter.getExecutions('flaky');
141+
expect(execs).toHaveLength(1);
142+
expect(execs[0].status).toBe('success');
143+
});
144+
145+
it('enforces a per-attempt timeout and records status "timeout"', async () => {
146+
adapter = new IntervalJobAdapter();
147+
await adapter.schedule(
148+
'slow',
149+
{ type: 'interval', intervalMs: 100000 },
150+
async () => { await new Promise((r) => setTimeout(r, 150)); },
151+
{ timeout: 25 },
152+
);
153+
await adapter.trigger('slow');
154+
const execs = await adapter.getExecutions('slow');
155+
expect(execs[0].status).toBe('timeout');
156+
expect(execs[0].error).toMatch(/timed out after 25ms/);
157+
});
158+
});

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

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
22

3-
import type { IJobService, JobSchedule, JobHandler, JobExecution } from '@objectstack/spec/contracts';
3+
import type { IJobService, JobSchedule, JobHandler, JobExecution, JobScheduleOptions } from '@objectstack/spec/contracts';
4+
import { runWithPolicy, JobTimeoutError } from './run-with-policy';
45

56
/**
67
* Internal record for a scheduled job.
@@ -9,6 +10,7 @@ interface JobRecord {
910
name: string;
1011
schedule: JobSchedule;
1112
handler: JobHandler;
13+
options?: JobScheduleOptions;
1214
timerId?: ReturnType<typeof setInterval> | ReturnType<typeof setTimeout>;
1315
executions: JobExecution[];
1416
}
@@ -38,11 +40,11 @@ export class IntervalJobAdapter implements IJobService {
3840
this.maxExecutions = options.maxExecutions ?? 100;
3941
}
4042

41-
async schedule(name: string, schedule: JobSchedule, handler: JobHandler): Promise<void> {
43+
async schedule(name: string, schedule: JobSchedule, handler: JobHandler, options?: JobScheduleOptions): Promise<void> {
4244
// Cancel any existing job with the same name
4345
await this.cancel(name);
4446

45-
const record: JobRecord = { name, schedule, handler, executions: [] };
47+
const record: JobRecord = { name, schedule, handler, options, executions: [] };
4648

4749
if (schedule.type === 'interval' && schedule.intervalMs) {
4850
record.timerId = setInterval(async () => {
@@ -111,10 +113,10 @@ export class IntervalJobAdapter implements IJobService {
111113

112114
const startMs = Date.now();
113115
try {
114-
await record.handler({ jobId: record.name, data });
116+
await runWithPolicy(record.name, () => record.handler({ jobId: record.name, data }), record.options);
115117
execution.status = 'success';
116118
} catch (err) {
117-
execution.status = 'failed';
119+
execution.status = err instanceof JobTimeoutError ? 'timeout' : 'failed';
118120
execution.error = err instanceof Error ? err.message : String(err);
119121
} finally {
120122
execution.completedAt = new Date().toISOString();
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import type { JobScheduleOptions } from '@objectstack/spec/contracts';
4+
5+
/**
6+
* Error thrown when a job attempt exceeds its configured `timeout`.
7+
* Executors map it to JobExecution status 'timeout' (vs plain 'failed').
8+
*/
9+
export class JobTimeoutError extends Error {
10+
constructor(jobId: string, timeoutMs: number) {
11+
super(`Job "${jobId}" timed out after ${timeoutMs}ms`);
12+
this.name = 'JobTimeoutError';
13+
}
14+
}
15+
16+
const RETRY_DEFAULTS = { maxRetries: 3, backoffMs: 1000, backoffMultiplier: 2 } as const;
17+
18+
function sleep(ms: number): Promise<void> {
19+
return new Promise((resolve) => {
20+
const t = setTimeout(resolve, ms);
21+
(t as any)?.unref?.();
22+
});
23+
}
24+
25+
function withTimeout(run: () => Promise<void>, jobId: string, timeoutMs?: number): Promise<void> {
26+
if (!timeoutMs || timeoutMs <= 0) return run();
27+
let timer: ReturnType<typeof setTimeout> | undefined;
28+
const guard = new Promise<never>((_, reject) => {
29+
timer = setTimeout(() => reject(new JobTimeoutError(jobId, timeoutMs)), timeoutMs);
30+
(timer as any)?.unref?.();
31+
});
32+
return Promise.race([run(), guard]).finally(() => clearTimeout(timer)) as Promise<void>;
33+
}
34+
35+
/**
36+
* Execute one job run under the authored `retryPolicy` / `timeout`
37+
* (#3494 — JobSchema's retryPolicy/timeout used to be parsed-but-ignored).
38+
*
39+
* - No options → exactly the legacy behavior: one attempt, no time limit.
40+
* - `timeout` applies per attempt; an over-limit attempt rejects with
41+
* {@link JobTimeoutError}. JavaScript cannot forcibly cancel the in-flight
42+
* handler — the attempt is abandoned, not killed.
43+
* - `retryPolicy` re-runs failed attempts (including timeouts) with
44+
* exponential backoff: delay = backoffMs * backoffMultiplier^(retry-1),
45+
* up to maxRetries retries after the initial attempt. The last error is
46+
* rethrown when all attempts fail.
47+
*/
48+
export async function runWithPolicy(
49+
jobId: string,
50+
run: () => Promise<void>,
51+
options?: JobScheduleOptions,
52+
): Promise<void> {
53+
const timeoutMs = options?.timeout;
54+
if (!options?.retryPolicy) {
55+
return withTimeout(run, jobId, timeoutMs);
56+
}
57+
58+
const maxRetries = options.retryPolicy.maxRetries ?? RETRY_DEFAULTS.maxRetries;
59+
const backoffMs = options.retryPolicy.backoffMs ?? RETRY_DEFAULTS.backoffMs;
60+
const multiplier = options.retryPolicy.backoffMultiplier ?? RETRY_DEFAULTS.backoffMultiplier;
61+
62+
let lastError: unknown;
63+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
64+
if (attempt > 0) {
65+
await sleep(backoffMs * Math.pow(multiplier, attempt - 1));
66+
}
67+
try {
68+
await withTimeout(run, jobId, timeoutMs);
69+
return;
70+
} catch (err) {
71+
lastError = err;
72+
}
73+
}
74+
throw lastError;
75+
}

0 commit comments

Comments
 (0)