Protect operation attempt iteration (#858) - #860
Conversation
The `operation_attempt` column family holds records and three prefixed secondary-index key spaces. Its inherent `iter` bounds the scan below the reserved range, but the record still implements `FromKeyValue`, which admitted its table to the blanket `Iterable` implementation: UFCS selects the unbounded trait `iter`, and `prefix_iter` with an empty or deliberately reserved prefix reaches index entries, which are not records and yield a decoding error rather than a row. Gate that implementation on a private opt-in marker carried by every other `FromKeyValue` record, so the exclusion holds unless it is removed on purpose rather than lapsing the next time a positive bound is broadened. `IndexedTable` keeps its unbounded implementation, leaving `NodeTable`'s hand-written iteration route unchanged. Closes #858. Part of #831.
`NodeTable` carries an inherent `iter` alongside its `Iterable` implementation, so method syntax in the new iteration test resolved to the inherent one and left half the test naming a route it did not take. Only `prefix_iter`, which has no inherent counterpart, reached the trait. Part of #858.
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #860 +/- ##
==========================================
+ Coverage 84.14% 84.26% +0.11%
==========================================
Files 92 92
Lines 36216 36331 +115
==========================================
+ Hits 30474 30613 +139
+ Misses 5742 5718 -24 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
[Reviewer Round 1] No findings. The private The paired rustdocs at |
|
[Review Verdict Round 1: APPROVED] |
Suggested squash commitTitle Body |
Summary
The
operation_attemptcolumn family holds records below a reserved byte and secondary index entries above it.Table<OperationAttempt>::iteralready bounds its scan below that byte, but the blanketIterableimplementation onTableadmitted the table anyway throughFromKeyValue: a caller could select the unbounded traititerwith UFCS, or callprefix_iterwith an empty or deliberately reserved prefix, and reach index keys that are not records and do not decode as one.This adds a private
iteration::Eligiblemarker insrc/tables.rs, implemented for every currentFromKeyValuerecord type exceptOperationAttempt, and requires it on theTableblanketIterableimplementation. The marker is an opt-in list rather than a negative bound, so the exclusion holds unless someone removes it on purpose rather than lapsing the next time a positive bound elsewhere is broadened. Nothing is exported, so no new public surface appears and nothing outside the crate can implement it.The
IndexedTableblanket implementation deliberately does not carry the bound — no table that reserves part of its key space is indexed, andNodeTable's hand-writtenIterableimplementation iterates through that route. Public rustdoc onIterable, on theoperation_attemptmodule, onOperationAttempt, and on the inherentTable<OperationAttempt>::iternow explains the record/index key-space boundary and why generic iteration excludes this table.The generic write API remains unavailable for
OperationAttempt, so every ledger write still maintains its indexes atomically. Index encoding, single-flight, expiry, sweep, retention, and the persisted record shape are unchanged, and no wall clock is read in the sweep or prune paths.Per the issue's out-of-scope section, no
CHANGELOG.mdentry is added: the column family is absent fromMAP_NAMESand the table has noStoreaccessor, so this change is not reachable from the last release.Closes #858
Test plan
cargo fmt -- --check --config group_imports=StdExternalCratepassescargo clippy --bins --tests --all-features -- -D warningspassescargo test --all-featurespassesOperationAttemptholds: the plain doctest taking&Table<TorExitNode>compiles and thecompile_fail,E0599doctest taking&Table<OperationAttempt>does not, with both making the identicaltable.prefix_iter(todo!(), None, b"")call, importingIterable, and leaving the iterator and theDirectionargument inferredcompile_failregressions for the generic write API onOperationAttemptstill fail to compileTable<OperationAttempt>::itertest covers a non-terminal attempt withcleanup_state = Some(..), which owns all three index entries, and the bounded scan yields the record with no decoding errorTable<CoreComponent>,Table<Agent>, andTable<ExternalService>, through bothiterand an empty-prefixprefix_iterNodeTable's hand-writtenIterableimplementation, including itsIndexedTable<InnerNode>-backed route, still compiles and composes eachNodefrom its three column familiesiteration::Eligiblemarker remains unexported and covers every currentFromKeyValuerecord type exceptOperationAttempt