Skip to content

fix(replication): drain priority sync queue and recover from routing-event lag#165

Open
mickvandijke wants to merge 18 commits into
mainfrom
fix/neighbor-sync-drain-window
Open

fix(replication): drain priority sync queue and recover from routing-event lag#165
mickvandijke wants to merge 18 commits into
mainfrom
fix/neighbor-sync-drain-window

Conversation

@mickvandijke

@mickvandijke mickvandijke commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

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, 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, or left holding LMDB/P2P resources after engine shutdown.

The branch contains eighteen commits:

  • 19bac10 - fix(replication): harden paid-list verification repair
  • 0a5f078 - fix(replication): tolerate paid-list edge churn
  • 2576f7a - fix(replication): drain priority sync queue and recover from routing-event lag
  • 95a8cdf - fix(replication): preserve verification retry capacity
  • 0c7bd9a - fix(replication): eliminate false audit challenge timeouts
  • 3800f9a - fix(replication): preserve dequeued retry reservations
  • 95fb3d9 - chore(replication): fix audit admission clippy
  • 6ccb9bf - fix(replication): release cancelled async work
  • b8d490d - fix(replication): silence no-logging audit label warnings
  • 77aa87c - fix(replication): unblock bootstrap when rejected peer leaves
  • c896fa4 - refactor(replication): prioritize source-aware hints
  • 5accdfb - refactor(replication): aggregate bootstrap hint batches
  • 1545551 - fix(replication): aggregate verification per peer
  • 6b701c6 - fix(replication): drain fresh offers through LMDB writes
  • d2dc3f5 - fix(replication): track detached audit work
  • f81d28e - fix(replication): stop message handler when event streams close
  • 4859608 - fix(replication): prune departed peers during DHT lag recovery
  • 50f6d7e - fix(replication): penalize rejected singleton replica hints

Problems Addressed

  1. 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.
  2. 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.
  3. Neighbor sync could stall after topology bursts. Notify coalescing 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.
  4. 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.
  5. 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.
  6. Bootstrap hint batches were published incrementally. Verification could race a partially admitted neighbor-sync batch and miss the complete source picture.
  7. Verification fan-out was unnecessarily fragmented. Per-peer key sets were split into many requests despite already having a bounded verification cycle.
  8. Bootstrap could remain blocked by departed peers. A peer that caused an admission-capacity rejection could leave while its rejection marker continued preventing drain.
  9. 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.
  10. Cancelled fresh offers could detach LMDB writes. Dropping an async spawn_blocking waiter does not cancel the blocking transaction, so shutdown could report drained while LMDB still owned the environment.
  11. 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.
  12. Closed event streams could spin the replication loop. Closed Tokio broadcast receivers remain immediately ready forever; continuing after RecvError::Closed could consume a core, flood P2P warnings, and prevent the replication pipeline from shutting down cleanly.
  13. 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.

Changes

Paid-list verification and repair

  • Allows a duplicate paid hint to proceed when the duplicate replica path was rejected, while preserving replica precedence when that path was admitted.
  • Keeps locally paid repair keys alive through inconclusive, no-holder, and exhausted-source rounds by deferring and re-verifying them.
  • Carries verification retry metadata through pending_verify, fetch queue, and in-flight fetch state.
  • Reserves global and per-sender pending capacity for retryable verified work until completion, discard, or restoration to verification.
  • Uses by-value dequeue APIs so retry reservations cannot be orphaned.
  • Includes valid replica-hint senders as fallback fetch sources and allows verified paid-only repair when this node is in storage range.
  • Caches responder-side storage and paid-list lookups per key and validates malformed paid-index input defensively.

Paid-list edge churn

  • Adds a four-peer flexible edge for full-width paid close groups.
  • Negative or missing edge votes do not enlarge the denominator; positive edge votes still count and expand it.
  • Keeps undersized groups on strict-majority rules and preserves inconclusive outcomes when unresolved votes could still change the result.

