Skip to main content

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;