fix(persistence): version-guarded restore_deleted, PUT race retry, and the two-handle cluster harness (Phase 0.1) - #905
Open
aacruzgon wants to merge 2 commits into
Open
fix(persistence): version-guarded restore_deleted, PUT race retry, and the two-handle cluster harness (Phase 0.1)#905aacruzgon wants to merge 2 commits into
aacruzgon wants to merge 2 commits into
Conversation
…y PUT races (F5)
Two unconditional PUTs racing onto one soft-deleted id both read the same
tombstone version, both computed the same next version, and wrote it back
with no version predicate: the second history insert violated the
(tenant_id, resource_type, id, version_id) primary key (a 500), and the live
row and history row disagreed on the body. This was the last unguarded
read-then-write on the Postgres write path (`update` and `delete` were
already compare-and-swaps), and it bites harder once several instances share
one database. Mongo's restore was already a CAS, but its loser surfaced
`NotFound`, so a PUT racing another PUT returned 404.
`restore_deleted` on Postgres now folds the row update and the history
insert into one statement guarded by `is_deleted = TRUE AND version_id =
$expected`; zero rows re-selects to report `VersionConflict` (with the
version actually stored) or `NotFound`. `create_or_update` on Postgres and
Mongo retries a bounded number of times from a fresh read when the write
reports `VersionConflict`, `AlreadyExists` or `NotFound` — none of which is a
legitimate answer from a method that decides existence itself — so
unconditional PUT stays last-writer-wins at the API surface. Mongo's restore
distinguishes the same two outcomes. The trait doc gains a concurrency
contract and an `# Errors` section.
Also lands the two-handle T2 cluster harness
(`tests/common/cluster_harness.rs`: two independently constructed backends
over one shared store, barrier-started racing, definition-of-done assertion
helpers). It is calibrated against the already-cluster-safe bulk-export job
store, so it is proven before any product change relies on it; every later
cluster-capable subsystem points the same helpers at its own store.
Tests: four harness calibration tests (bulk-export visibility, isolation,
claim exclusivity, fencing after release, durability) pass on the unchanged
tree. Red-first on the unchanged tree: the `create_or_update` race failed
with `VersionConflict { expected_version: "1", actual_version: "2" }`, the
Postgres restore race with `Failed to insert restore history: db error`, and
the Mongo restore race with `NotFound`; all pass after the fix (5 rounds per
run, 2 runs each). Wrong-tenant isolation rows on both backends. Full
`postgres_tests` (165) and `mongodb_tests` (80) binaries green.
The cluster-capable-state planning docs (docs/cluster-*.md) and the draft-issue scratch files (docs/draft-issues-*.md) are local working notes, like the tmp/ convention already documents; they never ship, and until now one broad git add could have staged them.
This was referenced Sep 2, 2026
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
F5: version-guarded restore + PUT race retry, and the two-handle cluster harness
First PR of the cluster-capable-state rebuild (discussion #223, Phase 0.1). It fixes the one live single-instance data bug left in the F5 finding and lands the two-instance test harness every later cluster PR is built on. No migration, no config change, no behaviour change for non-racing callers.
Why
Postgres
restore_deleted— the path an unconditionalPUTtakes onto a soft-deleted id — was the last read-then-write on the write path:SELECT version_id, compute+1in Rust,UPDATEwith no version predicate, then a separate historyINSERT. Two PUTs racing onto one tombstone both computed the same version; the second history insert hit the(tenant_id, resource_type, id, version_id)primary key (a 500), and the live row and history v3 carried different bodies.updateanddeletehad already been made compare-and-swaps onmain; this closes the gap. Mongo's restore was already a CAS, but a lost race surfaced asNotFound, so a PUT racing another PUT returned 404.Every later phase needs a test shape that proves a protocol across two instances. A cloned
Arcshares the in-process heap and proves nothing, so the harness constructs two backends independently over one shared database, races them from a barrier, and asserts the definition-of-done rows.Changes
Persistence — Postgres
restore_deletedlands the row update and the history insert in one statement guarded byis_deleted = TRUE AND version_id = $expected. Zero rows re-selects (deliberately without anis_deletedfilter: "someone restored" and "someone restored then re-deleted" are the same fact) and reportsVersionConflictwith the version actually stored, orNotFound. The pre-read stays: it is what lets the loser report a truthfulexpected_version, matching S3's restore contract.create_or_updateretries up to three times from a fresh read when the write reportsVersionConflict,AlreadyExists, orNotFound. None of those is a legitimate answer from a method that decides existence itself, so unconditional PUT stays last-writer-wins at the API surface.NotFoundis in the set becauseupdate's zero-row branch returns it when the row was deleted underneath it.Persistence — Mongo
create_or_update.restore_deleted's zero-match branch re-finds without theis_deletedfilter and reportsVersionConflictorNotFound, so the taxonomy matches Postgres and S3.Trait contract
ResourceStorage::create_or_updategains a# Concurrencyparagraph and an# Errorssection (it had none).Test harness
crates/persistence/tests/common/cluster_harness.rs,#[path]-included frompostgres_tests.rsandmongodb_tests.rs:two_handles, barrier-startedrace2,assert_exactly_one,assert_visible,assert_wrong_tenant_hidden.Hygiene
.gitignorecovers the local cluster planning docs underdocs/(separate commit).Testing
Red first, on the unchanged tree (3 of 3 runs):
Green after the fix (two runs each, five race rounds per run):
cargo test -p helios-persistence --features postgres --test postgres_tests -- cluster_— 9 passed (4 calibration, 3 F5 update/PUT/delete races, restore race, restore isolation).cargo test -p helios-persistence --features mongodb --test mongodb_tests -- cluster_restore— 2 passed.postgres_tests165 passed,mongodb_tests80 passed.cargo fmt --all; CI-exact clippy (--all-targets --all-features -D warningswith CI's-Aset) clean.Definition-of-done rows for the restore path: isolation ✓ (wrong-tenant PUT creates a fresh v1 and leaves the tombstone), exclusivity ✓ (exactly one racer restores; the other converges through
update), durability ✓ (history contiguous 1..=4, newest entry equals the live body). Visibility and fencing do not apply: a resource row has no lease, and cross-handle reads are plain CRUD already covered by the calibration suite.Notes
&Valuein the write paths would remove it.restore_deletedhas the same unguarded shape; out of scope here (SQLite cannot be clustered) and tracked as a follow-up.create_or_update_restores_deletedtests on both backends are untouched and still expect a three-entry history.