Skip to content

[dbsp] Retransmit unacknowledged multihost exchange messages - #6784

Closed
ryzhyk wants to merge 1 commit into
mainfrom
fix-multihost-exchange-retransmit
Closed

[dbsp] Retransmit unacknowledged multihost exchange messages#6784
ryzhyk wants to merge 1 commit into
mainfrom
fix-multihost-exchange-retransmit

Conversation

@ryzhyk

@ryzhyk ryzhyk commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

@blp , multihost tests were reliably getting stuck on my laptop. Since deterministic reproductions of non-deterministic bugs are rare and valuable, I ran claude on it, which came back with this. Could you please take over this PR to validate the diagnostics and the fix?


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.

Describe Manual Test Plan

Checklist

  • Unit tests added/updated
  • Integration tests added/updated
  • Documentation updated
  • Changelog updated

Breaking Changes?

Mark if you think the answer is yes for any of these components:

Describe Incompatible Changes

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.
@ryzhyk
ryzhyk requested a review from blp August 1, 2026 01:49
@ryzhyk ryzhyk added the DBSP core Related to the core DBSP library label Aug 1, 2026
@blp

blp commented Aug 1, 2026

Copy link
Copy Markdown
Member

What I don't understand is how a message can be lost when the connection stays up. TCP shouldn't lose messages.

Do you have an idea?

@ryzhyk

ryzhyk commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

What I don't understand is how a message can be lost when the connection stays up. TCP shouldn't lose messages.

Do you have an idea?

You're right that TCP doesn't lose messages, and it didn't. The message is lost above TCP, and I have it fully instrumented now.

What actually happens

Delivery in this protocol is only complete when the server writes the application-level ack at exchange.rs:919 (write_u64_le(next_sequence)). TCP's guarantee ends at the peer's socket buffer, and everything sitting in that buffer dies with the socket. Measured timeline for one wedged connection (client 49999 → server listener 49561):

┌───────────┬─────────────────────────────────────────────────────────────────────┐
│   time    │                                event                                │
├───────────┼─────────────────────────────────────────────────────────────────────┤
│ 35.557600 │ client connects; server accepts 45 µs later, serve task starts      │
├───────────┼─────────────────────────────────────────────────────────────────────┤
│ 35.951050 │ client finishes writing seq 1099 (46 messages total on this socket) │
├───────────┼─────────────────────────────────────────────────────────────────────┤
│ 35.952237 │ test injects "server failure" on the server side                    │
├───────────┼─────────────────────────────────────────────────────────────────────┤
│ 35.952269 │ serve future drops, accepted fd closes, seq 1099 never acked        │
├───────────┼─────────────────────────────────────────────────────────────────────┤
│ 45.953539 │ watchdog: tx parked, min_sequence=1100 channel_seq=1099 queued=1    │
└───────────┴─────────────────────────────────────────────────────────────────────┘

At the 10 s mark the client's socket is still fully alive: netstat shows it ESTABLISHED with Send-Q 0 (the peer's TCP had ACKed the bytes), and getpeername() still succeeds. The server side of that 4-tuple is gone from both netstat and lsof.

So the sender's view is "connection healthy, message sent", and the receiver that would have acked it no longer exists. The sender's only retransmit trigger is a connection error: run() catches it, reconnects, and resets min_sequence to 0 (exchange.rs:496-506). No error, no resend. The tx task parks on nonempty waiting for a new message that never comes, because every worker is blocked behind the unacked one.

The half-open leg

Why the client never observed the close is a rare race, not the normal path. I probed this box directly:

┌──────────────────────────────────┬───────────────────────────────────────────────────────────────┐
│        server closes with        │                          client sees                          │
├──────────────────────────────────┼───────────────────────────────────────────────────────────────┤
│ unread data in its receive queue │ RST, recv raises ECONNRESET, both PCBs vanish                 │
├──────────────────────────────────┼───────────────────────────────────────────────────────────────┤
│ data already consumed            │ FIN, recv returns EOF, client goes to CLOSE_WAIT              │
├──────────────────────────────────┼───────────────────────────────────────────────────────────────┤
│ never accept()ed (backlog)       │ connection stays healthy, server PCB still listed with Recv-Q │
└──────────────────────────────────┴───────────────────────────────────────────────────────────────┘

The log agrees: of ~6540 injected server failures in that run, nearly all produced a client-side Broken pipe or Connection reset by peer and recovered by reconnecting. Two did not, and those two are exactly the wedged sockets. Their peer PCB is simply gone with no FIN and no RST ever reaching the client, which is a genuinely half-open connection. Nothing probes it afterwards: keepalive is off, and neither side has anything left to send.

Why the fix is the right shape

A protocol layered on TCP cannot use "the connection reported an error" as its liveness signal, because that signal is not guaranteed to arrive. The retransmit timer (exchange.rs:337, 382, 2170) replaces it with an application-level ack timeout, which is the property the protocol actually needs. It covers the production versions of the same failure too: a peer whose process hangs, a lost RST, or a middlebox that silently forgets connection state. In all of those, TCP reports nothing and the current code waits forever.

