Skip to content

Commit c776441

Browse files
authored
Merge pull request #207 from grumbach/fix/capacity-aware-verification-cycle
fix(replication): stop a write-blocked node probing its close group
2 parents c35535c + 96556d3 commit c776441

8 files changed

Lines changed: 1084 additions & 8 deletions

File tree

docs/adr/ADR-0011-capacity-gated-source-discovery.md

Lines changed: 256 additions & 0 deletions
Large diffs are not rendered by default.

src/replication/config.rs

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,45 @@ const PENDING_VERIFY_MAX_AGE_SECS: u64 = 30 * 60;
626626
/// Maximum age for pending-verification entries before stale eviction.
627627
pub const PENDING_VERIFY_MAX_AGE: Duration = Duration::from_secs(PENDING_VERIFY_MAX_AGE_SECS);
628628

629+
/// How long a key waits for another look once this node's disk is **full**.
630+
///
631+
/// Only a full disk, not any refused write: a space query that fails says
632+
/// nothing about available space and keeps the ordinary retry schedule.
633+
///
634+
/// A node that cannot write cannot finish an acquisition, so before this gate
635+
/// the key came straight back. At [`VERIFICATION_REQUEST_TIMEOUT`] that was a
636+
/// close-group probe as often as every 15 s for every key the node owes — the
637+
/// requested delay, so cycle polling, round duration and bounded per-cycle
638+
/// selection make it an upper rate rather than an observed cadence. The owed set
639+
/// grows with the network rather than with anything this node does — 25.6% of a
640+
/// 195-service testnet, held full, produced 99.6% of every verification-request
641+
/// byte on the network (V2-987).
642+
///
643+
/// What this schedules is a *look*: the cycle re-reads local capacity and only
644+
/// probes if space has returned. A key that stays full and authorized sends no
645+
/// probes at all, because the gate stops the round before it is sent.
646+
///
647+
/// Five minutes is chosen against [`PENDING_VERIFY_MAX_AGE`], which it is well
648+
/// inside. That is not a guarantee that freed space is noticed while the entry
649+
/// lives: one deferred inside its final five minutes expires first, and a
650+
/// backlog can delay selection further.
651+
///
652+
/// This is deliberately a constant rather than a [`ReplicationConfig`] field.
653+
/// The struct is publicly re-exported and is not `#[non_exhaustive]`, so a new
654+
/// field would break downstream exhaustive construction for a knob nothing
655+
/// needs to tune at runtime.
656+
///
657+
/// Applied flat, through the ordinary `defer_pending`. A key deferred inside the
658+
/// last five minutes of its entry's life therefore expires at
659+
/// [`PENDING_VERIFY_MAX_AGE`] without a further look and comes back on the next
660+
/// neighbour-sync hint. An earlier revision clamped the delay to half the
661+
/// entry's remaining life to avoid that; it was cut because the extra looks it
662+
/// bought near expiry can themselves become ungated quorum rounds.
663+
const CAPACITY_BLOCKED_RETRY_SECS: u64 = 5 * 60;
664+
/// How long a key waits for another look once this node's disk is full.
665+
pub(crate) const CAPACITY_BLOCKED_RETRY: Duration =
666+
Duration::from_secs(CAPACITY_BLOCKED_RETRY_SECS);
667+
629668
/// Trust event weight for confirmed audit failures.
630669
pub const AUDIT_FAILURE_TRUST_WEIGHT: f64 = 5.0;
631670

