Self-heal a dropped gossip push without waiting for the timer

When a forwarder falls behind and drops pushes (broadcast lag), it sends
a digest with reply set; the peer answers with its own digest and we
re-send exactly what it is missing. Recovery no longer waits out a full
poll interval, so the 10s backstop rarely matters.
This commit is contained in:
Jean Chevronnet 2026-07-12 08:01:47 +00:00
parent d30f5c05a6
commit 64c6facd56
No known key found for this signature in database

View file

@ -28,7 +28,13 @@ const REDIAL: Duration = Duration::from_secs(5); // reconnect backoff for dialed
#[serde(tag = "t", rename_all = "lowercase")] #[serde(tag = "t", rename_all = "lowercase")]
enum Msg { enum Msg {
Hello { secret: String, origin: String }, Hello { secret: String, origin: String },
Digest { versions: HashMap<String, u64> }, // A version vector. `reply` asks the peer to answer with its own digest, so a
// node that dropped a push can pull back exactly what the peer is missing.
Digest {
versions: HashMap<String, u64>,
#[serde(default)]
reply: bool,
},
Entry { entry: LogEntry }, Entry { entry: LogEntry },
} }
@ -180,7 +186,7 @@ where
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
let versions = engine.lock().await.gossip_digest(); let versions = engine.lock().await.gossip_digest();
if send(&tx, &Msg::Digest { versions }).await.is_err() { if send(&tx, &Msg::Digest { versions, reply: false }).await.is_err() {
break; break;
} }
tokio::time::sleep(TICK).await; tokio::time::sleep(TICK).await;
@ -192,7 +198,7 @@ where
// propagates in milliseconds instead of waiting for the next digest. Idempotent // propagates in milliseconds instead of waiting for the next digest. Idempotent
// ingest on the far side absorbs the echo of an entry the peer just sent us. // ingest on the far side absorbs the echo of an entry the peer just sent us.
let forwarder = { let forwarder = {
let (tx, mut rx) = (tx.clone(), outbound.subscribe()); let (tx, engine, mut rx) = (tx.clone(), engine.clone(), outbound.subscribe());
tokio::spawn(async move { tokio::spawn(async move {
loop { loop {
match rx.recv().await { match rx.recv().await {
@ -201,7 +207,15 @@ where
break; break;
} }
} }
Err(broadcast::error::RecvError::Lagged(_)) => continue, // digest backstop catches up // We fell behind and dropped pushes the peer now lacks. Ask it
// to reply with its digest so we re-send exactly what it is
// missing, rather than waiting out the timer.
Err(broadcast::error::RecvError::Lagged(_)) => {
let versions = engine.lock().await.gossip_digest();
if send(&tx, &Msg::Digest { versions, reply: true }).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Closed) => break, Err(broadcast::error::RecvError::Closed) => break,
} }
} }
@ -212,11 +226,15 @@ where
let result = loop { let result = loop {
match reader.next_line().await { match reader.next_line().await {
Ok(Some(line)) => match serde_json::from_str::<Msg>(&line) { Ok(Some(line)) => match serde_json::from_str::<Msg>(&line) {
Ok(Msg::Digest { versions }) => { Ok(Msg::Digest { versions, reply }) => {
let missing = engine.lock().await.gossip_missing(&versions); let missing = engine.lock().await.gossip_missing(&versions);
for entry in missing { for entry in missing {
let _ = send(&tx, &Msg::Entry { entry }).await; let _ = send(&tx, &Msg::Entry { entry }).await;
} }
if reply {
let versions = engine.lock().await.gossip_digest();
let _ = send(&tx, &Msg::Digest { versions, reply: false }).await;
}
} }
Ok(Msg::Entry { entry }) => { Ok(Msg::Entry { entry }) => {
if let Err(e) = engine.lock().await.gossip_ingest(entry) { if let Err(e) = engine.lock().await.gossip_ingest(entry) {