Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
124e8f8
fix(replication): harden paid-list verification repair
mickvandijke Jul 1, 2026
72c1815
fix(replication): tolerate paid-list edge churn
mickvandijke Jul 1, 2026
3479a75
fix(replication): drain priority sync queue and recover from routing-…
mickvandijke Jul 1, 2026
819b4e2
fix(replication): preserve verification retry capacity
mickvandijke Jul 1, 2026
c61d432
fix(replication): eliminate false audit challenge timeouts
mickvandijke Jul 2, 2026
4099f6c
fix(replication): preserve dequeued retry reservations
mickvandijke Jul 2, 2026
712b514
chore(replication): fix audit admission clippy
mickvandijke Jul 2, 2026
0b721f9
fix(replication): release cancelled async work
mickvandijke Jul 2, 2026
6cd9c83
fix(replication): silence no-logging audit label warnings
mickvandijke Jul 2, 2026
da79e46
fix(replication): unblock bootstrap when rejected peer leaves
mickvandijke Jul 14, 2026
67cc46a
refactor(replication): prioritize source-aware hints
mickvandijke Jul 14, 2026
17b26ef
refactor(replication): aggregate bootstrap hint batches
mickvandijke Jul 14, 2026
d507303
fix(replication): aggregate verification per peer
mickvandijke Jul 14, 2026
61ebee8
fix(replication): drain fresh offers through LMDB writes
mickvandijke Jul 14, 2026
8c298e1
fix(replication): track detached audit work
mickvandijke Jul 14, 2026
09e44b4
fix(replication): stop message handler when event streams close
mickvandijke Jul 15, 2026
473b288
fix(replication): prune departed peers during DHT lag recovery
mickvandijke Jul 15, 2026
4ed6c45
fix(replication): penalize rejected singleton replica hints
mickvandijke Jul 15, 2026
7a86d1e
fix(replication): keep fresh offers off the serial message loop
mickvandijke Jul 15, 2026
ff58baa
fix(replication): gate replica downloads on storage responsibility
mickvandijke Jul 15, 2026
e521ed5
refactor(replication): admit hints through a single relevance gate
mickvandijke Jul 15, 2026
e3cb539
perf(replication): merge duplicate hints without rebuilding the fetch…
mickvandijke Jul 16, 2026
5467dba
fix(replication): drain detached LMDB blocking ops on shutdown
mickvandijke Jul 16, 2026
49093a0
fix(replication): expire orphaned capacity-rejection records to unsta…
mickvandijke Jul 16, 2026
a3dfc9b
fix(replication): recheck storage responsibility at the point of down…
mickvandijke Jul 16, 2026
3a7bdba
docs(adr): record replication repair hardening decisions (PR #165)
mickvandijke Jul 16, 2026
aa68bb0
fix(replication): address deep review findings
mickvandijke Jul 16, 2026
79aaebb
fix(replication): resolve all-target clippy failures
mickvandijke Jul 20, 2026
e5ec1ac
fix(docs): avoid private rustdoc link
mickvandijke Jul 20, 2026
6b0e4ce
fix(storage): guard raw LMDB reads against concurrent map resize
mickvandijke Jul 21, 2026
d061762
fix(replication): fairly share verification capacity
mickvandijke Jul 21, 2026
6b4f168
fix(replication): generalize bounded responder admission
mickvandijke Jul 21, 2026
2c58d94
fix(replication): isolate fetch responders
mickvandijke Jul 21, 2026
ddb1071
fix(replication): isolate verification responders
mickvandijke Jul 21, 2026
349a1df
fix(replication): isolate neighbor sync responders
mickvandijke Jul 21, 2026
e6b15f7
fix(replication): drop serial queue overflow
mickvandijke Jul 21, 2026
5b85d1f
fix(replication): count responder load shedding
mickvandijke Jul 21, 2026
0f9e3b1
feat(replication): trace audit request origins and latency
mickvandijke Jul 22, 2026
a5f60f3
feat(replication): log possession proof failure reasons
mickvandijke Jul 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -150,8 +150,7 @@ name = "poc_audit_handler_live"
path = "tests/poc_audit_handler_live.rs"
required-features = ["test-utils"]

# Bootstrap-stall DoS regression marker (documents the unfixed attack; the
# eventual fix must land with a follow-up test asserting bounded drain).
# Bootstrap-stall regression coverage for source cleanup after peer removal.
# Declared like the other PoC suites so CI invokes it explicitly.
[[test]]
name = "poc_bootstrap_stall"
Expand All @@ -166,6 +165,14 @@ name = "poc_price_floor_live"
path = "tests/poc_price_floor_live.rs"
required-features = ["test-utils"]

# Shutdown/LMDB-drain regression: `ReplicationEngine::shutdown()` must not
# return while a detached LMDB blocking op is still running. Uses the
# test-only storage put gate, so it requires the test-utils feature.
[[test]]
name = "poc_shutdown_lmdb_drain"
path = "tests/poc_shutdown_lmdb_drain.rs"
required-features = ["test-utils"]

[features]
default = ["logging"]
# Enable tracing/logging infrastructure.
Expand Down
451 changes: 451 additions & 0 deletions docs/adr/ADR-0005-replication-repair-hardening.md

Large diffs are not rendered by default.

195 changes: 127 additions & 68 deletions src/replication/admission.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ use saorsa_core::identity::PeerId;
use saorsa_core::P2PNode;

use crate::ant_protocol::XorName;
use crate::replication::config::{storage_admission_width, ReplicationConfig};
use crate::replication::config::ReplicationConfig;
use crate::replication::paid_list::PaidList;
use crate::storage::LmdbStorage;

Expand Down Expand Up @@ -66,16 +66,44 @@ pub async fn is_in_paid_close_group(
closest.iter().any(|n| n.peer_id == *self_id)
}

/// Is this key worth tracking at all?
///
/// One gate for every hint, whatever the sender labelled it. Admission asks
/// only whether the key is relevant to us — whether we should learn that it
/// exists and is paid for. That is the `PaidCloseGroup(K)` question, since
/// `paid_list_close_group_size` is exactly the width across which nodes track
/// payment validity.
///
/// Storage responsibility is a *different* question, asked later against live
/// routing state at the point of download. Keeping the two apart is what makes
/// the sender's replica/paid labelling unable to influence what we store.
async fn is_relevant(
self_id: &PeerId,
key: &XorName,
p2p_node: &Arc<P2PNode>,
config: &ReplicationConfig,
storage: &Arc<LmdbStorage>,
paid_list: &Arc<PaidList>,
pending_keys: &HashSet<XorName>,
) -> bool {
// Fast paths: we already hold the key, already track it, or already know it
// is paid for. Each means the relevance question is settled -- no
// routing-table lookup needed.
storage.exists(key).unwrap_or(false)
|| pending_keys.contains(key)
|| paid_list.contains(key).unwrap_or(false)
|| is_in_paid_close_group(self_id, key, p2p_node, config.paid_list_close_group_size).await
}

/// Admit neighbor-sync hints per Section 7.1 rules.
///
/// For each key in `replica_hints` and `paid_hints`:
/// - **Cross-set precedence**: if a key appears in both sets, keep only the
/// replica-hint entry.
/// - **Replica hints**: admitted if `self` is in the storage-admission group
/// (`close_group_size + STORAGE_ADMISSION_MARGIN`) or key already exists in
/// local store / pending set.
/// - **Paid hints**: admitted if `self` is in `PaidCloseGroup(K)` or key is
/// already in `PaidForList`.
/// Every key -- replica-hinted or paid-hinted -- passes the same `is_relevant`
/// gate. The hint set a key arrived on decides only whether the sender is
/// recorded as claiming possession, which makes it a candidate fetch source; it
/// does not decide admission, and it does not decide storage.
///
/// - **Cross-set precedence**: a key in both sets is settled by the replica
/// pass, so the paid pass skips it.
///
/// Returns an [`AdmissionResult`] with keys sorted into pipelines.
#[allow(clippy::too_many_arguments, clippy::implicit_hasher)]
Expand All @@ -95,29 +123,19 @@ pub async fn admit_hints(
rejected_keys: Vec::new(),
};

// Track all processed keys to deduplicate within and across sets.
let mut seen = HashSet::new();

// Process replica hints.
let mut seen_replica = HashSet::new();
for &key in replica_hints {
if !seen.insert(key) {
if !seen_replica.insert(key) {
continue;
}

// Fast path: already local or pending -- no routing-table lookup needed.
let already_local = storage.exists(&key).unwrap_or(false);
let already_pending = pending_keys.contains(&key);

if already_local || already_pending {
result.replica_keys.push(key);
continue;
}

if is_responsible(
if is_relevant(
self_id,
&key,
p2p_node,
storage_admission_width(config.close_group_size),
config,
storage,
paid_list,
pending_keys,
)
.await
{
Expand All @@ -127,22 +145,27 @@ pub async fn admit_hints(
}
}

// Process paid hints. Cross-set dedup is handled by `seen` — any key
// already processed in the replica-hints loop above is skipped here.
let mut seen_paid = HashSet::new();
for &key in paid_hints {
if !seen.insert(key) {
if !seen_paid.insert(key) {
continue;
}

// Fast path: already in PaidForList -- no routing-table lookup needed.
let already_paid = paid_list.contains(&key).unwrap_or(false);

if already_paid {
result.paid_only_keys.push(key);
// Cross-set precedence: the replica pass already ruled on this key,
// under the same gate this pass would apply. Re-asking cannot change
// the answer, and re-recording it would double-count the rejection.
if seen_replica.contains(&key) {
continue;
Comment thread
mickvandijke marked this conversation as resolved.
}

if is_in_paid_close_group(self_id, &key, p2p_node, config.paid_list_close_group_size).await
if is_relevant(
self_id,
&key,
p2p_node,
config,
storage,
paid_list,
pending_keys,
)
.await
{
result.paid_only_keys.push(key);
} else {
Expand Down Expand Up @@ -225,27 +248,22 @@ mod tests {

#[test]
fn deduplication_across_sets() {
// If a key appears in replica_hints AND paid_hints, the paid entry
// is skipped because seen already contains it from replica processing.
// If a key appears in replica_hints AND paid_hints and the replica
// side is admitted, the paid entry is skipped because the stronger
// replica pipeline already covers paid-list convergence.
let key = xor_name_from_byte(0xFF);
let replica_hints = vec![key];
let paid_hints = vec![key];

let replica_set: HashSet<XorName> = replica_hints.iter().copied().collect();
let mut seen: HashSet<XorName> = HashSet::new();
let mut admitted_replica: HashSet<XorName> = HashSet::new();

// Process replica hints first.
for &k in &replica_hints {
seen.insert(k);
admitted_replica.insert(k);
}

// Process paid hints: key is already in `seen` AND in `replica_set`.
let mut paid_admitted = Vec::new();
for &k in &paid_hints {
if !seen.insert(k) {
continue; // duplicate
}
if replica_set.contains(&k) {
if admitted_replica.contains(&k) {
continue; // cross-set precedence
}
paid_admitted.push(k);
Expand All @@ -257,6 +275,48 @@ mod tests {
);
}

#[test]
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);
Comment thread
mickvandijke marked this conversation as resolved.
Comment on lines +279 to +283
let replica_hints = vec![key];
let paid_hints = vec![key];

let mut seen_replica = HashSet::new();
let admitted_replica: HashSet<XorName> = HashSet::new();
let mut rejected_replica = Vec::new();

for &k in &replica_hints {
if seen_replica.insert(k) {
rejected_replica.push(k);
}
}

let mut admitted_paid = HashSet::new();
for &k in &paid_hints {
if admitted_replica.contains(&k) {
continue;
}
let in_paid_close_group = true;
if in_paid_close_group {
admitted_paid.insert(k);
}
}

assert!(
admitted_paid.contains(&key),
"paid hint must be considered after duplicate replica rejection"
);
assert!(
rejected_replica
.into_iter()
.all(|k| admitted_paid.contains(&k)),
"replica rejection should not remain terminal after paid admission"
);
}

#[test]
fn admission_result_empty_inputs() {
let result = AdmissionResult {
Expand Down Expand Up @@ -332,14 +392,13 @@ mod tests {
/// gate tested at the e2e level (scenario 17 tests the positive
/// case).
/// (b) Even if a sender IS in `LocalRT`, the per-key relevance check
/// (`is_responsible` with storage-admission width /
/// `is_in_paid_close_group`) in `admit_hints` still applies. Sender
/// identity does not grant key admission.
/// (`is_relevant`, i.e. `is_in_paid_close_group`) in `admit_hints`
/// still applies. Sender identity does not grant key admission.
///
/// This test exercises layer (b): the admission pipeline's dedup,
/// cross-set precedence, and relevance filtering using the same logic
/// that `admit_hints` performs — without the `P2PNode` dependency
/// needed for the actual `is_responsible` DHT lookup.
/// needed for the actual `is_in_paid_close_group` DHT lookup.
#[test]
fn scenario_5_sender_does_not_grant_key_relevance() {
let key_pending = xor_name_from_byte(0xB0);
Expand Down Expand Up @@ -368,10 +427,10 @@ mod tests {
admitted_replica.push(key);
continue;
}
// key_not_pending: not pending, not local -> needs the
// storage-admission check. Simulate it returning false.
let is_responsible = false;
if is_responsible {
// key_not_pending: not pending, not local, not paid -> needs the
// paid-close-group relevance check. Simulate it returning false.
let is_relevant = false;
if is_relevant {
admitted_replica.push(key);
} else {
rejected.push(key);
Expand Down Expand Up @@ -425,13 +484,13 @@ mod tests {

/// Scenario 7: Out-of-range key hint rejected regardless of quorum.
///
/// A key whose XOR distance from self is much larger than the distance
/// of the storage-admission members fails the `is_responsible` check in
/// `admit_hints`. The key never enters the verification pipeline, so
/// quorum is irrelevant.
/// A key whose XOR distance from self is much larger than the distance of
/// the paid-close-group members fails the `is_relevant` check in
/// `admit_hints`. The key never enters the verification pipeline, so quorum
/// is irrelevant.
///
/// This test exercises the distance-based reasoning that `admit_hints`
/// uses, tracing through the same logic path. Full `is_responsible`
/// uses, tracing through the same logic path. Full `is_in_paid_close_group`
/// requires a `P2PNode` for DHT lookups; here we verify the distance
/// comparison and admission outcome for both close and far keys.
#[test]
Expand All @@ -451,8 +510,8 @@ mod tests {

// -- Simulate admit_hints for these keys --
//
// When the storage-admission peers are all closer to far_key than
// self, `is_responsible(self, far_key)` returns false. The key is
// When the paid-close-group peers are all closer to far_key than self,
// `is_in_paid_close_group(self, far_key)` returns false. The key is
// rejected without entering verification or quorum.

let pending: HashSet<XorName> = HashSet::new();
Expand All @@ -470,12 +529,12 @@ mod tests {
admitted.push(key);
continue;
}
// Simulate is_responsible: self (0x00) has the full
// storage-admission group closer to far_key (0xFF) than itself.
// For close_key (0x01), self is very close -> responsible.
// Simulate is_in_paid_close_group: self (0x00) has the full
// paid close group closer to far_key (0xFF) than itself. For
// close_key (0x01), self is very close -> relevant.
let distance = xor_distance(&self_xor, &key);
let simulated_responsible = distance[0] < 0x80;
if simulated_responsible {
let simulated_relevant = distance[0] < 0x80;
if simulated_relevant {
admitted.push(key);
} else {
rejected.push(key);
Expand Down
Loading
Loading