Neighbor sync and bootstrap lifecycle

  • Drains priority peers back-to-back and parks only when the durable priority queue is empty.
  • Recovers from lagged DHT events by resnapshotting close neighbors, pruning queued peers that have departed, and queueing current members for priority sync.
  • Clears a departed peer's outstanding capacity-rejection marker and immediately rechecks bootstrap drain.
  • Updates bootstrap drain accounting when stale pending verification entries are evicted.
  • Runs each bootstrap neighbor batch concurrently, admits the completed batch under one queue lock, and keeps the batch outstanding until hints and drain accounting are fully published.
  • Prevents verification from selecting a partially published bootstrap batch.

Source-aware bounded verification

  • Retains all live hint sources per key, including the subset that explicitly claimed replica possession.
  • Prioritises ready work by corroborating source count while preserving bounded global/per-sender queue accounting.
  • Bounds one verification cycle to 8,192 keys and caps simultaneous verification exchanges at 32.
  • Aggregates each peer's keys into one verification request per cycle instead of splitting into 1,024-key fragments.
  • Accepts one full-cycle incoming request and rejects oversized requests with a bounded, wire-compatible empty response.
  • Removes the timing-based singleton aggregation delay; atomic bootstrap batch publication now supplies the complete source set deterministically.
  • Reports bounded trust failures for a sole replica advertiser when either the close group definitively rejects the key or that advertiser explicitly reports it absent; inconclusive, paid-only, and corroborated hints remain neutral.

Audit concurrency and observability

  • Adds a shared per-target AuditChallengeCoordinator across responsible, prune-confirmation, and possession audits.
  • Limits local concurrency to the deployed responder admission capacity and starts response deadlines only after local admission.
  • Makes coordinator reference accounting cancellation-safe with RAII cleanup.
  • Separates timeout, unreachable, and send-failure observability while retaining wire-compatible evidence semantics.
  • Records responder admission drops and digest dispatch latency, with logging-feature-safe metric labels.

Event-stream and detached-task lifecycle

  • Treats closed P2P and DHT broadcast streams as terminal for the replication message loop instead of repeatedly selecting an event source that can never recover.
  • Breaking the loop drops the replication sender and cascades a clean shutdown to the serial handler.
  • Tracks detached fresh-offer workers and waits for started handlers to complete LMDB writes instead of cancelling their async waiters.
  • Removes the best-effort timeout from the detached-task drain so shutdown cannot claim LMDB-safe completion while blocking storage work remains active.
  • Expands the shared tracker to digest, subtree, and byte responders; delayed possession checks; first-audit launches; and gossip-triggered audits.
  • Stops all producer loops before closing and draining the shared tracker, preventing late registration races.
  • Ensures engine shutdown does not return while tracked detached work still retains LMDB or P2P state.

Commit Details For Today's Follow-ups

f81d28e - stop the message handler when event streams close

Makes 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.

4859608 - prune departed peers during DHT lag recovery

Brings lag recovery in line with the normal KClosestPeersChanged path 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.

50f6d7e - penalize rejected singleton replica hints

Penalizes 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

77aa87c - unblock bootstrap when a rejected peer leaves

Retires capacity-rejection debt on PeerRemoved and immediately reruns bootstrap drain detection, so progress no longer depends on an unrelated later pipeline event.

c896fa4 - prioritise source-aware hints

Replaces 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.

5accdfb - aggregate bootstrap hint batches

Makes 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.

1545551 - aggregate verification per peer

Sends 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.

6b701c6 - drain fresh offers through LMDB writes

Removes 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.

d2dc3f5 - track detached audit work

Generalises 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.

Coverage Added Or Updated

  • 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, and undersized groups.
  • Neighbor-sync priority drain/termination and lag recovery.
  • Bootstrap completion after a capacity-rejected peer departs.
  • 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.
  • 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.
  • Existing prune, fetch-retry, and replication tests updated for shared coordination and reservation-aware APIs.

Expected Effect

  • 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.
  • Bootstrap cannot be held indefinitely by partial batch publication or departed capacity-rejected peers.
  • Local audit concurrency no longer manufactures false remote timeout verdicts.
  • 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.

SemVer

Patch.