@@ -1803,4 +1842,28 @@ mod tests {
18031842
"audit intervals should exhibit randomized jitter across samples"
18041843
);
18051844
}
1845+
1846+
/// The capacity stand-down has to be an order of magnitude above the retry
1847+
/// it replaces and still below the life of the entry it defers.
1848+
///
1849+
/// A stand-down only a little above the request timeout would leave the
1850+
/// repeat cost the same order as before, which is the cost this change
1851+
/// exists to remove. At or past `PENDING_VERIFY_MAX_AGE` every deferral
1852+
/// would instead become an eviction, which is a different design with
1853+
/// different failure modes: the key would only return on a fresh
1854+
/// neighbour-sync hint rather than on its own schedule.
1855+
///
1856+
/// What this does not show: that either gate uses the constant. It pins the
1857+
/// policy the constant encodes; the e2e proves the gates.
1858+
#[test]
1859+
fn capacity_blocked_retry_is_an_order_above_the_request_timeout_and_below_the_entry_lifetime() {
1860+
assert!(
1861+
CAPACITY_BLOCKED_RETRY >= VERIFICATION_REQUEST_TIMEOUT * 10,
1862+
"a stand-down near the request timeout leaves the repeat cost unchanged in order"
1863+
);
1864+
assert!(
1865+
CAPACITY_BLOCKED_RETRY < PENDING_VERIFY_MAX_AGE,
1866+
"a deferral at or past the entry lifetime is an eviction, not a deferral"
1867+
);
1868+
}
18061869
}

src/replication/mod.rs

Lines changed: 187 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -97,7 +97,7 @@ use crate::replication::types::{
9797
NeighborSyncState, PeerSyncRecord, PresenceEvidence, RepairProofs, VerificationEntry,
9898
VerificationState,
9999
};
100-
use crate::storage::LmdbStorage;
100+
use crate::storage::{CapacityVerdict, LmdbStorage};
101101
use saorsa_core::identity::{NodeIdentity, PeerId};
102102
use saorsa_core::{DhtNetworkEvent, P2PEvent, P2PNode, TrustEvent};
103103
use saorsa_pqc::api::sig::{MlDsaSecretKey, MlDsaVariant};
@@ -2149,6 +2149,42 @@ impl ReplicationEngine {
21492149
self.queues.read().await.contains_key(key)
21502150
}
21512151

2152+
/// Test-only: place `key` into pending verification as though `hinter` had
2153+
/// just advertised it with a replica hint. Returns whether it was admitted.
2154+
///
2155+
/// Enters the pipeline one stage earlier than
2156+
/// [`Self::enqueue_fetch_for_test`], so what the verification cycle itself
2157+
/// decides about the key is observable.
2158+
#[cfg(any(test, feature = "test-utils"))]
2159+
pub async fn enqueue_pending_verify_for_test(&self, key: XorName, hinter: PeerId) -> bool {
2160+
let now = Instant::now();
2161+
let entry = VerificationEntry {
2162+
state: VerificationState::PendingVerify,
2163+
verified_sources: Vec::new(),
2164+
tried_sources: HashSet::new(),
2165+
created_at: now,
2166+
next_verify_at: now,
2167+
hint_sources: HashSet::from([hinter]),
2168+
replica_hint_sources: HashSet::from([hinter]),
2169+
};
2170+
self.queues
2171+
.write()
2172+
.await
2173+
.add_pending_verify(key, entry)
2174+
.admitted()
2175+
}
2176+
2177+
/// Test-only: how far ahead `key`'s next verification round is scheduled,
2178+
/// or `None` when the key is not pending verification.
2179+
#[cfg(any(test, feature = "test-utils"))]
2180+
pub async fn pending_verify_delay_for_test(&self, key: &XorName) -> Option<Duration> {
2181+
self.queues.read().await.get_pending(key).map(|entry| {
2182+
entry
2183+
.next_verify_at
2184+
.saturating_duration_since(Instant::now())
2185+
})
2186+
}
2187+
21522188
/// Start all background tasks.
21532189
///
21542190
/// `dht_events` must be subscribed **before** `P2PNode::start()` so that
@@ -6546,6 +6582,99 @@ async fn handle_neighbor_sync_request(
65466582
Ok(())
65476583
}
65486584

