Skip to content

Commit 95f8260

Browse files
committed
feat(swim): implement gossip dissemination with piggyback propagation
Introduce the dissemination module (DisseminationQueue, PendingUpdate, apply_and_disseminate) that carries membership deltas as piggyback payloads on every outgoing probe datagram. Each outgoing Ping, PingReq, and Ack now attaches up to max_piggyback rumours selected from the queue. A rumour is retired after it has been forwarded ceil(lambda * log2(n+1)) times, matching the bound from the SWIM:GREED paper (Das et al. §4.3) that guarantees with high probability every live member receives each delta. Inbound piggyback is ingested before dispatching the message so that a self-refutation incarnation bump is reflected in the outgoing Ack of the same round-trip. SwimConfig gains max_piggyback and fanout_lambda fields with validation; ProbeRound and DetectorRunner are wired to pass them through. Integration tests cover cross-node delta propagation and self-refutation via piggyback.
1 parent 2b4a27c commit 95f8260

9 files changed

Lines changed: 743 additions & 15 deletions

File tree

nodedb-cluster/src/swim/config.rs

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,17 @@ pub struct SwimConfig {
3939
/// Seed incarnation for a freshly-booted local node. Always `0` in
4040
/// production; exposed for deterministic unit tests.
4141
pub initial_incarnation: Incarnation,
42+
43+
/// Maximum number of membership deltas to piggyback on a single
44+
/// outgoing SWIM datagram. Caps per-message bandwidth and bounds
45+
/// the encoded payload size below a UDP MTU.
46+
pub max_piggyback: usize,
47+
48+
/// Gossip fanout multiplier (`lambda` in Das §4.3). The
49+
/// dissemination queue drops a rumour after it has been carried
50+
/// on `ceil(fanout_lambda * log2(n+1))` outgoing messages, which
51+
/// with high probability reaches every member.
52+
pub fanout_lambda: u32,
4253
}
4354

4455
impl SwimConfig {
@@ -51,6 +62,8 @@ impl SwimConfig {
5162
suspicion_mult: 4,
5263
min_suspicion: Duration::from_secs(2),
5364
initial_incarnation: Incarnation::ZERO,
65+
max_piggyback: 6,
66+
fanout_lambda: 3,
5467
}
5568
}
5669

@@ -88,6 +101,18 @@ impl SwimConfig {
88101
reason: "must be non-zero",
89102
});
90103
}
104+
if self.max_piggyback == 0 {
105+
return Err(SwimError::InvalidConfig {
106+
field: "max_piggyback",
107+
reason: "must be at least 1",
108+
});
109+
}
110+
if self.fanout_lambda == 0 {
111+
return Err(SwimError::InvalidConfig {
112+
field: "fanout_lambda",
113+
reason: "must be at least 1",
114+
});
115+
}
91116
Ok(())
92117
}
93118
}
@@ -171,4 +196,30 @@ mod tests {
171196
})
172197
));
173198
}
199+
200+
#[test]
201+
fn zero_max_piggyback_rejected() {
202+
let mut cfg = SwimConfig::production();
203+
cfg.max_piggyback = 0;
204+
assert!(matches!(
205+
cfg.validate(),
206+
Err(SwimError::InvalidConfig {
207+
field: "max_piggyback",
208+
..
209+
})
210+
));
211+
}
212+
213+
#[test]
214+
fn zero_fanout_lambda_rejected() {
215+
let mut cfg = SwimConfig::production();
216+
cfg.fanout_lambda = 0;
217+
assert!(matches!(
218+
cfg.validate(),
219+
Err(SwimError::InvalidConfig {
220+
field: "fanout_lambda",
221+
..
222+
})
223+
));
224+
}
174225
}

nodedb-cluster/src/swim/detector/probe_round.rs

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ use crate::swim::wire::{Ping, PingReq, ProbeId, SwimMessage};
2828

2929
use super::scheduler::ProbeScheduler;
3030
use super::transport::Transport;
31+
use crate::swim::dissemination::DisseminationQueue;
3132
use crate::swim::membership::MembershipList;
3233

3334
/// Upper bound on concurrent inflight probes. The detector only issues a
@@ -98,8 +99,11 @@ pub struct ProbeRound<'a, F: Fn() -> ProbeId> {
9899
pub membership: &'a MembershipList,
99100
pub transport: &'a Arc<dyn Transport>,
100101
pub inflight: &'a Arc<InflightProbes>,
102+
pub dissemination: &'a Arc<DisseminationQueue>,
101103
pub probe_timeout: Duration,
102104
pub k_indirect: usize,
105+
pub max_piggyback: usize,
106+
pub fanout_lambda: u32,
103107
pub next_probe_id: F,
104108
pub local_incarnation: Incarnation,
105109
}
@@ -113,12 +117,17 @@ impl<'a, F: Fn() -> ProbeId> ProbeRound<'a, F> {
113117
membership,
114118
transport,
115119
inflight,
120+
dissemination,
116121
probe_timeout,
117122
k_indirect,
123+
max_piggyback,
124+
fanout_lambda,
118125
next_probe_id,
119126
local_incarnation,
120127
} = self;
121128

