ADR-000007: Backpressure — Stop Defeating TCP at the Last Hop
View Source- Status: PROPOSED
- Decision: Do not build an application-layer credit protocol. TCP
already provides byte-denominated, receiver-driven flow control end to end, and
Partisan preserves it everywhere except one hop: the receiving connection
process hands each message to an unbounded Erlang mailbox and immediately
re-arms
{active, once}. Close that hop by gating the re-arm on the destination's readiness, and TCP propagates backpressure the rest of the way by itself. Per-flow isolation comes from separate sockets (parallelism => Nplus a fixedpartition_key), not from per-flow credit. "Streams" as a distinct construct is reduced to a documented pattern, with at most a thin convenience wrapper.
Context
Partisan bounds two local resources — inbound RPC concurrency (ADR-000006) and
connection mailbox depth (connection_high_watermark) — but is assumed to have
no end-to-end backpressure. That assumption is wrong, and tracing the actual path
shows why:
| Hop | Bounded by |
|---|---|
| sender app → sender connection mailbox | connection_high_watermark |
| sender connection process → socket | gen_tcp:send/2 blocks on a full send buffer |
| network → receiver kernel buffer | TCP receive window |
| receiver kernel → receiver connection process | {active, once} — the process sets the pace |
| receiver connection process → application mailbox | nothing |
Sockets are {active, once} on both sides
(partisan_peer_service_client:468, partisan_peer_service_server:215), so the
receiving process controls its own read rate and TCP's window tracks it. Every
hop is covered except the last: receive_message/3 calls
partisan_peer_service_manager:deliver/2, an erlang:send into an unbounded
mailbox that always succeeds instantly, and the process then re-arms
unconditionally.
One hop, on one node, is the whole gap. It is a local problem, not a distributed one.
The decision
1. Gate the re-arm on the destination's readiness
After delivering an inbound message, the connection process checks the destination's mailbox depth and, when it is over a watermark, defers re-arming the socket until it drains:
%% partisan_peer_service_server:handle_inbound/2, and the client equivalent
case receive_ready(ServerRef) of
true -> reset_socket_opts(State); % today's behaviour
false -> defer_rearm(State) % re-check, then re-arm
endWith {active, once} at most one message is in flight, so not re-arming stops
the flow immediately. Data accumulates in the kernel receive buffer, the window
closes, the peer's gen_tcp:send/2 blocks, its connection mailbox fills, and
connection_high_watermark refuses the sending application with
{error, overloaded}.
That is the complete chain. It needs no protocol, no wire message, no credit accounting, and no obligation on the receiving application.
Readiness is observed with process_info(Pid, message_queue_len) — the same BIF
partisan_peer_connections:admit/2 already uses on the send side. infinity
(the default) means never defer, so this is opt-in exactly as
connection_high_watermark is.
2. Isolation comes from sockets, not from credit
Deferring the re-arm stalls the whole socket. Every destination sharing it, and the heartbeat traffic with them, stops too.
That is not a defect of the mechanism — it is what backpressure at a shared transport means — but it decides the shape of everything else: a flow that needs independent backpressure needs its own socket. Partisan already has the means, and it needs no new concept:
- a channel gives a set of connections with their own settings;
parallelism => Ngives N independent sockets on that channel;- a fixed
partition_keypins a flow to one of them, deterministically and for as long as the caller keeps using it.
A "flow" is therefore a {channel, partition_key} pair used consistently. It
already has FIFO (one connection, one process, one ordered mailbox), already has
sticky routing, and — with §1 — now has backpressure. parallelism is the
ceiling on independently-backpressured flows to a peer, which is the honest
cost of not multiplexing.
3. No wire-level multiplexing
Framing several flows onto one socket would reintroduce head-of-line blocking between them, which is the problem QUIC exists to solve and which Partisan has no reason to import.
Ruling it out is what makes §1 sufficient: with one flow per socket, TCP's window is already per-flow. A credit protocol only adds something when flows share a socket and need independent backpressure — precisely the case this rules out.
4. partisan_stream is not required, and probably should not exist
A stream API of the usual shape — open/2, send/3, consumed/2, cancel/1 —
earns very little once §1 and §2 are in place:
| Wanted | Already available |
|---|---|
| identity | partition_key, e.g. phash2(make_ref()) |
| pinning to one connection | a fixed partition_key |
| FIFO within the flow | consequence of pinning, on a non-monotonic channel |
| backpressure | §1 |
| cancellation of RPC | partisan_erpc already deactivates the request alias |
What remains is naming and ergonomics. If a wrapper is added it should be thin —
a handle that carries a {channel, partition_key} pair and validates it against
the channel's settings at open (refusing monotonic, whose drop-on-backlog
policy contradicts FIFO). It must not acquire a credit window, a wire
representation, or a lifecycle of its own.
5. RPC needs no stream
rpc_max_concurrency is a receiver-side, per-node cap that is blind to the
caller, so one caller can consume the allowance and starve the rest. The remedy
under this design is to give an RPC-heavy caller its own channel — which every
partisan_erpc surface already accepts per call — and let §1 bound it.
Alternatives considered
| Alternative | Why not |
|---|---|
| Per-stream and per-connection credit windows in bytes, as HTTP/2 does | Reimplements TCP's receive window one layer up. The per-connection window is TCP's window; the per-stream window is the same thing again whenever a flow owns its socket, which §3 requires. It adds something only under multiplexing, which is ruled out |
Explicit consumed/2 credit return by the application | Solves the real gap, but with an API obligation and an accounting scheme that can silently drift, where a mailbox-depth check needs neither |
| Return credit on delivery to the mailbox | Credits queueing rather than consumption; bounds the socket buffer while application memory grows. The most plausible-looking wrong answer |
Block or suspend the sender (busy_dist_port, RabbitMQ credit_flow) | Blocking a producer that is also a consumer invites deadlock. TCP's own blocking is confined to the connection process, which is not anybody's consumer |
| Bounded destination mailboxes | Erlang has no bounded mailboxes, and adding one per destination means a proxy process per destination — more machinery than the gap justifies |
| Do nothing | The gap is real: a fast peer fills a slow consumer's mailbox without limit, and every local indicator stays green while it happens |
Consequences
- Backpressure becomes end-to-end for the first time, without a line of protocol.
- Deferring the re-arm stalls the socket, including other destinations on it and the heartbeat. A long stall can therefore trip the peer's ping timeout and drop the connection. This is the sharpest consequence and it needs a bound: a deferral limit past which the message is dropped or the connection is allowed to fail, rather than an unbounded stall that manifests as a mysterious disconnect.
- A destination fed by several connections is not fully protected by stalling one of them. Covering that needs a destination→connections mapping on the receiving node — a local problem, but a real one, and out of scope here.
parallelismcaps independently-backpressured flows to a peer. The cost of refusing to multiplex.- Mailbox depth is a proxy, not a measure of readiness. A process that keeps a deliberately deep mailbox would be throttled when it is not actually behind. Opt-in per channel, off by default.
- One
process_info/2per inbound message, on the same order as the send-side check. It should be measured, not assumed —bench/exists for that, andp2p_roundtripresolved the equivalent send-side cost.
Related records
- ADR-000006 — bounded inbound RPC concurrency per node; §5 explains why it needs no stream construct to get per-caller isolation.
References
- TCP receive window — the flow-control layer this record defers to rather than duplicating. Reopens when the application reads, not when the kernel buffers, which is the property the whole design rests on.
- QUIC (RFC 9000) — why multiplexing over an ordered byte stream reintroduces head-of-line blocking (§3).
- HTTP/2 (RFC 9113)
WINDOW_UPDATE— the two-level credit scheme this record originally copied and now rejects: it exists because HTTP/2 multiplexes, and Partisan does not. - Reactive Streams (
Subscription.request/1) — explicit consumer demand, appropriate in-process where there is no transport underneath to inherit backpressure from. - RabbitMQ
credit_flow(rabbit_common) — credit between Erlang processes, for the same unbounded-mailbox reason. Blocks the producer, which §1 avoids by letting TCP do the blocking at the connection process. - ZeroMQ high-water mark — the analogue of
connection_high_watermark: a local per-socket bound that says nothing about the receiver.