fix(replication): drain priority sync queue and recover from routing-event lag - #165
fix(replication): drain priority sync queue and recover from routing-event lag#165mickvandijke wants to merge 55 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
This PR improves replication convergence under churn by (a) ensuring neighbor sync drains queued priority peers without waiting for periodic ticks and (b) recovering from lagged DHT routing-table broadcast events. It also expands verification/fetch pipeline behavior (batching caps, retry/defer semantics, paid-list edge-voter quorum handling) and adds an e2e scenario covering paid-list-authorized repair below storage quorum.
Changes:
- Neighbor-sync loop now drains the priority queue back-to-back and resyncs close peers on
RecvError::Lagged. - Verification/fetch pipeline enhancements: bounded verification batches, fetch→verification retry metadata, and deferred re-verification scheduling.
- Paid-list quorum evaluation updated with “edge voter” handling; adds a deterministic paid-list repair e2e scenario and bumps crate version.
Reviewed changes
Copilot reviewed 11 out of 12 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/poc_d1_bounded_queues.rs | Updates test VerificationEntry construction for new timing fields. |
| tests/poc_bootstrap_stall.rs | Updates test VerificationEntry construction for new timing fields. |
| tests/e2e/replication.rs | Adds deterministic e2e scenario for paid-list-majority repair under low storage quorum. |
| src/replication/types.rs | Adds next_verify_at, fetch retry metadata, and NeighborSyncState::has_priority_peers + test. |
| src/replication/scheduling.rs | Adds deferred pending scheduling, fetch retry→verification requeue, and returns evicted keys. |
| src/replication/quorum.rs | Adds edge-aware paid-list vote summary and splits verification requests into capped batches. |
| src/replication/mod.rs | Implements lag recovery, neighbor-sync drain-before-park, verification request caps, and retry/defer integration. |
| src/replication/config.rs | Introduces PAID_LIST_FLEX_EDGE_COUNT and MAX_VERIFICATION_KEYS_PER_REQUEST. |
| src/replication/bootstrap.rs | Updates test VerificationEntry construction for new timing fields. |
| src/replication/admission.rs | Adjusts cross-set dedup/admission so paid hints can survive replica rejection under churn. |
| Cargo.toml | Bumps version to 0.14.3. |
| Cargo.lock | Updates locked crate version to 0.14.3. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| results.push(protocol::KeyVerificationResult { | ||
| key: *key, | ||
| present, | ||
| present: cached.present.unwrap_or(false), | ||
| paid, | ||
| }); |
| let handles = | ||
| spawn_verification_batch_tasks(targets, p2p_node, config.verification_request_timeout); | ||
| collect_verification_batch_results(handles, targets, &mut evidence).await; |
1a6fe63 to
fc8d724
Compare
74c990e to
b8d490d
Compare
Carry the exact responsive possession validation failure through the probe verdict and emit it as the structured possession_failure_reason field. Cover stable labels and each existing validation branch without changing trust or penalty behavior. SemVer: minor
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
src/replication/storage_commitment_audit.rs:161
run_subtree_audit’s docstring still says “gossip-triggered” and “the peer just gossiped”, but this wrapper now always callsrun_subtree_audit_with_origin(..., SubtreeAuditOrigin::Manual). That makes the public docs misleading about how/when this API is used and what context it carries.
/// Run one gossip-triggered subtree audit against `challenged_peer`, pinned to
/// the commitment hash the peer just gossiped (`expected_commitment_hash`).
///
src/replication/admission.rs:283
- This test describes a scenario where a replica hint is rejected by a storage-close-group gate but the paid hint for the same key must still be admitted. With the new unified
is_relevantadmission gate (replica and paid hints both go through the same relevance check), that scenario should no longer be possible, so this test’s commentary and intent are likely outdated/misleading.
fn paid_hint_survives_duplicate_replica_rejection() {
// With churned local views, a sender may believe we are in the storage
// close group while our own view rejects the replica hint. If the same
// key also arrives as a paid hint, paid-list admission must still run.
let key = xor_name_from_byte(0xEE);
339f44b to
a5f60f3
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (2)
src/replication/storage_commitment_audit.rs:161
run_subtree_auditis documented as “gossip-triggered”, but it always callsrun_subtree_audit_with_origin(..., SubtreeAuditOrigin::Manual)and is used by the test-onlyaudit_peer_nowpath. This makes the docstring misleading and can confuse log interpretation when reading this API.
/// Run one gossip-triggered subtree audit against `challenged_peer`, pinned to
/// the commitment hash the peer just gossiped (`expected_commitment_hash`).
///
src/replication/pruning.rs:1568
- If
AuditChallengeCoordinator::acquire()returnsNonehere, that’s a local lifecycle/coordination condition (the semaphore was closed), not a malformed remote response. ReturningMalformedResponseflows intoreceive_prune_audit_responseand ultimately reports a prune-audit failure/trust penalty for the peer even though no request was sent.
let Some(_slot) = audit_challenge_coordinator.acquire(*peer).await else {
warn!(
"Prune audit challenge with {key_count} keys against {peer} could not acquire coordinator slot"
);
return PruneAuditChallengeResult::MalformedResponse;
| let msg = ReplicationMessage { | ||
| request_id: CRAFTED_PAID_REQUEST_ID, | ||
| body: ReplicationMessageBody::NeighborSyncRequest(req), | ||
| }; |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 24 out of 25 changed files in this pull request and generated 1 comment.
Comments suppressed due to low confidence (1)
src/replication/storage_commitment_audit.rs:160
- The doc comment for
run_subtree_auditstill says “gossip-triggered”, but this wrapper now hard-codesSubtreeAuditOrigin::Manualand is used by the test/manualaudit_peer_nowpath. This makes the documentation misleading (and could confuse log attribution expectations).
/// Run one gossip-triggered subtree audit against `challenged_peer`, pinned to
/// the commitment hash the peer just gossiped (`expected_commitment_hash`).
| fn drop(&mut self) { | ||
| let _permit = self.permit.take(); | ||
| self.coordinator.release_reference(self.peer); | ||
| } |
Document the three-day width-20 fast-delete path, request-bounded multi-key audit batching, and rolling compatibility with deployed nodes. SemVer: patch
Fast-delete mature records outside a complete width-20 view and batch audited candidates by peer under candidate and request caps. Retry deployed responder size limits without wire changes or trust penalties. SemVer: patch
Testnet comparison — #165 vs current RCRan this branch through a 4h staging testnet and compared it against the current RC staging run. SetupBoth runs used the same profile,
File operations — steady 3h window, both runsBoth runs: 100% success, zero failures, every file-size bucket, uploads and downloads. Durations: Uploads
Downloads — near-identical across all buckets (1049 MB: p50 98.0s vs 98.8s; mean 112s vs 110s). Statistically indistinguishable throughput and latency. #165 is marginally faster on the largest uploads (1049 MB mean 275s vs 307s), marginally slower on the smallest — all within run-to-run noise. No regression on the transfer path. Node host metrics — whole-run aggregateFleet mean of per-host averages:
#165 runs ~9 CPU points cheaper fleet-wide, with bootstraps moving the same direction at −6.5. The RC run carried the sustained Memory is marginally higher (+1 avg / +4 peak), well within headroom. Net and node-storage write are slightly up, consistent with nodes spending cycles on productive work rather than retry churn. Bottom line
|
grumbach
left a comment
There was a problem hiding this comment.
Reviewed at 4c9c681. Read the full diff against eb74b56, plus ADR-0005.
Great work, and the parts that land are genuinely strong. The responsibility recheck, the derived pipeline(), and the LMDB task tracking all do what the ADR says. My concern is size and blast radius: 42 commits and +9.6k across 25 files means the good fixes are hostage to the risky ones. I found three new remotely reachable resource paths and one removed defence that the ADR does not mention. I would like to see this split so items 1-3 under "Suggested split" can go in this week.
Must fix before merge
1. The new inbound queue is bounded by message count, not bytes
INBOUND_REPLICATION_SERIAL_QUEUE_CAPACITY = 256 (src/replication/mod.rs:197) holds fully decoded ReplicationMessage values, and the transport ceiling is 10 MiB (src/replication/config.rs:306, wired at src/config.rs:388). Worst case retention is around 2.5 GiB. Every body except AuditChallenge is queued (mod.rs:1648), including unsolicited response variants that only become no-ops after dequeue (mod.rs:3651).
The stall primitive is in the same file: handle_paid_notify still runs on-chain payment verification inline on the serial loop (mod.rs:3131). That is the pattern decision 2 of the ADR removes, but only for fresh offers. One PaidNotify parks the consumer on an EVM round trip and 256 near-limit messages pile up behind it. The 16-permit fresh-offer bound does not help because the permit is taken after dequeue (mod.rs:3706).
Suggested: byte budget before decode, drop unsolicited response variants on the one-way path, and move PaidNotify verification onto the same detached responder pattern you already built for neighbor sync and verification.
Small related thing: the doc comment on the constant says a full queue is handled inline rather than dropped, but mod.rs:1669 drops and warns. Worth correcting either way.
2. Fresh-offer refusal turns into an audit-severity penalty on the refuser, with no per-peer cap
- 16 global admission permits, 4 workers (
mod.rs:205,mod.rs:216), and no per-source share. Every other responder class in this PR has one (neighbor sync 1/peer, verification 1/peer, fetch 2/peer). - The permit is taken before any validation, so a peer holds one through the whole payment verification even with a junk proof, and a failed proof carries no trust penalty (
mod.rs:3956). - The sender never reads the
Rejectedresponse.fresh.rs:120usessend_message, so the only signal is the delayed possession check, which sees a missing key and reportsAUDIT_FAILURE_TRUST_WEIGHT.
So one peer can hold all 16 permits with cheap invalid offers, every honest offer in that window is refused, and the honest receiver gets penalised at audit severity by honest senders. That feeds the eviction path. The ADR lists refusal as a trade-off but does not note it doubles as a targeted eviction primitive. A per-peer share plus validating cheaply before taking the permit would close most of it.
3. The bootstrap capacity-rejection TTL never expires under sustained pressure
publish_bootstrap_admission_outcomes re-stamps capacity_rejected_sources.insert(source, now) on every admission unit (mod.rs:5237), and expire_capacity_rejected only fires on age. A peer that keeps overflowing the queue keeps its own record fresh forever, so check_bootstrap_drained never returns true, audits stay off, and the node keeps advertising bootstrapping: true.
mod.rs:5221 also re-stamps the displaced owner, so this can be pointed at an unrelated honest peer. Decision 6 closes the departed-peer race, which is real, but the live-peer case is still open. A first-seen timestamp that a repeat rejection does not refresh, or a cap on total refreshes, would fix it.
Should fix
4. shutdown() can hang
detached_task_tracker.wait() has no timeout (mod.rs:1024), which I agree is the right call for LMDB. But fresh-offer workers on that tracker never check the cancellation token and have no staleness shed, unlike the neighbor-sync and verification responders. They await verify_payment, whose HTTP call has no timeout at our level. A hung RPC hangs shutdown. The storage-layer trackers this PR adds are exactly what makes cancelling the outer async waiter safe now, so this should be cheap. Side note: every worker_semaphore.acquire_owned() error arm is dead, since none of those semaphores are ever closed.
5. The per-peer pending_verify cap is gone and the ADR does not say so
Base scheduling.rs:66 had MAX_PENDING_VERIFY_PER_PEER = 8_192, documented as the primary flood defence. There is no equivalent now. "A sole sender may borrow unused capacity" means one routing-table peer can hold all 131,072 slots instead of 6.25% of them, a 16x jump in single-peer memory. The max-min convergence is arguably better than the old hard cap, so this may well be the right call, but it needs to be in the ADR with the adversarial numbers rather than read as pure hardening.
6. Duplicate hints grow source associations with no budget
MAX_PENDING_VERIFY caps unique keys, but duplicates merge new sources into two per-key HashSets plus the reverse index at no capacity cost (scheduling.rs:254), and pending keys short-circuit the relevance check (admission.rs:80). Twenty routing-table peers re-advertising 131k keys gives millions of associations. This is new, since the base stored one hint_sender. A per-key source cap would bound it.
7. The four-peer flexible edge lowers the paid-list bar more than intended
At width 20, four silent or negative edge voters take the threshold from 11/20 to 9/16, and the new unit test pins that (quorum.rs:1271). At smaller configured widths it degrades badly: the guard is configured_group_size > PAID_LIST_FLEX_EDGE_COUNT, so a configured group of 5 leaves a core of 1 and a single Confirmed vote authorizes the key. validate() accepts any size at or above 1 (config.rs:587). Since PaidListVerified writes the paid list and enables repair fetches, this is worth tightening: scale the edge with the group, add an absolute confirmation floor, or reject unvalidated widths in validate().
Smaller
audit_metrics.rs:186:record_receivedinserts intoby_sourcebefore admission and is unbounded until the 60s snapshot, which then sorts the whole map. A bounded top-k plus an overflow counter would do.paid_list.rs:400:remove_batchcommits in the blocking closure but clears the hysteresis timestamps only after the await resumes, so a cancelled prune leaves the key deleted with stale timestamps.tests/e2e/verify_storage_gate.rs:295: the far-key parity test picks a key that both pre-PR gates rejected, so it does not discriminate, and "a sync response came back" does not prove admission. Needs a key in the symmetric difference and a positive control.scheduling.rs:1138:requeue_fetch_for_verificationleaves acapacity_owner_by_keyentry behind on the no-owner path.- Retry reservations are subtracted globally but never charged to an owner in
reclaim_borrowed_slot's demand map, so an owner with many in-flight retries looks under-loaded and gets a larger share than it should. pruning.rs:359andaudit.rs:108are unused wrappers that each build a throwawayAuditChallengeCoordinator, which would quietly defeat the shared coordinator if anyone called them. No callers today, so probably just delete.
What I liked
- The responsibility recheck is the strongest part. Two live-routing checks per attempt, before spending bandwidth and again before
storage.put(mod.rs:6031,mod.rs:6158), sharing the terminal path withStoredso drain cannot stall. The e2e has a real positive control and a conclusive negative, so it actually discriminates. - Deriving
pipeline()fromreplica_hint_sourcesinstead of storing it genuinely kills the two-message conscription path. Nice simplification on top of a security fix. - Putting the LMDB tracker at the storage layer rather than at each call site is the right choice, and it picked up a real pre-existing bug on the way:
all_keys()was iterating without theenv_lock, so a concurrentenv.resize()could run during an open read txn. AuditChallengeCoordinatorreference accounting is cancellation-safe including for parked acquires. I went looking for a leak and could not find one.- The titular fix is correct. Cooldown is still honoured for priority peers (
neighbor_sync.rs:286),queue_priority_peersdedupes, and the drain provably terminates. - Moving neighbor-sync and verification responders off the serial loop with per-peer admission and staleness shedding is a good pattern. It just needs to reach fresh offers and
PaidNotifytoo.
Suggested split
Ship these first, they look close to ready:
- Event-stream terminal and lagged handling, priority-sync drain, bootstrap liveness
- Fetch responsibility recheck and queue transition accounting
- LMDB lifetime tracking, with a bounded shutdown folded in (item 4)
Hold these for a redesign pass:
- Inbound queue byte budget and
PaidNotifyoff the serial loop (item 1) - Fresh-offer per-peer admission and the refusal to penalty path (item 2)
- Source association budgets and the paid-quorum edge policy (items 6 and 7)
Plus an ADR update to record the per-peer cap removal (item 5) and correct the TTL claim (item 3).
Needs a rebase either way, the branch conflicts with main. The Windows CI failure is a runner flake (Failed to create transport during node setup), not a code problem.
Drop the wire-wording split-and-retry recovery for oversized prune-audit batches. The responder's max_incoming_audit_keys already grants a 2x margin over the sender size, so an honestly-sized peer only rejects past a 4x close-group store spread; under the assumed ~2x spread a Rejected response is attributable misbehaviour. It now grades to a distinct non-recovering PruneAuditStatus::Rejected (logged size_reject) through the normal failure path, without parsing the reason text. Per-peer multi-key batching and request-bounded candidate selection are unchanged. Removes the string parser, split-request budget, retry loop, and their atomics; also drops an unused legacy budget constant and corrects a stale module-doc disposition list. ADR-0005 and REPLICATION_DESIGN document the store-spread assumption and add a size_reject re-open trigger. SemVer: patch Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ghting A bootstrap capacity-rejection record was stamped with the time of the most recent rejection, and the TTL was measured from that stamp. A source that keeps overflowing the queue therefore keeps its own record permanently fresh: `check_bootstrap_drained` never returns true, the node advertises `bootstrapping: true` indefinitely, and auditing stays off for the entire duration of the pressure (Invariant 19). No attacker is needed — an ordinarily busy peer wedges the node open. Record the FIRST rejection instead and never refresh it. A repeat rejection still re-asserts that the source owes work, but the expiry clock now measures how long the debt has stood rather than how recently the source last overflowed us, so the wedge is bounded at one TTL regardless of peer behaviour. The semantics live in one place, `BootstrapState::note_capacity_rejected`, shared by the production accounting and the bootstrap helper. Also stop charging a fairness displacement to its victim. Reclaiming a borrowed slot is this node's own decision, not a failure by the peer that held it, and recording the displaced owner let a sustained flooder block our drain through an unrelated honest peer that had never overflowed us. Only the displaced key is forfeited, to the same post-bootstrap neighbor-sync and audit/repair recovery path the TTL already relies on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
PaidNotify was still verified inline on the serial non-audit loop. That verification is not a bounded local computation: on the merkle path it performs an iterative Kademlia lookup capped only by the payment verifier's CLOSENESS_LOOKUP_TIMEOUT (240s). A single notify could therefore park every other non-audit message for minutes while the bounded inbound queue behind it overflowed and dropped unrelated replication traffic wholesale. Move it onto the detached-responder pattern the other classes already use. Fresh offers had a global admission bound but no per-source share, unlike every other responder class. One peer could hold all sixteen slots, and because a refusal is later read as absence by the sender's delayed possession check, the resulting refusals land as audit-severity trust penalties on the refuser rather than the flooder — making an unbounded global pool a targeted eviction primitive. Give them the same per-source treatment, and apply the zero-cost structural checks (missing proof, oversized payload) before the permit so malformed work cannot occupy a slot for the length of a verification. Size PaidNotify's bounds as a memory ceiling rather than a fairness device. It is one-way: no response, no retry, so refusing one does not shed load onto a requester, it discards durable paid-list evidence this node then lacks until a verification cycle re-derives it. Sizing it like a request responder measurably broke replication — a joining node lost entries for a fraction of its responsible keys and never fetched them. Finally, make the deliberately unbounded detached-task drain in shutdown() safe by making it finite. Close the responder worker semaphores so queued work takes the existing (previously unreachable) error arm and exits, shed superseded fresh offers at dequeue, and race payment verification against the shutdown token. The cancellation boundary is drawn at network I/O only: storage.put awaits spawn_blocking and is never cancelled, since dropping that awaiter would detach a live LMDB transaction and break the contract the unbounded wait upholds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…tions MAX_PENDING_VERIFY counts keys, not the peers remembered against each one. Duplicate advertisements take the AlreadyPresent path, which merges the advertiser into two per-key sets plus the reverse index at no capacity cost, and re-advertising an already-pending key is the cheapest route through admission — is_relevant short-circuits on it without even a routing-table lookup. N routing-table peers re-advertising a full queue therefore cost N * 131,072 associations charged against nothing, where the base branch stored exactly one hint_sender per key. Cap retention at MAX_HINT_SOURCES_PER_KEY. At the cap a replica claimant displaces a paid-only advertiser: only a claimant is usable as a fetch candidate, so a paid-only source forfeits its slot and the key loses one unit of corroboration weight — never a fetch candidate, and never the key itself. The capacity owner is never displaced, since insert_pending_owned_unchecked requires it to remain a live hint source. Any change to hint_sources re-pushes the key's eviction candidate, whose source_count snapshot pop_reclaimable_victim compares for equality. Separately, charge retry reservations to their capacity owner. A key promoted to the fetch pipeline leaves pending_keys_by_owner but keeps a reservation against the global pool; billing that to nobody let an owner with many in-flight retries read as under-loaded to reclaim_borrowed_slot and win a larger share than it earned, a skew growing with exactly how much work it already held. Allocate the full pool against resident + reserved per owner, matching pending_capacity_used. Add a convergence test for the elastic max-min accounting that replaced the removed MAX_PENDING_VERIFY_PER_PEER cap: fill the pool from one sender, then assert a late sender reclaims an even share. It also covers fair_rejection_cache and the stale-candidate skip loop in pop_reclaimable_victim, where a convergence failure would otherwise hide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The flexible edge discounted a fixed four peers whenever the paid close group was at its configured width. Four is a fifth of the production width of 20, but four fifths of a configured width of five: the guard was `configured_group_size > PAID_LIST_FLEX_EDGE_COUNT`, so a group of five discounted four of its members, left a voting core of one, and authorized on a single Confirmed vote. `validate()` accepts any width down to 1, and PaidListVerified writes the paid list and enables repair fetches, so that is the gate deciding what a node fetches and keeps. Scale the edge to the group (paid_group_size / 5, capped at PAID_LIST_FLEX_EDGE_COUNT) so the discount stays proportionate at every configured width. At the production width this is exactly 4, leaving shipped behaviour unchanged. Add PAID_LIST_ABSOLUTE_CONFIRM_FLOOR on top, bounded by the true group size so it can never demand more votes than there are peers to give them: the edge discount shrinks the denominator and with it the threshold, and the floor keeps a lone voter from being decisive whatever the discount does. Both call sites derive the width from the same helper. Computing edge membership in build_verification_targets from the old fixed width while applying the scaled count in summarize_paid_list_votes let `core_group_size + confirmed_edge` exceed the real group size and demand more confirmations than the group could produce. Not addressed here: an edge peer answering NotFound is still discounted identically to one that stays silent, and the width-20 relaxation from 11/20 to 9/16 stands. Both are recorded in ADR-0005 as follow-ups. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`run_prune_pass` and `audit_tick` had no callers, and each constructed a private AuditChallengeCoordinator instead of taking the shared one — so a future caller would have silently defeated the coordinator's bounded, shared challenge accounting. Remove the trap rather than document it. Hoist audit.rs's test-module imports to the top of the file under `#[cfg(test)]`, per the project's import convention. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`remove_batch` commits the delete inside spawn_blocking, which cancellation cannot reach, then clears the in-memory hysteresis timestamps only after that await resumes. Dropping the future in between leaves keys deleted on disk with paid_out_of_range entries still set. The window is deliberately left open: the only caller is the prune pass, reached from run_neighbor_sync_round inside a select! racing the shutdown token, so shutdown is the only way to hit it — and paid_out_of_range is in-memory only, so the stale entries die with the process that cancelled the prune. Document that, and why clearing the timestamps before the commit would be strictly worse: a failed commit would then leave a live key with a cleared hysteresis clock, restarting its prune countdown. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`far_key_rejected_under_either_label` picked a key out of range for both gates, so every version of the code rejects it and the test passed without testing anything. It also had no positive control, leaving "not stored" indistinguishable from "the pipeline never ran". Drive the question with a key in the symmetric difference of the two gates instead — inside the paid close group, outside storage_admission_width — which is precisely where the pre-fix code diverged: a replica hint drove a fetch and a store, a paid hint only tracked validity. Assert both labels reach the same verdict, and add a third scenario storing a key the target IS responsible for, so the two negatives are demonstrably refusals rather than silence. Verified against a live 12-node testnet: PARITY-RESULT replica_label_stored=false paid_label_stored=false in_range_control_stored=true. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ADR-0005 described the elastic max-min sender accounting but never recorded that it REPLACED a named defence: MAX_PENDING_VERIFY_PER_PEER (8,192), documented in the base branch as the primary flood defence. The trade is real and belongs in the record — a single sender's worst-case resident footprint rises 16x — along with why a convergence property is preferred to a hard cap, and the test that pins it. Also correct decision 6's TTL claim, which justified expiry by "silence that long means re-delivery was abandoned" while the implementation refreshed on every rejection; document the paid-list edge scaling and confirmation floor, and the NotFound-vs-silence asymmetry left open; record why PaidNotify is bounded as a memory ceiling rather than like a request responder; and note the fresh-offer refusal-to-trust-penalty path as a known gap, since it fires under ordinary load and not just attack. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Merged rather than rebased: the branch and main diverged independently inside the same functions, so replaying 53 commits would have meant ~40 semantic re-applications onto code main has since rewritten — each a chance to silently regress one side. A merge resolves the combined state once, and keeps the commits the open review is anchored to. Conflict resolutions: - Imports (mod.rs, tests/e2e/mod.rs): union of both sides. - GossipAuditTrigger: both sides added distinct fields. Kept the branch's `detached_task_tracker` and main's `lottery_attempts`, and took main's engine-owned `first_audit_observability` over the branch's per-task `Arc::new(FirstAuditObservability::default())`. - Audit admission drops: kept BOTH counters. They are complementary, not duplicates — main's `protocol::record_audit_drop` is the cumulative process-global tally feeding the periodic summary, the branch's `audit_metrics::record_admission_drop` plus `record_drop` are the windowed per-responder metrics behind the origin breakdown. - First-audit drainer: took main's side, dropping 131 lines of the branch's LruCache-based scheduler. Main replaced that implementation wholesale with `FirstAuditScheduler` / `try_reserve` / `sweep_expired`, which is already present above the conflict; re-introducing the old loop would have run a superseded scheduler alongside the new one. Follow-up, deliberately NOT bodged into this merge: main's rewritten first-audit launch path spawns with bare `tokio::spawn`, so it is not on the detached task tracker. Every other detached path the branch moved onto the tracker survives the merge, but that one launch site should be re-tracked so `shutdown()`'s drain still covers it. Verified on the merged tree: 828 lib tests pass, clippy clean at the project's -D panic/unwrap/expect level, rustfmt clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The monetized first-audit launch was the one remaining detached task spawned bare. It outlives the drainer loop that spawns it — the loop breaks on the shutdown token while the launched audit is still awaiting its subtree response — so a bare spawn let an in-flight audit keep running, holding its Arc<P2PNode>, past the point shutdown() claims every engine task has stopped. Spawn it on the shared detached task tracker instead, so shutdown()'s drain covers it like every other detached path. This site was introduced by main's first-audit scheduler rewrite, which landed independently of this branch's task-tracking work and so never picked it up. Every other bare tokio::spawn in the module is a producer loop whose handle is retained in task_handles and drained with the per-handle timeout; this was the only unmanaged one left. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Summary
This PR hardens replication repair under churn, load, queue pressure, event-stream failure, and shutdown. It expands the original neighbor-sync drain fix into a broader set of paid-list repair, verification, bootstrap accounting, audit concurrency, fresh-offer dispatch, admission gating, and async lifecycle fixes so legitimate repair work is not silently dropped, converted into false audit failures, delayed behind stale peers, left spinning on closed broadcasts, serialized behind a saturated worker pool and dropped through broadcast lag, used to conscript out-of-range nodes into storing arbitrary keys, downloaded and stored under responsibility decisions that topology churn had already invalidated, left holding LMDB/P2P resources — including detached LMDB blocking transactions — after engine shutdown, or wedged in permanent bootstrap by a peer-removal race that orphans capacity-rejection accounting.
The branch contains 54 commits (plus one merge of
main), listed oldest first:124e8f8-fix(replication): harden paid-list verification repair72c1815-fix(replication): tolerate paid-list edge churn3479a75-fix(replication): drain priority sync queue and recover from routing-event lag819b4e2-fix(replication): preserve verification retry capacityc61d432-fix(replication): eliminate false audit challenge timeouts4099f6c-fix(replication): preserve dequeued retry reservations712b514-chore(replication): fix audit admission clippy0b721f9-fix(replication): release cancelled async work6cd9c83-fix(replication): silence no-logging audit label warningsda79e46-fix(replication): unblock bootstrap when rejected peer leaves67cc46a-refactor(replication): prioritize source-aware hints17b26ef-refactor(replication): aggregate bootstrap hint batchesd507303-fix(replication): aggregate verification per peer61ebee8-fix(replication): drain fresh offers through LMDB writes8c298e1-fix(replication): track detached audit work09e44b4-fix(replication): stop message handler when event streams close473b288-fix(replication): prune departed peers during DHT lag recovery4ed6c45-fix(replication): penalize rejected singleton replica hints7a86d1e-fix(replication): keep fresh offers off the serial message loopff58baa-fix(replication): gate replica downloads on storage responsibilitye521ed5-refactor(replication): admit hints through a single relevance gatee3cb539-perf(replication): merge duplicate hints without rebuilding the fetch heap5467dba-fix(replication): drain detached LMDB blocking ops on shutdown49093a0-fix(replication): expire orphaned capacity-rejection records to unstall bootstrapa3dfc9b-fix(replication): recheck storage responsibility at the point of download3a7bdba-docs(adr): record replication repair hardening decisions (PR #165)aa68bb0-fix(replication): address deep review findings79aaebb-fix(replication): resolve all-target clippy failurese5ec1ac-fix(docs): avoid private rustdoc link6b0e4ce-fix(storage): guard raw LMDB reads against concurrent map resized061762-fix(replication): fairly share verification capacity6b4f168-fix(replication): generalize bounded responder admission2c58d94-fix(replication): isolate fetch respondersddb1071-fix(replication): isolate verification responders349a1df-fix(replication): isolate neighbor sync responderse6b15f7-fix(replication): drop serial queue overflow5b85d1f-fix(replication): count responder load shedding0f9e3b1-feat(replication): trace audit request origins and latencya5f60f3-feat(replication): log possession proof failure reasons7e8a068-fix(replication): align admission and expiry testsab0a313-fix: resolve clippy and rustdoc warnings4c9c681-fix: support no-logging builds20d3f88-docs(adr): define scalable prune backlog draining0abe74a-fix(replication): drain mature prune backlogs54fea24-fix(replication): grade prune-audit size rejections as failuresd3a1d56-fix(replication): bound bootstrap capacity-rejection debt by first sightinga84c42b-fix(replication): bound paid-notify and fresh-offer admission per source809f509-fix(replication): bound per-key hint sources and charge retry reservations1094543-fix(replication): scale the paid-list flexible edge with the group0a03c71-refactor(replication): delete unused prune-pass and audit-tick wrapperscca744c-docs(replication): record the remove_batch cancellation windoweeb068b-test(e2e): make the hint-label parity test discriminate1632ba7-docs(adr): record the removed per-peer cap and revised admission boundsd79752e-fix(replication): track the first-audit launch on the detached trackerProblems Addressed
Paid-list repair could become terminal too early. Duplicate replica/paid hints were deduplicated before their actual admission outcome was known, and verified repair work could lose its retry context after transient no-holder or no-source rounds.
Paid-list edge peers were too strict under churn. Boundary disagreement could reject a valid majority formed by the stable core of the paid close group.
Neighbor sync could stall after topology bursts.
Notifycoalescing allowed queued priority peers to wait for later 10-20 minute periodic ticks, while lagged DHT broadcasts could hide entrants entirely or leave departed peers ahead of current neighbors, each consuming a request timeout.Verification retry capacity could be stolen. Promoted verified keys stopped counting against pending capacity, allowing unrelated hints to consume the capacity required to requeue them.
Verification work lacked source-aware prioritisation. Duplicate hints discarded useful corroborating-source information, and large ready queues could schedule weak singleton claims ahead of better-supported repair work.
Bootstrap hint batches were published incrementally. Verification could race a partially admitted neighbor-sync batch and miss the complete source picture.
Verification fan-out was unnecessarily fragmented. Per-peer key sets were split into many requests despite already having a bounded verification cycle.
Bootstrap could remain blocked by departed peers. A peer that caused an admission-capacity rejection could leave while its rejection marker continued preventing drain.
Local audit bursts could look like honest-peer timeouts. Independent audit issuers could exceed the responder's per-source admission limit and interpret the resulting drops as remote failure.
Cancelled fresh offers could detach LMDB writes. Dropping an async
spawn_blockingwaiter does not cancel the blocking transaction, so shutdown could report drained while LMDB still owned the environment.Detached responder/audit work was outside engine lifecycle tracking. Digest, subtree, byte, possession-check, and audit-launch tasks could outlive engine shutdown while retaining storage or P2P state.
Closed event streams could spin the replication loop. Closed Tokio broadcast receivers remain immediately ready forever; continuing after
RecvError::Closedcould consume a core, flood P2P warnings, and prevent the replication pipeline from shutting down cleanly.Sole-source replica hints could avoid trust penalties. A definitive close-group rejection was only penalized when the advertising peer also explicitly denied possession, allowing unsupported free-replication claims to escape punishment.
Fresh-offer dispatch could serialize on the non-audit loop. Once all four worker permits were held, dispatch handled the next offer — an on-chain payment verification plus a multi-MiB LMDB write — inline on the serial message loop. Per-key shard locks (
key[0] % 64) collapsed the close-prefix accepted-key set onto a single shard, making the inline path the steady state, backing up the 256-slot inbound queue, and dropping replication messages wholesale once the P2P event loop lagged its broadcast receiver.Replica hints could conscript out-of-range nodes into storage.
HintPipeline::Replicaskipped theis_responsible(storage_admission_width)gate that paid keys had to pass, and a stored pipeline tag let a second replica message escalate a queued paid entry through thealready_pendingfast path, so a peer could name any key and force nodes ranked 10-20 for it to fetch and store it.Duplicate hint merges rebuilt the whole fetch heap. Merging a re-advertised key's advertiser only touches a field the heap never orders on, but the old path took the whole heap, scanned it linearly, and re-heapified — O(n) per duplicate and O(m·n) for a batch under the global queue write lock, which a neighbor could trigger deliberately by re-hinting queued keys.
Shutdown could return while detached LMDB transactions were still running. Several paths race a
select!on the shutdown token against futures awaiting aspawn_blockingLMDB transaction — the fetch worker'sstorage.put, the prune pass'sstorage.delete/paid_list.remove_batch, the verification worker'spaid_list.insert. Dropping the losing future does not cancel the blocking closure, which keeps running with a clonedEnv; per-fetch tasks were also bare-spawned outside both trackers, so a droppedin_flightset could leakArc<LmdbStorage>pastshutdown(). Reopening the same environment afterwards was undefined behavior.Bootstrap drain could stall permanently on a peer-removal race. A capacity-rejection record for source P was cleared only by P's next clean admission cycle or by P's
PeerRemovedcleanup — but the note sites and the removal handler run on different tokio tasks, with await points between the last "P is live" observation and the insert. A removal fully processed inside that window made its clear a no-op, and the subsequent note recorded an entry no future event could retire:check_bootstrap_drainedreturned false forever, audits stayed disabled (Invariant 19), and the node advertisedbootstrapping: trueindefinitely, drawing network-wide bootstrap-claim trust penalties — reachable deliberately by overflowingpending_verifyduring a victim's bootstrap and disconnecting. Adjacent liveness gap: every drain check was event-driven and a clean-cycle clear never re-checked, so a quiet node could satisfy the drain condition with nothing left to observe it.Fetch decisions could go stale between promotion and download. Storage responsibility was checked once, at verification-completion time, and never again before the chunk was downloaded and stored. The fetch queue holds up to 131,072 entries and dequeues nearest-first, so a far candidate can wait unboundedly long while closer keys jump ahead, and every per-source retry reused the same stale answer — topology churn after promotion still ended in a download, a disk write, and fetch→store→prune churn, violating the contract documented in
types.rsandadmission.rsthat responsibility is decided against live routing state at the point of download.Raw LMDB reads could race concurrent map resize.
all_keys()andget_raw()opened read transactions without the shared environment lock, allowing an exclusiveenv.resize()to change the memory map while those transactions were active.One routing peer could monopolize verification capacity. The global-only 131,072-entry admission bound let one peer fill the queue and continuously consume the 8,192-key verification cycle, rejecting honest hints before source-count prioritization could help; restoring a fixed 8,192-per-peer quota would instead truncate legitimate bootstrap batches containing tens of thousands of hints.
Bulk replication responders could monopolize the serial message loop. Fetch responses perform large LMDB reads and uploads, while verification and neighbor-sync responses perform synchronous point-lookups and queue publication; handling them directly let one source delay unrelated protocol traffic.
Serial-queue backpressure could defeat its own bound. When the bounded handoff filled or closed, the fallback executed the rejected handler inline on the P2P event receiver, allowing overload to propagate into broadcast lag and wider message loss.
Responder load shedding and audit latency lacked actionable attribution. Admission drops, stale dequeues, logical audit issuers, remote sources, and stage timings were not available as bounded counters and structured summaries.
Responsive possession-proof failures lost their precise cause. Validation branches collapsed into a generic failed verdict, making malformed proofs, missing records, and commitment mismatches indistinguishable in logs even though trust behavior correctly remained the same.
Mature prune backlogs could not drain at scale. The prune-confirmation audit budget was counted per candidate-to-peer edge, so a close group of 7 capped a pass at roughly nine records regardless of how many mature out-of-range records existed; far-out copies (ranked beyond the width-20 admission neighbourhood) were retained indefinitely because every deletion required a remote possession round. A first attempt to raise audit throughput then string-parsed a deployed responder's oversized-batch rejection to split-and-retry in-band — brittle wire-wording coupling whose failure mode was a trust penalty against an innocent capacity-limited peer.
Changes
Paid-list verification and repair
pending_verify, fetch queue, and in-flight fetch state.Paid-list edge churn
Neighbor sync and bootstrap lifecycle
HashMap<PeerId, Instant>instead of a bare set) and expires records only after a runtime-derived full-cycle window covering every neighbor batch, per-peer request deadlines, cooldown, and one slow-cadence interval of slack (125 minutes with defaults), forfeiting abandoned/departed-source debt consistently with peer-removal cleanup.pending_peer_requestsearly-returns: pending requests legitimately block the drain check itself but no longer block expiry, and a drain condition that became true without a triggering event is now observed within one tick.Source-aware bounded verification
Fresh-offer dispatch, admission gating, and fetch-queue merges
handle_fresh_offerhas a single caller and no inline verification/LMDB path on the serial loop.key[0] % 64shard locks with an exact per-key in-flight set behind an RAII guard, so unrelated keys never contend and concurrent duplicates collapse onto the first claimant instead of repeating its verification.replica_hint_sourcesinstead of storing the tag, deleting the paid→replica escalation and both demotion sites.is_responsible(storage_admission_width)at both fetch sites (local paid-list fast path and post-verification path), matching pruning width; fresh PUTs keep their wider accept window.paid_list_close_group_size(20), so mislabelling a hint gains nothing and the two-gate rescue dance (rejected_replicarescued byadmitted_paid) is removed; the admissible key set is unchanged.FetchCandidateintoFetchOrder(key + distance, the only fields theOrdimpl reads) held in the heap andFetchPayload(sources + retry metadata) held in a key-indexed map that also serves as the membership index, making a duplicate-source merge an O(1) map lookup and rebuilding the heap only when a departed peer actually orphans a candidate.is_responsible(storage_admission_width)on every fetch attempt insideexecute_single_fetch— before spending bandwidth (per-source retries re-enter there, so they are covered) and once more beforestorage.put, so bytes arriving after responsibility lapsed mid-round-trip are not written. A lapsed attempt resolves asFetchResult::NoLongerResponsible, which sharesStored's terminal path (retry-slot release plus bootstrap accounting) and reports no trust event; the verification-time check remains as a cheap pre-filter that keeps never-responsible keys out of the fetch queue.apply_fetch_result, so eachFetchResultvariant's queue transition is unit-testable without a live network.Audit concurrency and observability
AuditChallengeCoordinatoracross responsible, prune-confirmation, and possession audits.Bounded responder isolation and diagnostics
possession_failure_reasonfield without changing evidence, trust, or penalty semantics.Storage resize safety
env_lock.read()inside the tracked blocking closures forall_keys()andget_raw(), matching the other raw storage operations.env.resize()operation while preserving cancellation and shutdown tracking semantics.Event-stream and detached-task lifecycle
LmdbStorageandPaidListroute everyspawn_blockingthrough per-instanceTaskTrackers and exposewait_idle(), so a dropped async awaiter no longer untracks a live transaction (constructor-time opens stay untracked — they cannot outlive the constructor).shutdown()awaits timed-out long-lived tasks after requesting abort, then awaitswait_idle()on both environments after the detached-task drain; when it returns, producer tasks have actually exited, no LMDB blocking operation is still running, and no engine-spawned task holdsArc<LmdbStorage>/Arc<PaidList>.tokio::spawn; thein_flightplumbing and prompt network-I/O cancellation are unchanged — only the bounded in-flight LMDB transaction is awaited.Prune backlog draining
sqrt(local_stored_keys)sender limit, so a pass is bounded by actual batched requests and complete candidate proof sets rather than ~9 records.Rejectedas an attributable failure (loggedsize_reject) rather than renegotiating it in-band. The responder'smax_incoming_audit_keysalready carries a 2x margin, so an honestly-sized peer only rejects past a 4x close-group store spread — double the assumed ~2x — and aRejectedis never a possession signal (a peer lacking a key answersDigestswith an absent marker). This removed the earlier string-parsing split-and-retry, its request-budget atomics, and the innocent-peer trust-penalty risk. No wire-format change; the deployed multi-key challenge/response representation is unchanged.Latest Commit Details
54fea24- grade prune-audit size rejections as failuresDrops the wire-wording split-and-retry that
0abe74aused to recover from an oversized prune-audit batch. The responder rejects a batch only when itsmax_incoming_audit_keys(stored_chunks) = 2 * responsible_audit_key_limit(stored_chunks)is below the batch size; because that already carries a 2x margin over the challenger's ownsqrt-scaled sender size, an honestly-sized peer rejects only once the close-group store spread exceeds 4x (sqrt(cc) > 2*sqrt(rc)). Under the assumed ~2x spread — half the threshold — a size rejection is attributable misbehaviour, so aRejectedresponse (any reason) now grades to a distinct non-recoveringPruneAuditStatus::Rejected, flows through the normal failure path (fresh-responsibility-confirmation gate, per-peer-per-pass dedup) under asize_rejectmetric label, and never parses the reason text. ARejectedis never a possession signal — a peer lacking a key answersDigestswithABSENT_KEY_DIGEST— and nobody gains by dodging a prune audit (it only makes the challenger retain its own copy), so the label carries no in-band recovery. This removes the string parser, the split-request-budget/request-attempt atomics, and the retry loop (net −51 lines), and eliminates the trust-penalty-against-an-innocent-peer risk that a wire-wording drift would have created. Per-peer multi-key batching and request-bounded candidate selection are unchanged; no wire-format change. ADR-0005 and REPLICATION_DESIGN record the store-spread assumption and add asize_rejectre-open trigger. Validation: all replication library tests pass, no-default-feature build compiles, all-feature Clippy clean under the denied panic/unwrap/expect gate.0abe74a- drain mature prune backlogsAdds the width-20 fast-delete path and request-bounded audit batching described under "Prune backlog draining", replacing the candidate-to-peer-edge budget that capped a pass at roughly nine records. Fast-path and audited deletions both re-run a TOCTOU revalidation against the current routing table immediately before deleting; a key that moves inside width 9 clears hysteresis and is retained, and a key that moves outside a complete width-20 group during an audit may take the fast path after fast-path revalidation.
20d3f88- define scalable prune backlog drainingAdds ADR-0005 section 11 and updates REPLICATION_DESIGN: the three-day width-20 fast-delete path (with its accepted far-copy durability trade-off), request-bounded multi-key audit batching, and rolling compatibility with deployed nodes.
e0c0385- address deep review findingsCloses four review findings before merge. Bootstrap capacity-rejection expiry is now derived from the runtime neighbor scope, batch width, slowest cadence, per-peer request deadline, cooldown, and one cadence interval of slack; with defaults the window is 125 minutes rather than 70, so a live source near the end of a five-batch cycle is not forfeited before its next legitimate re-hint.
shutdown()now awaits a long-lived task after requestingabort, ensuring synchronous sections have actually exited and dropped storage/P2P clones before detached work and LMDB trackers are declared quiescent. Paid-list flexible-edge activation now usesReplicationConfig::paid_list_close_group_size, preserving strict majority for an undersized 20/24 group and enabling the intended four-edge tolerance for a full non-default 16-peer group. The commit also fixes the all-target/all-feature Clippy gate and adds focused regression coverage. Validation: 738 all-feature library tests, 434 no-default-feature replication tests, the shutdown-LMDB PoC, formatting, diff checks, and the exact CI Clippy command all pass.14dfd46- record replication repair hardening decisionsAdds ADR-0005, documenting the repair-hardening decisions, invariants, operational trade-offs, and lifecycle guarantees implemented by this PR.
4c8f821- recheck storage responsibility at the point of download0cda93bgated replica downloads onis_responsible(storage_admission_width), but only at verification-completion time — the answer was never re-asked before the chunk was actually downloaded and stored. The fetch queue is nearest-first and up to 131,072 entries deep, so a far candidate can wait unboundedly long while closer keys jump ahead, and the per-source retry path reused the same stale decision for every fallback source. Topology churn between promotion and download therefore still ended in a download, a disk write, and later fetch→store→prune churn once pruning evicted the record at the same width — whiletypes.rsandadmission.rsboth documented that responsibility is decided against live routing state at the point of download. Responsibility is now rechecked per fetch attempt insideexecute_single_fetch, where no queue lock is held: once at the top before spending bandwidth (retries re-enter there), and once more beforestorage.putso bytes that arrive after responsibility lapsed mid-round-trip are not written; edge flapping is dampened by the marginstorage_admission_widthalready adds overclose_group_size. A lapsed attempt resolves as the newFetchResult::NoLongerResponsible, which deliberately sharesStored's terminal path —complete_fetchreleases the verification retry-slot reservation and the worker's terminal handling shrinks the bootstrap pending set, so a declined key cannot stall bootstrap drain — and reports no trust event, since the source did nothing wrong. The promotion-time check stays as a cheap pre-filter; event-driven purging of the fetch queue on routing events was considered and rejected (O(queue) scans per churn event, racy, strictly more complex than the lazy recheck). The worker's result handling is extracted intoapply_fetch_resultfor direct unit coverage, and a new e2e drives a live 12-node network through a test-only seam that enqueues a fetch candidate for a key the target is not responsible for — the exact state a stale promotion leaves behind, since live topology cannot be shifted deterministically inside the millisecond promotion→dequeue window — asserting the chunk is never stored and the key exits the pipeline terminally, with an in-responsibility positive control through the same seam and holder. With the rechecks disabled the e2e fails by storing the stale chunk, confirming it discriminates.9cef69f- expire orphaned capacity-rejection records to unstall bootstrap37f8e34madePeerRemovedretire a departed source's capacity-rejection record, but the record's lifecycle was still purely event-driven and the note/removal paths run on different tokio tasks. Each note path has await points (LMDB reads, lock acquisitions) between its last "source is live" observation and the insert, so a removal fully processed inside that window cleared nothing and the subsequent note recorded an entry that no later admission cycle or removal event could ever retire — permanently blocking drain, disabling all audits (Invariant 19), and leaving the node claiming bootstrap network-wide, where other peers' bootstrap-claim abuse logic trust-penalizes it. An attacker could reach this deliberately by overflowing a victim'spending_verifyduring bootstrap and disconnecting immediately. The record now carries its most recent rejection time and expires after a runtime-derived full neighbor-cycle window (all configured batches and request deadlines, the cooldown floor, plus one slow-cadence interval of slack; 125 minutes with defaults), with expiry and a drain re-check run from the verification worker tick ahead of the pending-request early-returns. That same tick closes the adjacent liveness gap where a clean-cycle clear satisfied the drain condition with no event left to observe it. Expiry forfeits the keys the departed source still owed — consistent withupdate_bootstrap_after_peer_removed, which already forfeits a departed source's owed work wholesale; post-bootstrap neighbor sync and audit/repair recover them. Deliberately not a routing-table membership check at the note sites (only shrinks the TOCTOU window) and not a global bootstrap deadline (would change Invariant 19 semantics for genuinely busy bootstraps). Regression coverage reproduces the exact race ordering: removal cleanup first as a no-op, the rejection recorded after, drain asserted blocked, then TTL expiry drains it.44bd01d- keep fresh offers off the serial message loopOnce all four worker permits were held,
dispatch_fresh_offerhandled the next offer — an on-chain payment verification and a multi-MiB LMDB write — inline on the serial non-audit loop, backing up the 256-slot inbound queue until the P2P event loop also handled messages inline and its broadcast receiver lagged and dropped replication messages wholesale. The per-key shard locks (key[0] % 64) made that the steady state rather than the exception: a node only receives fresh offers for keys close to its own ID, so the accepted key set shares a long prefix and nearly every offer landed on one shard. The ordering those locks preserved has no meaning here, since the key is the content address of the data. Dispatch now only claims the key, takes an admission permit, and spawns; the worker permit is awaited inside the task sohandle_fresh_offerhas a single caller and no inline fallback exists. A 16-permit admission semaphore bounds outstanding offers at a 64 MiB ceiling, and an exact per-key in-flight set behind an RAII guard replaces the shard locks so unrelated keys never contend and concurrent duplicates collapse onto the first claimant. Past the bound an offer is refused — not stored, and therefore penalized as absent by the delayed possession check — rather than queued or handled inline.0cda93b- gate replica downloads on storage responsibilityA replica hint is a possession claim, but the receiver treated it as authorization:
HintPipeline::Replicaskipped theis_responsible(storage_admission_width)check thatPaidOnlykeys had to pass, so whoever labelled the hint decided what we store. Two messages were enough to exploit it — a paid hint queued key K asPaidOnly, then a replica re-advertisement hit thealready_pendingfast path inadmit_hintsand escalated the live entry toReplica, and both fetch paths downloaded without ever asking whether this node was in the top-9 for K. The tag is now derived fromreplica_hint_sourcesinstead of stored (the tag is "did any peer claim to hold this"), which deletes the escalation and both demotion sites, and downloads are gated on live routing state at both diverging fetch sites. This narrows replica repair from the sender's view to our own — a transiently skewed routing table now declines to repair a key it ranks outside the top-9, matching pruning, which already evicts at the same width — while fresh PUTs keep their wider accept window.92339a1- admit hints through a single relevance gateAdmission previously asked two questions depending on the sender's label — replica hints gated at
storage_admission_width(9), paid hints atpaid_list_close_group_size(20) — letting the sender choose its own gate. Admission now decides only relevance (should we learn this key exists and is paid for) at the 20-wide paid close group, since that is the width across which nodes track payment validity; storage responsibility stays at download time against live routing state. Mislabelling a hint gains an attacker nothing because both labels reach the same gate; the admissible key set is unchanged (a paid hint for any key a replica hint could carry was already admissible at 20), and the two-gate rescue dance is removed. It does recover information the old gate discarded: a replica hint for a key we rank 10-20 for was previously rejected outright even though that band exists precisely to track payment validity.b5c0367- merge duplicate hints without rebuilding the fetch heapA hint for a key already in the fetch queue only needs its advertiser merged into that candidate's source set — a field the heap never orders on — but the old path took the whole heap, scanned it linearly for the key, and re-heapified, costing O(n) per duplicate; under source aggregation a batch of m duplicates ran O(m·n) while holding the global queue write lock, which a neighbor could trigger by re-hinting queued keys.
FetchCandidateis split intoFetchOrder(key + distance, the only fields theOrdimpl reads) held in the heap andFetchPayload(sources + retry metadata) held in a key-indexed map that also serves as the membership index; merging a source is now an O(1) map lookup that never touches the heap, and the departed-peer path edits payloads in place and rebuilds only when a peer actually orphaned a candidate. Measured per-key merge cost goes from 302µs at a 50k-deep queue to a flat ~400ns independent of depth.3a00fef- drain detached LMDB blocking ops on shutdownReplicationEngine::shutdown()promises that when it returns no background work still holds the LMDB environment, but several paths race aselect!on the shutdown token against futures awaiting aspawn_blockingLMDB transaction — the fetch worker'sstorage.put, the neighbor-sync prune pass'sstorage.deleteandpaid_list.remove_batch(thePaidListis a second LMDB environment with the same pattern), and the verification worker'spaid_list.insert. Dropping the losing future does not cancelspawn_blocking: the closure keeps running on the blocking pool with a clonedEnv, so reopening the same environment after shutdown was undefined behavior. Rather than shielding every call site, the blocking tasks are now tracked at the storage layer:LmdbStorageandPaidListroute everyspawn_blockingthrough their ownTaskTrackerand exposewait_idle(), whichshutdown()awaits after its existing detached-task drain. Per-fetch tasks are additionally spawned on the detached tracker, so an aborted worker's droppedin_flightset can no longer leakArc<LmdbStorage>past shutdown. Cancellation semantics are unchanged — network I/O still aborts promptly, and shutdown waits only for the bounded in-flight transaction (milliseconds). Regression coverage parks a write inside its blocking closure, drops the awaiter, and proveswait_idle()/shutdown()block until it commits and both environments reopen cleanly.Preceding Follow-up Commit Details
ab848a2- stop the message handler when event streams closeMakes P2P lag remain recoverable but treats a closed P2P broadcast as terminal, matching the new terminal handling for a closed DHT broadcast. The replication loop now exits instead of spinning indefinitely on an immediately ready closed receiver; dropping its sender also lets the serial handler finish. The commit additionally hoists imports required by the Rust conventions hook.
3e8860c- prune departed peers during DHT lag recoveryBrings lag recovery in line with the normal
KClosestPeersChangedpath by retaining only the current close-peer set before requeueing its members. Stale peers from missed departure events no longer sit ahead of genuine entrants and burn one request timeout each.fc9dff0- penalize rejected singleton replica hintsPenalizes a sole replica advertiser when either the close group definitively rejects the key or that advertiser explicitly denies possessing it. This closes the free-replication abuse path while keeping inconclusive rounds without a direct contradiction, paid-only advertisements, and corroborated replica hints non-penalizing. Trust reports remain bounded per peer and verification cycle.
Earlier Follow-up Commit Details
37f8e34- unblock bootstrap when a rejected peer leavesRetires capacity-rejection debt on
PeerRemovedand immediately reruns bootstrap drain detection, so progress no longer depends on an unrelated later pipeline event.8f38d4c- prioritise source-aware hintsReplaces the single hint sender with live source sets, preserves replica claimants separately, prioritises corroborated work, bounds verification cycles and network concurrency, and simplifies stale proof-of-concept tests around the production queue implementation.
94705cf- aggregate bootstrap hint batchesMakes a bootstrap neighbor batch an atomic source-aggregation unit: sync requests run concurrently, response metadata is processed first, admitted hints are queued together, and verification waits until batch publication and drain accounting are complete.
f9263de- aggregate verification per peerSends one bounded request per target peer for the cycle, aligns the incoming limit with the cycle bound, and rejects oversized batches rather than processing a misleading prefix.
8d3834b- drain fresh offers through LMDB writesRemoves cancellation around a started fresh-offer handler and makes its tracker drain unconditional, preventing a dropped async waiter from detaching a live blocking LMDB transaction.
b577e8e- track detached audit workGeneralises the fresh-offer tracker into a shared detached-task lifecycle barrier and applies it to storage/P2P-capable audit responders, delayed possession checks, and audit launches.
Latest Follow-up Commit Details
172e97f- guard raw LMDB reads against concurrent map resizeall_keys()andget_raw()now acquire the shared LMDB environment lock inside their tracked blocking closures before opening a read transaction. This restores the invariant assumed bytry_resize(): its exclusive lock means no transaction can still reference the old memory map whenenv.resize()runs. A deterministic regression parks an in-flight raw read and proves resize waits for it to release.86c4910- fairly share verification capacityReplaces the removed hard per-peer admission quota with elastic max-min accounting under the unchanged global cap. A peer may use all otherwise idle capacity, preserving large legitimate bootstrap batches, but under contention an under-served sender can displace a low-priority borrowed entry held by an over-served sender. Verification service is independently fair across capacity owners via persistent round-robin scheduling, with slack redistribution and source-count/retry priority within each owner. Capacity ownership survives fetch retries, transfers when a source departs, and is separate from evidence provenance; bootstrap explicitly records displaced hints for redelivery. The wire protocol and serialized/public compatibility surface are unchanged.
Adversarial coverage includes a paid-only attacker filling all 131,072 slots before an honest source submits 10,000 hints, the reverse arrival order, a sole legitimate 50,000-hint source, full-cycle fairness, retry protection, owner transfer, duplicate-bookkeeping bounds, and bootstrap displacement accounting. Validation passes all 766 library tests, the seven bounded-queue PoCs, formatting, diff checks, and all-target/all-feature Clippy with warnings denied.
Newest Follow-up Commit Details
a40da7b- generalize bounded responder admissionExtracts the audit responder's global permit and per-peer slot into a reusable admission primitive. A provisional peer slot is RAII-owned while waiting for global capacity, so both normal guard drops and cancelled waits restore capacity.
ec8eb6a,864a5b4, and57873d0- isolate bulk respondersMoves fetch, verification, and neighbor-sync response work off the serial replication lane and onto bounded worker pools. Fetch uses three bandwidth-limited workers; verification and neighbor sync use two each and permit one outstanding batch per source. Requests that have already exceeded the caller's existing deadline are shed at dequeue, before stale LMDB or upload work begins.
7b5ee55and48db2d5- make overload bounded and visibleRemoves the inline execution fallback when the serial handoff is full or closed, preserving responsiveness of the P2P event receiver. Relaxed atomic counters record admission drops, stale dequeues, and per-variant serial drops without adding contention to the overloaded paths.
4ef753band339f44b- add audit and possession diagnosticsAdds structured audit issuer/source attribution, capacity decisions, stage latency, and bounded minute summaries. Responsive possession failures now retain a stable reason label for every existing validation branch; the wire protocol and trust decisions are unchanged.
Coverage Added Or Updated
Bounded responder guards release global and per-source slots on ordinary drop and cancelled admission waits.
Neighbor-sync admission serializes one source while allowing a different source to progress concurrently.
Expired responder work is shed at dequeue while fresh requests are served.
A full serial handoff drops the message instead of invoking its handler inline.
Bulk-responder counters remain separated by class and admission-versus-staleness outcome.
Audit summaries order top logical origins and preserve per-stage timings.
Possession validation branches map to stable structured failure labels.
LMDB resize waits for an in-flight
get_raw()transaction holding the shared environment lock.Elastic max-min admission under attacker-first and honest-first arrival orders, including large sole-source bootstrap batches and the unchanged global capacity bound.
Fair per-owner verification service with slack redistribution, retry protection, peer-removal ownership transfer, bounded duplicate bookkeeping, and displaced-bootstrap redelivery accounting.
Paid-hint admission after duplicate replica rejection.
Verification retry, reservation transfer, discard, exhaustion, and per-sender capacity behavior.
Paid-list edge vote behavior for negative, positive, unresolved, self-inclusive, undersized, and non-default runtime-configured group widths.
Neighbor-sync priority drain/termination and lag recovery.
Bootstrap completion after a capacity-rejected peer departs.
Capacity-rejection TTL derivation across the full configured sync cycle and runtime settings, plus semantics: a within-TTL rejection still blocks drain, an expired record unblocks it, expiry is per-source (a stale source's expiry does not forfeit a fresh source's owed re-delivery), and a repeat rejection refreshes the timestamp.
The peer-removal race ordering itself: removal cleanup runs first as a no-op, the rejection is recorded after for the now-departed peer, drain is blocked, then TTL expiry retires the orphaned record and drain completes.
The verification-tick self-heal helper: an orphaned record survives a within-TTL tick and a past-TTL tick expires it and completes bootstrap.
Duplicate hint source aggregation and source-aware scheduling.
Singleton replica-hint penalties for definitive close-group rejection and explicit source denial, including neutral inconclusive, paid-only, and corroborated cases.
Atomic bootstrap batch publication and full-cycle verification request bounds.
Fresh-offer admission bounding, per-key in-flight collapse of concurrent duplicates, and the refusal-past-bound possession penalty.
Replica-download responsibility gating at both fetch sites, including rejection of out-of-range keys and the removed paid→replica escalation path.
Single-gate hint admission parity across replica and paid labels for the unchanged admissible key set.
O(1) duplicate-source merge and heap rebuild only on genuine candidate orphaning.
Audit coordinator per-target serialization, cross-peer parallelism, and cancellation cleanup.
Closed P2P event handling now explicitly verifies terminal control flow; lagged P2P event handling still verifies continuation and metric accounting.
E2E paid-list majority repair below storage quorum.
Storage- and paid-list-level
wait_idleregression tests: a write parked inside its blocking closure with a dropped awaiter keepswait_idleblocked, commits after release, and leaves the store usable.Engine-level shutdown drain (
poc_shutdown_lmdb_drain):shutdown()blocks until a detached LMDB write commits, after which both environments reopen cleanly.Per-attempt responsibility recheck: worker disposition of
NoLongerResponsible(terminal exit, retry-slot release, no verification requeue), terminal-path parity withStored, and preserved source-failure retry/requeue transitions.E2E stale-fetch-candidate driver on a live 12-node network: a seam-enqueued candidate for an out-of-responsibility key is never stored and exits the pipeline terminally, with an in-responsibility positive control through the same seam and holder; the test fails when the rechecks are disabled.
Existing prune, fetch-retry, and replication tests updated for shared coordination and reservation-aware APIs.
Prune backlog draining: width-9 hysteresis classification; complete-width-20 fast deletion versus incomplete-width-20 audited fallback; fast-path and audited TOCTOU revalidation; dynamic square-root peer batches; request-budget admission of complete candidate proof sets; rotating fairness; and every
Rejectedreason grading to the non-recoveringRejectedstatus without parsing its wording (a challenge-id mismatch stays a plain failure).Expected Effect
Fetch, verification, and neighbor-sync floods cannot monopolize the serial replication lane; bounded, per-source-fair workers isolate their LMDB, queue, and upload costs.
Serial-queue saturation remains local to the rejected message instead of propagating inline work and lag into the P2P receiver.
Operators can distinguish admission pressure, expired queued work, audit-origin hotspots, slow response stages, and exact responsive possession-proof failures.
Raw LMDB reads cannot overlap an unsafe memory-map resize, closing the remaining resize-safety gap for audit, pruning, and commitment scans.
A routing peer can use idle verification capacity but cannot monopolize admission or service once honest peers contend; large genuine bootstrap batches remain admissible when capacity is available.
Partition heals and mass joins drain priority neighbor-sync work in seconds-scale rounds instead of waiting through periodic ticks or stale-peer timeouts.
Repair remains live through transient routing disagreement, no-holder/no-source rounds, queue pressure, and fetch exhaustion.
Better-corroborated hints are verified first without allowing verification rounds or request fan-out to grow without bound.
Sole peers cannot advertise unacknowledged replicas to offload storage for free without incurring bounded trust penalties.
Fresh-offer verification and storage never run inline on the serial loop, so worker saturation no longer backs up the inbound queue or drops replication messages through broadcast lag.
A peer cannot conscript out-of-range nodes into fetching and storing arbitrary keys by labelling hints as replicas.
Duplicate-hint bursts no longer scale the fetch-queue write-lock hold time with queue depth.
Topology churn between fetch promotion and download no longer costs a download, a disk write, and a prune cycle — stale candidates are declined at download time, terminally and without stalling bootstrap drain or penalizing an innocent source.
Bootstrap cannot be held indefinitely by partial batch publication or departed capacity-rejected peers — including one whose removal races the rejection record — and a drain condition that becomes true without a triggering event is observed within one worker tick, so audits cannot be disabled permanently nor the node trust-penalized for a perpetual bootstrap claim.
Local audit concurrency no longer manufactures false remote timeout verdicts.
Mature out-of-range records drain at scale: far-out copies beyond a complete width-20 view are fast-deleted in bounded local batches without remote traffic, and the audited rank-10-20 band is denser (multi-key peer batches bounded by actual requests) instead of ~9 records per pass. An oversized-batch rejection is graded as an attributable failure rather than parsed and retried, so no honest capacity-limited peer is trust-penalized under the assumed store spread.
Closed replication event streams terminate cleanly without CPU spin or repeated warnings.
Graceful shutdown does not return while tracked detached storage or P2P work is still active.
When
shutdown()returns, timed-out long-lived tasks have been aborted and joined, no LMDB blocking operation is still running on either environment, and no engine-spawned task holds the storage, so the same LMDB files can be reopened safely.SemVer
Minor.
Review Response (grumbach, 2026-07-27)
Investigated all thirteen findings from the review at
4c9c681. Twelveconfirmed; one did not hold (see below). Nine were fixed, three deliberately
deferred with the reasoning recorded in ADR-0005 rather than left implicit.
Must fix
d3a1d56). The record wasstamped with the most recent rejection and aged from that stamp, so a source
that keeps overflowing the queue kept its own record permanently fresh:
check_bootstrap_drainednever returned true, the node advertisedbootstrapping: trueindefinitely, and auditing stayed off for the wholeduration of the pressure (Invariant 19). No attacker needed — an ordinarily
busy peer wedges the node open. Now recorded first-seen and never refreshed.
A fairness displacement also no longer stamps its victim; charging our own
reclaim decision to the displaced owner let a flooder block our drain through
an unrelated honest peer.
PaidNotifyverified inline on the serial loop (a84c42b). Not a boundedlocal computation: on the merkle path it performs an iterative Kademlia lookup
capped only by the verifier's
CLOSENESS_LOOKUP_TIMEOUT(240s). One messagecould park every other non-audit message for minutes while the bounded inbound
queue behind it overflowed and dropped unrelated replication traffic wholesale.
Moved onto the detached-responder pattern.
a84c42b), unlike everyother responder class. One peer could hold all sixteen slots, and since a
refusal is later read as absence by the sender's delayed possession check, the
resulting refusals land as audit-severity penalties on the refuser — making
an unbounded global pool a targeted eviction primitive. Structural checks
(missing proof, oversized payload) now run before the permit, so malformed work
cannot occupy a slot for the length of a verification.
Should fix
shutdown()could hang indefinitely (a84c42b,d79752e). The detacheddrain is deliberately unbounded — a timeout could return with an
Arc<LmdbStorage>still held — so it was made finite instead: workersemaphores are closed so queued work takes the (previously unreachable) error
arm and exits, superseded fresh offers are shed at dequeue, and payment
verification races the shutdown token. The cancellation boundary is drawn at
network I/O only;
storage.putawaitsspawn_blockingand is never cancelled.809f509).MAX_PENDING_VERIFYcounts keys, not the peers remembered against each one, and re-advertising an
already-pending key is the cheapest path through admission. Capped at eight,
with replica claimants displacing paid-only advertisers.
809f509), so an owner with manyin-flight retries read as under-loaded and won more than its share.
(
1094543). A fixed four-peer edge is a fifth of the production width but fourfifths of a configured width of five, leaving a voting core of one and
authorizing on a single
Confirmedvote. Now scaled to the group with anabsolute confirmation floor. Production behaviour at width 20 is unchanged.
MAX_PENDING_VERIFY_PER_PEERwas undocumented (1632ba7,809f509). ADR-0005 described the replacement but never recorded that itremoved a named defence, or that one sender's worst case rises 8,192 → 131,072.
Now stated with the adversarial numbers, plus a test pinning the convergence
property that replaced the hard cap.
Smaller
cca744c) —including why clearing timestamps before the commit would be strictly worse.
0a03c71); each built a throwawayAuditChallengeCoordinatorthat would have defeated the shared one.eeb068b): it used akey out of range for both gates, so every version of the code passes it, and
had no positive control. Rewritten against a key in the symmetric difference of
the two gates, with a control proving the harness observes a store at all.
Not fixed, and why
scheduling.rsrequeue_fetch_for_verificationleaving a stalecapacity_owner_by_keyentry — this one does not hold. The no-owner branchis only reachable when the lookup already returned
None(the.or_elserunsonly on
None), so there is no entry to leave behind.the sender to read the
Rejectedresponse — the receiver already returns aprecise reason, it is simply discarded — and to grade the possession outcome
accordingly. The per-source share bounds how large the effect can be made
deliberately, but does not remove it. Recorded in ADR-0005 Trade-offs.
by_sourcetop-k deferred.Two regressions caught and fixed during this work
Both were found by the e2e suite and confirmed against a stashed baseline rather
than assumed:
summarize_paid_list_voteswithout scaling edgemembership in
build_verification_targetsletcore + confirmed_edgeexceedthe real group size and demand more confirmations than existed.
PaidNotifylike a request/response responder (8 outstanding / 2 perpeer / 15s shed) discards durable paid-list evidence rather than shedding load,
because the message is one-way with no retry. A joining node silently lost
entries for a fraction of its responsible keys. Now sized as a memory ceiling.
Integration
Merged
origin/mainrather than rebasing (d86e3f1). The branch and maindiverged independently inside the same functions — main has none of this
branch's detached-task tracking and replaced the first-audit scheduler wholesale
— so replaying fifty-three commits would have meant roughly forty semantic
re-applications onto rewritten code. The merge resolved ten conflicts once. Its
commit message records each resolution, notably taking main's rewritten
first-audit scheduler over this branch's superseded
LruCacheimplementation,and keeping both the cumulative and windowed audit-drop counters, which are
complementary rather than duplicates.
d79752ethen re-tracks main's new first-audit launch, the one detached taskits rewrite spawned bare.
Verification
On the merged tree: 828 lib tests, 92 e2e (3 pre-existing ignored), 38 PoC tests,
clippy clean at
-D clippy::panic -D clippy::unwrap_used -D clippy::expect_used,rustfmt clean, release and
--no-default-featuresbuilds green.