[Detail Bug] Saga recovery/retention returns terminal executions as incomplete due to stale etcd status index keys
Detail Bug Report for mirendev/runtime
Introduced in #232 by @evanphx on Oct 13, 2025
Summary
- Context:
EntityStorageis the durable, etcd-backed implementation of the sagaStorageinterface; itsSavewrites each execution as an entity andListIncomplete/ListTerminaldiscover executions by querying the entity store's per-status index. TheExecutorfirst persists every saga aspending, thenSaves it again on every status transition (running/undoing/completed/failed), and on restartRecoverresumes everythingListIncompletereturns. - Bug: On every
Saveafter the first, the prior status index key is never removed, so a saga's ID leaks into every status index it ever passed through; andEntityStorage.ListIncompletetrusts that index without re-verifying the decodedStatus(unlikeMemoryStorage, which filters by actual status). The two combine so terminal sagas are returned byListIncomplete. - Actual vs. expected: A
completed/failedsaga is reported byListIncompleteafter a normalpending→running→completed(or →failed) lifecycle; the contract (asserted by the conformance suite) is that "a completed saga must NOT appear inListIncomplete". - Impact: (1) On each restart
Recoverre-claims and re-decodes every terminal saga;failedsagas contribute asaga failed: …error per leaked entry, soRecover'srecovery completed with N errorsgrows with the historical failure count. (2)RunRetentionusesListIncompleteto determine which terminal children are still shielded by a live parent; a terminal parent leaking intoListIncompletemakes its expired terminal childrenSkippedforever, so they never get deleted (a retention stall). (3) The leakedpending/running/undoingcollection keys are never collected — neitherDeleteEntity, nor the backgroundCleanupStaleCollectionEntriessweeper, norReindexremoves entity-present-but-stale-attribute collection keys — so every saga that ever ran permanently leaves orphan status-index keys in etcd.
Code with Bug
pkg/entity/store.go — ReplaceEntity computes “original” indexed attributes from the replacement
// Keep track of original indexed attributes for removal
originalIndexedAttrs, err := s.collectIndexedAttributes(ctx, repl.attrs) // <-- BUG 🔴 reads replacement attrs, so old status index keys are never removed
pkg/saga/storage.go — ListIncomplete trusts the index and appends without re-checking status
exec, err := entityToExecution(sagaEntity)
if err != nil {
s.log.Warn("failed to convert saga entity, skipping", "id", ent.Id(), "error", err)
continue
}
executions = append(executions, exec) // <-- BUG 🔴 terminal sagas leaked into the non-terminal index are returned as "incomplete"
Explanation
- In etcd, the per-status index is maintained as separate collection keys. When a saga transitions status,
EntityStorage.Saveupdates the entity viaReplaceEntity. ReplaceEntitymistakenly collects the “original” indexed attributes fromrepl(the replacement entity), sobuildCollectionOpscompares new attrs against themselves and emits no delete operations for the prior status’s collection key.- As a result, a saga ID remains in every status collection it has ever had (e.g.,
pendingandrunningeven after it becomescompleted). EntityStorage.ListIncompletethen uses those index results without filtering by decodedexec.Status, so terminal executions can be returned as incomplete.
Codebase Inconsistency
UpdateEntity/PatchEntity source indexed attributes from the existing entity (the intended behavior), unlike ReplaceEntity:
originalIndexedAttrs, err := s.collectIndexedAttributes(ctx, entity.attrs)
Recommended Fix
- Fix root cause in
pkg/entity/store.goReplaceEntity: source “original indexed attributes” from the fetched original entity being replaced (matchingUpdateEntity/PatchEntity), so old status index keys are removed. - Defense-in-depth in
pkg/saga/storage.go: haveListIncompletefilter out decodedStatusCompleted/StatusFailed(mirroringMemoryStorage). Consider adding the symmetric guard inListTerminal(drop entries whose decoded status is not terminal). - Add a test against the real
EtcdStorethatReplaceEntitys an entity to a different indexed attribute value and assertsListIndexno longer returns the old value.
History
This bug was introduced in commit 783455ce. The change refactored the entity system to move ID/Revision/timestamps from struct fields to attributes and split ReplaceEntity to build a separate repl entity from the replacement attributes (via NewEntity(attributes)) alongside the fetched original entity. In doing so it flipped the "original indexed attributes" source from entity.Attrs (the existing entity, correct) to repl.Attrs (the replacement), so buildCollectionOps now compares the new attrs against themselves and never emits a delete for the prior status's collection key. The original 2c7324ebe (RFD 0017 CRUD) implementation was correct (collectIndexedAttributes(ctx, entity.Attrs) before entity.Attrs = attributes), and the later a4e3effc8 encapsulation refactor only mechanically renamed repl.Attrs → repl.attrs, carrying the bug forward without altering the logic.
Production confirmation: Selkie, September 6, 2026
This bug amplified a startup failure while upgrading a 2-core, 4 GB host from v0.11.1 to v0.14.0. Sandbox recovery loaded a large backlog of unrelated PostgreSQL teardown sagas. During a guarded retry, Miren RSS reached about 2.14 GB despite GOMEMLIMIT=768MiB; a host memory guard stopped it. No OOM kill was observed.
The pre-upgrade snapshot contains 94,837 teardown executions for one addon association: 74,873 failed and 19,964 pending, plus three other incomplete sagas. A later live pending index query reported 94,842 matches and returned entities whose actual status was failed.
We independently reproduced the writer bug against real etcd on main 34beeec5d054. Create an entity with indexed status pending, replace it with failed, then check the stored status and both indexes. The stored status and new index are correct, but the old index still returns the entity. Changing only repl.attrs to originalEntity.attrs makes the test pass. The defect is also present in v0.11.1. This was a local experiment; the correction has not been deployed.
Include repair of existing wrong-value index entries in the fix, with a regression test covering an entity that still exists. MIR-1389's missing-entity cleanup does not cover this case. Repair must be bounded, resumable, and safe against concurrent entity updates. Keep the decoded-status guards and real-etcd replacement regression already proposed above.
The companion recovery/retention issue covers bounded reads. Correcting indexes alone still leaves 19,967 actually incomplete executions in this snapshot.