One incidental find: AbortOnDrop in that file is dead code, defined and never used. Worth deleting, though I left it alone since it's unrelated to this fix.

✻ Cooked for 55m 32s

❯ is this macos-specific?

Good question, and it splits in two: the bug and the reproduction.

  Ran 2 shell commands

Two separate answers.

The bug is not macOS-specific. The sender's liveness signal is "TCP reported an error", and TCP does not report these, on any OS:

┌──────────────────────────────────────────────┬──────────────────────────────────────────────────────────────┐
│                  situation                   │                     what the sender sees                     │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
│ peer host crashes, panics, or loses power    │ nothing, ever. No FIN, no RST is generated                   │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
│ peer VM suspended or live-migrated           │ nothing while it is frozen                                   │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
│ RST lost in the network                      │ nothing. RST is a single datagram and is never retransmitted │
├──────────────────────────────────────────────┼──────────────────────────────────────────────────────────────┤
│ firewall or NAT evicts idle connection state │ nothing, and the wire is idle once the sender blocks         │
└──────────────────────────────────────────────┴──────────────────────────────────────────────────────────────┘

Any of these leaves the sender holding an unacked message with nothing new to send, parked forever, with the socket ESTABLISHED. The lost-RST row is the sharpest one: TCP retransmits data, but a reset is best-effort, so a single drop produces a permanently half-open connection on Linux exactly as on macOS. A protocol that acks at the application layer needs a timeout at the application layer; that conclusion does not depend on the platform.

The specific reproduction is macOS-only so far. The last leg, where the server-side PCB vanishes on loopback and the client's stays ESTABLISHED with no FIN and no RST, is what I measured on this box. I can't claim that microscopic race happens on Linux loopback, where the ordinary close path signals the same way my probes showed here.

Worth noting for calibration: these tests are not #[ignore]d, so Linux CI does run them, and the one recorded Linux failure (issue 6728) is a different symptom from the same fault-injection storm, a duplicate delivery off by one rather than a hang. So the area is racy on both platforms, but the wedge itself has only been seen here. Also, the trigger in the test, inject_fault("server failure"), is test-only; in production the equivalent is a real peer death, rarer but equally invisible to the sender.

If you want this settled empirically rather than by argument, Docker is available and I can run operators_multihost_dynamic in a Linux container against a scratch target dir. Cost is a full cold dbsp build in the container, roughly half an hour of saturated CPU, so I'd rather not start that on your machine while you're working unless you say go.

@mythical-fred mythical-fred left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

@blp

blp commented Aug 3, 2026

Copy link
Copy Markdown
Member

@ryzhyk I think I can do better, I'll submit an alternative PR

@blp

blp commented Aug 3, 2026

Copy link
Copy Markdown
Member

I am proposing #6793 instead of this PR.

@ryzhyk ryzhyk closed this Aug 3, 2026
blp added a commit that referenced this pull request Aug 4, 2026
A sender that has nothing new to send and hasn't been acknowledged can't
tell a half-open connection from a receiver that's simply behind, so
retransmitting unconditionally after a timeout risks resending a large
backlog into a receiver that's merely busy.

Probe with a ping instead: a few dozen bytes, built by reusing
ExchangeHeader with a reserved sequence number and zero-length payloads.
The receiver's serve loop can only answer anything -- a real
acknowledgement or a pong -- once it's back at read_message, so the ping
write doubles as the same "force a stale connection error to surface"
attempt a blind retransmit would make, at a fraction of the cost.
Actual retransmits are not necessary within a single connection, since
TCP doesn't lose data; we only retransmit if the connection drops and
reconnects.

Adds a deterministic test driving the wire protocol directly (ping
before retransmit), verified to catch the regression when the
confirmed-retransmit path is disabled.

Presented as an alternative to #6784

Signed-off-by: Ben Pfaff <blp@feldera.com>
mihaibudiu pushed a commit to mihaibudiu/dbsp that referenced this pull request Aug 4, 2026
A sender that has nothing new to send and hasn't been acknowledged can't
tell a half-open connection from a receiver that's simply behind, so
retransmitting unconditionally after a timeout risks resending a large
backlog into a receiver that's merely busy.

Probe with a ping instead: a few dozen bytes, built by reusing
ExchangeHeader with a reserved sequence number and zero-length payloads.
The receiver's serve loop can only answer anything -- a real
acknowledgement or a pong -- once it's back at read_message, so the ping
write doubles as the same "force a stale connection error to surface"
attempt a blind retransmit would make, at a fraction of the cost.
Actual retransmits are not necessary within a single connection, since
TCP doesn't lose data; we only retransmit if the connection drops and
reconnects.

Adds a deterministic test driving the wire protocol directly (ping
before retransmit), verified to catch the regression when the
confirmed-retransmit path is disabled.

Presented as an alternative to feldera#6784

Signed-off-by: Ben Pfaff <blp@feldera.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

DBSP core Related to the core DBSP library

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants