@@ -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} ;
101101use saorsa_core::identity::{NodeIdentity, PeerId};
102102use saorsa_core::{DhtNetworkEvent, P2PEvent, P2PNode, TrustEvent};
103103use 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+
65496678async 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
0 commit comments