Copilot AI review requested due to automatic review settings July 1, 2026 16:40

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/replication/mod.rs
Comment on lines 2543 to 2547
results.push(protocol::KeyVerificationResult {
key: *key,
present,
present: cached.present.unwrap_or(false),
paid,
});
Comment thread src/replication/mod.rs Outdated
}
continue;
}
Err(RecvError::Closed) => continue,
Comment thread src/replication/quorum.rs
Comment on lines +495 to +497
let handles =
spawn_verification_batch_tasks(targets, p2p_node, config.verification_request_timeout);
collect_verification_batch_results(handles, targets, &mut evidence).await;
Comment thread src/replication/config.rs
Comment on lines +43 to +47
/// Number of furthest paid-list close-group peers treated as churny edge
/// voters.
///
/// Once the paid-list close group reaches [`PAID_LIST_CLOSE_GROUP_SIZE`], edge
/// peers are queried, but a negative edge paid-list response does not count
@mickvandijke mickvandijke force-pushed the fix/neighbor-sync-drain-window branch from 1a6fe63 to fc8d724 Compare July 2, 2026 12:59
Copilot AI review requested due to automatic review settings July 2, 2026 12:59

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 3 comments.

Comment on lines +1284 to +1288
warn!(
"Prune audit challenge for {} against {peer} could not acquire coordinator slot",
hex::encode(key)
);
return PruneAuditChallengeResult::MalformedResponse;
Comment thread src/replication/mod.rs
Comment on lines +2837 to 2841
if cached.present.is_none() && paid.is_none() {
continue;
}

