Skip to content

Commit 69f0a31

Browse files
huangyiireneclaude
andauthored
fix(service-job): enforce job timeouts in the durable run record (#7734) (#7784)
A job declared with `timeout: 2000` whose handler ran for 10 s persisted `sys_job_run.status: 'success'` with `duration_ms` five times the declared limit, and left `sys_job.last_status: success`, `failure_count: 0`. The `timeout` verdict existed only in the in-memory `JobExecution` history that `sys_job_run` never reads. `DbJobAdapter.schedule()` wrapped the handler in its recorder and handed the WRAPPER to the timer adapter, which applied the `runWithPolicy` timeout guard around it — so the guard raced the recorder, not the handler. When the guard won, the recorder's `await handler(ctx)` was still pending on a handler JavaScript cannot cancel, and its eventual resolution wrote `success` over the run. The same seam is why every `sys_job_run.attempt` read `1`. The party that observes the timeout now records it: `runWithPolicy` takes an optional per-attempt `JobAttemptRecorder` reporting `timedOut` at the instant the guard fires, and `DbJobAdapter` runs the policy itself and records from those callbacks. An abandoned attempt's late value loses the race and reaches no observer, so it has no path to the row; a per-run latch keeps the one-terminal-write-per-row invariant explicit. Timer adapters receive a registration with `retryPolicy`/`timeout` removed, since the wrapper above them now applies both. - `sys_job_run.status` is `timeout`, with the guard message in `error` and `duration_ms` measuring the abandoned attempt - `sys_job.last_status` is `timeout` and `failure_count` increments - `sys_job_run.attempt` carries the real attempt number - `replay()`'s synthetic row mirrors any terminal status, not just `degraded` Claude-Session: https://claude.ai/code/session_01CPtM1rkfEraQfp1TZbgtyE Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9500ba4 commit 69f0a31

4 files changed

Lines changed: 456 additions & 46 deletions

File tree

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
"@objectstack/service-job": patch
3+
---
4+
5+
fix(service-job): a timed-out job run is recorded as `timeout`, not `success` (#7734)
6+
7+
A job declared with `timeout: 2000` whose handler ran for 10 s persisted
8+
`sys_job_run.status: 'success'` with `duration_ms` ≈ 10000 — five times the
9+
declared limit — and left `sys_job.last_status: success`, `failure_count: 0`.
10+
The scheduler did the right thing at runtime (it abandoned the attempt and
11+
retried it); only the record an operator reads was wrong, which is the worse
12+
half: the `timeout` verdict existed solely in the in-memory `JobExecution`
13+
history that `sys_job_run` never reads.
14+
15+
**The race.** `DbJobAdapter.schedule()` wrapped the handler in its recorder and
16+
handed the *wrapper* to the timer adapter, which applied the `runWithPolicy`
17+
timeout guard around it. So the guard raced the recorder rather than the
18+
handler: when the guard won, the recorder's own `await handler(ctx)` was still
19+
pending on a handler JavaScript cannot cancel, and whenever that finally
20+
resolved it wrote `success` over the run. The same seam is why every
21+
`sys_job_run.attempt` read `1` — the recorder ran once per attempt but had no
22+
way to know which attempt it was.
23+
24+
**The fix.** The party that observes the timeout is now the party that records
25+
it. `runWithPolicy` takes an optional per-attempt `JobAttemptRecorder`
26+
(`onAttemptStart` / `onAttemptSettled`, reporting `timedOut` at the instant the
27+
guard fires), and `DbJobAdapter` runs the policy itself and records from those
28+
callbacks. An abandoned attempt's late value loses the race and reaches no
29+
observer, so it has no path to the row at all; a per-run latch keeps that
30+
one-terminal-write-per-row invariant explicit. The timer adapters receive a
31+
registration with `retryPolicy`/`timeout` removed, since the wrapper above them
32+
now applies both.
33+
34+
- `sys_job_run.status` is `timeout` for a run that blew its limit, with the
35+
guard's message in `error` and `duration_ms` measuring the abandoned attempt.
36+
- `sys_job.last_status` is `timeout` and `failure_count` increments: a run that
37+
never finished is a failure, and alerting keys on that count.
38+
- `sys_job_run.attempt` carries the real attempt number, so a retry lands `2`.
39+
- `replay()`'s synthetic row now mirrors any terminal status of the run it
40+
replayed (it already did this for `degraded`), instead of pairing an honest
41+
`timeout` row with a `success` one.
42+
43+
Additive: a handler that finishes inside its timeout, or that carries no
44+
`timeout`/`retryPolicy` at all, records exactly what it recorded before.
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
4+
import { assertEngineUpdateDispatch } from '@objectstack/metadata-core';
5+
import { DbJobAdapter } from './db-job-adapter.js';
6+
import { CronJobAdapter } from './cron-job-adapter.js';
7+
8+
/**
9+
* #7734 — a job that blows its `timeout` must say so in the DURABLE record.
10+
*
11+
* Every assertion here reads a `sys_job_run` / `sys_job` cell, never the
12+
* in-memory `JobExecution` history. That is the whole point of the card: the
13+
* in-memory "records status 'timeout'" assertion in `interval-job-adapter.test.ts`
14+
* stayed green throughout the defect, because the timeout was computed in a
15+
* place `sys_job_run` never reads. An operator reading the run log saw
16+
* `status: 'success'` with a `duration_ms` five times the declared `timeout`.
17+
*/
18+
19+
function makeFakeEngine() {
20+
const tables = new Map<string, any[]>();
21+
return {
22+
tables,
23+
async find(table: string, opts: any = {}) {
24+
const t = tables.get(table) ?? [];
25+
let out = opts.where
26+
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => r[k] === v))
27+
: [...t];
28+
if (opts.limit) out = out.slice(0, opts.limit);
29+
return out;
30+
},
31+
async insert(table: string, data: any) {
32+
const t = tables.get(table) ?? [];
33+
t.push({ ...data });
34+
tables.set(table, t);
35+
return { id: data.id };
36+
},
37+
async update(table: string, patch: any, options?: any) {
38+
assertEngineUpdateDispatch(patch, options);
39+
const t = tables.get(table) ?? [];
40+
const r = t.find((x) => x.id === patch.id);
41+
if (!r) throw new Error(`row ${patch.id} not in ${table}`);
42+
Object.assign(r, patch);
43+
return r;
44+
},
45+
};
46+
}
47+
48+
const CRON = { type: 'cron', expression: '* * * * *' } as const;
49+
const TIMEOUT_MS = 20;
50+
const HANDLER_MS = 300;
51+
52+
/** A handler that outlives its timeout, then resolves — the reported symptom. */
53+
function slowHandler() {
54+
const state = { calls: 0, resolved: 0 };
55+
const handler = async () => {
56+
state.calls++;
57+
await new Promise<void>((resolve) => {
58+
const t = setTimeout(() => { state.resolved++; resolve(); }, HANDLER_MS);
59+
(t as any)?.unref?.();
60+
});
61+
};
62+
return { state, handler };
63+
}
64+
65+
function sleep(ms: number): Promise<void> {
66+
return new Promise((resolve) => {
67+
const t = setTimeout(resolve, ms);
68+
(t as any)?.unref?.();
69+
});
70+
}
71+
72+
describe('DbJobAdapter — a timed-out run is recorded as one (#7734)', () => {
73+
let engine: ReturnType<typeof makeFakeEngine>;
74+
let adapter: DbJobAdapter;
75+
76+
beforeEach(() => {
77+
engine = makeFakeEngine();
78+
adapter = new DbJobAdapter({ engine });
79+
});
80+
afterEach(async () => { await adapter.destroy(); });
81+
82+
const runRows = () => engine.tables.get('sys_job_run') ?? [];
83+
const jobRow = () => (engine.tables.get('sys_job') ?? [])[0];
84+
85+
it('persists sys_job_run.status = "timeout", not "success"', async () => {
86+
const { handler } = slowHandler();
87+
await adapter.schedule('slow', CRON, handler, { timeout: TIMEOUT_MS });
88+
await adapter.trigger('slow');
89+
90+
expect(runRows()).toHaveLength(1);
91+
// The cell an operator reads. Before #7734 this said 'success'.
92+
expect(runRows()[0].status).toBe('timeout');
93+
expect(runRows()[0].error).toMatch(/timed out after 20ms/);
94+
expect(runRows()[0].completed_at).toBeTruthy();
95+
});
96+
97+
it('counts the timeout as a failure on sys_job', async () => {
98+
const { handler } = slowHandler();
99+
await adapter.schedule('slow', CRON, handler, { timeout: TIMEOUT_MS });
100+
await adapter.trigger('slow');
101+
102+
expect(jobRow().last_status).toBe('timeout');
103+
expect(jobRow().last_error).toMatch(/timed out after 20ms/);
104+
// A run abandoned mid-flight is a failure — alerting keys on this count.
105+
expect(jobRow().failure_count).toBe(1);
106+
expect(jobRow().run_count).toBe(1);
107+
});
108+
109+
it('records the ABANDONED duration, not how long the handler kept running', async () => {
110+
const { handler } = slowHandler();
111+
await adapter.schedule('slow', CRON, handler, { timeout: TIMEOUT_MS });
112+
await adapter.trigger('slow');
113+
114+
// The symptom row carried duration_ms ≈ the handler's full runtime, which
115+
// is only possible if the recorder waited for the abandoned handler.
116+
expect(runRows()[0].duration_ms).toBeLessThan(HANDLER_MS);
117+
});
118+
119+
// ── the overwrite race, head-on ──────────────────────────────────────────
120+
121+
it('a handler that resolves AFTER the guard fired cannot overwrite the timeout row', async () => {
122+
const { state, handler } = slowHandler();
123+
await adapter.schedule('late', CRON, handler, { timeout: TIMEOUT_MS });
124+
await adapter.trigger('late');
125+
126+
expect(runRows()[0].status).toBe('timeout');
127+
expect(state.resolved).toBe(0); // the handler is still running right now
128+
129+
// Let the abandoned handler run to completion — this is the window in
130+
// which the old wrapper wrote `finishRun(runId, 'success')` over the row.
131+
await sleep(HANDLER_MS * 2);
132+
expect(state.resolved).toBe(1);
133+
134+
expect(runRows()).toHaveLength(1);
135+
expect(runRows()[0].status).toBe('timeout');
136+
expect(jobRow().last_status).toBe('timeout');
137+
expect(jobRow().run_count).toBe(1);
138+
expect(jobRow().failure_count).toBe(1);
139+
});
140+
141+
// ── attempt numbering ────────────────────────────────────────────────────
142+
143+
it('a retried timeout persists attempt 2 on its second row', async () => {
144+
const { state, handler } = slowHandler();
145+
await adapter.schedule('retried', CRON, handler, {
146+
timeout: TIMEOUT_MS,
147+
retryPolicy: { maxRetries: 1, backoffMs: 1 },
148+
});
149+
await adapter.trigger('retried');
150+
151+
expect(state.calls).toBe(2); // initial + one retry
152+
expect(runRows()).toHaveLength(2);
153+
// Every row used to read `attempt: 1` — the number was hardcoded.
154+
expect(runRows().map((r) => r.attempt)).toEqual([1, 2]);
155+
expect(runRows().map((r) => r.status)).toEqual(['timeout', 'timeout']);
156+
expect(jobRow().failure_count).toBe(2);
157+
});
158+
159+
it('a retried FAILURE numbers its attempts too', async () => {
160+
let calls = 0;
161+
await adapter.schedule('flaky', CRON, async () => {
162+
calls++;
163+
if (calls < 3) throw new Error('boom');
164+
}, { retryPolicy: { maxRetries: 3, backoffMs: 1 } });
165+
await adapter.trigger('flaky');
166+
167+
expect(runRows().map((r) => r.attempt)).toEqual([1, 2, 3]);
168+
expect(runRows().map((r) => r.status)).toEqual(['failed', 'failed', 'success']);
169+
});
170+
171+
// ── additivity ───────────────────────────────────────────────────────────
172+
173+
it('a handler that finishes inside its timeout is unchanged: success, attempt 1', async () => {
174+
await adapter.schedule('quick', CRON, async () => {}, { timeout: 60_000 });
175+
await adapter.trigger('quick');
176+
177+
expect(runRows()[0].status).toBe('success');
178+
expect(runRows()[0].attempt).toBe(1);
179+
expect(runRows()[0].error).toBeNull();
180+
expect(jobRow().last_status).toBe('success');
181+
expect(jobRow().failure_count).toBe(0);
182+
});
183+
184+
it('the in-memory execution and the persisted row report the SAME verdict', async () => {
185+
const { handler } = slowHandler();
186+
await adapter.schedule('agree', CRON, handler, { timeout: TIMEOUT_MS });
187+
await adapter.trigger('agree');
188+
189+
const [exec] = await adapter.getExecutions('agree');
190+
expect(exec.status).toBe('timeout');
191+
expect(runRows()[0].status).toBe('timeout');
192+
expect(await adapter.listExecutionsByStatus('timeout')).toHaveLength(1);
193+
expect(await adapter.listExecutionsByStatus('success')).toEqual([]);
194+
});
195+
196+
it('replay of a timing-out job writes NO success row', async () => {
197+
const { handler } = slowHandler();
198+
await adapter.schedule('rp', CRON, handler, { timeout: TIMEOUT_MS });
199+
await adapter.replay('rp');
200+
201+
// One synthetic `replay` row + one wrapped row, and they must agree.
202+
expect(runRows().map((r) => r.trigger).sort()).toEqual(['replay', 'schedule']);
203+
expect(runRows().map((r) => r.status)).toEqual(['timeout', 'timeout']);
204+
});
205+
});
206+
207+
describe('the timeout policy still applies through an injected cron adapter (#7734)', () => {
208+
it('a cron-scheduled run lands a timeout row even though the adapter no longer sees the policy', async () => {
209+
// DbJobAdapter now runs `retryPolicy`/`timeout` itself and hands the timer
210+
// adapter a policy-free registration. If that stripping ever outran the
211+
// wrapper that replaces it, this run would record `success`.
212+
const engine = makeFakeEngine();
213+
const cron = new CronJobAdapter();
214+
const adapter = new DbJobAdapter({ engine, cron });
215+
const { handler } = slowHandler();
216+
217+
await adapter.schedule('cronic', CRON, handler, { timeout: TIMEOUT_MS });
218+
await cron.trigger('cronic'); // fire the copy the cron adapter holds
219+
220+
const runs = engine.tables.get('sys_job_run') ?? [];
221+
expect(runs).toHaveLength(1);
222+
expect(runs[0].status).toBe('timeout');
223+
expect((engine.tables.get('sys_job') ?? [])[0].failure_count).toBe(1);
224+
expect((await cron.getExecutions('cronic'))[0].status).toBe('timeout');
225+
226+
await adapter.destroy();
227+
await cron.destroy();
228+
});
229+
});

0 commit comments

Comments
 (0)