Uh oh!
There was an error while loading. Please reload this page.
fix(automation): resume a paused run from the shared store, not from a stale per-replica snapshot - #14334
Conversation
…store WIP — implementation only; regression tests follow. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…block Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…-authoritative resume Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 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 945b6971f1e510ceba3a4ecbbe520f5580d63229 && git checkout 945b6971f1e510ceba3a4ecbbe520f5580d63229
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin f645d6f8879f5e868b8c0aac978dc0db92552739 74c5880bedb017c3f7a144579c71e4fe79d4544a && git checkout -B drift-repro f645d6f8879f5e868b8c0aac978dc0db92552739 && git merge --no-ff 74c5880bedb017c3f7a144579c71e4fe79d4544a
node scripts/docs-audit/affected-docs.mjs --json f645d6f8879f5e868b8c0aac978dc0db92552739
|
os-sales
commented
Sep 2, 2026
Landing provenance ( Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#13617
What was wrong
AutomationEnginekeeps paused runs in a per-processMap, andloadSuspendedRunStrictread that map first, consulting the durablesys_automation_runrow only on a miss. That is a correct read for exactly onedeployment shape: a single process. Put three replicas behind a load balancer
over one postgres and the map becomes a per-replica snapshot of the node a run
was parked at the last time that replica touched it — and nothing invalidates
it, because there is no invalidation channel to it at all.
Traced against the report's three-level approval flow:
lv1and keeps it in memory;lv1decision round-robins to replica B, which advances the run tolv2in the store and in B's memory — A's memory still says
lv1;lv2decision lands back on A, which read its own memory, resumed fromlv1, and traversed tolv2a second time.That is the reported symptom exactly: the same level re-opened as a fresh
pending request tens of milliseconds after the previous one completed, so one
approver approves every level twice and a three-level flow yields five
sys_approval_requestrows. Land the same one-beat-stale read on the finallevel and the run rolls back to the previous level and never terminates, which
is the second shape the reporter added in their follow-up comment. Both shapes
are one mechanism at two points in the flow. A single replica shows zero
duplicates because there is one map and it is never behind.
Two callers that must not be wrong funnel through that one reader:
resumeInternal(which node does this resume continue from) andhasSuspendedRun(the approvals pre-flight that decides whether to record adecision at all), so both were reading the same stale answer.
The fix
The resume path is now store-authoritative. With a
SuspendedRunStoreconfigured, the store answers where a run is parked; the in-memory map is
consulted only for a run whose durable save failed.
That last clause is deliberate and is why a new
cacheOnlySuspensionssetexists.
persistSuspendedRundocuments a degradation and logs it aterror: afailed durable save costs cross-restart durability, not in-process
resumability. Making the store authoritative without tracking which rows it
never received would have silently converted that into an unresumable run. The
store's "no row" is authoritative for every run it ever accepted — including the
runs this process advanced past, which is the whole defect — and says nothing
about a row it was never handed.
forgetSuspendedRunclears the qualifier alongside the cache entry; it is thesingle choke point every consumption passes through, so the set is bounded by
the map it qualifies.
The resume ordering is untouched. The suspension is still consumed before
traverseNext, andforgetSuspendedRunis unchanged — this changes onlywhich suspension is read, never when it is consumed. #13937 owns that fork
and it is not pre-empted here.
The triage questions, answered
1. Is the missing
attachClusterPubSub()this card's root cause, or anothergap in the same environment? Another gap — measured, not argued.
attachClusterPubSubexists only inpackages/metadata(MetadataManager) andpackages/objectql(the write-epoch mirror); a grep for cluster or pub/subwiring across
packages/services/service-automation/srcandpackages/plugins/plugin-approvals/srcreturns nothing but one unrelated prosecomment. There is no invalidation channel to the run-state map that the missing
bridge could have disabled. Attaching that bridge moves none of the tests in
this PR; reading the shared store moves all of them.
2. Is "resume from shared storage" the fix or a workaround? The fix, and it
is the card's own stated expectation. No lock and no serialization of
approvewas added — that would have been masking, and per the triage constraint it would
have been reported rather than chosen.
3. Cluster surface not re-done. Nothing outside
packages/services/service-automation/srcis touched. The reporter's negativecontrols (record-change dispatch fires once, the scheduled-job fence elects one
leader, one notification per event) are consistent with a defect local to this
one reader, and that is where the change stays.
packages/services/service-clusterwas read as an exemplar only — no write.
Sibling #13686 (interval-job leader election) refuted as the cause. An
approval resume arrives on the decision-write path —
ApprovalServicecallsautomation.resume(runId, ...)directly once it has recorded the decision — notfrom a job tick. No scheduling is involved, so leader election cannot be the
mechanism. #13686 landing did not and could not fix this.
Verification
Reverse verification, from the committed state, with the mutation confirmed on
disk (blob hash changed; injected line counted) and the restore confirmed by
byte identity against the HEAD blob plus an empty
git diff HEAD:Restoring the old cache-first order at the top of
loadSuspendedRunStricttakesthe new pin file to 4 red / 4 green, measured:
[ 'lv1', 'lv2', 'lv2' ]where['lv1','lv2','lv3']is correctexpected 'paused' to be undefined— the run never terminatesSTORE_UNAVAILABLEat all — the cache hit meant the broken store was never readThe four that stay green under that mutation are the ones that must: the
single-replica control (the report's own control), the healthy cold-replica
control, the no-store control, and the failed-durable-save degradation. A fix
that moved the defect rather than removing it would have taken one of those
with it.
Refusals are asserted by
code(RUN_NOT_FOUND,STORE_UNAVAILABLE) plus theabsence of a run
status, never by a bare throw.Ran, all at
74c5880bunless noted:pnpm --filter @objectstack/service-automation test— 97 files, 1157 tests, green.pnpm --filter @objectstack/plugin-approvals test— 35 files, 652 tests, green.These resolve
service-automationthrough its builtdist, so the fix isverified through the published entry point, not only in source. (Both packages
needed their dependency closure built first; the initial resolve failures were
a stale worktree, not this change.)
tsc --noEmitonservice-automation: exactly 3 errors, byte-identical tothe frozen ledger entry (TS2341 x3 in
nested-region-parity.test.tsat95/151/180). This change adds none.
--listFilesconfirmsengine.ts, the newpin, and
wait-node.test.tsare all really in the program.node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands(35 commands = 27 by path and kind, plus 8 the changeset brings): 34 green, 1
NOT MEASURED. The one is
scripts/check-test-completeness.mjs, which exits 3= PREREQUISITE NOT MET because it grades a saved
turbo run testlog that onlyCI produces; its own output says this branch is not a finding. Exit codes were
captured before any pipe.
pnpm lint(repo-wideeslint . --no-inline-config) — green, 71s. Notnarrowed; the whole scan ran.
check:type-check-debt,check:type-check-coverage,check:engine-double-contract,check:where-matcher,check:query-options-erasure,check:objectql-double-limit— all green.Also in this diff, and why
Three prose corrections that this change makes necessary rather than optional.
builtin/wait-node.tsand its test documented, at length, thatSTORE_UNAVAILABLEwas reachable only from the re-arm callback because a runparked in this process was answered from memory for the life of its suspension.
That is no longer true, and the ablation measured it turning red. The notes now
say so. The arming-path specimen itself is unchanged on purpose: it runs on an
engine with no store, so what it pins is still the handler's branch.
suspended-screen-durability.test.tshad one comment asserting a same-processread needs no store read; corrected, assertions untouched.
Deliberately not changed
cancelRunandfailAncestorsalso read the map before the store, andlistSuspendedRunsDurablemerges with in-memory entries winning. They are thesame class but not the same fix: the first two carry bespoke degradation
contracts with their own recorded verdicts, so correcting them is a judgment
call rather than a mechanical one, and the third has no in-repo consumer. Filed
as #14332 rather than folded in.
A residual this PR does not close: two decisions for the same run arriving
on two replicas simultaneously can still both read the same fresh row and both
advance, because the
resumingguard is per-process. That is a concurrencyrace, not the reported one-beat staleness (the report's decisions are human
approvals, sequential), and closing it needs a compare-and-set on the run's
advance — an optimistic-version column on
sys_automation_run, i.e. a schemachange and a widened
SuspendedRunStorecontract. Filed as #14333, not built.🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code