results.push(protocol::KeyVerificationResult {
Comment thread src/replication/mod.rs
Comment on lines 18 to 21
pub mod audit;
pub mod audit_coordinator;
pub(crate) mod audit_metrics;
pub mod bootstrap;
Copilot AI review requested due to automatic review settings July 2, 2026 15:53

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 18 changed files in this pull request and generated 2 comments.

Comment thread src/replication/mod.rs
Comment on lines +132 to +134
fn fresh_offer_key_lock_index(key: &XorName) -> usize {
usize::from(key[0]) % FRESH_OFFER_KEY_LOCK_SHARDS
}
Comment on lines +1283 to +1289
let Some(_slot) = audit_challenge_coordinator.acquire(*peer).await else {
warn!(
"Prune audit challenge for {} against {peer} could not acquire coordinator slot",
hex::encode(key)
);
return PruneAuditChallengeResult::MalformedResponse;
};
mickvandijke and others added 9 commits July 14, 2026 11:31
…event lag

Neighbor-sync paid-list hint propagation could stall for tens of minutes on
the furthest close-group members during correlated topology change (partition
heal, mass join). Two root causes:

- The neighbor-sync loop parked on the periodic 10-20 min tick after every
  single round, and `sync_trigger` is a coalescing `Notify` that collapses a
  burst of entrant wakeups into one. A churn burst that queued many priority
  peers therefore drained only one batch (<=4) promptly; the rest waited for
  subsequent ticks. Park only when `priority_order` is empty and otherwise run
  rounds back-to-back, draining the durable queue at round-trip speed. The
  drain terminates because `select_next_sync_peer` pops each priority peer
  unconditionally and `new_cycle` only refills under `is_cycle_complete()`.

- The DHT event handler discarded broadcast `RecvError::Lagged`, silently
  dropping `KClosestPeersChanged` events under load -- exactly when churn is
  heaviest -- so missed entrants were never queued. On lag, resynchronize from
  ground truth: snapshot the current close-peer set, queue every member for
  priority sync, and fire the trigger.

Add `NeighborSyncState::has_priority_peers` and a regression test covering the
loop's drain/termination contract.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SemVer: patch

Consume dequeued fetch candidates through reservation-aware queue APIs and requeue no-source retry candidates for verification.

Centralize pending_verify insertion bookkeeping so per-sender counters stay in lockstep.
Copilot AI review requested due to automatic review settings July 14, 2026 09:39
@mickvandijke mickvandijke force-pushed the fix/neighbor-sync-drain-window branch from 74c990e to b8d490d Compare July 14, 2026 09:39

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 16 out of 16 changed files in this pull request and generated 1 comment.

Comment on lines +631 to +633
self.insert_pending_unchecked(key, verification);
self.release_retry_slot(&sender);
true
Copilot AI review requested due to automatic review settings July 14, 2026 14:04

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Comment on lines +53 to +54
#[cfg(feature = "logging")]
impl AuditType {
Comment on lines +77 to +78
#[cfg(feature = "logging")]
impl AuditResponderClass {
Comment on lines +1283 to +1289
let Some(_slot) = audit_challenge_coordinator.acquire(*peer).await else {
warn!(
"Prune audit challenge for {} against {peer} could not acquire coordinator slot",
hex::encode(key)
);
return PruneAuditChallengeResult::MalformedResponse;
};
Copilot AI review requested due to automatic review settings July 14, 2026 15:06

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Comment on lines +204 to +209
if let Some(retry) = &mut candidate.retry_verification {
retry
.replica_hint_sources
.extend(entry.replica_hint_sources);
retry.hint_sources.extend(entry.hint_sources);
}
Comment on lines +222 to +227
if let Some(retry) = &mut in_flight.retry_verification {
retry
.replica_hint_sources
.extend(entry.replica_hint_sources);
retry.hint_sources.extend(entry.hint_sources);
}
Comment on lines +1283 to +1289
let Some(_slot) = audit_challenge_coordinator.acquire(*peer).await else {
warn!(
"Prune audit challenge for {} against {peer} could not acquire coordinator slot",
hex::encode(key)
);
return PruneAuditChallengeResult::MalformedResponse;
};
mickvandijke and others added 2 commits July 15, 2026 11:25
A closed Tokio broadcast receiver is immediately ready with
RecvError::Closed on every recv(), and both the P2P and DHT event
branches responded by continuing the select! loop — spinning a core
(and, on the P2P branch, flooding logs) until shutdown. A closed
broadcast channel can never yield again, so treat Closed as terminal:
handle_replication_event_recv_error now returns ControlFlow and both
branches break the loop, which also drops replication_tx and cascades
a clean shutdown of the serial handler.

Also hoist mid-file imports flagged by the rust-conventions hook:
the saorsa_pqc sig import moves to the top block and the tests module
switches to `use super::*;` with module-qualified paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The broadcast-lag recovery path requeued the current close-peer
snapshot but never called retain_sync_peers, unlike the normal
KClosestPeersChanged path. Peers that left the close set during the
lost event window stayed in priority_order ahead of genuine entrants,
each burning a request timeout before current peers could sync.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings July 15, 2026 09:54

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 3 comments.

Comment on lines +110 to +114
enum PruneAuditChallengeResult {
Response(Box<ReplicationMessage>),
NoResponse(AuditFailureClass),
MalformedResponse,
}
Comment on lines +1183 to +1187
PruneAuditChallengeResult::Response(decoded) => *decoded,
PruneAuditChallengeResult::NoResponse(class) => {
// No response means an immediate audit failure, but keep the local
// class split so timeout metrics are not polluted by pre-delivery
// failures.
Comment on lines +1283 to +1289
let Some(_slot) = audit_challenge_coordinator.acquire(*peer).await else {
warn!(
"Prune audit challenge for {} against {peer} could not acquire coordinator slot",
hex::encode(key)
);
return PruneAuditChallengeResult::MalformedResponse;
};
Copilot AI review requested due to automatic review settings July 15, 2026 12:49

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 17 out of 17 changed files in this pull request and generated 4 comments.

Comment on lines +321 to +326
if let Some(keys) = self.pending_keys_by_source.get_mut(source) {
keys.remove(key);
if keys.is_empty() {
self.pending_keys_by_source.remove(source);
}
}
Comment on lines +115 to +121
let Some(entry) = targets.get_mut(&peer) else {
return;
};
entry.references = entry.references.saturating_sub(1);
if entry.references == 0 {
targets.remove(&peer);
}
Comment on lines +53 to +54
#[cfg(feature = "logging")]
impl AuditType {
Comment on lines +77 to +78
#[cfg(feature = "logging")]
impl AuditResponderClass {
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants