Skip to content

Commit 8bcd994

Browse files
authored
feat(automation): run-history retention + durable single-run detail (#2585) (#2603)
1 parent d54be28 commit 8bcd994

8 files changed

Lines changed: 525 additions & 21 deletions

File tree

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/service-automation': minor
3+
---
4+
5+
Automation run observability follow-ups (#2585): retention for `sys_automation_run` run history, and durable single-run detail.
6+
7+
**Retention (closes the unbounded-growth risk #2581 introduced).** Terminal run-history rows are now bounded by default, ADR-0057 posture:
8+
9+
- A write-time per-flow cap keeps the newest 100 terminal runs per flow (`runHistoryMaxPerFlow`, `0` disables).
10+
- A default-on periodic sweep deletes terminal rows older than 30 days (`runHistoryRetentionDays`, `0` disables; `runHistorySweepMs` tunes the interval, default 1 h).
11+
- Suspended (`paused`) rows are live resumable state and are never pruned.
12+
13+
**Durable single-run detail.** `AutomationEngine.getRun(runId)` now falls back to the durable history row when the run is no longer in the in-memory buffer (e.g. after a restart), and terminal rows persist a bounded per-node step log (`steps_json`: newest 200 steps, stacks stripped, 64 KB cap) — so "open a past failed run and see which node blew up" survives a restart. New `SuspendedRunStore.loadTerminal(runId)` backs this; `RunRecord` gains `finishedAt` and `steps`.

packages/services/service-automation/src/engine.ts

Lines changed: 59 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,14 @@ export interface ConnectorDescriptor {
242242
*/
243243
export const DEFAULT_MAX_EXECUTION_LOG_SIZE = 1000;
244244

245+
/**
246+
* Max steps persisted per terminal run-history row (#2585). The tail of the
247+
* step log is kept — the last steps carry the failure — so durable single-run
248+
* detail stays meaningful without letting a pathological loop-heavy run write
249+
* an unbounded `steps_json` column.
250+
*/
251+
export const MAX_PERSISTED_HISTORY_STEPS = 200;
252+
245253
/** Construction options for {@link AutomationEngine}. */
246254
export interface AutomationEngineOptions {
247255
/**
@@ -364,12 +372,20 @@ export interface RunRecord {
364372
status: 'completed' | 'failed';
365373
startedAt: string;
366374
startTime?: number;
375+
/** When the run reached its terminal state. */
376+
finishedAt?: string;
367377
durationMs?: number;
368378
/** Failure reason for a `failed` run — what a designer needs to fix it. */
369379
error?: string;
370380
nodeId?: string;
371381
organizationId?: string | null;
372382
userId?: string | null;
383+
/**
384+
* Bounded per-node step log (see {@link AutomationEngine.compactStepsForHistory}),
385+
* so "which node blew up?" survives a restart. Optional — history rows
386+
* written before this field existed have none.
387+
*/
388+
steps?: StepLogEntry[];
373389
}
374390

375391
export interface SuspendedRunStore {
@@ -391,6 +407,12 @@ export interface SuspendedRunStore {
391407
recordTerminal?(record: RunRecord): Promise<void>;
392408
/** Newest terminal run-history records for a flow (for the Runs tab). */
393409
listHistory?(flowName: string, limit: number): Promise<RunRecord[]>;
410+
/**
411+
* Load one terminal run-history record by its raw `runId`, or `null` when
412+
* none is stored. Backs {@link AutomationEngine.getRun}'s durable fallback
413+
* so "open a past failed run" works after a restart.
414+
*/
415+
loadTerminal?(runId: string): Promise<RunRecord | null>;
394416
}
395417

396418
export class AutomationEngine implements IAutomationService {
@@ -997,23 +1019,40 @@ export class AutomationEngine implements IAutomationService {
9971019
}
9981020

9991021
/** Rehydrate a durable {@link RunRecord} into an {@link ExecutionLogEntry}
1000-
* for the Runs list. Step-level detail isn't persisted in the summary. */
1022+
* for the Runs surfaces. Steps carry the bounded persisted step log (rows
1023+
* written before step persistence have none). */
10011024
private runRecordToLogEntry(r: RunRecord): ExecutionLogEntry {
10021025
return {
10031026
id: r.runId,
10041027
flowName: r.flowName,
10051028
flowVersion: r.flowVersion,
10061029
status: r.status, // 'completed' | 'failed' — both valid ExecutionLog statuses
10071030
startedAt: r.startedAt,
1031+
completedAt: r.finishedAt,
10081032
durationMs: r.durationMs,
10091033
trigger: { type: '', userId: r.userId ?? undefined },
1010-
steps: [],
1034+
steps: r.steps ?? [],
10111035
error: r.error,
10121036
};
10131037
}
10141038

10151039
async getRun(runId: string): Promise<ExecutionLogEntry | null> {
1016-
return this.executionLogs.find(l => l.id === runId) ?? null;
1040+
const inMem = this.executionLogs.find(l => l.id === runId);
1041+
if (inMem) return inMem;
1042+
// Durable fallback: after a restart (or ring-buffer eviction) the run's
1043+
// terminal history row still answers "what happened, at which node?".
1044+
// Best-effort — a store failure degrades to "not found", never throws.
1045+
if (this.store?.loadTerminal) {
1046+
try {
1047+
const rec = await this.store.loadTerminal(runId);
1048+
if (rec) return this.runRecordToLogEntry(rec);
1049+
} catch (err) {
1050+
this.logger.warn(
1051+
`[Automation] durable run lookup failed for '${runId}': ${(err as Error)?.message}`,
1052+
);
1053+
}
1054+
}
1055+
return null;
10171056
}
10181057

10191058
/**
@@ -1691,15 +1730,19 @@ export class AutomationEngine implements IAutomationService {
16911730
entry.status === 'cancelled' ||
16921731
entry.status === 'timed_out';
16931732
if (terminal && this.store?.recordTerminal) {
1733+
const lastStep = entry.steps[entry.steps.length - 1];
16941734
const record: RunRecord = {
16951735
runId: entry.id,
16961736
flowName: entry.flowName,
16971737
flowVersion: entry.flowVersion,
16981738
status: entry.status === 'completed' ? 'completed' : 'failed',
16991739
startedAt: entry.startedAt,
1740+
finishedAt: entry.completedAt,
17001741
durationMs: entry.durationMs,
17011742
error: entry.error,
17021743
userId: entry.trigger?.userId,
1744+
nodeId: lastStep?.nodeId,
1745+
steps: this.compactStepsForHistory(entry.steps),
17031746
};
17041747
void this.store.recordTerminal(record).catch((err) => {
17051748
this.logger.warn(
@@ -1709,6 +1752,19 @@ export class AutomationEngine implements IAutomationService {
17091752
}
17101753
}
17111754

1755+
/**
1756+
* Compact a run's step log for durable history: keep the newest
1757+
* {@link MAX_PERSISTED_HISTORY_STEPS} steps (the tail carries the failure)
1758+
* and drop `error.stack` (the code/message pair is the designer-facing
1759+
* "why"; stacks bloat rows without aiding the Runs surface). Bounds the
1760+
* `steps_json` column so history rows stay cheap under retention (#2585).
1761+
*/
1762+
private compactStepsForHistory(steps: StepLogEntry[]): StepLogEntry[] {
1763+
return steps.slice(-MAX_PERSISTED_HISTORY_STEPS).map((s) =>
1764+
s.error?.stack ? { ...s, error: { code: s.error.code, message: s.error.message } } : s,
1765+
);
1766+
}
1767+
17121768
/**
17131769
* Validate each node's `type` against the live action registry (ADR-0018).
17141770
* A type is known if it is structural (start/end), has a registered

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

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

33
// Core engine
4-
export { AutomationEngine, DEFAULT_MAX_EXECUTION_LOG_SIZE } from './engine.js';
4+
export { AutomationEngine, DEFAULT_MAX_EXECUTION_LOG_SIZE, MAX_PERSISTED_HISTORY_STEPS } from './engine.js';
55
export type {
66
AutomationEngineOptions,
77
NodeExecutor,
@@ -15,6 +15,7 @@ export type {
1515
ConnectorActionDescriptor,
1616
SuspendedRun,
1717
SuspendedRunStore,
18+
RunRecord,
1819
StepLogEntry,
1920
} from './engine.js';
2021

@@ -23,8 +24,10 @@ export type {
2324
export {
2425
InMemorySuspendedRunStore,
2526
ObjectStoreSuspendedRunStore,
27+
DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW,
28+
DEFAULT_RUN_HISTORY_RETENTION_DAYS,
2629
} from './suspended-run-store.js';
27-
export type { SuspendedRunStoreEngine } from './suspended-run-store.js';
30+
export type { SuspendedRunStoreEngine, ObjectStoreSuspendedRunStoreOptions } from './suspended-run-store.js';
2831

2932
// The sys_automation_run object backing the durable store — registered by
3033
// AutomationServicePlugin and exported for hosts wiring a custom store.

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

Lines changed: 66 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,12 @@ import type { IJobService } from '@objectstack/spec/contracts';
55
import { AutomationEngine } from './engine.js';
66
import { installBuiltinNodes, rearmSuspendedWaitTimers } from './builtin/index.js';
77
import { SysAutomationRun } from './sys-automation-run.object.js';
8-
import { ObjectStoreSuspendedRunStore, type SuspendedRunStoreEngine } from './suspended-run-store.js';
8+
import {
9+
ObjectStoreSuspendedRunStore,
10+
DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW,
11+
DEFAULT_RUN_HISTORY_RETENTION_DAYS,
12+
type SuspendedRunStoreEngine,
13+
} from './suspended-run-store.js';
914

1015
/**
1116
* Configuration options for the AutomationServicePlugin.
@@ -31,6 +36,26 @@ export interface AutomationServicePluginOptions {
3136
* to {@link DEFAULT_MAX_EXECUTION_LOG_SIZE} (1000).
3237
*/
3338
maxLogSize?: number;
39+
/**
40+
* Retention window in days for durable terminal run history in
41+
* `sys_automation_run` (#2585; ADR-0057 posture — platform self-telemetry
42+
* must be bounded). When > 0, a periodic sweep deletes terminal
43+
* (completed / failed) history rows older than the window; suspended
44+
* (`paused`) rows are live resumable state and are never pruned.
45+
* **Default-on** at {@link DEFAULT_RUN_HISTORY_RETENTION_DAYS} (30). Set
46+
* to `0` to disable age pruning (history kept until the per-flow cap).
47+
*/
48+
runHistoryRetentionDays?: number;
49+
/**
50+
* Per-flow cap on terminal run-history rows, enforced at write time (the
51+
* "or 100 runs/flow, whichever first" half of the #2585 retention
52+
* contract). Defaults to {@link DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW} (100);
53+
* `0` disables the cap.
54+
*/
55+
runHistoryMaxPerFlow?: number;
56+
/** Run-history retention sweep interval in ms (default 1 hour). Only used
57+
* when `runHistoryRetentionDays` > 0. */
58+
runHistorySweepMs?: number;
3459
}
3560

3661
/**
@@ -72,6 +97,8 @@ export class AutomationServicePlugin implements Plugin {
7297

7398
private engine?: AutomationEngine;
7499
private readonly options: AutomationServicePluginOptions;
100+
/** Periodic run-history retention sweep (#2585); cleared on destroy. */
101+
private retentionTimer?: ReturnType<typeof setInterval>;
75102
/**
76103
* Flow names this plugin has registered into the engine from the
77104
* artifact / ObjectQL registry, tracked so a `metadata:reloaded` re-sync
@@ -155,14 +182,47 @@ export class AutomationServicePlugin implements Plugin {
155182
try { dataEngine = ctx.getService<SuspendedRunStoreEngine>('objectql'); }
156183
catch { try { dataEngine = ctx.getService<SuspendedRunStoreEngine>('data'); } catch { /* none */ } }
157184
if (dataEngine && typeof dataEngine.find === 'function' && typeof dataEngine.insert === 'function') {
158-
durableStore = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger);
185+
durableStore = new ObjectStoreSuspendedRunStore(dataEngine, ctx.logger, {
186+
maxTerminalRunsPerFlow:
187+
this.options.runHistoryMaxPerFlow ?? DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW,
188+
});
159189
this.engine.setSuspendedRunStore(durableStore);
160190
ctx.logger.info('[Automation] Suspended-run persistence enabled (sys_automation_run)');
161191
} else {
162192
ctx.logger.info('[Automation] No ObjectQL engine — suspended runs kept in-memory only');
163193
}
164194
}
165195

196+
// Run-history age retention (#2585, ADR-0057 posture): default-on sweep
197+
// so `sys_automation_run` terminal history can't grow without bound.
198+
// Runs once at kernel:ready then on a low-frequency interval; the timer
199+
// is unref'd so it never keeps the process alive. Mirrors the
200+
// service-messaging notification retention sweep.
201+
const retentionDays = this.options.runHistoryRetentionDays ?? DEFAULT_RUN_HISTORY_RETENTION_DAYS;
202+
if (durableStore && retentionDays > 0 && typeof ctx.hook === 'function') {
203+
const store = durableStore;
204+
const sweepMs = this.options.runHistorySweepMs ?? 3_600_000;
205+
ctx.hook('kernel:ready', async () => {
206+
const sweep = () => {
207+
void store.pruneHistory(retentionDays).then((deleted) => {
208+
if (deleted === undefined || deleted > 0) {
209+
ctx.logger.info(
210+
`[Automation] run-history retention: pruned ${deleted ?? '?'} terminal run(s) older than ${retentionDays}d`,
211+
);
212+
}
213+
}).catch((err) =>
214+
ctx.logger.warn(`[Automation] run-history retention sweep failed: ${(err as Error)?.message ?? err}`),
215+
);
216+
};
217+
sweep();
218+
this.retentionTimer = setInterval(sweep, sweepMs);
219+
this.retentionTimer.unref?.();
220+
ctx.logger.info(
221+
`[Automation] run-history retention on (terminal runs > ${retentionDays}d pruned every ${Math.round(sweepMs / 1000)}s; cap ${this.options.runHistoryMaxPerFlow ?? DEFAULT_MAX_TERMINAL_RUNS_PER_FLOW}/flow at write)`,
222+
);
223+
});
224+
}
225+
166226
// #1870 — bridge `script`-node function calls to the host function
167227
// registry. ObjectQL holds the name→handler map populated from
168228
// `bundle.functions` / `defineStack({ functions })` (the same registry
@@ -422,6 +482,10 @@ export class AutomationServicePlugin implements Plugin {
422482
}
423483

424484
async destroy(): Promise<void> {
485+
if (this.retentionTimer) {
486+
clearInterval(this.retentionTimer);
487+
this.retentionTimer = undefined;
488+
}
425489
this.engine = undefined;
426490
}
427491
}

