Repository navigation
feat(replication)!: erase out-of-range records only under disk pressure - #271
Conversation
2db0ec4 to
d5a5f28
Compare
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — two source-confirmed lifecycle defects remain.
Coverage: 2/2 seats reviewed across all four scoped source bundles (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3), after one narrowed retry for unavailable scope responses. This is review coverage, not team consensus. The seats differed on starvation; I checked the objection against the ordering and budget code and the stated contract. The GLM review itself notes that arrivals at or above the limit can defer retries for the whole pressure spell.
The CI test, format, platform and storage checks pass. Security Audit fails on inherited crypto-bigint 0.5.5 (RUSTSEC-2026-0387); this PR does not change Cargo.lock, so it is not introduced by this change. The ADR also says the required dev testnet validation has not been run. The PR remains a draft.
No local test suite was run during this review. Author-reported test totals are not treated as independently executed results.
| let order_key = |record: &T| { | ||
| let key = key_of(record); | ||
| ( | ||
| // `None` sorts first, so records not demoted go first. |
There was a problem hiding this comment.
[P2] Keep failed candidates from starving under a saturated arrival stream.
erasure_order puts every never-demoted candidate before every failed candidate, then the caller applies a bounded candidate/request budget. If each pressure pass brings at least that many new eligible candidates, the budget is consumed by the new (undemoted) prefix every time; a previously failed candidate is never retried, even after its close group becomes able to prove it. This contradicts the module contract at lines 169–172 that a failed record gets another turn within the spell “however many new candidates arrive.”
Reserve bounded retry capacity or rotate the complete eligible set so failed candidates make progress under a saturated stream. Add a regression where arrivals meet or exceed the per-pass selection budget.
There was a problem hiding this comment.
Confirmed: the doc comment overclaimed, and the test only covered a few arrivals per pass. Fixed in cdfea33, which corrects the comment and ADR-0028 and renames the test: failed records wait while records not yet tried fill a pass's budget.
I'm keeping the order rather than reserving retry capacity. A waiting record stays on disk, which is the safe direction, and the budget still goes to records that may free space. The one loss is a pass where every new record fails while an older one would now pass. a_limit_takes_the_first_records_of_the_whole_order pins this order.
| @@ -342,6 +383,19 @@ impl PaidList { | |||
| self.record_out_of_range.write().remove(key); | |||
There was a problem hiding this comment.
[P2] Make cleared record clocks durable before they can be reused.
This removes a clock only from memory. The saved snapshot is rewritten later, after the prune pass; if the process crashes before that write (or the write fails), the old file still contains the mature timestamp. After restart, if the record is out of range again before a completed pass durably clears the old snapshot, that stale age makes it immediately eligible instead of starting a fresh hysteresis period. This breaks the continuous-out-of-range prerequisite and can permit early erasure after a topology change.
Persist a reset/tombstone with crash-safe ordering (or use a generation scheme) before a later out-of-range observation may reuse the old clock. Add a regression for clear → interrupted/failed save → restart → out-of-range again.
There was a problem hiding this comment.
Confirmed. In cdfea33 the record pass saves its clocks before any audit or deletion, as the pointer pass already does, so a restart during those can't bring back a clock the pass stopped.
A crash before that save, or a failed write, still can. I'm not adding a tombstone or generation scheme for that window: ADR-0028 already accepts the same result after a long outage, and I've noted this case there. Erasing the record still needs disk pressure and 6 of 7 proofs from the current group. I haven't added the regression test, because it would need test hooks inside the pass to crash at the right point.
A node now erases a record it is no longer responsible for only when its disk needs the room: farthest first, and each only after 6 of 7 of its current close group prove they hold the record (ADR-0028). An out-of-range copy costs nothing until then, and it is one more copy repair can draw on. Pressure starts below 10% free, or twice the store's reserve, and ends at 15% and three times the reserve. Free space is measured again before each deletion, and a failed measurement erases nothing and logs a warning. Under pressure, passes also run between neighbour-sync cycles, once the minimum sync interval has passed since the last one finished. Records whose proofs failed go last, oldest failure first. Every prune deletion now needs close-group proofs: the fast path and the pointer far path are gone. The prune audit budget rises to 1,024 requests a pass, since every erasure now needs them. Pointer proofs ask the whole group with a 5 s limit per request and await every answer. A retained record keeps the paid-list entry that authorises its repair, so entries are no longer pruned by rank and confirmations. Above 15 million entries, about 85% of the store, a pass evicts down to the threshold: farthest from the node first, never entries near it or those of records it still stores. Sync hints read only the entries near the node, so their cost doesn't grow with the list. The store's map grows to 1 GiB. Out-of-range clocks are wall-clock times, saved when they change, so a restart doesn't leave a node with nothing to erase. In-range records clear their clock only once bootstrap has drained. Atomic file writes now work on Windows, which also fixes the commitment retention snapshot there. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
406b37b to
c435a2b
Compare
A prune pass stopped clocks in memory at its start and wrote them only at its end, so a restart in between brought back the old clock. Save them before any audit or deletion, as the pointer pass already does. Failed records wait while records not yet tried fill a pass's budget. Correct the comment and ADR-0028, which said otherwise, and rename the test that covers a few arrivals per pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
REQUEST_CHANGES — a paid-hint prefilter omits keys for which the target peer is responsible.
On this head, both paid-hint builders restrict enumeration to keys near this node before checking each key's actual close group. A sparse far-key region breaks that implication. With zero-extended IDs self=0, peers A=1 and B=2, and group size 2, the local radius is 2 and near_bits_for_radius returns 4. The scan around self therefore covers 0..15 and drops K=129. Yet for K=129, XOR distances are A=128, self=129, B=131; find_closest_nodes_local_with_self(K, 2) includes A and self. A therefore misses a paid hint it should receive. The same self-only prefilter is used in build_sync_hints_for_peers and build_paid_hints_for_peer.
Use a conservative filter that cannot exclude a key for which any target peer may be in the paid close group (or keep the full scan), and add regression tests for both builders with a sparse/far key.
Coverage: both seats independently returned CHANGES_REQUIRED on the current neighbor-sync scope, and both returned APPROVE on the focused lifecycle-finding re-review (2/2 seats each: sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). The previous four-scope review covered the earlier 20-file diff at head 406b37b; it is not claimed as full-panel coverage at this head. The prior findings were re-reviewed separately. No merge authority is implied.
| } | ||
|
|
||
| let all_paid_keys = match paid_list.all_keys() { | ||
| let self_xor: XorName = *p2p_node.peer_id().as_bytes(); |
There was a problem hiding this comment.
[P2] Keep the paid-hint candidate filter conservative.
Filtering by this node's radius drops paid keys for which the target peer is still in the key's paid close group. With zero-extended IDs self=0, peers 1 and 2, group size 2, the self-radius is 2 and the 4-bit prefix scan includes only 0..15; key 129 is excluded. But for key 129, XOR distances are 128 to peer 1, 129 to self, and 131 to peer 2, so peer 1 and self are the closest two. The per-key responsibility check never sees this key. build_paid_hints_for_peer uses the same prefilter. Use a conservative candidate bound that cannot omit a key needed by a target peer, or keep the full scan, and add regression tests for both builders.
There was a problem hiding this comment.
Confirmed: where few peers are known near a key far from this node, this node or a neighbour can still be in its paid close group, and my comment saying otherwise was wrong. Fixed in 468bc3b: both builders now share one step that reads every entry up to 100,000 entries, as main does.
Above 100,000 entries it still reads only near keys, so the gap you describe remains there. I'm not keeping the full scan at any size because of its cost:
- Each entry read costs a routing table lookup, measured at 14.5 µs with 24 peers, which extrapolates to about 100 µs with 200.
- Hints are built every round and for every inbound sync, and entries are no longer pruned by rank.
I also looked at a filter based on bucket fill, which would skip nothing. On our 60-node testnet only 10 nodes had a full far bucket, so most would read everything anyway. With evenly spread IDs, a simulation of 100 to 2,000 nodes found no misses with the near filter.
Tests:
a_neighbour_gets_a_paid_hint_for_a_key_far_from_this_nodedrivesbuild_paid_hints_for_peeron a 25-node network. It failed on both runs with the full scan switched off.a_far_key_is_read_for_hints_until_the_list_is_too_large_to_read_wholeuses your example.build_sync_hints_for_peershas no test of its own. It is covered only through the shared step, because it isn't public.
…rase The warning sat in the measurement that `capacity_verdict()` shares, and the pointer verification loop polls that every 500 ms. A dev-testnet run with a broken store path logged it twice a second. Log it from `disk_space()` instead, which only the prune path calls. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…entries Hint builds read only the keys near this node, assuming a neighbour never needs a hint for a far key. Where few peers are known near a key, this node or a neighbour can still be in its paid close group, so a hint could be missed. `main` reads every entry and had no such gap. Read every entry up to 100,000 entries. Above that, keep reading only near keys: each entry read costs a routing table lookup, hints are built every round and for every inbound sync, and entries are no longer pruned by rank. ADR-0028 records the limit, and comments that claimed far entries never matter are corrected. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Brings the branch up to main f37429a, which adds pressure-gated retention (WithAutonomi#271), get-or-closer chunk GETs (WithAutonomi#249), the WebRTC UPnP mapping (WithAutonomi#264) and listener summary (WithAutonomi#278), and the consolidated PR CI (WithAutonomi#269). The only conflict was the ADR index: ADR-0024 now sits before main's ADR-0027 and ADR-0028. None of main's changes touch the fetch scheduler.
Takes the per-source scheduler branch at f568f91, which merged upstream main f37429a, so this branch carries main's pressure-gated retention (WithAutonomi#271), get-or-closer chunk GETs (WithAutonomi#249), WebRTC changes (WithAutonomi#264, WithAutonomi#278) and the consolidated PR CI (WithAutonomi#269) through it. The only conflict was the ADR index: ADR-0025 now sits before main's ADR-0027 and ADR-0028. The resulting tree equals a direct merge of this branch with main apart from that index.
Brings the branch up to main f37429a. The conflicts came from pressure-gated retention (WithAutonomi#271, ADR-0028), which rewrote the paid list, pruning and paid hints that the price-contested mark hooks into: - paid_list.rs: the mark, its in-memory copy, the write order and the mark reads now sit on main's paid list, with its saved record clocks, its MapFull reporting (a marked insert reports a full store the same way) and its range reads. The paid out-of-range timers and the prune cursor stay removed. Eviction goes through remove_batch, which already drops the in-memory mark of every key it was asked to remove. - pruning.rs: paid pruning by rank is gone, so the rule that kept a price-contested entry through it while its record was held goes too: eviction keeps the entry of every record still stored, marked or not. Prune audits still score no absent peer for a price-contested key. - neighbor_sync.rs: main's paid-hint reads and the replica-hint filter for marked keys are both kept. - tests: the e2e test that a prune audit scores no one for a price-contested key now runs its passes under disk pressure, since without it no prune audit is sent. The price-floor PoC keeps this branch's rewrite, which attaches a commitment source, rather than main's ignore of the old test that had none.
Linear issue
Closes V2-1833
Risk tier
Compatibility
AuditChallengepath, and they are only sent under disk pressure.record_out_of_range.binandpointer_out_of_range.binhold the saved clocks, and older binaries ignore them. The paid-list LMDB map grows from 256 MiB to 1 GiB; its file grows only as entries do. Nodes keep out-of-range records until disk pressure, so disks fill to about 85–90% before anything is erased.PrunePassContextgainsdisk_pressure.ReplicationConfiggainspaid_list_evict_start_entries.PointerReplication::prune_passtakes a&DiskPressure.audit::audit_tick_with_repair_proofstakes thePaidList, to sample only in-range records.NeighborSyncState::prune_cursoris removed.replication::config::MAX_FAST_PRUNE_DELETIONS_PER_PASSis removed, andMAX_PRUNE_AUDIT_REQUESTS_PER_PASSrises from 256 to 1,024.PruneResultgainsdisk_pressureandrecords_retained, and its paid counters becomepaid_entries_evicted.disk_pressure=andretained=, drops the fast-path fields, and its paid part becomespaid total= evicted=.PaidListloses its paid timers, its prune cursor andall_keys, and gainskeys_nearandevict_far. Its record timestamps areSystemTimes, andremove/remove_batchleave them alone.replication::config::PAID_HINT_FULL_SCAN_ENTRIES,storage::DiskSpace,ChunkStore::disk_space,ChunkStore::disk_reserve, and the test-onlyChunkStore::set_test_disk_space.Semver impact
Test evidence
cargo test --lib --features test-utils: 1248 passed, 0 failed.replication::retention: the thresholds and their hysteresis, the reserve-based bounds, a failed measurement erasing nothing, farthest-first order with failed proofs last, pass spacing, and two fairness tests.a_few_new_candidates_each_pass_do_not_stop_failed_ones_being_retriedfailed before the retry order was fixed, with a failed record getting 1 turn in 40 passes.replication::out_of_range_clocks: a saved clock keeps its start and counts time offline, a wall clock set back, saving only on change, a corrupt or missing file.replication::neighbor_sync: a far key read for hints while the list can be read whole, and only near keys above that.replication::paid_list: near ranges, eviction farthest first up to its limit and never what it keeps or what is near, record clocks surviving a reopen, the file not taking the whole map up front.cargo test --test e2e --features test-utils -- --test-threads=1 paid_ prun pointer out_of_range_records_stay audit: 41 passed, 0 failed.MDB_FIXEDSIZE, which this build doesn't set, and a test pins it.cargo clippy --all-features -- -D clippy::panic -D clippy::unwrap_used -D clippy::expect_used: clean.cargo clippy --all-targets --all-features -- -D warnings: no findings in changed code. The six remaining warnings predate this PR.cargo fmt --all -- --checkandRUSTDOCFLAGS="-D warnings" cargo doc --all-features --no-deps: clean.c435a2bf, before the follow-ups; same fleet, with a test build using a 20-minute hysteresis, a paid threshold of 1,000 and no near margin; 12 GB uploaded, no failures):ed82e2f4.New dependency
none.
fs2is already a dependency; this adds a call to itstotal_space.ADR
docs/REPLICATION_DESIGN.md: §11 and "Capacity-managed mode" updated. The spec already asked to evict the farthest out-of-range keys when full; nothing implemented it until now.Mitigation / rollback
Revert this PR to go back to scheduled pruning. Watch disk usage and the prune line's
disk_pressure,retained,budget_deferred,audit_below_thresholdand paidtotal=counters. A node that reaches its reserve while still holding retained records means erasure isn't keeping up.Summary
A node erases a record it is no longer responsible for only when its disk needs the room: farthest first, and each only after its close group proves it holds the record.
When pressure applies:
Under pressure:
Without pressure: no prune audits are sent at all.
Why: an out-of-range copy costs a node nothing until its disk needs the room, and until then it is one more copy repair can draw on.
Paid-list entries: a retained record keeps the entry that authorises its repair, so entries are no longer pruned by rank.
Clocks: out-of-range clocks are wall-clock times, saved when they change, before a pass audits or erases anything. A restart doesn't leave a node with nothing to erase. Time offline counts, and in-range records clear their clock only once bootstrap has drained.
Also:
Code:
src/replication/retention.rs(new): the shared pressure state, its hysteresis, the erase order and pass spacing.src/replication/out_of_range_clocks.rs(new): keeping and saving the clocks.src/replication/paid_list.rs: near ranges and eviction.src/replication/pruning.rs,src/replication/pointer.rs: gate on pressure, order farthest first, demote failed proofs, re-check before each delete.src/storage/chunk_store.rs:disk_space()measures the partition the chunk and pointer stores share.Known limits:
🤖 Generated with Claude Code