6585+
/// Test-only record of who *sent* a verification request covering a watched key.
6586+
///
6587+
/// The capacity gate's entire effect is a request that is never sent, and a
6588+
/// request that was not sent leaves no production counter anywhere — not on the
6589+
/// sender, whose traffic counters are process-global and so cannot separate one
6590+
/// node of a single-process testnet from another, and not on the responder,
6591+
/// which simply never hears from it. Recording who asked is what lets a test
6592+
/// tell "held the key back" apart from "probed the close group, was refused at
6593+
/// the dial, and then held the key back", which are otherwise identical from the
6594+
/// queue's point of view.
6595+
///
6596+
/// Arming replaces the previous watch, so the outer map holds exactly the keys
6597+
/// the current test named, and each inner map holds at most one entry per node
6598+
/// in the process. Nothing is recorded until a test arms it, and the armed path
6599+
/// costs one read lock on the sending side.
6600+
///
6601+
/// Counting is per key because a node that cannot write legitimately keeps
6602+
/// sending verification requests for keys it has not yet authorized — that is
6603+
/// `PaidForList` convergence, which the gate leaves alone — so a per-peer total
6604+
/// would assert something untrue.
6605+
///
6606+
/// It counts *send attempts*, deliberately, recorded immediately before the wire
6607+
/// call. A responder sheds requests at admission and as stale, so a
6608+
/// receiver-side count would report zero for probes that were really sent, and
6609+
/// an assertion that this node asked nobody would pass on the strength of the
6610+
/// receiver dropping the question. It is not proof of delivery: a send that
6611+
/// fails immediately, with no route or during shutdown, is still counted.
6612+
///
6613+
/// `OnceLock` rather than `LazyLock`, which needs a newer Rust than this crate's
6614+
/// MSRV.
6615+
#[cfg(any(test, feature = "test-utils"))]
6616+
type VerificationWatch = HashMap<XorName, HashMap<PeerId, usize>>;
6617+
6618+
#[cfg(any(test, feature = "test-utils"))]
6619+
static VERIFICATION_WATCH: std::sync::OnceLock<std::sync::RwLock<VerificationWatch>> =
6620+
std::sync::OnceLock::new();
6621+
6622+
#[cfg(any(test, feature = "test-utils"))]
6623+
fn verification_watch() -> &'static std::sync::RwLock<VerificationWatch> {
6624+
VERIFICATION_WATCH.get_or_init(|| std::sync::RwLock::new(HashMap::new()))
6625+
}
6626+
6627+
/// Test-only: start counting verification requests for `keys`, from zero.
6628+
///
6629+
/// Replaces any previous watch rather than adding to it, so the map is bounded
6630+
/// by the keys of the test that armed it last and nothing accumulates across a
6631+
/// process running many tests.
6632+
#[cfg(any(test, feature = "test-utils"))]
6633+
pub fn watch_verification_requests_for_test(keys: &[XorName]) {
6634+
if let Ok(mut watch) = verification_watch().write() {
6635+
watch.clear();
6636+
for key in keys {
6637+
watch.insert(*key, HashMap::new());
6638+
}
6639+
}
6640+
}
6641+
6642+
/// Record that `requester` sent a verification request covering `keys`, for
6643+
/// whichever of them are watched. A poisoned lock is ignored rather than
6644+
/// propagated: this is observation for tests and must never change behaviour.
6645+
#[cfg(any(test, feature = "test-utils"))]
6646+
pub(crate) fn record_verification_request_sent(requester: &PeerId, keys: &[XorName]) {
6647+
// Fast path under a read lock: with nothing watched — every production
6648+
// build, and every test that did not ask — this is all the sender pays.
6649+
match verification_watch().read() {
6650+
Ok(watch) if watch.is_empty() => return,
6651+
Ok(watch) if !keys.iter().any(|key| watch.contains_key(key)) => return,
6652+
Ok(_) => {}
6653+
Err(_) => return,
6654+
}
6655+
if let Ok(mut watch) = verification_watch().write() {
6656+
for key in keys {
6657+
if let Some(by_peer) = watch.get_mut(key) {
6658+
*by_peer.entry(*requester).or_insert(0) += 1;
6659+
}
6660+
}
6661+
}
6662+
}
6663+
6664+
/// Test-only: how many times `requester` has asked this process about `key`.
6665+
/// Zero unless the key was registered with `watch_verification_requests_for_test`.
6666+
#[cfg(any(test, feature = "test-utils"))]
6667+
#[must_use]
6668+
pub fn verification_requests_for_key_from_for_test(requester: &PeerId, key: &XorName) -> usize {
6669+
verification_watch().read().map_or(0, |watch| {
6670+
watch
6671+
.get(key)
6672+
.and_then(|by_peer| by_peer.get(requester))
6673+
.copied()
6674+
.unwrap_or(0)
6675+
})
6676+
}
6677+
65496678
async fn handle_verification_request(
65506679
source: &PeerId,
65516680
request: &protocol::VerificationRequest,
@@ -7752,6 +7881,30 @@ async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) {
77527881
}
77537882
let initial_pending_count = pending_keys.len();
77547883