packages/services/service-automation/src/run-history.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,56 @@ describe('automation run history (durable observability)', () => {
8686
expect(runs[0].id).toBeTruthy();
8787
});
8888

89+
it('getRun falls back to durable history after a restart, WITH step detail (#2585)', async () => {
90+
const store = new InMemorySuspendedRunStore();
91+
const engineA = new AutomationEngine(silent, store);
92+
engineA.registerNodeExecutor({
93+
type: 'boom',
94+
async execute() { throw new Error('kaboom'); },
95+
} as never);
96+
engineA.registerFlow('bad_flow', failingFlow('bad_flow') as never);
97+
await engineA.execute('bad_flow', { event: 'test' } as AutomationContext);
98+
await flush();
99+
100+
// Simulate a restart: a fresh engine with empty in-memory logs. Before
101+
// #2585 its getRun returned null even though the history row existed.
102+
const engineB = new AutomationEngine(silent, store);
103+
const [listed] = await engineB.listRuns('bad_flow', { limit: 1 });
104+
const run = await engineB.getRun(listed.id);
105+
expect(run).not.toBeNull();
106+
expect(run!.status).toBe('failed');
107+
expect(run!.error ?? '').toMatch(/kaboom/);
108+
// Durable single-run detail: the persisted step log names the node that
109+
// blew up (stacks are stripped — code/message is the designer-facing why).
110+
const failedStep = run!.steps.find((s) => s.status === 'failure');
111+
expect(failedStep?.nodeId).toBe('boom');
112+
expect(failedStep?.error?.message).toMatch(/kaboom/);
113+
expect(failedStep?.error?.stack).toBeUndefined();
114+
});
115+
116+
it('getRun returns null for an unknown run id even with a store attached', async () => {
117+
const engine = new AutomationEngine(silent, new InMemorySuspendedRunStore());
118+
expect(await engine.getRun('run_nope')).toBeNull();
119+
});
120+
121+
it('caps terminal history per flow (retention stop-gap, #2585)', async () => {
122+
const store = new InMemorySuspendedRunStore({ maxTerminalRunsPerFlow: 2 });
123+
const engine = new AutomationEngine(silent, store);
124+
engine.registerFlow('busy', trivialFlow('busy') as never);
125+
engine.registerFlow('other', trivialFlow('other') as never);
126+
127+
for (let i = 0; i < 5; i++) {
128+
await engine.execute('busy', { event: 'test' } as AutomationContext);
129+
}
130+
await engine.execute('other', { event: 'test' } as AutomationContext);
131+
await flush();
132+
133+
// Only the newest 2 'busy' runs survive; the cap is per flow, so the
134+
// single 'other' run is untouched.
135+
expect(await store.listHistory('busy', 10)).toHaveLength(2);
136+
expect(await store.listHistory('other', 10)).toHaveLength(1);
137+
});
138+
89139
it('a failing history store never breaks the run (best-effort isolation)', async () => {
90140
const store = new InMemorySuspendedRunStore();
91141
// Override recordTerminal to reject — the run must still complete.

0 commit comments

Comments
 (0)