Skip to content

Commit b0d54bf

Browse files
fix(service-automation): stop splicing driver text into engine.ts's last three store-seam log messages (#6299) (#6498)
All three catches sit around the `SuspendedRunStore` driver and rendered their failure by interpolating the thrown value's `.message` into the log MESSAGE. `ObjectLogger.write()` adds one `<ts> <LEVEL>` head per call, so a multi-line driver error turned ONE record into several physical lines of which only the first was greppable. The cause now goes to the logger's structured slot via `describeThrownForLog`, closing the #5048 / #5575 / #5636 / #5661 / #5737 / #5912 / #6230 family for this file. The #4632 level was judged per seam rather than batch-copied from #6230: - forgetSuspendedRun -> raised to `error` (durability): the hot cache is dropped before the store delete, so a failed delete leaves the suspension consumed in-process and the durable row alive, to be re-listed and re-resumed after the next restart. - cancelRun -> raised to `error` (durability): an unreadable store makes the read report "no such suspended run", so the method returns `false` (its contract's idempotent success) and the cancellation is silently skipped. - listSuspendedRunsDurable -> stays `warn` (functional): nothing claimed-persisted failed to land; the listing degrades to the in-memory cache and the message now says so out loud. Claude-Session: https://claude.ai/code/session_01USNUyHEr7uaU6MoEWXitei Co-authored-by: Claude <noreply@anthropic.com>
1 parent 19f7586 commit b0d54bf

3 files changed

