use std::cmp::Reverse;
use std::collections::{BinaryHeap, HashMap, HashSet};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use iroh::{Endpoint, EndpointAddr, PublicKey};
use rand::RngExt;
use tokio::sync::broadcast;
use tokio_util::sync::CancellationToken;
use crate::dht::chain_projection::with_lock;
use crate::dht::client;
use crate::dht::routing::{NodeId, RoutingTable};
use decdn_protocol::ContentHash;
use decdn_protocol::Coverage;
use decdn_protocol::dht::MAX_BATCH_STORE_HASHES;
pub const REPUBLISH_FANOUT: usize = 23;
pub const STEADY_STATE_MIN: Duration = Duration::from_mins(30);
pub const STEADY_STATE_MAX: Duration = Duration::from_mins(50);
pub const COLD_START_MAX: Duration = Duration::from_mins(40);
pub const REPUBLISH_LOOKAHEAD: Duration = Duration::from_mins(5);
pub const LOOKAHEAD_RECEIVER_CAP: usize = 32;
pub const LOOKAHEAD_MIN_SPACING: Duration = Duration::from_secs(2);
const _: () = assert!(REPUBLISH_LOOKAHEAD.as_secs() < STEADY_STATE_MIN.as_secs());
const _: () = assert!(LOOKAHEAD_RECEIVER_CAP <= MAX_BATCH_STORE_HASHES);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct Entry {
due_us: u64,
hash: ContentHash,
}
impl PartialOrd for Entry {
fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Entry {
fn cmp(&self, other: &Self) -> std::cmp::Ordering {
self.due_us
.cmp(&other.due_us)
.then_with(|| self.hash.cmp(&other.hash))
}
}
fn schedule_at(heap: &mut BinaryHeap<Reverse<Entry>>, hash: ContentHash, due_us: u64) {
heap.push(Reverse(Entry { due_us, hash }));
}
fn now_us() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_micros()).unwrap_or(u64::MAX))
}
fn jitter_us(lo: Duration, hi: Duration) -> u64 {
let lo_us = u64::try_from(lo.as_micros()).unwrap_or(u64::MAX);
let hi_us = u64::try_from(hi.as_micros()).unwrap_or(u64::MAX);
let mut rng = rand::rng();
if hi_us <= lo_us {
return lo_us;
}
rng.random_range(lo_us..=hi_us)
}
#[allow(missing_debug_implementations)]
pub struct RepublishScheduler {
heap: Arc<Mutex<BinaryHeap<Reverse<Entry>>>>,
scheduled: Arc<Mutex<HashMap<ContentHash, u64>>>,
announced: Arc<Mutex<HashSet<ContentHash>>>,
}
impl RepublishScheduler {
#[must_use]
pub fn new() -> Self {
Self {
heap: Arc::new(Mutex::new(BinaryHeap::new())),
scheduled: Arc::new(Mutex::new(HashMap::new())),
announced: Arc::new(Mutex::new(HashSet::new())),
}
}
#[must_use]
pub fn len(&self) -> usize {
with_lock(&self.scheduled, "dht republish scheduled set", |s| s.len())
}
#[must_use]
pub fn is_empty(&self) -> bool {
with_lock(&self.scheduled, "dht republish scheduled set", |s| {
s.is_empty()
})
}
pub fn schedule_steady(&self, hash: ContentHash) {
let offset = jitter_us(STEADY_STATE_MIN, STEADY_STATE_MAX);
self.schedule_with_offset(hash, offset);
}
pub fn schedule_steady_if_absent(&self, hash: ContentHash) -> bool {
let offset = jitter_us(STEADY_STATE_MIN, STEADY_STATE_MAX);
self.schedule_if_absent(hash, offset)
}
pub fn mark_announced(&self, hash: ContentHash) {
with_lock(&self.announced, "dht republish announced set", |a| {
a.insert(hash);
});
}
#[must_use]
pub fn is_announced(&self, hash: &ContentHash) -> bool {
with_lock(&self.announced, "dht republish announced set", |a| {
a.contains(hash)
})
}
pub fn seed_cold_start<I>(&self, iter: I) -> usize
where
I: IntoIterator<Item = ContentHash>,
{
let mut count = 0usize;
for hash in iter {
let offset = jitter_us(Duration::ZERO, COLD_START_MAX);
if self.schedule_if_absent(hash, offset) {
count = count.saturating_add(1);
}
}
count
}
fn schedule_with_offset(&self, hash: ContentHash, offset_us: u64) {
self.with_slots(offset_us, |heap, scheduled, due_us| {
scheduled.insert(hash, due_us);
schedule_at(heap, hash, due_us);
true
});
}
fn schedule_if_absent(&self, hash: ContentHash, offset_us: u64) -> bool {
self.with_slots(offset_us, |heap, scheduled, due_us| {
if scheduled.contains_key(&hash) {
return false;
}
scheduled.insert(hash, due_us);
schedule_at(heap, hash, due_us);
true
})
}
fn with_slots<F>(&self, offset_us: u64, f: F) -> bool
where
F: FnOnce(&mut BinaryHeap<Reverse<Entry>>, &mut HashMap<ContentHash, u64>, u64) -> bool,
{
let due_us = now_us().saturating_add(offset_us);
with_lock(&self.heap, "dht republish heap", |heap| {
with_lock(
&self.scheduled,
"dht republish scheduled set",
|scheduled| f(heap, scheduled, due_us),
)
})
}
fn drain_cycle<F>(&self, now_us: u64, horizon_us: u64, mut admit: F) -> Vec<ContentHash>
where
F: FnMut(&ContentHash, bool) -> bool,
{
let mut out = Vec::new();
with_lock(&self.heap, "dht republish heap", |heap| {
with_lock(
&self.scheduled,
"dht republish scheduled set",
|scheduled| {
while let Some(Reverse(Entry { due_us, hash })) = heap.peek().copied() {
if due_us > now_us.max(horizon_us) {
break;
}
if scheduled.get(&hash) != Some(&due_us) {
heap.pop();
continue;
}
let mandatory = due_us <= now_us;
if mandatory {
admit(&hash, true);
} else if out.is_empty() || !admit(&hash, false) {
break;
}
heap.pop();
out.push(hash);
scheduled.remove(&hash);
}
},
);
});
out
}
#[cfg(test)]
fn drain_due(&self, now_us: u64) -> Vec<ContentHash> {
self.drain_cycle(now_us, now_us, |_, _| true)
}
pub fn unschedule(&self, hash: &ContentHash) {
with_lock(&self.scheduled, "dht republish scheduled set", |s| {
s.remove(hash);
});
with_lock(&self.announced, "dht republish announced set", |a| {
a.remove(hash);
});
}
}
impl Default for RepublishScheduler {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug)]
pub(crate) struct HolderSnapshot {
pub(crate) hashes: HashSet<decdn_cache::Hash>,
pub(crate) store_error: Option<decdn_cache::CacheError>,
pub(crate) rescan_probe_faults: u64,
pub(crate) rescan_enumerate_failures: u64,
pub(crate) ownership_probe_faults: u64,
}
impl HolderSnapshot {
pub(crate) const fn is_degraded(&self) -> bool {
self.store_error.is_some()
|| self.rescan_probe_faults > 0
|| self.rescan_enumerate_failures > 0
|| self.ownership_probe_faults > 0
}
pub(crate) fn report_degradation(
&self,
metrics: &crate::metrics::Metrics,
context: &str,
) -> bool {
if let Some(err) = &self.store_error {
metrics.dht_republish_seed_store_walk_failure();
tracing::warn!(
context,
error = %err.display_chain(),
"dht republish: seed could not walk the store and covered the \
origin-held half only. Blobs held only in the store stay \
un-republished until a later seed walks it successfully"
);
}
if self.ownership_probe_faults > 0 {
metrics.dht_republish_seed_origin_probe_failures(self.ownership_probe_faults);
tracing::warn!(
context,
faults = self.ownership_probe_faults,
"dht republish: seed could not put every stored hash to its \
origin; each one it could not confirm is left out of the \
announce set rather than advertised"
);
}
if self.rescan_probe_faults > 0 || self.rescan_enumerate_failures > 0 {
tracing::warn!(
context,
faults = self.rescan_probe_faults,
enumerate_failures = self.rescan_enumerate_failures,
"dht republish: the origin-held half of this seed comes from a \
rescan that could not resolve everything; entries it carried \
forward may name content the origin has dropped, and an origin \
it could not list contributed nothing at all"
);
}
self.is_degraded()
}
}
pub(crate) async fn holder_snapshot(
cache: &decdn_cache::CacheEngine,
relay_foreign_namespaces: bool,
) -> HolderSnapshot {
let report = cache.origin_held_snapshot();
let mut hashes = report.hashes;
let mut ownership_probe_faults = 0u64;
let mut store_error = None;
match cache.iter_hashes().await {
Ok(stored) => {
for hash in stored {
if hashes.contains(&hash) {
continue;
}
if relay_foreign_namespaces {
hashes.insert(hash);
continue;
}
match cache.origin_probe_presence(hash).await {
decdn_cache::OriginPresence::Present(_) => {
hashes.insert(hash);
}
decdn_cache::OriginPresence::Absent => {}
decdn_cache::OriginPresence::Fault => {
ownership_probe_faults = ownership_probe_faults.saturating_add(1);
}
}
}
}
Err(err) => store_error = Some(err),
}
HolderSnapshot {
hashes,
store_error,
rescan_probe_faults: report.probe_faults,
rescan_enumerate_failures: report.enumerate_failures,
ownership_probe_faults,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SweepOutcome {
reseeded: usize,
degraded: bool,
}
async fn lag_sweep(
cache: &decdn_cache::CacheEngine,
relay_foreign_namespaces: bool,
scheduler: &RepublishScheduler,
metrics: &crate::metrics::Metrics,
) -> SweepOutcome {
let snapshot = holder_snapshot(cache, relay_foreign_namespaces).await;
let degraded = snapshot.report_degradation(metrics, "lag sweep");
let reseeded = scheduler.seed_cold_start(
snapshot
.hashes
.into_iter()
.map(|h| ContentHash::from_bytes(*h.as_bytes())),
);
metrics.dht_republish_sweep_reseeded(u64::try_from(reseeded).unwrap_or(u64::MAX));
SweepOutcome { reseeded, degraded }
}
const SWEEP_IDLE: u8 = 0;
const SWEEP_RUNNING: u8 = 1;
const SWEEP_QUEUED: u8 = 2;
struct SweepSlotGuard<'a> {
state: &'a std::sync::atomic::AtomicU8,
armed: bool,
}
impl Drop for SweepSlotGuard<'_> {
fn drop(&mut self) {
if self.armed {
self.state
.store(SWEEP_IDLE, std::sync::atomic::Ordering::Release);
}
}
}
#[derive(Clone, Copy)]
struct SweepSlot<'a> {
state: &'a Arc<std::sync::atomic::AtomicU8>,
cache: &'a decdn_cache::CacheEngine,
scheduler: &'a Arc<RepublishScheduler>,
metrics: &'a Arc<crate::metrics::Metrics>,
shutdown: &'a CancellationToken,
relay_foreign_namespaces: bool,
}
fn spawn_lag_sweep(slot: &SweepSlot<'_>) {
use std::sync::atomic::Ordering;
loop {
match slot.state.compare_exchange_weak(
SWEEP_IDLE,
SWEEP_RUNNING,
Ordering::AcqRel,
Ordering::Acquire,
) {
Ok(_) => break,
Err(SWEEP_RUNNING) => {
if slot
.state
.compare_exchange_weak(
SWEEP_RUNNING,
SWEEP_QUEUED,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
slot.metrics.dht_republish_lag_sweep_coalesced();
tracing::debug!(
"dht republish: lag sweep already running; queued a further pass"
);
return;
}
}
Err(SWEEP_QUEUED) => {
slot.metrics.dht_republish_lag_sweep_coalesced();
tracing::debug!("dht republish: lag sweep already queued");
return;
}
Err(_) => {}
}
}
let cache = slot.cache.clone();
let scheduler = Arc::clone(slot.scheduler);
let metrics = Arc::clone(slot.metrics);
let state = Arc::clone(slot.state);
let shutdown = slot.shutdown.clone();
let relay_foreign_namespaces = slot.relay_foreign_namespaces;
tokio::spawn(run_sweep_worker(
state,
cache,
scheduler,
metrics,
shutdown,
relay_foreign_namespaces,
));
}
#[allow(clippy::cognitive_complexity)]
async fn run_sweep_worker(
state: Arc<std::sync::atomic::AtomicU8>,
cache: decdn_cache::CacheEngine,
scheduler: Arc<RepublishScheduler>,
metrics: Arc<crate::metrics::Metrics>,
shutdown: CancellationToken,
relay_foreign_namespaces: bool,
) {
use std::sync::atomic::Ordering;
let mut guard = SweepSlotGuard {
state: &state,
armed: true,
};
loop {
let mut walk = tokio::spawn({
let cache = cache.clone();
let scheduler = Arc::clone(&scheduler);
let metrics = Arc::clone(&metrics);
async move { lag_sweep(&cache, relay_foreign_namespaces, &scheduler, &metrics).await }
});
let joined = tokio::select! {
biased;
() = shutdown.cancelled() => {
walk.abort();
tracing::debug!(
"dht republish: shutdown during a lag sweep; abandoning the walk"
);
return;
}
joined = &mut walk => joined,
};
match joined {
Ok(outcome) => {
tracing::info!(
reseeded = outcome.reseeded,
degraded = outcome.degraded,
"dht republish: lag sweep complete"
);
}
Err(err) if err.is_panic() => {
metrics.dht_republish_lag_sweep_panicked();
tracing::error!(
error = %err,
"dht republish: lag sweep panicked; whatever the walk \
seeded before it died stays scheduled, and a queued \
request still runs"
);
}
Err(err) => {
tracing::debug!(
error = %err,
"dht republish: lag sweep walk cancelled; abandoning"
);
return;
}
}
if state
.compare_exchange(
SWEEP_RUNNING,
SWEEP_IDLE,
Ordering::AcqRel,
Ordering::Acquire,
)
.is_ok()
{
guard.armed = false;
return;
}
state.store(SWEEP_RUNNING, Ordering::Release);
}
}
#[allow(clippy::cognitive_complexity, clippy::too_many_arguments)]
pub async fn run_republish(
endpoint: Endpoint,
self_id: PublicKey,
routing: Arc<Mutex<RoutingTable>>,
scheduler: Arc<RepublishScheduler>,
cache: decdn_cache::CacheEngine,
relay_foreign_namespaces: bool,
metrics: Arc<crate::metrics::Metrics>,
mut cache_inserts: broadcast::Receiver<iroh_blobs::Hash>,
stop: CancellationToken,
) {
let self_node_id = NodeId::from_bytes(*self_id.as_bytes());
let sweep_state = Arc::new(std::sync::atomic::AtomicU8::new(SWEEP_IDLE));
let mut ticker = tokio::time::interval(Duration::from_secs(1));
let mut last_cycle_us = None;
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
ticker.tick().await;
loop {
tokio::select! {
biased;
() = stop.cancelled() => {
tracing::debug!("dht republish: shutdown signal received");
return;
}
insert = cache_inserts.recv() => {
match insert {
Ok(hash) => {
let hash_bytes = ContentHash::from_bytes(*hash.as_bytes());
scheduler.schedule_steady_if_absent(hash_bytes);
if !scheduler.is_announced(&hash_bytes) {
let accepted =
publish_hash(&endpoint, self_node_id, &routing, &cache, hash_bytes)
.await;
metrics.dht_store_published(accepted);
if accepted > 0 {
scheduler.mark_announced(hash_bytes);
}
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(
missed = n,
"dht republish: cache-insert channel lagged; \
re-seeding the scheduler from local state"
);
metrics.dht_republish_lag_sweep();
spawn_lag_sweep(&SweepSlot {
state: &sweep_state,
cache: &cache,
scheduler: &scheduler,
metrics: &metrics,
shutdown: &stop,
relay_foreign_namespaces,
});
}
Err(broadcast::error::RecvError::Closed) => {
tracing::error!(
"dht republish: cache insert channel closed; stopping the republisher"
);
return;
}
}
}
_ = ticker.tick() => {
let (due, budget) =
plan_cycle(&scheduler, &routing, now_us(), &mut last_cycle_us);
let mut held = Vec::with_capacity(due.len());
let mut store_faults = 0usize;
for hash in due {
match cache_still_holds(&cache, &hash).await {
Some(true) => held.push(hash),
Some(false) => scheduler.unschedule(&hash),
None => {
store_faults = store_faults.saturating_add(1);
scheduler.schedule_steady(hash);
}
}
}
if store_faults > 0 {
tracing::warn!(
faults = store_faults,
"dht republish: store queries faulted at the due-time \
gate; the affected hashes stay scheduled and retry on \
their next cycle"
);
}
if !held.is_empty() {
for hash in &held {
scheduler.schedule_steady(*hash);
}
let groups = budget.into_groups(&held.into_iter().collect());
let ep = endpoint.clone();
let cache_cloned = cache.clone();
let metrics = Arc::clone(&metrics);
let scheduler = Arc::clone(&scheduler);
tokio::spawn(async move {
let outcome =
publish_batch(&ep, self_node_id, &cache_cloned, groups).await;
metrics.dht_store_published(outcome.accepted);
for hash in outcome.announced {
scheduler.mark_announced(hash);
}
});
}
}
}
}
}
fn plan_cycle(
scheduler: &RepublishScheduler,
routing: &Mutex<RoutingTable>,
now_us: u64,
last_cycle_us: &mut Option<u64>,
) -> (Vec<ContentHash>, ReceiverBudget) {
let micros = |d: Duration| u64::try_from(d.as_micros()).unwrap_or(u64::MAX);
let spaced = last_cycle_us
.is_none_or(|last| now_us.saturating_sub(last) >= micros(LOOKAHEAD_MIN_SPACING));
let horizon_us = if spaced {
now_us.saturating_add(micros(REPUBLISH_LOOKAHEAD))
} else {
now_us
};
let mut budget = ReceiverBudget::default();
let drained = scheduler.drain_cycle(now_us, horizon_us, |hash, mandatory| {
let receivers = with_lock(routing, "dht routing table", |table| {
table.closest_unbounded(hash.as_bytes(), REPUBLISH_FANOUT)
});
budget.admit(*hash, &receivers, mandatory)
});
if !drained.is_empty() {
*last_cycle_us = Some(now_us);
}
(drained, budget)
}
async fn cache_still_holds(cache: &decdn_cache::CacheEngine, hash: &ContentHash) -> Option<bool> {
let h = iroh_blobs::Hash::from_bytes(*hash.as_bytes());
if cache.refuses(h) {
return Some(false);
}
if cache.origin_held_size(h).is_some() {
return Some(true);
}
match cache.coverage(h).await {
Ok(coverage) => Some(!coverage.is_empty()),
Err(err) => {
tracing::debug!(
hash = ?hash,
error = %err.display_chain(),
"dht republish: store query faulted at the due-time gate"
);
None
}
}
}
async fn publish_hash(
endpoint: &Endpoint,
self_node_id: NodeId,
routing: &Arc<Mutex<RoutingTable>>,
cache: &decdn_cache::CacheEngine,
hash: ContentHash,
) -> u64 {
let coverage = fetch_coverage(cache, hash).await;
if coverage.is_empty() {
return 0;
}
let targets: Vec<NodeId> = with_lock(routing, "dht routing table", |table| {
table.closest_unbounded(hash.as_bytes(), REPUBLISH_FANOUT)
});
if targets.is_empty() {
return 0;
}
let mut handles = Vec::with_capacity(targets.len());
for peer in targets {
let endpoint_cloned = endpoint.clone();
let coverage_cloned = coverage.clone();
handles.push(tokio::spawn(async move {
let target_pk = match PublicKey::from_bytes(peer.as_bytes()) {
Ok(k) => k,
Err(e) => {
tracing::warn!(
peer = %peer,
error = %e,
"dht republish: routing-table peer not a valid public key"
);
return 0;
}
};
let addr = EndpointAddr::new(target_pk);
match client::store(&endpoint_cloned, addr, hash, self_node_id, coverage_cloned).await {
Ok(ack) if ack.accepted => 1,
Ok(_) => {
tracing::debug!(
peer = %peer,
hash = ?hash,
"dht republish: peer rejected Store (not staked, over quota, etc)"
);
0
}
Err(e) => {
tracing::debug!(
peer = %peer,
hash = ?hash,
error = %e,
"dht republish: Store request failed"
);
0
}
}
}));
}
let mut accepted = 0;
for h in handles {
accepted += h.await.unwrap_or(0);
}
accepted
}
async fn fetch_coverage(cache: &decdn_cache::CacheEngine, hash: ContentHash) -> Coverage {
let h = iroh_blobs::Hash::from_bytes(*hash.as_bytes());
match cache.coverage(h).await {
Ok(coverage) => coverage,
Err(err) => {
tracing::debug!(
hash = ?hash,
error = %err.display_chain(),
"dht republish: coverage query faulted; publishing empty coverage this cycle"
);
Coverage::empty()
}
}
}
#[derive(Debug, Default)]
struct ReceiverBudget {
groups: HashMap<NodeId, Vec<ContentHash>>,
}
impl ReceiverBudget {
fn admit(&mut self, hash: ContentHash, receivers: &[NodeId], mandatory: bool) -> bool {
let full =
|peer: &NodeId| self.groups.get(peer).map_or(0, Vec::len) >= LOOKAHEAD_RECEIVER_CAP;
if !mandatory && (receivers.is_empty() || receivers.iter().any(full)) {
return false;
}
for peer in receivers {
self.groups.entry(*peer).or_default().push(hash);
}
true
}
fn into_groups(self, held: &HashSet<ContentHash>) -> HashMap<NodeId, Vec<ContentHash>> {
self.groups
.into_iter()
.filter_map(|(peer, mut hashes)| {
hashes.retain(|h| held.contains(h));
(!hashes.is_empty()).then_some((peer, hashes))
})
.collect()
}
}
async fn publish_batch(
endpoint: &Endpoint,
self_node_id: NodeId,
cache: &decdn_cache::CacheEngine,
groups: HashMap<NodeId, Vec<ContentHash>>,
) -> BatchOutcome {
if groups.is_empty() {
return BatchOutcome::default();
}
let hashes: HashSet<ContentHash> = groups.values().flatten().copied().collect();
let mut coverage_by_hash = HashMap::with_capacity(hashes.len());
for hash in hashes {
coverage_by_hash.insert(hash, fetch_coverage(cache, hash).await);
}
let coverage_by_hash = Arc::new(coverage_by_hash);
let mut handles = Vec::with_capacity(groups.len());
for (peer, peer_hashes) in groups {
let endpoint_cloned = endpoint.clone();
let coverage_by_hash = Arc::clone(&coverage_by_hash);
handles.push(tokio::spawn(async move {
let target_pk = match PublicKey::from_bytes(peer.as_bytes()) {
Ok(k) => k,
Err(e) => {
tracing::warn!(
peer = %peer,
error = %e,
"dht republish: routing-table peer not a valid public key"
);
return (0, Vec::new());
}
};
let addr = EndpointAddr::new(target_pk);
let mut accepted: u64 = 0;
let mut announced = Vec::new();
for chunk in peer_hashes.chunks(MAX_BATCH_STORE_HASHES) {
let entries: Vec<(ContentHash, Coverage)> = chunk
.iter()
.map(|h| {
let coverage = coverage_by_hash.get(h).cloned().unwrap_or_else(Coverage::empty);
(*h, coverage)
})
.collect();
match client::batch_store(
&endpoint_cloned,
addr.clone(),
entries.clone(),
self_node_id,
)
.await
{
Ok(ack) => {
announced.extend(announced_in_batch(&entries, &ack.results));
let ok = ack.results.iter().filter(|accepted| **accepted).count();
accepted += u64::try_from(ok).unwrap_or(u64::MAX);
let rejected = ack.results.iter().filter(|accepted| !**accepted).count();
if rejected > 0 {
tracing::debug!(
peer = %peer,
rejected,
batch = chunk.len(),
"dht republish: peer rejected some batched Stores (not staked, over quota, etc)"
);
}
}
Err(e) => {
tracing::debug!(
peer = %peer,
batch = chunk.len(),
error = %e,
"dht republish: BatchStore request failed"
);
}
}
}
(accepted, announced)
}));
}
let mut outcome = BatchOutcome::default();
for h in handles {
let (accepted, announced) = h.await.unwrap_or_default();
outcome.accepted += accepted;
outcome.announced.extend(announced);
}
outcome
}
#[derive(Debug, Default)]
struct BatchOutcome {
accepted: u64,
announced: HashSet<ContentHash>,
}
fn announced_in_batch<'a>(
entries: &'a [(ContentHash, Coverage)],
results: &'a [bool],
) -> impl Iterator<Item = ContentHash> + 'a {
entries
.iter()
.zip(results)
.filter(|((_, coverage), accepted)| **accepted && !coverage.is_empty())
.map(|((hash, _), _)| *hash)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing
)]
mod tests;