Skip to content

Commit 439349e

Browse files
committed
fix(service-job,service-automation): map a degraded job outcome to sys_job_run.status instead of success (#5548)
1 parent 2ef1807 commit 439349e

9 files changed

Lines changed: 598 additions & 11 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
'@objectstack/service-job': patch
3+
'@objectstack/service-automation': patch
4+
---
5+
6+
Job runs that finish without doing their work are now audited as `degraded`, not `success` (#5548)
7+
8+
`DbJobAdapter` decided a run's outcome solely by whether the handler threw, so a
9+
handler that failed internally and deliberately did not throw was recorded as
10+
`sys_job_run.status: 'success'` — the audit surface Studio's jobs view reads
11+
reported the one thing that had definitely not happened.
12+
13+
The adapters now consume the `JobRunOutcome` channel `JobHandler` gained in
14+
#6617, using the `degraded` status vocabulary added in #7072:
15+
16+
- a handler resolving `{ outcome: 'degraded', reason? }` lands
17+
`sys_job_run.status: 'degraded'` with the reason in `error`, and mirrors onto
18+
`sys_job.last_status` / `last_error`;
19+
- `degraded` is not a failure: `failure_count` stays flat and nothing retries
20+
(retry keys on a rejected promise only, unchanged);
21+
- `IntervalJobAdapter` / `CronJobAdapter` report the same verdict through
22+
`getExecutions()`, so the in-memory history and the persisted row agree.
23+
24+
Strictly additive: a handler that resolves `undefined` — every handler written
25+
before #6617 — is still recorded as `success`, byte for byte as before.
26+
27+
The first adopter is the `wait` node's timer wake-up: a shot that fires into an
28+
unreachable suspended-run store now reports `degraded` / `STORE_UNAVAILABLE`
29+
while still keeping its one-shot armed and its `sys_job` row active (#5529).

packages/services/service-automation/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
"@objectstack/driver-sql": "workspace:*",
2727
"@objectstack/objectql": "workspace:*",
2828
"@objectstack/plugin-security": "workspace:*",
29+
"@objectstack/service-job": "workspace:*",
2930
"@types/node": "^26.1.2",
3031
"typescript": "^6.0.3",
3132
"vitest": "^4.1.10"
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
import { describe, it, expect } from 'vitest';
4+
import { DbJobAdapter } from '@objectstack/service-job';
5+
import type { IJobService, JobSchedule, JobHandler } from '@objectstack/spec/contracts';
6+
import { AutomationEngine } from '../engine.js';
7+
import type { NodeExecutor } from '../engine.js';
8+
import { InMemorySuspendedRunStore } from '../suspended-run-store.js';
9+
import { registerWaitNode, rearmSuspendedWaitTimers } from './wait-node.js';
10+
11+
/**
12+
* #5548, end to end: the scenario that produced the finding, driven through the
13+
* REAL job adapter rather than a fake that records calls.
14+
*
15+
* The specimen is #5529's wait wake-up firing into an unreachable durable store.
16+
* That shot consumes nothing — the run stays parked, and the one-shot is kept
17+
* ARMED on purpose so it can be re-fired — and it deliberately does **not**
18+
* throw, because a throw is the retry signal `IJobService` implementations key
19+
* on (which is why option A was rejected). The consequence, until now, was that
20+
* the job's audit row said `success`: the operator-facing surface reported the
21+
* one thing that definitely did not happen.
22+
*
23+
* Why the real `DbJobAdapter` and not a spy: the defect lives in the mapping
24+
* from "what the handler reported" to "what got written", so a case that
25+
* asserts the handler was called, or that it did not throw, cannot see it —
26+
* that criterion IS the defect. Every assertion below reads the value in the
27+
* persisted `sys_job_run` / `sys_job` cell.
28+
*/
29+
30+
function silentLogger() {
31+
return { info() {}, warn() {}, error() {}, debug() {}, child() { return silentLogger(); } } as any;
32+
}
33+
34+
/** A fake job service for "process 1", which only has to park the run. */
35+
function fakeJobCtx() {
36+
const scheduled: Array<{ name: string; schedule: JobSchedule; handler: JobHandler }> = [];
37+
const cancelled: string[] = [];
38+
const job: IJobService = {
39+
async schedule(name, schedule, handler) { scheduled.push({ name, schedule, handler }); },
40+
async cancel(name) { cancelled.push(name); },
41+
async trigger() {},
42+
};
43+
const ctx = { logger: silentLogger(), getService: (id: string) => (id === 'job' ? job : undefined) } as any;
44+
return { ctx, scheduled, cancelled };
45+
}
46+
47+
function markerExecutor(ran: string[]): NodeExecutor {
48+
return { type: 'mark', async execute(node) { ran.push(node.id); return { success: true }; } };
49+
}
50+
51+
/** Minimal ObjectQL stand-in for the two audit tables `DbJobAdapter` writes. */
52+
function makeFakeEngine() {
53+
const tables = new Map<string, any[]>();
54+
return {
55+
tables,
56+
async find(table: string, opts: any = {}) {
57+
const t = tables.get(table) ?? [];
58+
const out = opts.where
59+
? t.filter((r) => Object.entries(opts.where).every(([k, v]) => r[k] === v))
60+
: [...t];
61+
return opts.limit ? out.slice(0, opts.limit) : out;
62+
},
63+
async insert(table: string, data: any) {
64+
const t = tables.get(table) ?? [];
65+
t.push({ ...data });
66+
tables.set(table, t);
67+
return { id: data.id };
68+
},
69+
async update(table: string, patch: any) {
70+
const t = tables.get(table) ?? [];
71+
const r = t.find((x) => x.id === patch.id);
72+
if (!r) throw new Error(`row ${patch.id} not in ${table}`);
73+
Object.assign(r, patch);
74+
return r;
75+
},
76+
};
77+
}
78+
79+
const waitFlow = (waitConfig: Record<string, unknown>) => ({
80+
name: 'wait_flow',
81+
label: 'Wait Flow',
82+
type: 'autolaunched',
83+
nodes: [
84+
{ id: 'start', type: 'start', label: 'Start' },
85+
{ id: 'pause', type: 'wait', label: 'Wait', waitEventConfig: waitConfig },
86+
{ id: 'after', type: 'mark', label: 'After' },
87+
{ id: 'end', type: 'end', label: 'End' },
88+
],
89+
edges: [
90+
{ id: 'e1', source: 'start', target: 'pause' },
91+
{ id: 'e2', source: 'pause', target: 'after' },
92+
{ id: 'e3', source: 'after', target: 'end' },
93+
],
94+
});
95+
96+
const config = { eventType: 'timer', timerDuration: 'P1D' };
97+
98+
/** A store whose resume-time `load` is unreachable; everything else works. */
99+
function storeWithUnreadableLoad(inner: InMemorySuspendedRunStore) {
100+
return {
101+
inner,
102+
async save(run: any) { return inner.save(run); },
103+
async load(_runId: string): Promise<any> { throw new Error('connection refused'); },
104+
async delete(runId: string) { return inner.delete(runId); },
105+
async list() { return inner.list(); },
106+
};
107+
}
108+
109+
/**
110+
* Park a run in "process 1", then cold-boot "process 2" whose durable read is
111+
* broken and whose job service is a real `DbJobAdapter`. Returns the adapter,
112+
* the fake ObjectQL tables, and the wake-up job's name.
113+
*/
114+
async function coldBootOntoDbJobAdapter(broken: boolean) {
115+
const inner = new InMemorySuspendedRunStore();
116+
const boot1 = fakeJobCtx();
117+
const e1 = new AutomationEngine(silentLogger());
118+
e1.registerNodeExecutor(markerExecutor([]));
119+
registerWaitNode(e1, boot1.ctx);
120+
e1.setSuspendedRunStore(inner);
121+
e1.registerFlow('wait_flow', waitFlow(config));
122+
const paused = await e1.execute('wait_flow');
123+
expect(paused.status).toBe('paused');
124+
125+
const store = broken ? (storeWithUnreadableLoad(inner) as any) : inner;
126+
const ran: string[] = [];
127+
const objectql = makeFakeEngine();
128+
const jobService = new DbJobAdapter({ engine: objectql });
129+
const e2 = new AutomationEngine(silentLogger());
130+
e2.registerNodeExecutor(markerExecutor(ran));
131+
// The wait node is registered against the REAL adapter, so the teardown that
132+
// fires when the run leaves the node (#5512) goes through it too.
133+
registerWaitNode(e2, {
134+
logger: silentLogger(),
135+
getService: (id: string) => (id === 'job' ? jobService : undefined),
136+
} as any);
137+
e2.setSuspendedRunStore(store);
138+
e2.registerFlow('wait_flow', waitFlow(config));
139+
// The re-arm pass registers the one-shot on the real adapter — from here on
140+
// every run of that job goes through `DbJobAdapter.wrap`.
141+
expect(await rearmSuspendedWaitTimers(e2, store, jobService, silentLogger())).toBe(1);
142+
143+
return { paused, inner, ran, objectql, jobService, jobName: `flow-wait:${paused.runId}:pause` };
144+
}
145+
146+
describe('#5548 — the #5529 wait wake-up that consumed nothing is audited as degraded, not success', () => {
147+
it('the wake-up into an unreachable store lands sys_job_run.status = "degraded"', async () => {
148+
const { inner, ran, objectql, jobService, jobName, paused } = await coldBootOntoDbJobAdapter(true);
149+
150+
// Fire the wake-up the way an operator or the timer would.
151+
await jobService.trigger(jobName);
152+
153+
// The pause really was not consumed — the run is still parked, its row still
154+
// there. This is the premise the audit row has to reflect.
155+
expect(ran).toEqual([]);
156+
expect((await inner.list()).map((r) => r.runId)).toEqual([paused.runId]);
157+
158+
const runs = objectql.tables.get('sys_job_run') ?? [];
159+
expect(runs).toHaveLength(1);
160+
expect(runs[0].job_name).toBe(jobName);
161+
// The cell this card exists for. Before the wiring it read 'success'.
162+
expect(runs[0].status).toBe('degraded');
163+
expect(runs[0].error).toBe('STORE_UNAVAILABLE');
164+
165+
await jobService.destroy();
166+
});
167+
168+
it('the job row mirrors it, stays active, and does NOT count as a failure', async () => {
169+
const { objectql, jobService, jobName } = await coldBootOntoDbJobAdapter(true);
170+
await jobService.trigger(jobName);
171+
172+
const [job] = objectql.tables.get('sys_job') ?? [];
173+
expect(job.last_status).toBe('degraded');
174+
expect(job.last_error).toBe('STORE_UNAVAILABLE');
175+
// #5529's half is untouched: the one-shot is kept ARMED so the stuck run
176+
// stays visible and the wake-up re-firable.
177+
expect(job.active).toBe(true);
178+
// `degraded` is not a failure: the retry/alerting signal does not move.
179+
expect(job.failure_count).toBe(0);
180+
expect(job.run_count).toBe(1);
181+
182+
await jobService.destroy();
183+
});
184+
185+
it('a wake-up that DOES resume the run is still audited as success (control)', async () => {
186+
const { ran, objectql, jobService, jobName } = await coldBootOntoDbJobAdapter(false);
187+
await jobService.trigger(jobName);
188+
189+
// The pause was consumed and traversal continued…
190+
expect(ran).toEqual(['after']);
191+
const runs = objectql.tables.get('sys_job_run') ?? [];
192+
expect(runs).toHaveLength(1);
193+
// …so the row says success, exactly as before this change.
194+
expect(runs[0].status).toBe('success');
195+
expect(runs[0].error).toBeNull();
196+
const [job] = objectql.tables.get('sys_job') ?? [];
197+
expect(job.last_status).toBe('success');
198+
// The one-shot had its shot and settled the pause, so it disarms — the
199+
// `sys_job` row goes inactive, which is the OPPOSITE of the degraded case.
200+
expect(job.active).toBe(false);
201+
202+
await jobService.destroy();
203+
});
204+
});

packages/services/service-automation/src/builtin/wait-node.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
import type { PluginContext } from '@objectstack/core';
44
import { defineActionDescriptor } from '@objectstack/spec/automation';
5-
import type { IJobService } from '@objectstack/spec/contracts';
5+
import type { IJobService, JobRunOutcome } from '@objectstack/spec/contracts';
66
import type { AutomationEngine, SuspendedRunStore } from '../engine.js';
77
import { describeThrownForLog, type ThrownCauseMeta } from '../thrown-cause-diagnostics.js';
88

@@ -56,6 +56,13 @@ interface WaitTimerLogger {
5656
* healthy timer is the durability degradation AGENTS.md's log-level rule is
5757
* about (#4632), and this path was previously silent — the result was
5858
* discarded by the callback without so much as a `warn`.
59+
*
60+
* Since #5548 this outcome is also RESOLVED as `{ outcome: 'degraded',
61+
* reason: 'STORE_UNAVAILABLE' }`, so the job service records the run as
62+
* `degraded` rather than `success` — the log line said the shot missed, the
63+
* audit row said it succeeded. The `reason` is the short code only: the
64+
* driver's own message can be multi-line and belongs in the log record's
65+
* `meta` (#5737), not in an audit column.
5966
* - **everything else** — cancel, exactly as before. Success consumed the
6067
* pause; `RESUME_IN_PROGRESS` means a concurrent resume is consuming it (and
6168
* #5512's `onSuspensionReleased` drops this job when it does); a machine-state
@@ -89,15 +96,26 @@ function makeWaitTimerJobHandler(
8996
runId: string,
9097
jobName: string,
9198
logger: WaitTimerLogger,
92-
): () => Promise<void> {
99+
): () => Promise<void | JobRunOutcome> {
93100
return async () => {
94101
// Set only on the one outcome that must NOT disarm the job. A thrown
95102
// `resume` leaves it false, so the `finally` still cancels.
96103
let keepArmed = false;
104+
// #5548 — what this shot reports to the JOB service, as opposed to what it
105+
// logs. `STORE_UNAVAILABLE` is the specimen the ruling names: the handler
106+
// completes normally (deliberately — #5529 refused to make it throw,
107+
// because a throw is the retry signal third-party `IJobService`
108+
// implementations key on), so before the `JobRunOutcome` channel existed
109+
// the run was recorded as `success` on an audit surface whose whole job is
110+
// to say whether the work happened. Resolving `degraded` instead moves the
111+
// `sys_job_run` row and nothing else: still no throw, still no retry, and
112+
// the job still stays ARMED and `active` exactly as #5529 fixed it.
113+
let outcome: JobRunOutcome | undefined;
97114
try {
98115
const result = await engine.resume(runId);
99116
if (result?.code === 'STORE_UNAVAILABLE') {
100117
keepArmed = true;
118+
outcome = { outcome: 'degraded', reason: 'STORE_UNAVAILABLE' };
101119
// #5737 — the cause goes to `meta`, never into the message. This one is
102120
// NOT a thrown value and so does NOT go through `describeThrownForLog`:
103121
// `AutomationResult.error` is a STRING the engine already composed
@@ -138,6 +156,9 @@ function makeWaitTimerJobHandler(
138156
}
139157
}
140158
}
159+
// Resolved, never thrown — the report rides the RETURN value precisely so
160+
// the failure semantics of `IJobService` stay untouched (#6617).
161+
return outcome;
141162
};
142163
}
143164

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

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -155,8 +155,16 @@ export class CronJobAdapter implements IJobService {
155155
};
156156
const startMs = Date.now();
157157
try {
158-
await runWithPolicy(record.name, () => record.handler({ jobId: record.name, data }), record.options);
159-
execution.status = 'success';
158+
const outcome = await runWithPolicy(record.name, () => record.handler({ jobId: record.name, data }), record.options);
159+
// #5548 — same mapping as `IntervalJobAdapter.executeJob`, deliberately
160+
// one shape and not two spellings: a resolved `degraded` outcome is a
161+
// completed run whose work did not happen, never a `success`.
162+
if (outcome && outcome.outcome === 'degraded') {
163+
execution.status = 'degraded';
164+
execution.error = outcome.reason;
165+
} else {
166+
execution.status = 'success';
167+
}
160168
} catch (err) {
161169
execution.status = err instanceof JobTimeoutError ? 'timeout' : 'failed';
162170
execution.error = err instanceof Error ? err.message : String(err);

0 commit comments

Comments
 (0)