Skip to content

runtime-next: bound status trailers so connector errors aren't masked by ResourceExhausted - #3356

Open
jacobmarble wants to merge 4 commits into
masterfrom
jgm-resource-exhausted
Open

runtime-next: bound status trailers so connector errors aren't masked by ResourceExhausted#3356
jacobmarble wants to merge 4 commits into
masterfrom
jgm-resource-exhausted

Conversation

@jacobmarble

Copy link
Copy Markdown
Contributor

Description:

Fixes #3353. When a materialization connector died, shards often reported
ResourceExhausted: h2 protocol error: http2 error instead of the connector's
actual error — which for a recent data-loss bug meant an operator had to know to
go read the connector's own logs to find the real cause.

Here's the mechanism. A gRPC error status travels entirely in an HTTP/2 trailer
(a header block sent after the response body). hyper caps how big a header block
can be at 16 KiB, which lets h2 spread one across at most 5 "continuation"
frames. Go past that and h2 assumes it's under attack: it kills the whole TCP
connection with an ENHANCE_YOUR_CALM error, which tonic reports as
ResourceExhausted. Every stream on that connection dies, and the status text
that was in flight is lost.

connector-init — which runs inside the connector container and reports the
connector's exit — built that status with no size limit at all: the connector's
last stderr line as the message, plus the whole encoded log again as binary
metadata. A connector that prints a big diagnostic as it dies therefore destroys
the very message explaining why it died.

Three changes:

  1. Bound the status before it's sent (connector-init). Truncate the final
    log message, drop oversized structured fields, and bound the metadata copy.
    Budget is 2 KiB raw, so even worst-case encoding fits one 16 KiB frame with no
    continuation frame at all.
  2. Stop the leader from spreading one shard's failure to all of them. When a
    session fails, the leader used to forward the failed peer's status object
    verbatim to every shard — so a single shard's transport error became every
    shard's reported cause, and an oversized trailer got relayed onto more
    connections. It now sends a formatted leader session failed: … message,
    which names the peer that actually failed and carries no metadata.
  3. Prefer the connector's error over the leader's echo. When a shard's
    connector fails, the leader's broadcast comes back to that same shard. If the
    leader's copy lands first, the shard now checks whether its connector already
    has an error waiting and reports that instead.

Also bounded the two remaining copies of anyhow_to_status (in runtime and
shuffle) that had the same gap. The bounding helpers moved to proto-grpc so
all four crates share one copy instead of three drifting ones.

Workflow steps:

No user-facing workflow change. Shard failures in flowctl catalog status and in
task alerts should now name the connector's own error rather than a transport
error.

Documentation links affected:

None.

Notes for reviewers:

  • The connector-init half ships in an image. It needs a
    flow-connector-init release plus a bump of the inline pin before it takes
    effect in production. The runtime-side changes here protect tasks still running
    older images, since they stop the oversized trailer from being relayed onward.
  • Nothing in the repo reads the last-log-bin metadata — only connector-init's
    own test. I bounded it rather than deleting it, per discussion.
  • prefer_connector_error discards a ready connector response on the error
    path. The session is failing either way; the only question is which error
    describes why.
  • I found four adjacent issues while tracing this and deliberately left them
    alone: an asymmetric h2 window setting in gazette (connection window raised,
    stream window at default, which disables adaptive windowing); the full built
    spec crossing five gRPC hops inline at session start; several clients still at
    tonic's 4 MB decode default; and vendored gazette's MaxGRPCRecvSize sitting at
    its 4 MiB default. Happy to open issues for these.
  • Not verified end-to-end against a live stack. Unit tests cover the bounding
    arithmetic, the truncation, an end-to-end pass through bidi with a large
    stderr blob, and the connector-vs-leader error preference.

`MAX_STATUS_MESSAGE_LEN` and its helpers live in `runtime-next`, but three
other crates map errors onto the same wire and need the same ceiling. Move
them to `proto-grpc`, which each of those crates already depends on, and
re-export from `runtime-next` so callers are unchanged.

No behavior change: `runtime-next` bounds exactly what it bounded before.
Both crates carried their own copy of `anyhow_to_status` which formatted an
error chain into a status message of unbounded length. A gRPC status rides
in an HTTP/2 trailer, and an oversized trailer is not merely truncated: it
spills across more CONTINUATION frames than `h2` allows, which tears down
the connection and replaces the status text with an opaque transport error.

Use the shared bounded implementation, as `runtime-next` already did.
When a connector exits non-zero we report its final stderr log as the RPC's
terminal Status: the log's message as `grpc-message`, and the whole encoded
`ops::Log` again as `last-log-bin` metadata. Neither was bounded.

That status is the sole carrier of the connector's error, and it travels
entirely in an HTTP/2 trailer. hyper pins `max_header_list_size` to 16 KiB,
which gives `h2` a budget of five CONTINUATION frames; past that it trips
`too_many_continuations` and answers with a connection-level
GOAWAY(ENHANCE_YOUR_CALM). Every stream on the connection then fails with an
opaque `ResourceExhausted: h2 protocol error` — so a connector which prints
a large diagnostic as it dies destroys the message explaining why it died.

Bound the log before it reaches the wire. The budget is small enough that
even worst-case encoding (3x percent-encoding of the message, 4/3 base64 of
the metadata) fits a single frame, needing no CONTINUATION frame at all.

Part of #3353.
…ilure

A shard which fails takes its leader's session down with it, and the leader
broadcasts that failure to every shard of the task. Two things then went
wrong with what an operator sees.

The leader forwarded the failed peer's `Status` verbatim. A `Status` describes
the stream it arrived on, not the streams it's relayed to, so one shard's
transport error became every shard's reported cause — and its metadata rode
along, so an oversized trailer that had already broken one connection was
relayed onto every other. Format the failure into a message instead, which
names the peer that failed and carries no metadata.

The broadcast also returns to the shard whose connector caused it. The
`biased` select prefers the connector arm, but only if the loop polls again;
if the leader's echo lands first, a shard reports the echo of its own failure
rather than the connector error behind it. Prefer a connector error which is
already ready.

Part of #3353.
@jacobmarble
jacobmarble force-pushed the jgm-resource-exhausted branch from 5ed95d3 to ea39819 Compare August 13, 2026 15:44
@jacobmarble
jacobmarble marked this pull request as ready for review August 13, 2026 15:51
@jacobmarble
jacobmarble requested a review from a team August 13, 2026 15:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

runtime_next: connector terminal errors are masked by ResourceExhausted / h2 protocol errors, which also occur independently

1 participant