Lines changed: 805 additions & 5 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/service-automation": patch
3+
---
4+
5+
fix(service-automation): the last three `engine.ts` seams stop splicing a driver's failure into the log message, and two of them are re-graded `error` (#6299)
6+
7+
All three catches sit around the `SuspendedRunStore` driver and rendered their
8+
failure by interpolating the thrown value's `.message` into the log MESSAGE.
9+
`ObjectLogger.write()` adds exactly one `<ts> <LEVEL>` head per call, so a
10+
driver error carrying newlines turned ONE record into several physical lines of
11+
which only the first was greppable — and on the `warn` path, inside `serve`'s
12+
boot-quiet window, `BootLogCapture.offer()` keeps only lines with a level head,
13+
so the continuation lines were dropped outright. Measured on the restored
14+
concatenation: a three-line driver error became 3 physical lines and the boot
15+
filter retained 1, and that one carried no driver fact. The cause now goes to
16+
the logger's structured slot (`describeThrownForLog`), so the record stays on
17+
one physical line in every format. This closes the family of #5048 / #5575 /
18+
#5636 / #5661 / #5737 / #5912 / #6230 for this file.
19+
20+
The level was judged per seam (#4632), not batch-copied from #6230:
21+
22+
- **`forgetSuspendedRun` → raised to `error`.** The hot cache is dropped before
23+
the store delete and this is the single choke point every consumption of a
24+
suspension passes through, so a failed `delete` leaves the suspension gone
25+
in-process and the durable row alive. Callers still report success, and the
26+
surviving row is re-listed and re-resumed after the next restart, running a
27+
continuation that already ran.
28+
- **`cancelRun` → raised to `error`.** An unreadable store makes the failed read
29+
read as "no such suspended run", so the method returns `false` — which its
30+
contract calls idempotent success — and the cancellation is silently skipped
31+
while the call reads clean. The run stays parked and durably resumable.
32+
- **`listSuspendedRunsDurable` → stays `warn`.** Nothing claimed-persisted
33+
failed to land: the rows are intact and still resumable by id. The listing
34+
degrades to the in-memory cache alone, so the message now says out loud that
35+
the result is short and that the caller cannot tell.
36+
37+
Operator-visible: two records move from stdout to stderr and from `WARN` to
38+
`ERROR`, and all three messages are reworded to state their consequence. Log
39+
filters or alert rules keyed on the old `warn`-level text for a failed
40+
suspended-run delete or cancel need updating.

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

Lines changed: 136 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1262,15 +1262,64 @@ export class AutomationEngine implements IAutomationService {
12621262
* its pause is over ({@link NodeExecutor.onSuspensionReleased}, #5512). It
12631263
* therefore takes the whole {@link SuspendedRun}: the notification needs the
12641264
* node and the correlation the executor minted, not just the id.
1265+
*
1266+
* A durable-store `delete` failure does NOT fail the consumption — the run
1267+
* has already left the node — but it is reported at `error`, not `warn`
1268+
* (#4632/#6299): the cache entry is gone while the row survives, so the run
1269+
* reads as terminal now and as still-suspended after a restart. See the
1270+
* catch below for the full verdict.
12651271
*/
12661272
private async forgetSuspendedRun(run: SuspendedRun, reason: SuspensionReleaseReason): Promise<void> {
12671273
this.suspendedRuns.delete(run.runId);
12681274
if (this.store) {
12691275
try {
12701276
await this.store.delete(run.runId);
12711277
} catch (err) {
1272-
this.logger.warn(
1273-
`[automation] failed to delete suspended run '${run.runId}' from durable store: ${(err as Error).message}`,
1278+
// #6299 — the cause goes to the logger's STRUCTURED slot, never
1279+
// spliced into the message: it is the datasource DRIVER's own
1280+
// failure text, we do not control how many lines it has, and
1281+
// `ObjectLogger.write()` adds exactly one `<ts> <LEVEL>` head
1282+
// per call — so a newline in it turns this ONE record into
1283+
// several physical lines of which only the first is greppable.
1284+
// The family of #5048 / #5575 / #5636 / #5661 / #5737 / #5912 /
1285+
// #6230, and cloud#971's shape.
1286+
//
1287+
// #4632 verdict: DURABILITY — raised from `warn` to `error`, and
1288+
// deliberately NOT a copy of #6230's "the level stays warn". The
1289+
// hot cache is dropped on the line ABOVE the try, and this
1290+
// method is the single choke point every consumption of a
1291+
// suspension passes through (resume / terminal failure /
1292+
// cancel), so on this path the suspension is gone in-process
1293+
// while the durable row SURVIVES. Every caller still reports
1294+
// success — `resume()` returns a successful result, `cancelRun()`
1295+
// returns `true`, `recordLog` writes a terminal record — and the
1296+
// surviving row is read straight back out after the next
1297+
// restart: `rearmSuspendedWaitTimers` lists it and `resume()`
1298+
// rehydrates it through `loadSuspendedRunStrict`'s store read,
1299+
// re-running a continuation that has already run. Nothing
1300+
// retries this delete. That is the shape the durability gate's
1301+
// own vocabulary already grades `error` for the metadata store
1302+
// (`deleteMetaItemFromLoader`, #5259: "the surviving row is read
1303+
// straight back out of storage … so the 'deleted' item reappears
1304+
// and survives every restart"). `check:durability-log-level`
1305+
// cannot see it HERE only because `SuspendedRunStore.delete` is
1306+
// not in its declared callee vocabulary — which is exactly why
1307+
// this level is pinned by a test instead.
1308+
//
1309+
// THIRD argument, per the `Logger` contract
1310+
// (`packages/spec/src/contracts/logger.ts`):
1311+
// `error(message, error?, meta?)`. The `Error` slot is left
1312+
// empty on purpose (#5575) — a raw `Error` there ships its stack
1313+
// trace on every record.
1314+
this.logger.error(
1315+
`[automation] suspended run '${run.runId}' was consumed (${reason}) but could NOT be deleted from ` +
1316+
`the durable store — the in-memory suspension is already gone while the durable row SURVIVES, ` +
1317+
`so this run reads as terminal now and as still-suspended after the next restart, where ` +
1318+
`rearmSuspendedWaitTimers lists it and resume() rehydrates it from the store and re-runs a ` +
1319+
`continuation that has already run. Nothing retries this delete. Fix the store failure in this ` +
1320+
`record's meta, then delete the row for '${run.runId}' by hand.`,
1321+
undefined,
1322+
describeThrownForLog(err),
12741323
);
12751324
}
12761325
}
@@ -3515,15 +3564,57 @@ export class AutomationEngine implements IAutomationService {
35153564
* reject edge to resume down, so the run must end, not continue. Returns
35163565
* `false` when no suspended run exists under the id (already terminal /
35173566
* unknown), which callers treat as idempotent success.
3567+
*
3568+
* ⚠️ An UNREADABLE durable store also lands on that `false` — the two are
3569+
* indistinguishable to the caller, so the run may still be parked and
3570+
* resumable. That path is reported at `error` (#4632/#6299) precisely
3571+
* because nothing above it can tell the difference; see the catch below.
35183572
*/
35193573
async cancelRun(runId: string, reason?: string): Promise<boolean> {
35203574
let run = this.suspendedRuns.get(runId) ?? null;
35213575
if (!run && this.store) {
35223576
try {
35233577
run = await this.store.load(runId);
35243578
} catch (err) {
3525-
this.logger.warn(
3526-
`[automation] cancelRun: failed to load suspended run '${runId}' from durable store: ${(err as Error).message}`,
3579+
// #6299 — same family, same mechanism as `forgetSuspendedRun`
3580+
// above: the driver's uncontrolled text goes to the structured
3581+
// slot so the record stays one physical line.
3582+
//
3583+
// #4632 verdict: DURABILITY — raised from `warn` to `error`. The
3584+
// failed read is silently turned into "no such suspended run"
3585+
// and this method returns `false`, which its own contract
3586+
// documents as idempotent success (already terminal / unknown),
3587+
// so the cancellation is SKIPPED while the call reads clean. The
3588+
// only in-repo caller measures the cost: plugin-approvals'
3589+
// revise-window recall
3590+
// (`packages/plugins/plugin-approvals/src/approval-service.ts`)
3591+
// never reads the boolean at all — it only catches a THROW, and
3592+
// grades that throw `error` with "the run may be stranded"
3593+
// (#4420). A store-read failure produces precisely that stranded
3594+
// run WITHOUT firing that alarm: the request is marked
3595+
// `recalled`, the record lock is released, `resumeError` stays
3596+
// undefined — and the run stays parked in the store, to be
3597+
// re-armed and resumed by the next restart, inside a flow whose
3598+
// approval has already been withdrawn.
3599+
//
3600+
// This is why #6230's verdict must not be copied here.
3601+
// `loadSuspendedRun` is a DECLARED best-effort reader for
3602+
// incidental callers (a gate lookup, a screen fetch), and
3603+
// `resumeInternal` takes the strict form exactly where the
3604+
// difference matters. `cancelRun` has no strict alternative, and
3605+
// its degradation decides a WRITE.
3606+
//
3607+
// THIRD argument (`error(message, error?, meta?)`), `Error` slot
3608+
// deliberately empty (#5575).
3609+
this.logger.error(
3610+
`[automation] cancelRun('${runId}') could not read the durable suspended-run store, so the ` +
3611+
`cancellation was SKIPPED and reported as idempotent success — this call returns false, which ` +
3612+
`its callers read as "no such suspended run". The run is NOT cancelled: if it is parked in the ` +
3613+
`store it stays parked, and the next restart re-arms and resumes it while the caller has ` +
3614+
`already recorded the cancellation. Fix the store failure in this record's meta, then re-issue ` +
3615+
`cancelRun('${runId}').`,
3616+
undefined,
3617+
describeThrownForLog(err),
35273618
);
35283619
}
35293620
}
@@ -3598,7 +3689,47 @@ export class AutomationEngine implements IAutomationService {
35983689
byId.set(r.runId, { runId: r.runId, flowName: r.flowName, nodeId: r.nodeId, correlation: r.correlation });
35993690
}
36003691
} catch (err) {
3601-
this.logger.warn(`[automation] failed to list suspended runs from durable store: ${(err as Error).message}`);
3692+
// #6299 — driver text to the structured slot, message one line,
3693+
// same as the two seams above. The SLOT differs: the `Logger`
3694+
// contract declares `warn(message, meta?)`, so `meta` is the
3695+
// SECOND argument here — `warn` has no `Error` slot, and a
3696+
// `meta` passed third to it is silently ignored.
3697+
//
3698+
// #4632 verdict: FUNCTIONAL — the level deliberately STAYS
3699+
// `warn`, and this is the one of #6299's three sites where that
3700+
// is the answer. Nothing the system claims to have persisted
3701+
// failed to land: the durable rows are intact, still resumable
3702+
// by id, and the next boot's `rearmSuspendedWaitTimers` still
3703+
// re-arms them off its OWN `store.list()` — which builtin/
3704+
// wait-node.ts grades `error` precisely because THAT failure
3705+
// breaks the promise to resume them. This one breaks no promise,
3706+
// so #4632's judgment question ("does something the system
3707+
// claims is persisted fail to land while it keeps looking
3708+
// healthy?") answers NO.
3709+
//
3710+
// What IS wrong here is the shape #5186 owns: an answer INVENTED
3711+
// for a read that failed — a silently SHORT list. That rule's
3712+
// remedy is propagation (rethrow, or a discriminated result),
3713+
// which changes this method's return contract and is outside
3714+
// #6299's scope, so the record has to say the shortfall out loud
3715+
// instead. Its scan roots (`packages/metadata`,
3716+
// `metadata-protocol`, `objectql`) do not reach this package, so
3717+
// `check:durability-log-level` reports neither rule here.
3718+
// Reachability is also the weakest of the three: this method has
3719+
// no production consumer in-repo and is not on the
3720+
// `AutomationService` spec contract (only the synchronous
3721+
// `listSuspendedRuns` is), so nothing decides anything on this
3722+
// list today. Raising it to `error` would alarm for the duration
3723+
// of an outage on a read nobody acts on — #4632's mirror-image
3724+
// misuse, the trap #6230 avoided.
3725+
this.logger.warn(
3726+
`[automation] the durable suspended-run store could not be listed — this listing DEGRADES to the ` +
3727+
`in-memory cache alone, so every run parked by a previous process is missing from the result ` +
3728+
`and the caller cannot tell a short list from a complete one (after a restart the cache is ` +
3729+
`empty, so this answers []). The runs themselves are untouched — still stored, still resumable ` +
3730+
`by id. Fix the store failure in this record's meta.`,
3731+
describeThrownForLog(err),
3732+
);
36023733
}
36033734
}
36043735
// In-memory entries win — they are the freshest copy.

0 commit comments

Comments
 (0)