decdn_node/dht/publish.rs
1//! DHT republish scheduler (ADR 022 §STORE Flow & §Bootstrap).
2//!
3//! When a hash becomes advertisable — a whole-blob commit, or a ranged
4//! admit that completes a discovery block
5//! ([`decdn_cache::CacheEngine::subscribe_inserts`]) — and has not announced
6//! yet, the scheduler eagerly publishes it to its K+3 closest peers with
7//! per-hash `StoreRequest`s (a size-1 batch buys nothing) and schedules its
8//! next republish. A hash that has announced waits for its cycle, which
9//! carries its widened coverage.
10//!
11//! On a periodic per-record jittered timer the scheduler:
12//!
13//! 1. Drains every record whose republish window has come due. On a
14//! tick where one has, it also drains the records due within
15//! [`REPUBLISH_LOOKAHEAD`], in due order, while no receiver's set
16//! passes [`LOOKAHEAD_RECEIVER_CAP`] (ADR 022 §STORE Flow "Drain
17//! cycle"). It keeps those still held in the cache. Per-record jitter spreads due times so thinly that a
18//! 1 s tick alone holds about one hash; the look-ahead is what gives
19//! a steady-state batch more than one.
20//! 2. Groups the drained hashes by receiver — each hash's K+3 closest
21//! peers — so all hashes bound for one receiver ride a single
22//! `BatchStoreRequest { hashes, holder: self }`, split at the
23//! [`MAX_BATCH_STORE_HASHES`] wire cap. This is one RPC per
24//! publisher-receiver pair per cycle (ADR 022 §STORE Flow Batched
25//! STORE & §DHT Bandwidth Analysis). Every DHT node implements
26//! `BatchStore`, so there is no per-hash fallback.
27//! 3. Schedules the next republish at `now + uniform(30 min, 50 min)`
28//! per ADR 022 §STORE Flow step 3. Jitter is drawn independently
29//! per record so the next republish window for a given hash is
30//! not predictable from outside the publisher. Taking a record early
31//! only shortens its refresh gap: the draw keeps it at or below
32//! 50 min, and [`REPUBLISH_LOOKAHEAD`] keeps it at or above 25 min.
33//!
34//! Cold-start (ADR 022 §Bootstrap "Cold-start re-publish scheduling"):
35//! a node booting with C cached blobs draws the first republish offset
36//! from `uniform(0, 40 min)` per blob, NOT `uniform(30, 50)`. The
37//! single-tick "republish everything on the next scheduler tick"
38//! pattern is explicitly non-conforming — it saturates the per-peer
39//! rate limit at every receiver. The cold-start uniform-0-40-min
40//! window matches the steady-state mean cycle so bootstrap rate equals
41//! steady-state rate by construction.
42//!
43//! Lag sweep (ADR 022 §Bootstrap "Re-seed after a lost commit
44//! window"): the commit-event channel is bounded and best-effort, and
45//! the cache retains nothing it drops, so a `Lagged` receiver cannot
46//! backfill. The scheduler instead re-derives the held set
47//! (`holder_snapshot`) and seeds whatever is missing. Seeding is
48//! idempotent and uses the same `uniform(0, 40 min)` window as cold
49//! start, so a sweep costs one cold-start window rather than a burst —
50//! the immediate bulk republish is non-conforming here for the same
51//! reason it is at boot.
52//!
53//! Implementation: a single tokio task drives all records via a
54//! `BinaryHeap<(due_at, hash)>` and a 1 s ticker. The heap is small (one
55//! entry per cached blob, capped by the cache's blob count). Sending does
56//! not hold the heap mutex; a drain cycle's batches go out in a spawned
57//! task so a slow receiver doesn't stall the rest of the schedule.
58
59use std::cmp::Reverse;
60use std::collections::{BinaryHeap, HashMap, HashSet};
61use std::sync::{Arc, Mutex};
62use std::time::Duration;
63
64use iroh::{Endpoint, EndpointAddr, PublicKey};
65use rand::RngExt;
66use tokio::sync::broadcast;
67use tokio_util::sync::CancellationToken;
68
69use crate::dht::chain_projection::with_lock;
70use crate::dht::client;
71use crate::dht::routing::{NodeId, RoutingTable};
72use decdn_protocol::ContentHash;
73use decdn_protocol::Coverage;
74use decdn_protocol::dht::MAX_BATCH_STORE_HASHES;
75
76/// Number of receivers per republish (ADR 022 §STORE Flow step 1:
77/// "K+3 closest nodes to H"). Three slots beyond `K=20` give the
78/// record headroom against an attacker who suppresses up to K
79/// receivers.
80pub const REPUBLISH_FANOUT: usize = 23;
81
82/// Steady-state minimum republish gap. ADR 022 §STORE Flow step 3.
83pub const STEADY_STATE_MIN: Duration = Duration::from_mins(30);
84
85/// Steady-state maximum republish gap. ADR 022 §STORE Flow step 3.
86pub const STEADY_STATE_MAX: Duration = Duration::from_mins(50);
87
88/// Cold-start jitter window upper bound (ADR 022 §Bootstrap "Cold-
89/// start re-publish scheduling"). The lower bound is 0 — a record
90/// drawn at 0 fires on the next tick.
91pub const COLD_START_MAX: Duration = Duration::from_mins(40);
92
93/// How far ahead a drain cycle reaches for records not yet due (ADR 022
94/// §STORE Flow "Drain cycle"). Sending early only shortens a record's refresh
95/// gap, which the [`STEADY_STATE_MAX`] draw bound keeps inside the
96/// receiver-anchored 1 h TTL. This bound keeps the gap at or above 25 min,
97/// which caps the extra refresh load the look-ahead adds.
98pub const REPUBLISH_LOOKAHEAD: Duration = Duration::from_mins(5);
99
100/// The most hashes one receiver's set holds once the look-ahead has pulled
101/// records forward (ADR 022 §STORE Flow "Drain cycle"). A full batch fits the
102/// 40-token per-peer burst of ADR 022 §DHT Rate Limiting with 8 tokens to
103/// spare for the same publisher's lookups and eager `Store`s. Records already
104/// due are not held to it.
105pub const LOOKAHEAD_RECEIVER_CAP: usize = 32;
106
107/// The shortest time between a drain cycle and a later cycle that pulls
108/// records forward (ADR 022 §STORE Flow "Drain cycle"). The per-receiver cap
109/// bounds one cycle, not two a second apart: after a backlog empties a
110/// receiver's per-peer bucket, a look-ahead batch on the next tick would find
111/// about 20 tokens and have its tail refused. At the 20-token/s per-peer
112/// refill, 2 s refill the whole 40-token burst.
113pub const LOOKAHEAD_MIN_SPACING: Duration = Duration::from_secs(2);
114
115// A record rescheduled at `now + STEADY_STATE_MIN` must fall outside the
116// look-ahead, or every cycle would pull it forward again.
117const _: () = assert!(REPUBLISH_LOOKAHEAD.as_secs() < STEADY_STATE_MIN.as_secs());
118// A look-ahead set fits in one `BatchStore`.
119const _: () = assert!(LOOKAHEAD_RECEIVER_CAP <= MAX_BATCH_STORE_HASHES);
120
121/// Per-record scheduler entry.
122#[derive(Debug, Clone, Copy, PartialEq, Eq)]
123struct Entry {
124 /// Wall-clock-anchored due time in microseconds since `UNIX_EPOCH`.
125 due_us: u64,
126 /// Hash being republished.
127 hash: ContentHash,
128}
129
130impl PartialOrd for Entry {
131 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
132 Some(self.cmp(other))
133 }
134}
135
136impl Ord for Entry {
137 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
138 // Default `BinaryHeap` is a max-heap; we want the *earliest*
139 // due time at the top. The driver wraps with `Reverse` so
140 // this `Ord` is just the natural `(due_us, hash)` order.
141 self.due_us
142 .cmp(&other.due_us)
143 .then_with(|| self.hash.cmp(&other.hash))
144 }
145}
146
147/// Schedule a single republish for `hash`. Used by the
148/// `subscribe_inserts` event loop and by cold-start startup.
149fn schedule_at(heap: &mut BinaryHeap<Reverse<Entry>>, hash: ContentHash, due_us: u64) {
150 heap.push(Reverse(Entry { due_us, hash }));
151}
152
153/// Wall-clock now in microseconds since `UNIX_EPOCH`. Matches the
154/// `now_us` helper in [`crate::handlers::dht`] — falls back to 0 if
155/// the host clock is mis-set. The handler-side helper additionally
156/// logs the fallback at error level; the scheduler only needs the
157/// numeric value so we don't re-log here.
158fn now_us() -> u64 {
159 std::time::SystemTime::now()
160 .duration_since(std::time::UNIX_EPOCH)
161 .map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
162}
163
164/// Draw a uniform jitter offset (in microseconds) in `[lo, hi]`.
165fn jitter_us(lo: Duration, hi: Duration) -> u64 {
166 let lo_us = u64::try_from(lo.as_micros()).unwrap_or(u64::MAX);
167 let hi_us = u64::try_from(hi.as_micros()).unwrap_or(u64::MAX);
168 let mut rng = rand::rng();
169 if hi_us <= lo_us {
170 return lo_us;
171 }
172 rng.random_range(lo_us..=hi_us)
173}
174
175/// Republish-scheduler handle. Owns the per-hash heap and the abort
176/// signal so the runtime can stop it on shutdown.
177///
178/// # The authoritative-due invariant
179///
180/// A `BinaryHeap` cannot remove or reschedule an interior entry, so any
181/// rescheduling leaves the old entry behind as a tombstone. `scheduled` is
182/// therefore the authority: it maps each live hash to the ONE `due_us` that
183/// counts, and `drain_cycle` discards a popped entry whose `due_us`
184/// disagrees. Without that check a tombstone is resurrected the moment its
185/// hash is scheduled again — it pops already-overdue, passes a
186/// membership-only test, and buys a spurious republish. Two call patterns hit
187/// that: a re-seed racing the tick path's drain-then-reschedule, and
188/// [`Self::unschedule`] followed by a later re-seed.
189#[allow(missing_debug_implementations)]
190pub struct RepublishScheduler {
191 heap: Arc<Mutex<BinaryHeap<Reverse<Entry>>>>,
192 /// Live hash -> its authoritative `due_us`. A hash absent here has no
193 /// live entry, whatever the heap still holds.
194 scheduled: Arc<Mutex<HashMap<ContentHash, u64>>>,
195 /// Hashes a peer accepted a `Store` with non-empty coverage for, from an
196 /// eager publish or a drain cycle's `BatchStore`. This, not
197 /// `scheduled`, is what dedupes the eager publish on cache events. A bulk
198 /// seed schedules hashes that have published nothing yet — a partial that
199 /// covered no block when the seed walked it — and the block it completes
200 /// before its due time must still publish at once. [`Self::unschedule`]
201 /// clears the mark.
202 announced: Arc<Mutex<HashSet<ContentHash>>>,
203}
204
205impl RepublishScheduler {
206 /// Construct an empty scheduler.
207 #[must_use]
208 pub fn new() -> Self {
209 Self {
210 heap: Arc::new(Mutex::new(BinaryHeap::new())),
211 scheduled: Arc::new(Mutex::new(HashMap::new())),
212 announced: Arc::new(Mutex::new(HashSet::new())),
213 }
214 }
215
216 /// Number of hashes currently scheduled.
217 #[must_use]
218 pub fn len(&self) -> usize {
219 with_lock(&self.scheduled, "dht republish scheduled set", |s| s.len())
220 }
221
222 /// Whether the scheduler is empty.
223 #[must_use]
224 pub fn is_empty(&self) -> bool {
225 with_lock(&self.scheduled, "dht republish scheduled set", |s| {
226 s.is_empty()
227 })
228 }
229
230 /// Schedule `hash` with the steady-state jitter window (30–50 min),
231 /// superseding any due time it already has.
232 ///
233 /// A fresh draw per cycle is ADR 022 §STORE Flow step 3. Superseding is
234 /// safe because the displaced heap entry no longer matches the
235 /// authoritative due time and `drain_cycle` discards it.
236 pub fn schedule_steady(&self, hash: ContentHash) {
237 let offset = jitter_us(STEADY_STATE_MIN, STEADY_STATE_MAX);
238 self.schedule_with_offset(hash, offset);
239 }
240
241 /// Schedule `hash` with the steady-state jitter window (30–50 min) only if
242 /// it has no live entry. Returns whether it was added.
243 ///
244 /// The cache-event path uses this so that a repeated event (a ranged fill
245 /// announces once per completed block) keeps the due time the hash already
246 /// has rather than pushing it back.
247 pub fn schedule_steady_if_absent(&self, hash: ContentHash) -> bool {
248 let offset = jitter_us(STEADY_STATE_MIN, STEADY_STATE_MAX);
249 self.schedule_if_absent(hash, offset)
250 }
251
252 /// Record that a peer accepted a `Store` with non-empty coverage for `hash`.
253 pub fn mark_announced(&self, hash: ContentHash) {
254 with_lock(&self.announced, "dht republish announced set", |a| {
255 a.insert(hash);
256 });
257 }
258
259 /// Whether a peer accepted a `Store` with non-empty coverage for `hash`
260 /// since it was last unscheduled.
261 #[must_use]
262 pub fn is_announced(&self, hash: &ContentHash) -> bool {
263 with_lock(&self.announced, "dht republish announced set", |a| {
264 a.contains(hash)
265 })
266 }
267
268 /// Batch-schedule every hash in `iter` with an independent
269 /// cold-start jitter draw (uniform(0, 40 min) per hash, NOT a
270 /// shared timestamp). Callers pass a `holder_snapshot` result,
271 /// or `origin_held_snapshot` for the origin-only rescan — never
272 /// `access_times_snapshot`, which is eviction-recency state rather
273 /// than the on-disk set; see the `iter_hashes` rustdoc.
274 ///
275 /// Seeding leaves an already-scheduled hash alone: it keeps its existing
276 /// due time rather than being pulled earlier by a re-seed. The lag sweep
277 /// in [`run_republish`] and the periodic origin rescan both run against a
278 /// populated scheduler, so without this a burst of re-seeds would keep
279 /// dragging every due time toward the present.
280 ///
281 /// Returns the number of hashes *newly* scheduled — useful for
282 /// the startup log line so operators can see how big the
283 /// cold-start queue is, and for the sweep so a no-op sweep is
284 /// distinguishable from a repair.
285 pub fn seed_cold_start<I>(&self, iter: I) -> usize
286 where
287 I: IntoIterator<Item = ContentHash>,
288 {
289 let mut count = 0usize;
290 for hash in iter {
291 let offset = jitter_us(Duration::ZERO, COLD_START_MAX);
292 if self.schedule_if_absent(hash, offset) {
293 count = count.saturating_add(1);
294 }
295 }
296 count
297 }
298
299 /// Set `hash`'s authoritative due time to `now + offset_us`, replacing any
300 /// existing one.
301 fn schedule_with_offset(&self, hash: ContentHash, offset_us: u64) {
302 self.with_slots(offset_us, |heap, scheduled, due_us| {
303 scheduled.insert(hash, due_us);
304 schedule_at(heap, hash, due_us);
305 true
306 });
307 }
308
309 /// Schedule `hash` at `now + offset_us` only if it has no live entry.
310 /// Returns whether it was added.
311 ///
312 /// "Already scheduled" means "has a live entry that will drain", never
313 /// "was ever scheduled" — a hash the tick path just drained is absent
314 /// again and re-seeds normally.
315 fn schedule_if_absent(&self, hash: ContentHash, offset_us: u64) -> bool {
316 self.with_slots(offset_us, |heap, scheduled, due_us| {
317 if scheduled.contains_key(&hash) {
318 return false;
319 }
320 scheduled.insert(hash, due_us);
321 schedule_at(heap, hash, due_us);
322 true
323 })
324 }
325
326 /// Run `f` under both locks with a freshly computed due time.
327 ///
328 /// Both guards are held across the read-and-push so a concurrent
329 /// `drain_cycle` cannot act on a half-applied change. Acquired
330 /// heap-then-scheduled to match `drain_cycle`'s order — the reverse would
331 /// risk deadlocking against it, which is why the [`with_lock`] calls nest
332 /// in that order rather than running side by side.
333 fn with_slots<F>(&self, offset_us: u64, f: F) -> bool
334 where
335 F: FnOnce(&mut BinaryHeap<Reverse<Entry>>, &mut HashMap<ContentHash, u64>, u64) -> bool,
336 {
337 let due_us = now_us().saturating_add(offset_us);
338 with_lock(&self.heap, "dht republish heap", |heap| {
339 with_lock(
340 &self.scheduled,
341 "dht republish scheduled set",
342 |scheduled| f(heap, scheduled, due_us),
343 )
344 })
345 }
346
347 /// Drain one republish cycle: every hash whose authoritative due time has
348 /// passed, then — only if at least one has — the hashes due by
349 /// `horizon_us`, in due order, while `admit` accepts them.
350 ///
351 /// `admit(hash, mandatory)` sees every live hash the cycle takes, plus the
352 /// one look-ahead hash it refuses. Every due hash reaches `admit` before
353 /// any look-ahead hash. `mandatory` is `true` for a hash already due: it
354 /// always drains, so `admit` must record it, and its answer is ignored.
355 /// For a look-ahead hash `admit` decides, and the first refusal ends the
356 /// cycle with that hash and every later one still scheduled. The
357 /// look-ahead never runs on its own: pulling hashes forward on a tick with
358 /// nothing due would only shift the whole schedule earlier and batch
359 /// nothing. A `horizon_us` at or below `now_us` turns it off.
360 ///
361 /// A popped entry counts only if `scheduled` still names that hash at
362 /// exactly that `due_us`. Anything else is a tombstone left by a
363 /// supersede or an unschedule, and is dropped without reaching `admit`.
364 fn drain_cycle<F>(&self, now_us: u64, horizon_us: u64, mut admit: F) -> Vec<ContentHash>
365 where
366 F: FnMut(&ContentHash, bool) -> bool,
367 {
368 let mut out = Vec::new();
369 with_lock(&self.heap, "dht republish heap", |heap| {
370 with_lock(
371 &self.scheduled,
372 "dht republish scheduled set",
373 |scheduled| {
374 while let Some(Reverse(Entry { due_us, hash })) = heap.peek().copied() {
375 if due_us > now_us.max(horizon_us) {
376 break;
377 }
378 if scheduled.get(&hash) != Some(&due_us) {
379 heap.pop();
380 continue;
381 }
382 let mandatory = due_us <= now_us;
383 if mandatory {
384 admit(&hash, true);
385 } else if out.is_empty() || !admit(&hash, false) {
386 break;
387 }
388 heap.pop();
389 out.push(hash);
390 // Drop the live entry so the next `schedule_*` for this hash
391 // re-adds it. The caller re-schedules after publishing.
392 scheduled.remove(&hash);
393 }
394 },
395 );
396 });
397 out
398 }
399
400 /// Drain every hash whose authoritative due time has passed, with no
401 /// look-ahead.
402 #[cfg(test)]
403 fn drain_due(&self, now_us: u64) -> Vec<ContentHash> {
404 self.drain_cycle(now_us, now_us, |_, _| true)
405 }
406
407 /// Unschedule `hash` — used when the cache evicts the blob, or holds no
408 /// whole block of it. Clears its announced mark, so the next cache event
409 /// for it publishes eagerly again.
410 pub fn unschedule(&self, hash: &ContentHash) {
411 with_lock(&self.scheduled, "dht republish scheduled set", |s| {
412 s.remove(hash);
413 });
414 with_lock(&self.announced, "dht republish announced set", |a| {
415 a.remove(hash);
416 });
417 // The heap entry is left in place; `drain_cycle` discards it, and a
418 // later re-schedule cannot resurrect it because its `due_us` will no
419 // longer match the authoritative one.
420 }
421}
422
423impl Default for RepublishScheduler {
424 fn default() -> Self {
425 Self::new()
426 }
427}
428
429/// Every hash this node holds and may advertise, as one snapshot.
430///
431/// The union of the origin-held index and — when the node relays foreign
432/// namespaces — the blobs in the local store, complete or partial. The set is
433/// a superset of what gets announced: the due-time gate ([`cache_still_holds`])
434/// drops a partial that covers no discovery block. This is the input to both
435/// *full* seeds of the republish scheduler: bring-up cold start and the
436/// lag sweep in [`run_republish`] need the same set, and computing it in one
437/// place is what keeps them from drifting apart. The periodic origin rescan is
438/// not one of them — it seeds the origin-held half alone and deliberately does
439/// not walk the store.
440#[derive(Debug)]
441pub(crate) struct HolderSnapshot {
442 /// The union. Deduplicated, so a hash that is both stored and origin-held
443 /// draws one jitter offset rather than two.
444 pub(crate) hashes: HashSet<decdn_cache::Hash>,
445 /// `Some` when the store walk failed. The origin-held half is still in
446 /// `hashes`; the caller decides how loudly to report the degradation, since
447 /// bring-up and the sweep phrase it differently.
448 pub(crate) store_error: Option<decdn_cache::CacheError>,
449 /// Size probes that faulted on the rescan the origin-held half comes from.
450 ///
451 /// Carried from the index rather than measured here, so it describes a pass
452 /// that may be as old as the rescan cadence — kept separate from
453 /// `ownership_probe_faults` for that reason, and because it is already
454 /// metered on the cache's own counter.
455 pub(crate) rescan_probe_faults: u64,
456 /// Origins whose listing failed on that rescan. More severe than a probe
457 /// fault: no candidate was produced, so nothing could be carried forward.
458 pub(crate) rescan_enumerate_failures: u64,
459 /// Stored hashes this walk could not put to the origin — the ownership test
460 /// applied under the origin-only policy, answered neither way.
461 ///
462 /// Measured by this snapshot, so it describes right now. Zero when the node
463 /// relays foreign namespaces, which asks the origin nothing.
464 pub(crate) ownership_probe_faults: u64,
465}
466
467impl HolderSnapshot {
468 /// Whether this snapshot is short of what the node actually holds.
469 pub(crate) const fn is_degraded(&self) -> bool {
470 self.store_error.is_some()
471 || self.rescan_probe_faults > 0
472 || self.rescan_enumerate_failures > 0
473 || self.ownership_probe_faults > 0
474 }
475
476 /// Meter and log every way this snapshot came up short, returning
477 /// [`Self::is_degraded`].
478 ///
479 /// One place, because both full seeds — bring-up cold start and the lag
480 /// sweep — degrade identically and differ only in what they call themselves.
481 /// `context` names the caller in each line.
482 pub(crate) fn report_degradation(
483 &self,
484 metrics: &crate::metrics::Metrics,
485 context: &str,
486 ) -> bool {
487 if let Some(err) = &self.store_error {
488 metrics.dht_republish_seed_store_walk_failure();
489 tracing::warn!(
490 context,
491 error = %err.display_chain(),
492 "dht republish: seed could not walk the store and covered the \
493 origin-held half only. Blobs held only in the store stay \
494 un-republished until a later seed walks it successfully"
495 );
496 }
497 // Only this walk's own faults. The rescan's are already on
498 // `decdn_cache_origin_probe_failures_total`, and re-counting them here
499 // would move the seed's counter on a node that asks the origin nothing.
500 if self.ownership_probe_faults > 0 {
501 metrics.dht_republish_seed_origin_probe_failures(self.ownership_probe_faults);
502 tracing::warn!(
503 context,
504 faults = self.ownership_probe_faults,
505 "dht republish: seed could not put every stored hash to its \
506 origin; each one it could not confirm is left out of the \
507 announce set rather than advertised"
508 );
509 }
510 if self.rescan_probe_faults > 0 || self.rescan_enumerate_failures > 0 {
511 tracing::warn!(
512 context,
513 faults = self.rescan_probe_faults,
514 enumerate_failures = self.rescan_enumerate_failures,
515 "dht republish: the origin-held half of this seed comes from a \
516 rescan that could not resolve everything; entries it carried \
517 forward may name content the origin has dropped, and an origin \
518 it could not list contributed nothing at all"
519 );
520 }
521 self.is_degraded()
522 }
523}
524
525/// Collect the [`HolderSnapshot`] for `cache`.
526///
527/// The store half is every blob the store holds, complete or partial
528/// ([`decdn_cache::CacheEngine::iter_hashes`]); the due-time gate
529/// ([`cache_still_holds`]) later drops a partial that covers no discovery block.
530///
531/// Under the origin-only policy (`relay_foreign_namespaces == false`) the store
532/// half is not taken wholesale — the store may hold leftover foreign content
533/// from before the toggle was set, and announcing it would advertise blobs the
534/// serve gate now declines. Each stored hash is instead put to
535/// `origin_probe_presence`, the same question the origin-only serve gate asks,
536/// so the snapshot advertises exactly what this node would serve.
537///
538/// That check is what recovers a node's *own* store-only content. The
539/// origin-held index covers enumerable origins plus present pins, and a remote
540/// origin (S3/R2/HTTP) deliberately does not enumerate — so an unpinned object
541/// this node owns, committed but with its insert event dropped, is in neither
542/// half without it. `Fault` is skipped rather than admitted: a transport blip
543/// must not turn into an advertisement for content the serve path might then
544/// refuse. A fault is memoised for `cache.origin_probe_fault_ttl_sec`, so a
545/// sweep landing inside that window re-reads the same fault rather than
546/// re-probing — the hash stays out of the announce set until the memo expires,
547/// which bounds how long a blip suppresses it and is why the fault TTL is
548/// capped below the positive TTL. Skipping is still a hash this node holds and
549/// does not announce, so each one counts toward `origin_probe_faults` —
550/// otherwise an origin-only node drops its own store-only content on a blip and
551/// reports a healthy snapshot.
552///
553/// Never fails: a store-walk error degrades to the origin-held half rather than
554/// yielding nothing, because a partial announce strictly beats none. Every way
555/// the set can come up short reports itself: `store_error` when the walk failed
556/// outright, `rescan_probe_faults` and `ownership_probe_faults` for questions
557/// the origin would not answer — the last rescan's and this walk's own
558/// ownership tests — and `rescan_enumerate_failures` for an origin that could
559/// not be listed at all.
560pub(crate) async fn holder_snapshot(
561 cache: &decdn_cache::CacheEngine,
562 relay_foreign_namespaces: bool,
563) -> HolderSnapshot {
564 let report = cache.origin_held_snapshot();
565 let mut hashes = report.hashes;
566 let mut ownership_probe_faults = 0u64;
567 let mut store_error = None;
568 match cache.iter_hashes().await {
569 Ok(stored) => {
570 for hash in stored {
571 if hashes.contains(&hash) {
572 continue;
573 }
574 if relay_foreign_namespaces {
575 hashes.insert(hash);
576 continue;
577 }
578 match cache.origin_probe_presence(hash).await {
579 decdn_cache::OriginPresence::Present(_) => {
580 hashes.insert(hash);
581 }
582 decdn_cache::OriginPresence::Absent => {}
583 decdn_cache::OriginPresence::Fault => {
584 ownership_probe_faults = ownership_probe_faults.saturating_add(1);
585 }
586 }
587 }
588 }
589 Err(err) => store_error = Some(err),
590 }
591 HolderSnapshot {
592 hashes,
593 store_error,
594 rescan_probe_faults: report.probe_faults,
595 rescan_enumerate_failures: report.enumerate_failures,
596 ownership_probe_faults,
597 }
598}
599
600/// What one lag sweep did, for the completion log.
601///
602/// `degraded` rides along so the completion line names the outcome: the warnings
603/// come from inside [`lag_sweep`], several frames away, and an unqualified
604/// "complete" would read as a clean run to anyone grepping for it.
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606struct SweepOutcome {
607 /// Hashes this sweep newly scheduled.
608 reseeded: usize,
609 /// Whether this sweep could not resolve everything the node holds — a failed
610 /// store walk, a faulted origin probe, or an origin that could not be listed.
611 degraded: bool,
612}
613
614/// Re-seed the republish scheduler from local state after the cache-commit
615/// event window was lost.
616///
617/// Seeding draws `uniform(0, 40 min)` per record, so a sweep of C blobs spreads
618/// over the steady-state mean cycle instead of firing as one burst — ADR 022
619/// §Bootstrap makes the single-tick bulk republish non-conforming for exactly
620/// this reason, which is why the sweep goes through the scheduler and never
621/// calls [`publish_batch`] directly.
622async fn lag_sweep(
623 cache: &decdn_cache::CacheEngine,
624 relay_foreign_namespaces: bool,
625 scheduler: &RepublishScheduler,
626 metrics: &crate::metrics::Metrics,
627) -> SweepOutcome {
628 let snapshot = holder_snapshot(cache, relay_foreign_namespaces).await;
629 let degraded = snapshot.report_degradation(metrics, "lag sweep");
630 let reseeded = scheduler.seed_cold_start(
631 snapshot
632 .hashes
633 .into_iter()
634 .map(|h| ContentHash::from_bytes(*h.as_bytes())),
635 );
636 metrics.dht_republish_sweep_reseeded(u64::try_from(reseeded).unwrap_or(u64::MAX));
637 SweepOutcome { reseeded, degraded }
638}
639
640/// Sweep slot: no worker, and none requested.
641const SWEEP_IDLE: u8 = 0;
642/// A worker owns the slot; nothing queued behind it.
643const SWEEP_RUNNING: u8 = 1;
644/// A worker owns the slot and a further sweep is queued behind it.
645const SWEEP_QUEUED: u8 = 2;
646
647/// RAII release for the sweep slot, for the abandon paths only.
648///
649/// The clean path releases through a `SWEEP_RUNNING -> SWEEP_IDLE`
650/// compare-exchange and then disarms this, because releasing and re-checking
651/// the queue MUST be one atomic step — a plain store would clobber a request
652/// published between the check and the release. On the abandon paths —
653/// shutdown, and a walk task cancelled under the worker at runtime teardown —
654/// there is nothing to re-check: no worker survives them, so an unconditional
655/// release is right.
656struct SweepSlotGuard<'a> {
657 state: &'a std::sync::atomic::AtomicU8,
658 armed: bool,
659}
660
661impl Drop for SweepSlotGuard<'_> {
662 fn drop(&mut self) {
663 if self.armed {
664 self.state
665 .store(SWEEP_IDLE, std::sync::atomic::Ordering::Release);
666 }
667 }
668}
669
670/// Everything one lag-sweep worker borrows from [`run_republish`].
671///
672/// A struct rather than six positional parameters because four of the six
673/// are `&Arc<_>` / `&_` and would otherwise be transposable at the call site.
674#[derive(Clone, Copy)]
675struct SweepSlot<'a> {
676 /// Three-state slot: see `SWEEP_IDLE` / `SWEEP_RUNNING` / `SWEEP_QUEUED`.
677 state: &'a Arc<std::sync::atomic::AtomicU8>,
678 cache: &'a decdn_cache::CacheEngine,
679 scheduler: &'a Arc<RepublishScheduler>,
680 metrics: &'a Arc<crate::metrics::Metrics>,
681 /// Cancelled by [`run_republish`] on its way out, so a walk in flight at
682 /// shutdown is abandoned rather than classified as a degradation.
683 shutdown: &'a CancellationToken,
684 relay_foreign_namespaces: bool,
685}
686
687/// Request a lag sweep, starting a worker if the slot is free.
688///
689/// Detached, like the batch publish: the store walk costs one `status()` per
690/// blob, and running it on the `select!` loop would stall shutdown and the
691/// eager per-insert publishes. Cancelled on shutdown — the next lag, or the
692/// next boot, re-seeds.
693///
694/// The cancellation is what keeps a clean restart quiet. The runtime cancels
695/// before it flushes the store, and the `select!` below is `biased` with
696/// cancellation first, so a worker polled any time after the flush takes the
697/// cancel arm rather than observing the store it was walking disappear and
698/// reporting that as a degradation. Nothing joins the worker, so it may outlive
699/// the flush by a poll — it just cannot report anything once cancelled.
700///
701/// Coalescing re-runs rather than drops. Each pass re-derives the advertised
702/// set once, at its start, so a lag observed mid-walk concerns commits that
703/// snapshot cannot contain; skipping it would strand exactly the blobs the
704/// sweep exists to recover. A request therefore survives every ordinary
705/// outcome: it either starts a worker or moves the slot to `SWEEP_QUEUED`, and
706/// the running worker's release is a compare-exchange that fails if a request
707/// landed first.
708///
709/// Only shutdown discards a queued request instead of running it, and does so
710/// deliberately — the next boot re-seeds. A panic inside the walk does not:
711/// the walk runs in its own task, so the worker observes the panic as a failed
712/// join rather than unwinding through it, takes its normal release path, and a
713/// queued request earns its further pass.
714fn spawn_lag_sweep(slot: &SweepSlot<'_>) {
715 use std::sync::atomic::Ordering;
716 loop {
717 match slot.state.compare_exchange_weak(
718 SWEEP_IDLE,
719 SWEEP_RUNNING,
720 Ordering::AcqRel,
721 Ordering::Acquire,
722 ) {
723 Ok(_) => break,
724 // A worker is mid-walk: queue behind it. Its release CAS will fail
725 // and it will take another pass.
726 Err(SWEEP_RUNNING) => {
727 if slot
728 .state
729 .compare_exchange_weak(
730 SWEEP_RUNNING,
731 SWEEP_QUEUED,
732 Ordering::AcqRel,
733 Ordering::Acquire,
734 )
735 .is_ok()
736 {
737 slot.metrics.dht_republish_lag_sweep_coalesced();
738 tracing::debug!(
739 "dht republish: lag sweep already running; queued a further pass"
740 );
741 return;
742 }
743 }
744 // Already queued — one further pass covers this lag too, because
745 // that pass has not taken its snapshot yet.
746 Err(SWEEP_QUEUED) => {
747 slot.metrics.dht_republish_lag_sweep_coalesced();
748 tracing::debug!("dht republish: lag sweep already queued");
749 return;
750 }
751 // Spurious failure or a state change under us; re-read and retry.
752 Err(_) => {}
753 }
754 }
755
756 let cache = slot.cache.clone();
757 let scheduler = Arc::clone(slot.scheduler);
758 let metrics = Arc::clone(slot.metrics);
759 let state = Arc::clone(slot.state);
760 let shutdown = slot.shutdown.clone();
761 let relay_foreign_namespaces = slot.relay_foreign_namespaces;
762 tokio::spawn(run_sweep_worker(
763 state,
764 cache,
765 scheduler,
766 metrics,
767 shutdown,
768 relay_foreign_namespaces,
769 ));
770}
771
772/// One sweep worker: walk passes until a clean release or an abandon path.
773/// Owns the `SWEEP_RUNNING` slot its spawner claimed; split from
774/// [`spawn_lag_sweep`] so the slot-claim protocol and the worker loop each
775/// stay readable on their own.
776//
777// Linear "spawn walk → join → release-or-repeat" loop. Splitting further
778// would scatter the slot protocol's release invariant across helpers; the
779// function body is one state machine.
780#[allow(clippy::cognitive_complexity)]
781async fn run_sweep_worker(
782 state: Arc<std::sync::atomic::AtomicU8>,
783 cache: decdn_cache::CacheEngine,
784 scheduler: Arc<RepublishScheduler>,
785 metrics: Arc<crate::metrics::Metrics>,
786 shutdown: CancellationToken,
787 relay_foreign_namespaces: bool,
788) {
789 use std::sync::atomic::Ordering;
790 // RAII for the paths that leave without a successful release CAS:
791 // shutdown, and a walk task cancelled under this worker at runtime
792 // teardown. Either would otherwise strand the slot and disable every
793 // later sweep for the process lifetime, while `lag_sweeps_total` kept
794 // climbing — a wedge that reads as health.
795 let mut guard = SweepSlotGuard {
796 state: &state,
797 armed: true,
798 };
799 loop {
800 // The walk runs in its own task so a panic inside it (`iter_hashes`
801 // goes through iroh-blobs, which sits outside the workspace
802 // anti-panic lints) surfaces below as `JoinError::is_panic` rather
803 // than unwinding through this worker. The worker then takes its
804 // normal release path, which consumes a queued request instead of
805 // discarding it. Same join-and-branch pattern as the runtime,
806 // admin, and buyer-channel layers (`Cargo.toml`'s
807 // `panic = "unwind"` note).
808 let mut walk = tokio::spawn({
809 let cache = cache.clone();
810 let scheduler = Arc::clone(&scheduler);
811 let metrics = Arc::clone(&metrics);
812 async move { lag_sweep(&cache, relay_foreign_namespaces, &scheduler, &metrics).await }
813 });
814 let joined = tokio::select! {
815 biased;
816 // Shutdown wins: the store is about to be flushed and closed,
817 // so a walk that continues here reports its own teardown as a
818 // store-walk failure. Abort it — nothing awaits it after this
819 // — and let the slot guard release on the way out.
820 () = shutdown.cancelled() => {
821 walk.abort();
822 tracing::debug!(
823 "dht republish: shutdown during a lag sweep; abandoning the walk"
824 );
825 return;
826 }
827 joined = &mut walk => joined,
828 };
829 match joined {
830 Ok(outcome) => {
831 tracing::info!(
832 reseeded = outcome.reseeded,
833 degraded = outcome.degraded,
834 "dht republish: lag sweep complete"
835 );
836 }
837 Err(err) if err.is_panic() => {
838 metrics.dht_republish_lag_sweep_panicked();
839 tracing::error!(
840 error = %err,
841 "dht republish: lag sweep panicked; whatever the walk \
842 seeded before it died stays scheduled, and a queued \
843 request still runs"
844 );
845 }
846 // Cancelled without this worker aborting it: the runtime is
847 // tearing down. Leave through the guard, as on shutdown.
848 Err(err) => {
849 tracing::debug!(
850 error = %err,
851 "dht republish: lag sweep walk cancelled; abandoning"
852 );
853 return;
854 }
855 }
856 if state
857 .compare_exchange(
858 SWEEP_RUNNING,
859 SWEEP_IDLE,
860 Ordering::AcqRel,
861 Ordering::Acquire,
862 )
863 .is_ok()
864 {
865 // Released cleanly with nothing queued. The guard must not
866 // store again: a producer may already have claimed the slot.
867 guard.armed = false;
868 return;
869 }
870 // The CAS can only fail because a request arrived, so consume it
871 // and take another pass.
872 state.store(SWEEP_RUNNING, Ordering::Release);
873 }
874}
875
876/// Long-running task: consumes a [`broadcast::Receiver<Hash>`] from
877/// the cache, drives the scheduler heap, and publishes each hash that has not
878/// announced yet with a `Store` to its K+3 closest peers and each drain cycle
879/// with one `BatchStore` per receiver. Exits on `stop`, or on a closed cache
880/// subscribe channel — which this task's own `CacheEngine`
881/// clone makes unreachable in practice.
882///
883/// `stop` is owned by the runtime, which cancels it before flushing the cache
884/// store. That ordering is what a detached lag sweep needs: the sweep selects
885/// against this same token, so its walk is abandoned ahead of the flush rather
886/// than on whatever tick this task next happens to be polled — a signal this
887/// task had to forward would give no such guarantee.
888///
889/// Returns on shutdown so the runtime's `JoinSet` can drain it.
890//
891// Linear "shutdown? new commit? tick?" select loop. Splitting would
892// scatter the three-way priority across helpers; the function body
893// is a flat dispatcher.
894#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)]
895pub async fn run_republish(
896 endpoint: Endpoint,
897 self_id: PublicKey,
898 routing: Arc<Mutex<RoutingTable>>,
899 scheduler: Arc<RepublishScheduler>,
900 cache: decdn_cache::CacheEngine,
901 relay_foreign_namespaces: bool,
902 metrics: Arc<crate::metrics::Metrics>,
903 mut cache_inserts: broadcast::Receiver<iroh_blobs::Hash>,
904 stop: CancellationToken,
905) {
906 let self_node_id = NodeId::from_bytes(*self_id.as_bytes());
907 // One sweep at a time, with a queued re-run rather than a dropped request.
908 // A lag is a symptom of sustained commit pressure, so `Lagged` commonly
909 // repeats; see `spawn_lag_sweep` for the slot protocol.
910 let sweep_state = Arc::new(std::sync::atomic::AtomicU8::new(SWEEP_IDLE));
911 // Use a relatively short polling interval — the driver wakes on
912 // cache-insert events, on the scheduler ticking, OR on the
913 // shutdown signal. A 1-second poll keeps the worst-case latency
914 // between "hash became due" and "Store sent" bounded.
915 let mut ticker = tokio::time::interval(Duration::from_secs(1));
916 let mut last_cycle_us = None;
917 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
918 ticker.tick().await; // burn first tick
919
920 loop {
921 tokio::select! {
922 biased;
923 () = stop.cancelled() => {
924 tracing::debug!("dht republish: shutdown signal received");
925 return;
926 }
927 insert = cache_inserts.recv() => {
928 match insert {
929 Ok(hash) => {
930 let hash_bytes = ContentHash::from_bytes(*hash.as_bytes());
931 // ADR 022 §STORE Flow steps 2–3: publish
932 // *immediately* once the hash becomes advertisable
933 // (step 2), with the next republish at `T +
934 // uniform(30, 50) min` (step 3). Without the eager
935 // publish a freshly-cached blob isn't discoverable
936 // for up to 50 minutes.
937 //
938 // Only a hash that has not announced yet publishes: a
939 // ranged fill announces once per completed block, and
940 // an announced hash's coverage widens on its next
941 // cycle (ADR 022 §Content Records and TTL). A hash
942 // whose publish reached no peer stays unannounced, so
943 // its next event retries.
944 scheduler.schedule_steady_if_absent(hash_bytes);
945 if !scheduler.is_announced(&hash_bytes) {
946 let accepted =
947 publish_hash(&endpoint, self_node_id, &routing, &cache, hash_bytes)
948 .await;
949 metrics.dht_store_published(accepted);
950 if accepted > 0 {
951 scheduler.mark_announced(hash_bytes);
952 }
953 }
954 }
955 Err(broadcast::error::RecvError::Lagged(n)) => {
956 // ADR 022 §STORE Flow & the `subscribe_inserts`
957 // contract: the cache does not retain the missed
958 // hashes, so a lagged consumer MUST re-derive the
959 // held set rather than try to backfill. Without the
960 // sweep a hash announced inside the lag window stays
961 // undiscoverable until an operator restarts.
962 tracing::warn!(
963 missed = n,
964 "dht republish: cache-insert channel lagged; \
965 re-seeding the scheduler from local state"
966 );
967 metrics.dht_republish_lag_sweep();
968 spawn_lag_sweep(&SweepSlot {
969 state: &sweep_state,
970 cache: &cache,
971 scheduler: &scheduler,
972 metrics: &metrics,
973 shutdown: &stop,
974 relay_foreign_namespaces,
975 });
976 }
977 Err(broadcast::error::RecvError::Closed) => {
978 // Unreachable while this task holds a `CacheEngine`
979 // clone, which owns the sender. Terminal rather than
980 // `continue` because `recv` on a closed channel
981 // resolves instantly and forever: re-polling it in
982 // this biased `select!` starves the ticker and spins
983 // a core.
984 tracing::error!(
985 "dht republish: cache insert channel closed; stopping the republisher"
986 );
987 return;
988 }
989 }
990 }
991 _ = ticker.tick() => {
992 let (due, budget) =
993 plan_cycle(&scheduler, &routing, now_us(), &mut last_cycle_us);
994 // ADR 022 §Content Records and TTL: "A node
995 // stops re-publishing when it evicts the blob." Keep only
996 // the still-held hashes; drop evicted ones from the
997 // scheduler instead of re-adding them.
998 let mut held = Vec::with_capacity(due.len());
999 let mut store_faults = 0usize;
1000 for hash in due {
1001 match cache_still_holds(&cache, &hash).await {
1002 Some(true) => held.push(hash),
1003 Some(false) => scheduler.unschedule(&hash),
1004 // A store fault is not eviction evidence: keep the
1005 // hash scheduled with a fresh steady-state draw and
1006 // skip this cycle's publish, rather than advertising
1007 // content the serve path cannot confirm — or worse,
1008 // unscheduling it for the process lifetime.
1009 None => {
1010 store_faults = store_faults.saturating_add(1);
1011 scheduler.schedule_steady(hash);
1012 }
1013 }
1014 }
1015 if store_faults > 0 {
1016 tracing::warn!(
1017 faults = store_faults,
1018 "dht republish: store queries faulted at the due-time \
1019 gate; the affected hashes stay scheduled and retry on \
1020 their next cycle"
1021 );
1022 }
1023 if !held.is_empty() {
1024 // Re-schedule with the steady-state jitter window first
1025 // (ADR 022 §STORE Flow step 3 — fresh jitter draw per record per
1026 // cycle). The reschedule is independent of publish
1027 // outcome, so doing it before the publish lets the
1028 // publish run detached below.
1029 for hash in &held {
1030 scheduler.schedule_steady(*hash);
1031 }
1032 // One BatchStore per receiver (ADR 022 §STORE Flow
1033 // Batched STORE) rather than a per-hash fan-out — the
1034 // cycle's hashes share receiver sets, which is exactly
1035 // what batching folds into a single RPC. The sets are the
1036 // ones the drain admitted, so each look-ahead batch stays
1037 // within the cap. Fire it off the select loop: a slow
1038 // or unreachable receiver would otherwise hold the loop
1039 // for up to (chunks × DHT_CLIENT_TIMEOUT), delaying the
1040 // shutdown signal and eager cache-insert publishes. The
1041 // sweep is best-effort — abandoned on shutdown, retried
1042 // next cycle.
1043 //
1044 // A hash counts as announced only once a peer accepts it
1045 // with non-empty coverage. One that no peer took — no
1046 // routing peers yet, or every receiver refused — stays
1047 // eligible for the eager publish on its next cache event.
1048 let groups = budget.into_groups(&held.into_iter().collect());
1049 let ep = endpoint.clone();
1050 let cache_cloned = cache.clone();
1051 let metrics = Arc::clone(&metrics);
1052 let scheduler = Arc::clone(&scheduler);
1053 tokio::spawn(async move {
1054 let outcome =
1055 publish_batch(&ep, self_node_id, &cache_cloned, groups).await;
1056 metrics.dht_store_published(outcome.accepted);
1057 for hash in outcome.announced {
1058 scheduler.mark_announced(hash);
1059 }
1060 });
1061 }
1062 }
1063 }
1064 }
1065}
1066
1067/// Plan one drain cycle at `now_us`: drain it from `scheduler` and group it by
1068/// receiver (ADR 022 §STORE Flow "Drain cycle").
1069///
1070/// The cycle takes the records due now and, while no receiver's set passes
1071/// [`LOOKAHEAD_RECEIVER_CAP`], the ones due within [`REPUBLISH_LOOKAHEAD`].
1072/// Per-record jitter spreads due times so thinly that a tick alone holds about
1073/// one hash per receiver. The look-ahead is off when the previous non-empty
1074/// cycle, recorded in `last_cycle_us`, is less than [`LOOKAHEAD_MIN_SPACING`]
1075/// old.
1076///
1077/// Lock order is heap → scheduled → routing: nothing takes a scheduler lock
1078/// while it holds the routing table.
1079fn plan_cycle(
1080 scheduler: &RepublishScheduler,
1081 routing: &Mutex<RoutingTable>,
1082 now_us: u64,
1083 last_cycle_us: &mut Option<u64>,
1084) -> (Vec<ContentHash>, ReceiverBudget) {
1085 let micros = |d: Duration| u64::try_from(d.as_micros()).unwrap_or(u64::MAX);
1086 let spaced = last_cycle_us
1087 .is_none_or(|last| now_us.saturating_sub(last) >= micros(LOOKAHEAD_MIN_SPACING));
1088 let horizon_us = if spaced {
1089 now_us.saturating_add(micros(REPUBLISH_LOOKAHEAD))
1090 } else {
1091 now_us
1092 };
1093 let mut budget = ReceiverBudget::default();
1094 let drained = scheduler.drain_cycle(now_us, horizon_us, |hash, mandatory| {
1095 let receivers = with_lock(routing, "dht routing table", |table| {
1096 table.closest_unbounded(hash.as_bytes(), REPUBLISH_FANOUT)
1097 });
1098 budget.admit(*hash, &receivers, mandatory)
1099 });
1100 if !drained.is_empty() {
1101 *last_cycle_us = Some(now_us);
1102 }
1103 (drained, budget)
1104}
1105
1106/// Whether this node would still advertise `hash` — `Some(true)` when it
1107/// holds at least one verified discovery block (ADR 039-adjacent partial-
1108/// holder discovery; a partial holder is advertise-eligible, not just a
1109/// `Complete` one), `Some(false)` when it holds none, `None` when the store
1110/// query faulted and the question has no answer. The due-time gate that
1111/// stops the scheduler from re-publishing content LRU drift or an
1112/// operator-evict already removed.
1113///
1114/// Origin-held counts, not just the local store. `CacheEngine::coverage`
1115/// derives its bitmap from cached blocks only, but the probe path
1116/// advertises origin-held content through `origin_held_size` — so a
1117/// filesystem or pinned-origin hash that was never imported would be
1118/// advertised at probe time and yet dropped here at its first due time,
1119/// publishing no `Store` at all. Both bulk seeds feed exactly that content
1120/// in, so a cache-only check silently discards what they schedule.
1121///
1122/// A store error is `None`, never `Some(false)`: eviction is the only honest
1123/// reason to stop republishing, and a fault is not eviction evidence. Folding
1124/// it into "no longer held" would have the tick path `unschedule` the hash —
1125/// dropping it from the announce set for the process lifetime on a transient
1126/// store blip, with every signal green.
1127///
1128/// Both reads are in-memory or local; the live origin probe is deliberately not
1129/// used, because this runs per hash per republish cycle.
1130///
1131/// This gate is also what drops a partial the bulk seeds scheduled without
1132/// reading its coverage ([`decdn_cache::CacheEngine::iter_hashes`]): one that
1133/// covers no discovery block answers `Some(false)` and leaves the schedule
1134/// until a later completed block announces it or a later seed re-adds it.
1135async fn cache_still_holds(cache: &decdn_cache::CacheEngine, hash: &ContentHash) -> Option<bool> {
1136 let h = iroh_blobs::Hash::from_bytes(*hash.as_bytes());
1137 if cache.refuses(h) {
1138 return Some(false);
1139 }
1140 // `origin_held_size` applies the live refusal filter itself, so a
1141 // blacklisted or operator-evicted hash cannot re-enter through it. It is
1142 // an in-memory index read that cannot fault, so it answers first.
1143 if cache.origin_held_size(h).is_some() {
1144 return Some(true);
1145 }
1146 match cache.coverage(h).await {
1147 Ok(coverage) => Some(!coverage.is_empty()),
1148 Err(err) => {
1149 tracing::debug!(
1150 hash = ?hash,
1151 error = %err.display_chain(),
1152 "dht republish: store query faulted at the due-time gate"
1153 );
1154 None
1155 }
1156 }
1157}
1158
1159/// Send a `Store` to the K+3 closest peers for `hash` in parallel.
1160/// Failures are logged at debug level — a missed receiver in this cycle
1161/// will be retried on the next cycle (or on the next cold start).
1162///
1163/// `coverage` is derived once, up front — every receiver gets the same
1164/// snapshot of what this node can currently serve for `hash` rather than a
1165/// per-RPC re-derivation that could drift mid-fan-out. An empty coverage sends
1166/// nothing: the record would advertise no block a requester can fetch. That
1167/// covers a cache event for a hash the store no longer holds, a partial with no
1168/// whole block, and a coverage query that faulted.
1169///
1170/// Returns how many peers accepted the record.
1171async fn publish_hash(
1172 endpoint: &Endpoint,
1173 self_node_id: NodeId,
1174 routing: &Arc<Mutex<RoutingTable>>,
1175 cache: &decdn_cache::CacheEngine,
1176 hash: ContentHash,
1177) -> u64 {
1178 let coverage = fetch_coverage(cache, hash).await;
1179 if coverage.is_empty() {
1180 return 0;
1181 }
1182 let targets: Vec<NodeId> = with_lock(routing, "dht routing table", |table| {
1183 // ADR 022 §STORE Flow step 1 specifies K+3 (= 23) closest
1184 // nodes — the three positions beyond K are overflow targets
1185 // so an attacker suppressing receivers has to take down K+3
1186 // hosts rather than K. The default `closest` caps at K (=
1187 // wire `MAX_CLOSER_NODES`), which we MUST NOT use here;
1188 // `closest_unbounded` returns up to `n` peers regardless of
1189 // the wire cap.
1190 table.closest_unbounded(hash.as_bytes(), REPUBLISH_FANOUT)
1191 });
1192 if targets.is_empty() {
1193 // No routing-table entries yet (e.g. boot before bootstrap).
1194 // Nothing to do this cycle; the scheduler will retry.
1195 return 0;
1196 }
1197 let mut handles = Vec::with_capacity(targets.len());
1198 for peer in targets {
1199 let endpoint_cloned = endpoint.clone();
1200 let coverage_cloned = coverage.clone();
1201 handles.push(tokio::spawn(async move {
1202 let target_pk = match PublicKey::from_bytes(peer.as_bytes()) {
1203 Ok(k) => k,
1204 Err(e) => {
1205 tracing::warn!(
1206 peer = %peer,
1207 error = %e,
1208 "dht republish: routing-table peer not a valid public key"
1209 );
1210 return 0;
1211 }
1212 };
1213 let addr = EndpointAddr::new(target_pk);
1214 match client::store(&endpoint_cloned, addr, hash, self_node_id, coverage_cloned).await {
1215 Ok(ack) if ack.accepted => 1,
1216 Ok(_) => {
1217 tracing::debug!(
1218 peer = %peer,
1219 hash = ?hash,
1220 "dht republish: peer rejected Store (not staked, over quota, etc)"
1221 );
1222 0
1223 }
1224 Err(e) => {
1225 tracing::debug!(
1226 peer = %peer,
1227 hash = ?hash,
1228 error = %e,
1229 "dht republish: Store request failed"
1230 );
1231 0
1232 }
1233 }
1234 }));
1235 }
1236 let mut accepted = 0;
1237 for h in handles {
1238 accepted += h.await.unwrap_or(0);
1239 }
1240 accepted
1241}
1242
1243/// Derive `hash`'s current [`Coverage`] from the cache, folding any store
1244/// fault into [`Coverage::empty`] — a republish that can't answer the
1245/// coverage question advertises nothing for this cycle rather than
1246/// blocking on it; the hash stays scheduled and retries next cycle via the
1247/// `cache_still_holds` due-time gate.
1248async fn fetch_coverage(cache: &decdn_cache::CacheEngine, hash: ContentHash) -> Coverage {
1249 let h = iroh_blobs::Hash::from_bytes(*hash.as_bytes());
1250 match cache.coverage(h).await {
1251 Ok(coverage) => coverage,
1252 Err(err) => {
1253 tracing::debug!(
1254 hash = ?hash,
1255 error = %err.display_chain(),
1256 "dht republish: coverage query faulted; publishing empty coverage this cycle"
1257 );
1258 Coverage::empty()
1259 }
1260 }
1261}
1262
1263/// One republish cycle's `receiver → hashes` grouping, which is also the
1264/// admission rule behind the look-ahead in
1265/// [`RepublishScheduler::drain_cycle`].
1266///
1267/// The grouping that admits a hash is the grouping that publishes it:
1268/// [`publish_batch`] sends exactly these sets rather than asking the routing
1269/// table again. A peer that joins the table between the two steps cannot then
1270/// collect uncharged hashes and push its batch past the cap.
1271///
1272/// A set holds at most `max(cap, due hashes)` because `drain_cycle` presents
1273/// every due hash before any look-ahead hash; the type itself does not track
1274/// the order.
1275#[derive(Debug, Default)]
1276struct ReceiverBudget {
1277 groups: HashMap<NodeId, Vec<ContentHash>>,
1278}
1279
1280impl ReceiverBudget {
1281 /// Add `hash` to the set of each of `receivers` — its K+3 closest peers
1282 /// (ADR 022 §STORE Flow step 1). A `mandatory` hash is always added. A
1283 /// look-ahead hash is added only if it has a receiver and no receiver's
1284 /// set is already at [`LOOKAHEAD_RECEIVER_CAP`]; otherwise nothing is
1285 /// added and the answer is `false`. With no receivers (boot before
1286 /// bootstrap) the cycle publishes nothing, so pulling a record forward
1287 /// would only spend its refresh.
1288 fn admit(&mut self, hash: ContentHash, receivers: &[NodeId], mandatory: bool) -> bool {
1289 let full =
1290 |peer: &NodeId| self.groups.get(peer).map_or(0, Vec::len) >= LOOKAHEAD_RECEIVER_CAP;
1291 if !mandatory && (receivers.is_empty() || receivers.iter().any(full)) {
1292 return false;
1293 }
1294 for peer in receivers {
1295 self.groups.entry(*peer).or_default().push(hash);
1296 }
1297 true
1298 }
1299
1300 /// The per-receiver sets, keeping only the hashes in `held`. A hash the
1301 /// due-time gate dropped (evicted, or a store fault) leaves every set, and
1302 /// a receiver left with nothing gets no batch.
1303 fn into_groups(self, held: &HashSet<ContentHash>) -> HashMap<NodeId, Vec<ContentHash>> {
1304 self.groups
1305 .into_iter()
1306 .filter_map(|(peer, mut hashes)| {
1307 hashes.retain(|h| held.contains(h));
1308 (!hashes.is_empty()).then_some((peer, hashes))
1309 })
1310 .collect()
1311 }
1312}
1313
1314/// Publish a cycle's `receiver → hashes` sets ([`ReceiverBudget::into_groups`])
1315/// as one `BatchStore` per receiver, split at the [`MAX_BATCH_STORE_HASHES`]
1316/// wire cap (ADR 022 §STORE Flow Batched STORE). Each receiver's batches run
1317/// in their own task so a
1318/// slow peer doesn't stall the rest of the sweep. A rejected hash (peer
1319/// not staked, over quota) or a failed exchange is logged at debug — the
1320/// record retries on the next cycle. Every DHT node implements
1321/// `BatchStore`, so there is no per-hash fallback.
1322///
1323/// Returns how many `(peer, hash)` records the peers accepted, and which
1324/// hashes at least one peer accepted with non-empty coverage.
1325async fn publish_batch(
1326 endpoint: &Endpoint,
1327 self_node_id: NodeId,
1328 cache: &decdn_cache::CacheEngine,
1329 groups: HashMap<NodeId, Vec<ContentHash>>,
1330) -> BatchOutcome {
1331 if groups.is_empty() {
1332 // No routing-table entries yet (e.g. boot before bootstrap).
1333 // Nothing to do this cycle; the scheduler will retry.
1334 return BatchOutcome::default();
1335 }
1336 // Derive each hash's coverage once, up front, and share it across every
1337 // receiver's batch — the same rationale as `publish_hash`'s single
1338 // snapshot: every receiver of a given hash this cycle sees the same
1339 // coverage rather than a per-batch re-derivation that could drift.
1340 let hashes: HashSet<ContentHash> = groups.values().flatten().copied().collect();
1341 let mut coverage_by_hash = HashMap::with_capacity(hashes.len());
1342 for hash in hashes {
1343 coverage_by_hash.insert(hash, fetch_coverage(cache, hash).await);
1344 }
1345 let coverage_by_hash = Arc::new(coverage_by_hash);
1346 let mut handles = Vec::with_capacity(groups.len());
1347 for (peer, peer_hashes) in groups {
1348 let endpoint_cloned = endpoint.clone();
1349 let coverage_by_hash = Arc::clone(&coverage_by_hash);
1350 handles.push(tokio::spawn(async move {
1351 let target_pk = match PublicKey::from_bytes(peer.as_bytes()) {
1352 Ok(k) => k,
1353 Err(e) => {
1354 tracing::warn!(
1355 peer = %peer,
1356 error = %e,
1357 "dht republish: routing-table peer not a valid public key"
1358 );
1359 return (0, Vec::new());
1360 }
1361 };
1362 let addr = EndpointAddr::new(target_pk);
1363 let mut accepted: u64 = 0;
1364 let mut announced = Vec::new();
1365 for chunk in peer_hashes.chunks(MAX_BATCH_STORE_HASHES) {
1366 let entries: Vec<(ContentHash, Coverage)> = chunk
1367 .iter()
1368 .map(|h| {
1369 let coverage = coverage_by_hash.get(h).cloned().unwrap_or_else(Coverage::empty);
1370 (*h, coverage)
1371 })
1372 .collect();
1373 match client::batch_store(
1374 &endpoint_cloned,
1375 addr.clone(),
1376 entries.clone(),
1377 self_node_id,
1378 )
1379 .await
1380 {
1381 Ok(ack) => {
1382 announced.extend(announced_in_batch(&entries, &ack.results));
1383 let ok = ack.results.iter().filter(|accepted| **accepted).count();
1384 accepted += u64::try_from(ok).unwrap_or(u64::MAX);
1385 let rejected = ack.results.iter().filter(|accepted| !**accepted).count();
1386 if rejected > 0 {
1387 tracing::debug!(
1388 peer = %peer,
1389 rejected,
1390 batch = chunk.len(),
1391 "dht republish: peer rejected some batched Stores (not staked, over quota, etc)"
1392 );
1393 }
1394 }
1395 Err(e) => {
1396 tracing::debug!(
1397 peer = %peer,
1398 batch = chunk.len(),
1399 error = %e,
1400 "dht republish: BatchStore request failed"
1401 );
1402 }
1403 }
1404 }
1405 (accepted, announced)
1406 }));
1407 }
1408 let mut outcome = BatchOutcome::default();
1409 for h in handles {
1410 let (accepted, announced) = h.await.unwrap_or_default();
1411 outcome.accepted += accepted;
1412 outcome.announced.extend(announced);
1413 }
1414 outcome
1415}
1416
1417/// What one drain cycle's [`publish_batch`] achieved.
1418#[derive(Debug, Default)]
1419struct BatchOutcome {
1420 /// `(peer, hash)` records the peers accepted.
1421 accepted: u64,
1422 /// Hashes at least one peer accepted with non-empty coverage.
1423 announced: HashSet<ContentHash>,
1424}
1425
1426/// The hashes of `entries` that a `BatchStoreAck` accepted and whose entry
1427/// advertised at least one block. `results` answers `entries` in request
1428/// order; an entry it does not answer is not accepted.
1429fn announced_in_batch<'a>(
1430 entries: &'a [(ContentHash, Coverage)],
1431 results: &'a [bool],
1432) -> impl Iterator<Item = ContentHash> + 'a {
1433 entries
1434 .iter()
1435 .zip(results)
1436 .filter(|((_, coverage), accepted)| **accepted && !coverage.is_empty())
1437 .map(|((hash, _), _)| *hash)
1438}
1439
1440#[cfg(test)]
1441#[allow(
1442 clippy::unwrap_used,
1443 clippy::expect_used,
1444 clippy::panic,
1445 clippy::indexing_slicing
1446)]
1447mod tests;