release 0.2.1: fast recovery from PublishFailing
This commit is contained in:
5 files changed
+186
-5
No files matched your search
@@ -6,6 +6,34 @@ format is based on [Keep a Changelog], and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.1] — 2026-05-11
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- **Fast recovery from `DhtStatus::PublishFailing`.** The write loop
|
||||||
|
previously slept the full `write_period` (default 5 min) between
|
||||||
|
every iteration and still applied the `1/(n+1)` probability gate,
|
||||||
|
so a node stuck in `PublishFailing` could wait many minutes — even
|
||||||
|
half an hour with a populated active view — before retrying. The
|
||||||
|
loop now checks `dht_status` before sleeping: while failing, it
|
||||||
|
uses the new `failure_retry_period` (default 15 s) and bypasses
|
||||||
|
the gate, so every tick attempts a write until one succeeds. On
|
||||||
|
success the steady-state cadence and gate are restored.
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- `Builder::failure_retry_period(Duration)` — override the recovery
|
||||||
|
cadence (additive, non-breaking). Public constant
|
||||||
|
`DEFAULT_FAILURE_RETRY_PERIOD = 15 s`.
|
||||||
|
|
||||||
|
### Rationale
|
||||||
|
|
||||||
|
The probability gate exists to keep N healthy publishers from
|
||||||
|
storming the DHT with redundant writes — but once *our* publish has
|
||||||
|
failed, redundancy is irrelevant; we need to land *one* successful
|
||||||
|
write. Skipping the gate while failing turns the recovery time into
|
||||||
|
a function of `failure_retry_period`, not of `(n + 1) × write_period`.
|
||||||
|
|
||||||
## [0.2.0] — 2026-05-11
|
## [0.2.0] — 2026-05-11
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
Generated
+1
-1
@@ -1963,7 +1963,7 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "iroh-gossip-rendezvous"
|
name = "iroh-gossip-rendezvous"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "iroh-gossip-rendezvous"
|
name = "iroh-gossip-rendezvous"
|
||||||
version = "0.2.0"
|
version = "0.2.1"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
rust-version = "1.93.0"
|
rust-version = "1.93.0"
|
||||||
authors = ["Stephen Waits <steve@waits.net>"]
|
authors = ["Stephen Waits <steve@waits.net>"]
|
||||||
|
|||||||
@@ -33,6 +33,12 @@ pub const DEFAULT_EPOCH_WRITES: u64 = 64;
|
|||||||
pub const DEFAULT_WRITE_PERIOD: Duration = Duration::from_secs(300);
|
pub const DEFAULT_WRITE_PERIOD: Duration = Duration::from_secs(300);
|
||||||
/// T_h — mean heal-loop period.
|
/// T_h — mean heal-loop period.
|
||||||
pub const DEFAULT_HEAL_PERIOD: Duration = Duration::from_secs(30);
|
pub const DEFAULT_HEAL_PERIOD: Duration = Duration::from_secs(30);
|
||||||
|
/// T_f — write-loop period while `DhtStatus::PublishFailing`. Used in
|
||||||
|
/// place of `T_w` until a publish succeeds; the probability gate is also
|
||||||
|
/// skipped during recovery so every tick attempts a write. Bounds the
|
||||||
|
/// recovery latency from a transient DHT failure to roughly this period
|
||||||
|
/// (± jitter) regardless of neighbor count.
|
||||||
|
pub const DEFAULT_FAILURE_RETRY_PERIOD: Duration = Duration::from_secs(15);
|
||||||
/// σ — jitter factor (± fraction of the period).
|
/// σ — jitter factor (± fraction of the period).
|
||||||
pub const DEFAULT_JITTER: f32 = 0.5;
|
pub const DEFAULT_JITTER: f32 = 0.5;
|
||||||
|
|
||||||
@@ -49,6 +55,7 @@ pub struct Builder {
|
|||||||
epoch_size: u64,
|
epoch_size: u64,
|
||||||
write_period: Duration,
|
write_period: Duration,
|
||||||
heal_period: Duration,
|
heal_period: Duration,
|
||||||
|
failure_retry_period: Duration,
|
||||||
jitter: f32,
|
jitter: f32,
|
||||||
wait_for_first_neighbor: Option<Duration>,
|
wait_for_first_neighbor: Option<Duration>,
|
||||||
/// Test-only: inject a custom DhtSlots backend (e.g., InMemoryDht).
|
/// Test-only: inject a custom DhtSlots backend (e.g., InMemoryDht).
|
||||||
@@ -71,6 +78,7 @@ impl Default for Builder {
|
|||||||
epoch_size: DEFAULT_EPOCH_WRITES,
|
epoch_size: DEFAULT_EPOCH_WRITES,
|
||||||
write_period: DEFAULT_WRITE_PERIOD,
|
write_period: DEFAULT_WRITE_PERIOD,
|
||||||
heal_period: DEFAULT_HEAL_PERIOD,
|
heal_period: DEFAULT_HEAL_PERIOD,
|
||||||
|
failure_retry_period: DEFAULT_FAILURE_RETRY_PERIOD,
|
||||||
jitter: DEFAULT_JITTER,
|
jitter: DEFAULT_JITTER,
|
||||||
wait_for_first_neighbor: None,
|
wait_for_first_neighbor: None,
|
||||||
#[cfg(feature = "test-support")]
|
#[cfg(feature = "test-support")]
|
||||||
@@ -156,6 +164,21 @@ impl Builder {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Override `T_f` — the write-loop period used while
|
||||||
|
/// [`DhtStatus::PublishFailing`](crate::state::DhtStatus::PublishFailing).
|
||||||
|
/// Default 15 seconds.
|
||||||
|
///
|
||||||
|
/// While failing, the probability gate `1/(n+1)` is bypassed and a
|
||||||
|
/// write is attempted every tick. Returning to
|
||||||
|
/// [`DhtStatus::Ready`](crate::state::DhtStatus::Ready) on a
|
||||||
|
/// successful publish restores the steady-state `write_period`
|
||||||
|
/// cadence and re-engages the gate.
|
||||||
|
#[must_use]
|
||||||
|
pub fn failure_retry_period(mut self, period: Duration) -> Self {
|
||||||
|
self.failure_retry_period = period;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Override `σ`. Default 0.5 (± 50%).
|
/// Override `σ`. Default 0.5 (± 50%).
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn jitter(mut self, sigma: f32) -> Self {
|
pub fn jitter(mut self, sigma: f32) -> Self {
|
||||||
@@ -257,6 +280,7 @@ impl Builder {
|
|||||||
epoch_size: self.epoch_size,
|
epoch_size: self.epoch_size,
|
||||||
write_period: self.write_period,
|
write_period: self.write_period,
|
||||||
heal_period: self.heal_period,
|
heal_period: self.heal_period,
|
||||||
|
failure_retry_period: self.failure_retry_period,
|
||||||
jitter: self.jitter,
|
jitter: self.jitter,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+132
-3
@@ -48,6 +48,12 @@ pub(crate) struct ProtocolConfig {
|
|||||||
pub epoch_size: u64,
|
pub epoch_size: u64,
|
||||||
pub write_period: Duration,
|
pub write_period: Duration,
|
||||||
pub heal_period: Duration,
|
pub heal_period: Duration,
|
||||||
|
/// Sleep between write-loop ticks while `dht_status == PublishFailing`.
|
||||||
|
/// Used in place of `write_period` to recover from a failed publish
|
||||||
|
/// faster than the steady-state cadence allows. The probability gate
|
||||||
|
/// is also skipped while failing — every tick attempts a write until
|
||||||
|
/// one succeeds.
|
||||||
|
pub failure_retry_period: Duration,
|
||||||
pub jitter: f32,
|
pub jitter: f32,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,19 +382,39 @@ pub(crate) fn spawn_loops(
|
|||||||
first_iter = false;
|
first_iter = false;
|
||||||
true
|
true
|
||||||
} else {
|
} else {
|
||||||
let sleep = jittered(state.config.write_period, state.config.jitter);
|
// While the last publish is failing, retry on the
|
||||||
|
// shorter `failure_retry_period` cadence and bypass
|
||||||
|
// the `1/(n+1)` probability gate — the gate exists
|
||||||
|
// to avoid swarm-wide write storms when N is large
|
||||||
|
// and the DHT is healthy, but once *our* publish
|
||||||
|
// has failed we should retry unconditionally until
|
||||||
|
// it succeeds.
|
||||||
|
let failing = matches!(
|
||||||
|
state.observable.lock().map(|s| s.dht_status),
|
||||||
|
Ok(DhtStatus::PublishFailing)
|
||||||
|
);
|
||||||
|
let period = if failing {
|
||||||
|
state.config.failure_retry_period
|
||||||
|
} else {
|
||||||
|
state.config.write_period
|
||||||
|
};
|
||||||
|
let sleep = jittered(period, state.config.jitter);
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
() = tokio::time::sleep(sleep) => {}
|
() = tokio::time::sleep(sleep) => {}
|
||||||
() = cancel.cancelled() => break,
|
() = cancel.cancelled() => break,
|
||||||
}
|
}
|
||||||
// Write with probability 1/(n+1). Scope the (non-Send)
|
if failing {
|
||||||
// ThreadRng so it doesn't live across the await.
|
true
|
||||||
|
} else {
|
||||||
|
// Scope the (non-Send) ThreadRng so it doesn't
|
||||||
|
// live across the await.
|
||||||
let n = gossip.neighbors().len();
|
let n = gossip.neighbors().len();
|
||||||
let r = {
|
let r = {
|
||||||
let mut rng = rand::rng();
|
let mut rng = rand::rng();
|
||||||
rng.random::<f32>()
|
rng.random::<f32>()
|
||||||
};
|
};
|
||||||
should_fire_write(r, n)
|
should_fire_write(r, n)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
if should_write {
|
if should_write {
|
||||||
write_once(&state, dht.as_ref(), gossip.as_ref()).await;
|
write_once(&state, dht.as_ref(), gossip.as_ref()).await;
|
||||||
@@ -472,6 +498,61 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fault-injecting `DhtSlots`. Fails the first `fail_writes` write calls
|
||||||
|
/// (counting attempts, not slots), then delegates to an inner
|
||||||
|
/// [`InMemoryDht`]. Reads always delegate.
|
||||||
|
struct FaultyDht {
|
||||||
|
inner: InMemoryDht,
|
||||||
|
remaining_failures: Mutex<usize>,
|
||||||
|
write_attempts: Arc<std::sync::atomic::AtomicUsize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FaultyDht {
|
||||||
|
fn new(fail_writes: usize) -> (Self, Arc<std::sync::atomic::AtomicUsize>) {
|
||||||
|
let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||||
|
(
|
||||||
|
Self {
|
||||||
|
inner: InMemoryDht::new(),
|
||||||
|
remaining_failures: Mutex::new(fail_writes),
|
||||||
|
write_attempts: attempts.clone(),
|
||||||
|
},
|
||||||
|
attempts,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl DhtSlots for FaultyDht {
|
||||||
|
async fn read(
|
||||||
|
&self,
|
||||||
|
slot: crate::dht::SlotKey,
|
||||||
|
) -> Result<Option<crate::dht::SlotRecord>, crate::dht::DhtError> {
|
||||||
|
self.inner.read(slot).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn write(
|
||||||
|
&self,
|
||||||
|
slot: crate::dht::SlotKey,
|
||||||
|
record: crate::dht::SlotRecord,
|
||||||
|
) -> Result<(), crate::dht::DhtError> {
|
||||||
|
self.write_attempts
|
||||||
|
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let should_fail = {
|
||||||
|
let mut r = self.remaining_failures.lock().unwrap();
|
||||||
|
if *r > 0 {
|
||||||
|
*r -= 1;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if should_fail {
|
||||||
|
return Err(crate::dht::DhtError::Transport("injected".into()));
|
||||||
|
}
|
||||||
|
self.inner.write(slot, record).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn test_config(self_id: [u8; 32], shards: usize) -> ProtocolConfig {
|
fn test_config(self_id: [u8; 32], shards: usize) -> ProtocolConfig {
|
||||||
ProtocolConfig {
|
ProtocolConfig {
|
||||||
self_id,
|
self_id,
|
||||||
@@ -482,6 +563,7 @@ mod tests {
|
|||||||
epoch_size: 64,
|
epoch_size: 64,
|
||||||
write_period: Duration::from_secs(1),
|
write_period: Duration::from_secs(1),
|
||||||
heal_period: Duration::from_secs(1),
|
heal_period: Duration::from_secs(1),
|
||||||
|
failure_retry_period: Duration::from_millis(50),
|
||||||
jitter: 0.5,
|
jitter: 0.5,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -567,6 +649,53 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn write_loop_recovers_from_publish_failing_on_short_period() {
|
||||||
|
// Loop should retry on `failure_retry_period` (not `write_period`)
|
||||||
|
// and bypass the `1/(n+1)` gate while `DhtStatus::PublishFailing`.
|
||||||
|
// Setup: write_period=10s, failure_retry_period=30ms, dht fails
|
||||||
|
// first 3 writes then succeeds. Within a few hundred ms we should
|
||||||
|
// see ≥4 attempts (3 fails + 1 success) and status == Ready.
|
||||||
|
|
||||||
|
let self_id = [42u8; 32];
|
||||||
|
let mut config = test_config(self_id, 3);
|
||||||
|
config.write_period = Duration::from_secs(10);
|
||||||
|
config.heal_period = Duration::from_secs(10);
|
||||||
|
config.failure_retry_period = Duration::from_millis(30);
|
||||||
|
// No jitter so the test is deterministic.
|
||||||
|
config.jitter = 0.0;
|
||||||
|
|
||||||
|
let state = Arc::new(test_state(config));
|
||||||
|
let (faulty, attempts) = FaultyDht::new(3);
|
||||||
|
let dht: Arc<dyn DhtSlots> = Arc::new(faulty);
|
||||||
|
let (gossip_view, _joined) = MockGossip::new(vec![]);
|
||||||
|
let gossip: Arc<dyn GossipView> = Arc::new(gossip_view);
|
||||||
|
let cancel = CancellationToken::new();
|
||||||
|
|
||||||
|
let mut tasks = spawn_loops(state.clone(), dht.clone(), gossip.clone(), cancel.clone());
|
||||||
|
|
||||||
|
// 4 × 30ms = 120ms minimum. Give 500ms — comfortably above the
|
||||||
|
// retry budget, comfortably below `write_period`. If the loop
|
||||||
|
// were waiting on `write_period` (10s) we'd see only 1 attempt.
|
||||||
|
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||||
|
|
||||||
|
let made = attempts.load(std::sync::atomic::Ordering::SeqCst);
|
||||||
|
let status = state.observable.lock().unwrap().dht_status;
|
||||||
|
|
||||||
|
cancel.cancel();
|
||||||
|
while tasks.join_next().await.is_some() {}
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
made >= 4,
|
||||||
|
"expected ≥4 write attempts (3 fails + 1 success) within 500ms; got {made}"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
status,
|
||||||
|
DhtStatus::Ready,
|
||||||
|
"status should flip to Ready after the 4th write succeeded"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn seq_is_monotonic_across_writes() {
|
async fn seq_is_monotonic_across_writes() {
|
||||||
let self_id = [1u8; 32];
|
let self_id = [1u8; 32];
|
||||||
|
|||||||
Reference in new issue
Block a user