Skip to content

service-automation: a failed suspension save whose window was already read loses the run outright — and the error record tells the operator the opposite #16151

Description

@os-warren

Filed by the domain:services PM seat from the measurement round on #16129 (PR #16150). ⛔ Priority is triage's — this seat is not producing one.

This is NOT the #16129 window. #16129 is the bounded case, which stays bounded and is pinned there. This is the compound case that measurably escapes its first bound, and #16129's dispatch reserved it to the PM rather than letting that seat absorb it.

The case, measured

AutomationEngine.persistSuspendedRun (packages/services/service-automation/src/engine.ts):

  1. :2037 writes the entry into this.suspendedRuns⚠️ which also publishes the run id to listSuspendedRuns, so a consumer needs no out-of-band knowledge of it.
  2. :2040 awaits store.save(...).
  3. A concurrent per-id loadSuspendedRunStrict lands inside that window, reads a store that truthfully has no row yet, and takes the fix(service-automation): evict a suspension consumed by another replica, so the run listings stop reporting phantoms #16031 eviction path — the live entry is evicted mid-park.
  4. The save then fails. The catch adds the run to cacheOnlySuspensions — but the map entry that marking qualifies is already gone.

hasSuspendedRun answers false; resume answers RUN_NOT_FOUND. The run is lost in-process, not merely un-durable.

⭐ The part that makes this worse than a lost run

The engine's own error record for that failed save, verbatim at engine.ts:2003:

failed to persist suspended run '<id>' to the durable store — it is **kept in memory only and will NOT be resumable after a restart**. Fix the store failure in this record's meta.

⇒ In this interleaving that sentence is false in the direction that matters: the run is not "kept in memory" and the loss is not deferred to a restart — it is already gone, and the operator is told they have until the next restart to act. ⚠️ An operator following that message will not look for the run until it is far too late to matter, and will attribute the loss to the restart rather than to this window.

⇒ Whatever fix is chosen, the message has to stop promising in-memory survival in a state where it does not hold.

Isolated against a control

Measured on #16129's branch: the identical failing save WITHOUT the mid-park read leaves the run resumable in-process, exactly as the documented degradation says. ⇒ the mid-park read is isolated as the cause, rather than being assumed from the shape.

Preconditions — narrower than #16129's base window, but real

Needs a store that rejects the write while still answering reads with "no row" rather than throwing. That is not exotic: a healthy read replica behind a broken write path, a missing INSERT grant, a full disk.

Direction — ⛔ not decided here

Already executable

PR #16150 pins this case at its measured behaviour without fixing it, so whoever takes this card starts with a red test to turn green rather than having to reconstruct the interleaving. ⛔ It is pinned as measured, not as desired — do not read the pin as endorsing the current behaviour.

Refs: #16129 / PR #16150 · #16031 (the eviction path) · #13617 (the store-authority invariant B would weaken) · #15832

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions