From b95ea7acdfe5f2ca827be6eb51ef4ecc1c2aa10a Mon Sep 17 00:00:00 2001 From: Leonid Ryzhyk Date: Fri, 31 Jul 2026 18:18:28 -0700 Subject: [PATCH] [dbsp] Retransmit unacknowledged multihost exchange messages A message lost while its connection stays up wedged the exchange forever. The sender only retransmits when it reconnects, and it only reconnects when the socket reports an error, so nothing resent the message: the receiver waited for it, the sender waited for its acknowledgement, and in a synchronous exchange no new message could arrive to drive the sender, because the workers on both ends were waiting for that one. `operators_multihost_dynamic` hung on this reliably at 16 workers over 4 hosts, and took `multihost` and `sharded_accumulator_multihost` down with it when they ran together. The wedged state, dumped from a live hang: tx peer=12..16 min_sequence=6784 channel_seq=6779 queued=5 receiver on that host expects sequence 6779 The sender had sent 6779..6783 and was waiting for acknowledgements; the receiver had seen none of them. Every serve task on both ends sat in `read_message` with nothing to read, and no worker could produce another message. Retransmit from the oldest unacknowledged message when an acknowledgement does not arrive within a timeout. The receiver already drops messages whose sequence number it has seen, so a needless retransmission costs one message. This closes the liveness hole for any cause of loss rather than for one particular cause. The tests inject connection, send, and read failures at a 1% rate, which is what made this reachable there; the same hole exists in production, where a lost message without a broken connection stalls the pipeline. --- .../src/operator/communication/exchange.rs | 46 ++++++++++++++++++- 1 file changed, 45 insertions(+), 1 deletion(-) diff --git a/crates/dbsp/src/operator/communication/exchange.rs b/crates/dbsp/src/operator/communication/exchange.rs index 9e1c5d7d53a..9537b85aa8e 100644 --- a/crates/dbsp/src/operator/communication/exchange.rs +++ b/crates/dbsp/src/operator/communication/exchange.rs @@ -55,7 +55,7 @@ use tokio::{ }, sync::{Notify, OnceCell, futures::OwnedNotified}, task::JoinHandle, - time::sleep, + time::{sleep, timeout}, }; use tokio_util::sync::{CancellationToken, DropGuard}; use tracing::{error, info, warn}; @@ -331,6 +331,12 @@ impl ExchangeChannel { fn drain_waiter(&self) -> Option { self.inner().drain_waiter() } + + /// True if the channel holds messages that the receiver has not + /// acknowledged yet. + fn has_unacknowledged(&self) -> bool { + !self.inner().messages.is_empty() + } } pub struct ExchangeClient { @@ -373,6 +379,26 @@ impl ExchangeClient { // Get the next message to send. let (message, sequence) = match channel.get(min_sequence) { Ok(result) => result, + Err(notified) if channel.has_unacknowledged() => { + // Nothing new to send, but the receiver has not + // acknowledged everything we sent. Retransmit from the + // oldest unacknowledged message if that does not change + // before the timeout. + // + // Without this, a message lost while the connection stays + // up wedges both ends forever: the receiver waits for the + // message, we wait for its acknowledgement, and only a new + // message or a reconnection would make us send anything. + // In a synchronous exchange no new message can arrive, + // because the workers on both ends are waiting for this + // one. Retransmitting costs nothing when it is not + // needed: the receiver drops messages whose sequence + // number it has already seen. + if timeout(retransmit_timeout(), notified).await.is_err() { + min_sequence = 0; + } + continue; + } Err(notified) => { notified.await; continue; @@ -2132,6 +2158,24 @@ fn inject_fault(_kind: impl Display) -> bool { false } +/// How long a connection waits for an acknowledgement before retransmitting +/// the messages it has already sent. See [ExchangeClient::run_connection_tx]. +/// +/// A receiver acknowledges a message only after its workers have consumed it, +/// which can legitimately take a while under load, and retransmitting a large +/// message needlessly is not free. The timeout is therefore generous: it +/// recovers from a wedged connection, which would otherwise last forever, and +/// is not meant to recover promptly. +#[cfg(test)] +fn retransmit_timeout() -> Duration { + Duration::from_millis(100) +} + +#[cfg(not(test))] +fn retransmit_timeout() -> Duration { + Duration::from_secs(10) +} + #[cfg(test)] fn backoff_time() -> Duration { use rand::Rng;