129+
let fanout = DisseminationQueue::fanout_threshold(membership.len(), fanout_lambda);
130+
122131
let Some((target_id, target_addr)) = scheduler.next_target(membership) else {
123132
return Ok(ProbeOutcome::Idle);
124133
};
@@ -134,7 +143,7 @@ impl<'a, F: Fn() -> ProbeId> ProbeRound<'a, F> {
134143
probe_id: direct_id,
135144
from: local.clone(),
136145
incarnation: local_incarnation,
137-
piggyback: vec![],
146+
piggyback: dissemination.take_for_message(max_piggyback, fanout),
138147
}),
139148
)
140149
.await?;
@@ -170,7 +179,7 @@ impl<'a, F: Fn() -> ProbeId> ProbeRound<'a, F> {
170179
from: local.clone(),
171180
target: target_id.clone(),
172181
target_addr: target_addr.to_string(),
173-
piggyback: vec![],
182+
piggyback: dissemination.take_for_message(max_piggyback, fanout),
174183
}),
175184
)
176185
.await?;
@@ -251,6 +260,8 @@ mod tests {
251260
suspicion_mult: 4,
252261
min_suspicion: Duration::from_millis(500),
253262
initial_incarnation: Incarnation::ZERO,
263+
max_piggyback: 6,
264+
fanout_lambda: 3,
254265
}
255266
}
256267

@@ -287,13 +298,17 @@ mod tests {
287298
let list = membership_with_peers("local", 7000, &[]).await;
288299
let mut sched = ProbeScheduler::with_seed(1);
289300
let inflight = Arc::new(InflightProbes::new());
301+
let dissemination = Arc::new(DisseminationQueue::new());
290302
let outcome = ProbeRound {
291303
scheduler: &mut sched,
292304
membership: &list,
293305
transport: &local,
294306
inflight: &inflight,
307+
dissemination: &dissemination,
295308
probe_timeout: cfg().probe_timeout,
296309
k_indirect: 2,
310+
max_piggyback: 6,
311+
fanout_lambda: 3,
297312
next_probe_id: pid_gen(1),
298313
local_incarnation: Incarnation::ZERO,
299314
}
@@ -314,13 +329,17 @@ mod tests {
314329
let list = membership_with_peers("local", 7000, &[("n1", 7001, MemberState::Alive)]).await;
315330
let mut sched = ProbeScheduler::with_seed(1);
316331
let inflight = Arc::new(InflightProbes::new());
332+
let dissemination = Arc::new(DisseminationQueue::new());
317333
let outcome = ProbeRound {
318334
scheduler: &mut sched,
319335
membership: &list,
320336
transport: &local,
321337
inflight: &inflight,
338+
dissemination: &dissemination,
322339
probe_timeout: cfg().probe_timeout,
323340
k_indirect: 2,
341+
max_piggyback: 6,
342+
fanout_lambda: 3,
324343
next_probe_id: pid_gen(1),
325344
local_incarnation: Incarnation::ZERO,
326345
}
@@ -367,13 +386,17 @@ mod tests {
367386
}
368387
});
369388

389+
let dissemination = Arc::new(DisseminationQueue::new());
370390
let outcome = ProbeRound {
371391
scheduler: &mut sched,
372392
membership: &list,
373393
transport: &local,
374394
inflight: &inflight,
395+
dissemination: &dissemination,
375396
probe_timeout: cfg().probe_timeout,
376397
k_indirect: 2,
398+
max_piggyback: 6,
399+
fanout_lambda: 3,
377400
next_probe_id: pid_gen(1),
378401
local_incarnation: Incarnation::ZERO,
379402
}
@@ -436,13 +459,17 @@ mod tests {
436459
}
437460
});
438461

462+
let dissemination = Arc::new(DisseminationQueue::new());
439463
let outcome = ProbeRound {
440464
scheduler: &mut sched,
441465
membership: &list,
442466
transport: &local,
443467
inflight: &inflight,
468+
dissemination: &dissemination,
444469
probe_timeout: cfg().probe_timeout,
445470
k_indirect: 2,
471+
max_piggyback: 6,
472+
fanout_lambda: 3,
446473
next_probe_id: pid_gen(1),
447474
local_incarnation: Incarnation::ZERO,
448475
}
@@ -468,5 +495,4 @@ mod tests {
468495
.expect_err("full");
469496
assert!(matches!(err, SwimError::ProbeInflightOverflow));
470497
}
471-
472498
}

0 commit comments

Comments
 (0)