Add operation_attempt secondary indexes, expiry sweep, and retention (#828) - #857
Conversation
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #857 +/- ##
==========================================
+ Coverage 83.83% 84.14% +0.30%
==========================================
Files 92 92
Lines 35419 36216 +797
==========================================
+ Hits 29695 30474 +779
- Misses 5724 5742 +18 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
[Reviewer Round 1]
|
|
[Review Verdict Round 1: NOT_APPROVED] |
|
[Author Round 1]
|
|
[Reviewer Round 2] The Round 1 P1 is resolved. No new findings. The updated diff implements the three required prefixed key spaces, preserves owed cleanup during expiry finalization, and applies the retention floor and OR-bound semantics using the required [Review Verdict Round 2: APPROVED] |
|
[Review Verdict Round 2: APPROVED] |
Suggested squash commitTitle Body |
The unique idempotency key alone cannot answer "is there a live operation for this host, target and instance?", which the orchestration guards need, and the answer has to survive a REView restart rather than sitting in process memory. Three secondary indexes live in the operation_attempt column family as prefixed key spaces, told apart by a leading byte no UTF-8 string can start with, so they need no column family of their own and none of the registration that would then have to land with the format bump. Every index entry is written and removed in the same transaction as its row, so a single-flight slot can never be held by a row that is gone. The sweep finalizes an attempt whose deadline has passed as failed, which frees that slot but leaves any owed cleanup recorded and visible: clearing it here is what would orphan a minted bootroot identity. The prune bounds the table by age and by count per triple while keeping the most recent finished attempt of each, because after a self-update tears down the response channel that record is the only thing left that can report the outcome. Both take the instant to measure against instead of reading a clock, so every threshold is deterministic under test. Closes #828
Deleting a row whose value no longer decodes left its index entries behind, on the reasoning that the table cannot name them without the record to derive them from. The single-flight entry, though, is checked as raw presence rather than by resolving the row it names, so an orphan holds its triple's slot against every later attempt while `live_attempt` reports the slot free — and no call can clear it, because the row it would be cleared through is already gone. Delete is the repair path for exactly the row that reaches this branch, so it reads the entries back out of the index space instead. That costs a scan of it, which a sound row never pays. Part of #828
An attempt keeps its `expires_at` index entry once it is finalized, so every deadline that has ever passed comes back on every later sweep. The sweep took an exclusive lock on each of those rows only to find it already terminal, which grows the transaction's validation set with the whole retained history and turns any concurrent write to an old row into a retry of the entire sweep. The lock is now taken only where the pre-transaction read saw work to do; the re-read under it still decides. Part of #828
The owed-cleanup key carries the idempotency key so that one triple can owe several cleanups at once, and the onboarding case reaches that index through an empty target segment, but nothing exercised either: every existing case had one owing row per triple and a non-empty target. The deadline scan's pre-epoch ordering was untested too, so dropping the sign flip that makes byte order time order left the suite green. Part of #828
Every write to `operation_attempt` has to maintain three secondary indexes in the same transaction as the row, and documenting that the generic `Table` writers bypass them left the bypass reachable: once the `Store` accessor lands, `table.put(&attempt)` would store a live row that `live_attempt` never returns and the sweep never finalizes, and `delete_with_transaction` would drop a row and strand its entries. The generic write API is bounded by `UniqueKey` and `Value`, so the record now implements neither and reaches its key and serialized value through private inherent accessors instead. `put`, `insert`, `update_with_transaction` and `delete_with_transaction` no longer exist for this table, which leaves the index-aware operations as the only way to write it. A `compile_fail` doctest pins that, paired with a passing one over a record that does implement the trait so a rename cannot make the negative test vacuous. Part of #828
341aa59 to
865b9c5
Compare
Summary
Adds the durable lookups, expiry sweep, and retention prune the
operation_attemptledger needs, all inside the singleoperation_attemptcolumn family — no new CF name, noStoreaccessor, no format bump.Three indexes as prefixed key spaces. A reserved leading tag byte (
0xf8non-terminal,0xf9owed-cleanup,0xfaexpires_at) separates each index from the record key space; no valid UTF-8 idempotency key starts that high, so a record key and an index key can never collide. Composite keys use length-prefixed segments, so(host = "ab", target = "c")and(host = "a", target = "bc")encode distinctly. The reserved bytes are documented in one table at the top of the module, and each index has one encoder used by every write, delete, and lookup path. Every entry is written and removed in the same transaction as its row, over oneOptimisticTransactionDBtransaction with the retry-on-"Resource busy" loop, so an entry can never outlive its record. Nothing can write the row on its own: the generic write API onTableis bounded byUniqueKeyandValue, andOperationAttemptimplements neither, soput,insert,update_with_transactionanddelete_with_transactiondo not exist for this table at all and the index-aware operations are the only writers the compiler admits.(host, target, instance)— the single-flight guard.upsertrefuses a second live attempt for a triple; the instance is part of the key, so a second instance may install alongside the first.live_attempt(host, target, instance)answers the lookup.(target, host, instance)— over rows whosecleanup_stateisSome(..), terminal ones included.attempts_owing_cleanup(target, host, instance)answers it.expires_at— over all rows whatever their action, timestamp-encoded so byte order is time order.expired_attempts(instant)returns them earliest deadline first.sweep_expired(instant)scans theexpires_atindex and finalizes every overdue non-terminal attempt withoutcome = Some(Outcome::Failed), touching no other field. The row leaves the non-terminal index — the single-flight slot is freed — while any owedcleanup_statestays intact and visible in the owed-cleanup index, so a host that never returns leaks neither the slot nor the record of an identity still owed a teardown. Idempotent: a second run finalizes nothing.prune(bound, instant)takesRetentionBound { max_age, max_terminal_per_triple }— both required, OR'd — and the instant to measure against. It keeps the most recent terminal attempt per(host, target, instance), where "most recent" is the greatest(started_at, idempotency_key)pair, plus every attempt that is still non-terminal or still owes a cleanup; those keep-rules beat the bound unconditionally. Age is measured fromstarted_at, never fromexpires_at. The boundary at each bound is specified in rustdoc and tested: an attempt exactly at the age cutoff is kept, and a triple holding exactly the count loses none.Neither operation reads the wall clock — both always take a
chrono::DateTime<Utc>, with noUtc::now()fallback.RetentionBoundis re-exported asOperationRetentionBound, andCHANGELOG.mdfolds the new capabilities into the existing unreleasedOperationAttemptentry, since no released version ever saw the record.Closes #828
Test plan
(host-a, piglet, Some(1))is refused while the first is live, and accepted once the first becomes terminal(host-a, piglet, Some(1))does not block(host-a, piglet, Some(2))Installattempt whose deadline passed is finalizedFailed, leaves the non-terminal index, and keeps its owedcleanup_statevisible in the owed-cleanup indexOnboardattempt expires by the same path as anInstallattemptcleanup_stateisSome(..), terminal ones included, and drops a row once its cleanup is clearedOnboardattempt owes its teardown under an empty targetcleanup_statebeing clearedcompile_faildoctest pins thatOperationAttemptis not admitted by it, paired with a passing one over a record that is, so the negative test cannot go vacuousstarted_at: withstarted_atorder deliberately the reverse ofexpires_atorder, the greateststarted_atsurvives; on a sharedstarted_at, the greateridempotency_keysurvivesexpires_atso ordering by it would give a different answercargo fmt -- --check --config group_imports=StdExternalCratepassescargo clippy --bins --tests --all-features -- -D warningspassescargo test --all-featuresis green (585 passed)markdownlint-cli2onCHANGELOG.mdis cleanubuntu-latestandmacOS-latest, plus thecoveragejob