7884+
// Capacity verdict for this cycle — the same pre-check the PUT handler and
7885+
// the fresh-offer path already run (V2-411), read once and reused below.
7886+
//
7887+
// It gates only the two steps that exist to enable a fetch: the presence
7888+
// probe that discovers holders, and the promotion that queues the download.
7889+
// Everything a cycle does that does not need a local write still runs on a
7890+
// full node — the local paid-list fast path, `PaidForList` convergence
7891+
// through the quorum round, and the terminal checks that retire a key this
7892+
// node already holds or is no longer admitted for. Gating earlier than this
7893+
// would stop a full node learning which of its keys were paid for, and
7894+
// would strand keys that should have retired, which keeps bootstrap drain
7895+
// pending and audits disabled until stale eviction.
7896+
// Only a *full* disk is a standing condition worth minutes of backoff. A
7897+
// failed `statvfs` says nothing about available space and may have cleared
7898+
// by the next cycle, so it is treated as writable here and left to the
7899+
// pre-check at the dial, which queries again and may well permit the write.
7900+
let write_blocked = storage.capacity_verdict() == CapacityVerdict::Full;
7901+
// Counted per gate rather than combined. `local_paid_probe` below counts
7902+
// probes actually sent, so it reads zero once the first gate fires; keeping
7903+
// the two deferral counts apart is what lets an operator see which branch a
7904+
// full node's keys are taking without adding a code path to find out.
7905+
let mut capacity_deferred_probe = 0usize;
7906+
let mut capacity_deferred_promote = 0usize;
7907+
77557908
let self_id = *p2p_node.peer_id();
77567909

77577910
// Step 1: Check local PaidForList for fast-path authorization (Section 9,
@@ -7802,6 +7955,21 @@ async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) {
78027955
terminal_keys.push(key);
78037956
}
78047957
}
7958+
7959+
// Capacity gate, first of two. Everything above this point has already
7960+
// run: authorization succeeded via the local `PaidForList` hit, and the
7961+
// keys that should retire (already held, or no longer storage-admitted)
7962+
// have retired. What is left is a probe whose only purpose is finding a
7963+
// holder to download from, and this node cannot write what it would
7964+
// download.
7965+
if write_blocked && !local_paid_presence_probe_keys.is_empty() {
7966+
let mut q = queues.write().await;
7967+
for key in std::mem::take(&mut local_paid_presence_probe_keys) {
7968+
if q.defer_pending(&key, config::CAPACITY_BLOCKED_RETRY) {
7969+
capacity_deferred_probe += 1;
7970+
}
7971+
}
7972+
}
78057973
}
78067974

