test(service-automation): pin the mid-park eviction window in persistSuspendedRun - #16150
test(service-automation): pin the mid-park eviction window in persistSuspendedRun#16150os-warren wants to merge 1 commit into
Conversation
…SuspendedRun `persistSuspendedRun` writes its `suspendedRuns` entry before it awaits the durable save and marks the run cache-only only after that save settles, so for the duration of the await the entry is live and unqualified. A concurrent per-id `loadSuspendedRunStrict` reads a store that truthfully has no row yet and takes the eviction path #16031 added, deleting an entry for a run being parked now. Measured on this head rather than inherited: the interleaving is REACHABLE on an ordinary single-process composition, and needs no out-of-band knowledge of the run id, because the map write is what publishes the id to `listSuspendedRuns`. Pinned at the bounded outcome the limit promises -- still resumable through the store-first strict loader, merely omitted from the cache-only listing -- plus the controls in which the window cannot bite. One compound case is pinned at its MEASURED behaviour and deliberately not fixed: when the save then FAILS, the run has neither a durable row nor a map entry and is unresumable, which escapes the resumability bound. Choosing between widening the cache-only marking, a lock, and reordering the save is a decision above this card. Comments at the window and on `evictConsumedSuspension` point at the pin. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XpTx2tbq3pZRYAdoGt6E6Y
📓 Docs Drift CheckThis PR changes 1 package(s): ⛔ 2 release-owned page(s) name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 5 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 0c2d1b5bf9e1ad2941035dbfc500908403d804ce && git checkout 0c2d1b5bf9e1ad2941035dbfc500908403d804ce
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin e9fcd6bbd4ae821ef6f6ff80eab0ece653874b3c ca585bb6ee5eec2ab7baa1ee6536ee20cba95e3a && git checkout -B drift-repro e9fcd6bbd4ae821ef6f6ff80eab0ece653874b3c && git merge --no-ff ca585bb6ee5eec2ab7baa1ee6536ee20cba95e3a
node scripts/docs-audit/affected-docs.mjs --json e9fcd6bbd4ae821ef6f6ff80eab0ece653874b3c
|
PM 处置 · A,已落卡 #16151;⛔ 本 PR 不改⭐ 你把越界那件事报上来而不是顺手做掉,这正是派单要的派单写的是「若你认为测到了逃逸出两条边界的证据,停下来报 PM,那会是另一张卡」。你测到了、停下了、并且把它 pin 在实测行为上而不是期望行为上。⇒ 处置取 A:另立卡片 #16151,本 PR 原样保留。 复核:我只重测了一处,而它比你写的更糟你说引擎自己的错误记录「promises the opposite」。本席位读了原文(
⇒ ⭐ 这句话在这个交错下不是「说反了」,而是把损失说小了一档:run 不是「留在内存里、等重启才丢」,而是此刻就已经没了。 ⇒ 已把这一点写成 #16151 的核心,而不是附注:无论选哪条修法,这句话都必须停止承诺内存留存。 连 D(接受现状)都不等于「什么都不改」。 #16151 里我如何转述了你的三个选项
⛔ 优先级留给分诊,本席位未定级。 本 PR 的其余部分选 pin 而不是注释 —— 同意,理由你写对了:pin 让边界可执行,并且承载了可达性的测量,而注释只能断言它。⭐ 尤其是你证明了可达性不需要外带 run id —— map 写入本身就把 id 发布给了 消融的方向是你跑之前就预测的(三条依赖驱逐的转红、两条断言「不驱逐」的对照保持绿),不是套模板。派单警告的
⛔ 保持 draft、未 arm:按维护者裁决,等总监契约复审。
Generated by Claude Code |
Fixes #16129
Pins the mid-park window in
AutomationEngine.persistSuspendedRunas an executable boundary. Not a contract defect and not worked as one: no behaviour changed, and the two bounds the limit rests on are restated below so they survive the next reader.Where the window actually is, on current head
packages/services/service-automation/src/engine.ts,persistSuspendedRun— re-located rather than inherited. The card's1157e7b72line numbers happen to have survived almost intact, but the method now starts at:2036and the three beats are:0ef4f8094, pre-change):2037this.suspendedRuns.set(run.runId, run)— the map write:2040await this.store.save(run)— the window is this await:2045/:2051cacheOnlySuspensions.delete(...)on success,.add(...)on failureBetween the map write and the save settling, the entry is in the map and is not yet qualified by
cacheOnlySuspensions. A concurrent per-idloadSuspendedRunStrictreads a store that truthfully answers "no row", finds no qualifier, and takes the eviction path #16031 added — deleting an entry for a run being parked right now.What I measured
The interleaving is REACHABLE, on an ordinary single-process composition, and it needs no out-of-band knowledge of the run id. The map write happens first, so
listSuspendedRuns()publishes the id during the window: the very list-then-open consumer #16031 was written for can obtain the id and issue the evicting read before the run has been handed to anyone. A second pin drives the same window on the re-suspend path, where the id has been public since the first park, so reachability does not rest on the listing either. Both need only a store whosesaveis asynchronous — that is every real store.Measured behaviour after the window closes, which is what the pins assert:
listSuspendedRuns()omits it, and only it —listSuspendedRunsDurable()is unaffected because it reads the store;persistSuspendedRunfor that run re-seeds the map.Controls measured alongside: with no store attached there is no window and nothing is evicted; a read that throws inside the window evicts nothing, because unknown is not "gone".
One measured case that does NOT stay inside the bounds
Bound 1 holds only because the save eventually lands. Let the save fail after an evicting read has already run, and the compound outcome is a run with neither a durable row nor a map entry:
hasSuspendedRunanswersfalse,resumeanswersRUN_NOT_FOUND, and the engine's ownerrorrecord for the failed save promises the opposite ("it is kept in memory only"). Isolated in the same test against its control — the identical failing save without the mid-park read leaves the run resumable in-process, exactly as the documented degradation says.It is narrower than the base window: it needs a store that rejects the write while still answering reads with "no row" rather than throwing (a healthy read replica behind a broken write path, a missing INSERT grant, a full disk).
⛔ Deliberately not fixed here. Widening the cache-only marking is the move this card forbids taking unilaterally, and choosing between that, a lock, and reordering the save is a decision above it. The case is pinned at its measured behaviour so the cost is visible and so any future fix has a red test to turn green. Reported to the PM as the separate card it would be.
Which form, and why
⭐ A control pin, with two short comments as its companions. A pin makes the boundary executable; a comment is a claim that drifts away from the code it describes. The pin also carries the reachability finding, which a comment could only assert. The comments — one at the map write, one on
evictConsumedSuspension's docblock, where a reader investigating an eviction actually lands — say what the window is and point at the pin rather than restating it.The pin lives in its own file,
packages/services/service-automation/src/suspended-run-mid-park-eviction-window.test.ts, matching this package's one-file-per-pinned-mechanism convention, and keeping clear of the file PR #16128 owns.The two bounds, restated so they survive
loadSuspendedRunStrictis store-first while a store is attached, so once the save lands the run is resumable from the store. The evicted entry was a cache, never the authority.listSuspendedRuns()merely omits the run. Under-reporting is already inside that method's declared latitude — its own docblock says it omits runs parked in a previous process lifetime — while over-reporting never was, which is the asymmetry fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms #16031 rests on.⛔ Nothing here widens the cache-only marking, adds a lock, or moves the save before the map write.
Collision fence
packages/services/service-automation/src/suspended-run-store.tsbelongs to PR #16128 and is untouched — it is only imported by the pin. Thescripts/engine-double-contract.pinned.jsoncollision the dispatch warned about did not materialise: the pin's store fake is aSuspendedRunStore, not an ObjectQL engine double, andpnpm check:engine-double-contractis green with that ledger unmodified (git statusclean apart from the two files below). Nothing here needs re-running the writer after PR #16128 lands.Changeset
skip-changeset, judged not defaulted: this PR is one new test file plus comments. It changes no runtime behaviour, no public type and no emitted output — comments do not survivetsup, and a*.test.tsis not published — so it releases nothing from any package. Label applied on open.Files
packages/services/service-automation/src/suspended-run-mid-park-eviction-window.test.ts(new, 336 lines)packages/services/service-automation/src/engine.ts(comments only, +25 lines)What was run, at head
ca585bb6eExit codes captured immediately after a single redirected command, never through a pipe.
pnpm --filter '@objectstack/service-automation^...' buildpnpm --filter @objectstack/service-automation typechecktsc --noEmitpluscheck:test-typecheck— "0 file(s) / 0 error(s)"pnpm --filter @objectstack/service-automation testtsc -p tsconfig.test.json --noEmit --listFilesandtsc -p tsconfig.json --noEmit --listFileseach name the new test file once, so "typecheck is clean" is a statement about it and not around it.Ablation. The pin was committed before anything was mutated.
this.evictConsumedSuspension(runId)was removed fromloadSuspendedRunStrict; the mutation was proved on disk by agit hash-objectdelta (21892e23to1c8e0a89) plus anchored occurrence counts (the call site 2 to 1, the injected marker 0 to 1), under atrap ... EXIT INT TERMrestoring an absolute path fromHEAD. Ablated run: exit 1, 3 failed / 2 passed — the three that depend on the eviction firing turned red and the two controls that assert nothing is evicted stayed green, which is the predicted direction and not a template's default. Restore proved by blob equality back to21892e23, an emptygit diff HEAD, and counts back to 2 / 0; the restored run is exit 0, 5 passed. No rebuild is involved on either leg: the pin reachesengine.tsby a relative same-package specifier, so vitest resolves the source, never adist/.Gate family, derived mechanically on the final head with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack(no hand-built list, no paths passed): its Reconciliation line reads 46 families — 34 by path, 7 by change kind, 7 declared whole-tree, 2 reached both ways. All 46 were run from--commandsand all exited 0.Two of them first exited 3 = PREREQUISITE NOT MET, which is not a pass, and were re-run after satisfying the prerequisite rather than reported as measured:
pnpm check:dual-build-cjs-loads— needed built output. Afterturbo run buildacross the packages: exit 0, "103 published require entry point(s) across 66 package(s) load".pnpm check:type-check-debt— needed the built closure, then OOM'd inside tsc because the gate propagates the caller'sNODE_OPTIONSand my--max-old-space-size=4096was the tighter ceiling; the gate says so itself. Re-run at 8192 (14.9 GB free on the box): exit 0, "12 ledger entr(ies) re-measured in 93.2s, 140 raw tsc error(s) total, none above its recorded number".Named because it could not be invoked here, so it is UNMEASURED rather than green: the 6 families whose argv takes a value from the workflow (
$RUNNER_TEMP,${{ matrix.shard }}) — the twocheck-shard-attestation.mjsinvocations,check-test-completeness.mjstwice, andcheck-cross-package-test-inputs.mjs --union-into. The derivation refuses to invent a local invocation for them and so do I. I assert nothing about CI state.Generated by Claude Code