Repository navigation
fix(replication): build sync hints from close groups per key range, and widen the paid near range - #280
fix(replication): build sync hints from close groups per key range, and widen the paid near range#280mickvandijke wants to merge 6 commits into
Conversation
A node's near range now reaches its 60th closest routing-table peer with no margin, instead of its 20th with two extra bits. It covers about the same number of nodes per key, and reads local peer density from more peers. With that range and evenly spread IDs, a simulation of 500 to 10,000 nodes found no key this node or a neighbour needs outside it, so a sync hint build now reads only near entries at any list size. The 100,000-entry limit is gone. A node knowing fewer than 60 peers still counts every key as near. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The 100 µs figure was extrapolated from test builds, which this repo compiles at opt-level 1. Replaces it with a release-build measurement, and names the hardware it was taken on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A hint build looked up every stored record and every paid entry it read in the routing table, one key at a time, and each lookup clones and sorts the whole table. A build now takes one copy of the routing table and works out each key's close group once per key range, then reads only the ranges where the peer it builds for can be in the group, from the record store and the paid list. Each key gets the group a lookup for it would give, so the hints don't change. Because each key's group is exact, a peer gets the paid hint for a key however far the key is from this node, so hints no longer read only near entries. The near range now only decides eviction, and its helper moves to pruning. Inbound syncs use the same batch builder as outbound ones, so they take one routing-table copy instead of two and are logged. The log line reports candidates=, stored_keys_read= and paid_keys_read=. PaidList::keys_near is replaced by a crate-private keys_in_ranges, and ChunkStore gains a crate-private keys_in_ranges. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dmission A prune pass looked up every stored record in the routing table, including the out-of-range records now kept until disk pressure, and hint admission looked up every hinted key this node didn't already know. Each now builds one map per pass or batch: the retention width for pruning, and the paid close group for admission. CloseGroupMap::group_of returns a group closest first, as a lookup does, so the strict group is still the head of the retention group. The check just before an erasure still does a fresh lookup, since the routing table may have changed while the proofs were gathered. Both widths are pinned by unit tests: mapping either at the wrong width passed every existing test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
clippy 1.99 rejects assert!(x.is_empty()) under -D warnings (assert_is_empty). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
MaidSafe-Hermes
left a comment
There was a problem hiding this comment.
Review — Changes requested
Blocker: unbounded map construction can exhaust CPU and memory. CloseGroupMap::new recursively splits key space in Walk::split without a work or range limit. For 30 candidate IDs consisting of zero plus one ID with each of 29 distinct high bits set, and group_size = 20, the branches recur as F(n,k)=F(n-1,k)+F(n-1,k-1), producing C(30,20) = 30,045,015 leaf ranges. Each leaf is stored as a GroupRange. The map is built from routing-table peers during hint builds, admission, and pruning, so a pathological candidate set can turn those paths into an allocation/CPU denial of service. Add a bounded split/range budget and fall back to a range with the remaining open candidates for per-key evaluation; add a regression test for the skewed candidate pattern.
Panel coverage: 2/2 seats reviewed both evidence scopes (sol-1 gpt-6.1-sol xhigh; glm-1 z-ai/glm-5.3). They disagreed on the map's worst-case complexity: sol-1 identified the recurrence above; glm-1 assessed the recursion as O(n). I independently checked the split paths and recurrence; the O(n) assessment does not account for the same candidate subsets recurring with different need values.
Checks: PR CI is green except Security Audit, which reports crypto-bigint 0.5.5 / RUSTSEC-2026-0387. Cargo.lock is byte-identical to the base, so this audit failure is inherited, not introduced here. The targeted local Rust test did not finish: dependency compilation exceeded the command time limit. No dev testnet run is reported.
Reviewed head: 7b689c6e2a548fc7fd5bb9d4352d1aa40229d5a6. This review does not authorise a merge.
| // Sorted by ID and sharing every bit above this one, the open candidates | ||
| // run from those with this bit clear to those with it set. | ||
| let run = self.candidates.get(open.clone()).unwrap_or(&[]); | ||
| let set_from = open.start + run.partition_point(|peer| !bit(peer.as_bytes(), depth)); |
There was a problem hiding this comment.
High — bound this recursive construction. With candidate IDs 0 and one-hot IDs at each of 29 successive high bits, each split creates (a) the large remaining subset at need=k and (b) the same subset at need=k-1. At 30 candidates and group width 20 this gives C(30,20) = 30,045,015 leaves, all materialised as GroupRanges. Since routing peers feed this constructor on network-driven hint/admission/prune paths, a skewed set can cause severe CPU and memory use. Add a global split/range budget; once reached, emit the current prefix as one range retaining open and need so group_in does exact per-key selection. Add a regression test that asserts bounded range count and exact groups for this pattern.
There was a problem hiding this comment.
Fixed in ca44084b. Your recurrence was right: unlimited, that pattern splits into exactly C(n, G) ranges. I measured 10.4 million ranges, built in 17 s, at 26 IDs and a group of 13.
- The limit: a map now stops splitting after 1,024 ranges (
MAX_SPLIT_RANGES). The routing tables I measured gave 13 to 202. - Past the limit: each prefix still pending on the walk becomes one range that keeps its open candidates and
need, andgroup_inpicks the closest of them for each key. Groups stay exact at a higher cost per key, and a map holds at most 1,024 plus one range per bit of depth. - Regression test:
a_skewed_set_of_candidates_stays_within_the_range_limituses your pattern of zero plus one-hot IDs at 29 successive high bits, with groups of 1, 2, 7 and 20. It asserts the bound and checks every probed key's group against a full sort. With the limit removed, it fails at the group of 7, which splits into about 2 million ranges.
The same commit makes the loops over keys in hint builds, prune passes, admission and commitment rotation call consume_budget, so a large store doesn't hold a runtime worker. Commitment rotation now uses one map too.
A map now stops splitting after 1,024 ranges. Zero plus one ID per high bit splits into C(n, G) ranges, about 30 million for 30 IDs and a group of 20, where routing tables gave 13 to 202. Past the limit, each prefix still pending becomes one range whose open candidates each key compares, so groups stay exact. The loops over keys in hint builds, prune passes, admission and commitment rotation call consume_budget, so a large store doesn't hold a runtime worker the way the per-key lookups' awaits used to prevent. Commitment rotation uses one map instead of a lookup per stored key and pointer. The near range is three paid close groups out (PAID_LIST_NEAR_GROUPS), so it follows a configured group size; the 60th peer by default. A sync from a peer outside the routing table returns before building maps, and hint helpers read their store outside the shared loop, so the record-store read no longer goes through an artificial Result. Docs: the design doc's definition of near, and ADR-0028's lists. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Linear issue
Closes V2-1833
Risk tier
Compatibility
replication::config::PAID_LIST_NEAR_MARGIN_BITS,replication::config::PAID_HINT_FULL_SCAN_ENTRIES,paid_list::near_bits_for_radiusandPaidList::keys_near. Addsreplication::config::PAID_LIST_NEAR_GROUPSandpaid_list::near_bits_for_peer_distances. The removed items came in with feat(replication)!: erase out-of-range records only under disk pressure #271, so the only release they have shipped in is0.24.0-rc.1. Everything else added is crate-private. TheNeighbor-sync hint buildlog line reportscandidates=,stored_keys_read=andpaid_keys_read=instead ofstored_keys=andpaid_keys=, and inbound syncs are now logged too.Semver impact
Test evidence
Run locally on this branch:
cargo fmt --all -- --check: clean.cargo clippy --all-features -- -D clippy::panic -D clippy::unwrap_used -D clippy::expect_used: clean.cargo doc --all-features --no-deps: clean.cargo check --lib --no-default-features --locked: clean.cargo test --lib --features test-utils: 1,265 passed.cargo test --test e2e --features test-utils -- --test-threads=1 paid_ prun pointer out_of_range_records_stay audit hint_builds neighbor sync replica: 67 passed, and-- hint late_joiner scenario_26: 8 passed, includingverify_storage_gate's two hint tests.cargo clippy --all-targets --all-features -- -D warningsshows only the six findings already on the base branch, none in code this PR changes.The close-group map gives the same groups as sorting every candidate by distance:
hint_builds_match_a_lookup_per_key(e2e, 25 nodes): for every peer in a node's routing table, the paid hints over 400 paid entries and the replica hints over 150 stored records equal what a lookup per key gives.Each check was confirmed by breaking the code:
hint_builds_match_a_lookup_per_key.only_keys_whose_group_holds_a_target_are_hinted. The 25-node e2e test didn't catch this one, so that unit test was added for it.a_skewed_set_of_candidates_stays_within_the_range_limit, which uses zero plus one ID per high bit for 30 IDs: unlimited, that splits into C(n, G) ranges, about 2 million for a group of 7 and 30 million for 20.test_prune_veto_for_committed_out_of_range_key.scenario_26_paid_list_majority_repairs_missing_replica_below_storage_quorum,test_late_joiner_replicates_responsible_chunksandhint_label_does_not_change_what_gets_stored.PaidListunit tests, and measuring the near range from the closest counted peer fails a third.Hint-build cost, release build, Apple M3 Ultra, 300-node local network (the node knew 91 peers), one node building for its nearest neighbour:
These keys were spread over the whole key space, so the neighbour's ranges held under a tenth of them. A real node's keys cluster around it, so a build reads a larger share of its keys. Including the copy of the routing table and building the map, the new path cost under 0.6 µs per key hinted here, so even a build that had to read every key should stay several times cheaper than before. That last part is an estimate, not a measurement.
Typical node hardware is slower, roughly in proportion to its single-core speed, but the ratio should hold.
Not run on a dev testnet. The hints are the same as before by construction and by the tests above, so what a testnet would add is the cost at production table sizes, which
Neighbor-sync hint build ... elapsed_ms=lines will show in the RC's testing.New dependency
none
ADR
ADR-0028, updated here.
docs/REPLICATION_DESIGN.md§6.2 rules 8 and 16 are updated too.Mitigation / rollback
Revert this PR. Nothing stored or sent on the wire changes, so nodes can move either way at any time.
Summary
A follow-up to #271 for
rc-2026.11.1. It changes how sync hints are built, and how a node decides that a paid entry is near it.src/replication/close_group_map.rs). It reads only the ranges where the peer it builds for can be in the group, from the record store and the paid list, which are both kept sorted by key. Each key gets the group a lookup for it would give, so hints don't change.MAX_SPLIT_RANGES), where routing tables gave 13 to 202; a skewed set of peer IDs could otherwise need millions. Past that, each remaining range compares its competing peers for each key, which gives the same groups at more cost a key. The loops over keys let other tasks run in between (consume_budget), as the per-key lookups' awaits did, and a sync request from a peer outside the routing table returns before any map is built.🤖 Generated with Claude Code