78077975
let local_paid_probe_count = local_paid_presence_probe_keys.len();
@@ -8023,7 +8191,16 @@ async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) {
80238191
let mut fetch_sources = sources;
80248192
add_replica_hint_sources(&mut fetch_sources, &replica_hint_sources);
80258193
let fetch_eligible = fetch_allowed_keys.contains(&key);
8026-
if fetch_eligible && !fetch_sources.is_empty() {
8194+
if fetch_eligible && write_blocked {
8195+
// Capacity gate, second of two, and deliberately after
8196+
// Step 4: the key is now recorded in `PaidForList` and
8197+
// its verification stands. Only the download is held,
8198+
// because `execute_single_fetch` would refuse it and
8199+
// hand the key straight back here.
8200+
if q.defer_pending(&key, config::CAPACITY_BLOCKED_RETRY) {
8201+
capacity_deferred_promote += 1;
8202+
}
8203+
} else if fetch_eligible && !fetch_sources.is_empty() {
80278204
let distance =
80288205
crate::client::xor_distance(&key, p2p_node.peer_id().as_bytes());
80298206
// Atomic remove+enqueue: on fetch_queue capacity miss
@@ -8098,12 +8275,12 @@ async fn run_verification_cycle(ctx: VerificationCycleContext<'_>) {
80988275
if elapsed_ms >= VERIFICATION_CYCLE_SLOW_LOG_MS {
80998276
info!(
81008277
target: "ant_node::replication::verification",
8101-
"Slow replication verification cycle: pending_start={initial_pending_count}, local_paid_probe={local_paid_probe_count}, network_verify={keys_needing_network_count}, terminal={terminal_key_count}, pending_after={pending_after}, fetch_after={fetch_after}, in_flight_after={in_flight_after}, elapsed_ms={elapsed_ms}",
8278+
"Slow replication verification cycle: pending_start={initial_pending_count}, capacity_deferred_probe={capacity_deferred_probe}, capacity_deferred_promote={capacity_deferred_promote}, local_paid_probe={local_paid_probe_count}, network_verify={keys_needing_network_count}, terminal={terminal_key_count}, pending_after={pending_after}, fetch_after={fetch_after}, in_flight_after={in_flight_after}, elapsed_ms={elapsed_ms}",
81028279
);
81038280
} else {
81048281
debug!(
81058282
target: "ant_node::replication::verification",
8106-
"Replication verification cycle: pending_start={initial_pending_count}, local_paid_probe={local_paid_probe_count}, network_verify={keys_needing_network_count}, terminal={terminal_key_count}, pending_after={pending_after}, fetch_after={fetch_after}, in_flight_after={in_flight_after}, elapsed_ms={elapsed_ms}",
8283+
"Replication verification cycle: pending_start={initial_pending_count}, capacity_deferred_probe={capacity_deferred_probe}, capacity_deferred_promote={capacity_deferred_promote}, local_paid_probe={local_paid_probe_count}, network_verify={keys_needing_network_count}, terminal={terminal_key_count}, pending_after={pending_after}, fetch_after={fetch_after}, in_flight_after={in_flight_after}, elapsed_ms={elapsed_ms}",
81078284
);
81088285
}
81098286
}
@@ -8342,6 +8519,12 @@ fn apply_fetch_result(
83428519
// stranded — it comes back once capacity allows — and the fallthrough
83438520
// to `Terminal` when there is no retry metadata is what keeps
83448521
// bootstrap drain accounting correct, exactly as for a source failure.
8522+
//
8523+
// The ordinary requeue delay, deliberately. A standing capacity block
8524+
// does not need a longer one here: the key returns to pending, and the
8525+
// gate at the head of the next cycle defers it for minutes. A separate
8526+
// backoff on this path would duplicate that to save one 15 s round, on
8527+
// a race the gate already makes rare.
83458528
FetchResult::LocalWriteFailed => {
83468529
if q.requeue_fetch_for_verification(key, verification_retry_after) {
83478530
FetchFollowUp::RequeuedForVerification

src/replication/quorum.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -600,6 +600,16 @@ fn spawn_verification_batch_task(
600600
}
601601
};
602602

603+
// Recorded at the wire call, after the permit and the encode, so the
604+
// count is send attempts rather than scheduled batches — recording where
605+
// a batch is merely queued would stay positive if the encode or the task
606+
// itself regressed. It is not proof of delivery: a send that fails
607+
// immediately is still counted. Recording on the responder would be
608+
// worse, reading zero for requests the receiver shed at admission or as
609+
// stale.
610+
#[cfg(any(test, feature = "test-utils"))]
611+
crate::replication::record_verification_request_sent(p2p.peer_id(), &requested_keys);
612+
603613
let response = match p2p
604614
.send_request(&peer, REPLICATION_PROTOCOL_ID, encoded, timeout)
605615
.await

0 commit comments

Comments
 (0)