Skip to content

fix(bes): backpressure the sink instead of dying when acks fall behind - #1344

Open
cristifalcas wants to merge 3 commits into
mainfrom
cf/bes-sink-backpressure
Open

fix(bes): backpressure the sink instead of dying when acks fall behind#1344
cristifalcas wants to merge 3 commits into
mainfrom
cf/bes-sink-backpressure

Conversation

@cristifalcas

@cristifalcas cristifalcas commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Changes are visible to end-users: yes

Have the relevant documentation and comments been updated: yes

Breaking change (forces users to change their own code or config): no

The changes in this PR will be included in the release notes: yes

  • Large builds no longer lose the tail of their build event stream when the BES backend falls behind on acks; the invocation now shows as finished rather than running forever.
  • A build event stream that ends without Bazel's final event now emits a warning instead of failing silently.

Problem

A large build outruns the backend's acks. The CLI always passes --build_event_publish_all_actions, so //workflows/... in silo emits ~66k events. The replay buffer hits its 10,000-entry cap, RetryBuffer::push returns BufferOverflow, and DriveOutcome::BufferFull is terminal — the sink exits mid-stream. BuildFinished never ships, so the backend leaves the invocation in_progress indefinitely.

Reproduced against a dev Workflows deployment (ASPECT_DEBUG=1 aspect build --aspect-remote-cache --aspect-bes-backend -- //workflows/...):

drive_stream returned after 35.147157167s: outcome=BufferFull(
  retry buffer overflowed (cap=10000) while attempting to buffer seq 51705)

That run was not cancelled — the overflow hit at 35s on an ordinary build. About 15,000 of 66,539 events reached the backend; BuildFinished sits around event 63,800, so it never arrived. The WARNING: … giving up, build events were not delivered this prints is line 3,257 of a 3,300-line build log, among dozens of unrelated Bazel warnings.

Fix

Backpressure. Treat the cap as a flow-control window rather than a cliff. drive_stream's event-receive arm is gated on !state.buffer.is_full(): at capacity the loop stops pulling from event_rx and only reads acks until they drain the buffer. Events wait on the unbounded event_rx instead of being dropped. This cannot wedge the sink — a server that stops acking altogether is still caught by the existing ack-progress watchdog, which tears the stream down for a retry exactly as before. The BufferFull arm is kept as a backstop.

Half-close deadline resets on ack progress. A build that ends while backpressured half-closes holding a full buffer, and the ack-progress watchdog is disabled once half-closed. A flat 30s to drain would tear down a stream that is acking fine, just not fast enough — reconnecting and replaying the whole buffer, converging on the same give-up. The deadline is a budget for silence, not for how long a drain may take, so each ack pushes it out. The 30s bound against a genuinely silent backend is unchanged.

Replay drains acks as it goes. Reconnecting with a full buffer writes thousands of events without ever reading the response stream, letting the server's connection window fill until it stops reading requests and the replay trips send_stall_timeout. Acks are collected during the loop and applied once it releases its borrow of the buffer; the few redundant replayed events that costs are deduped by sequence number server-side.

Warning on UpstreamClosed. The reader closing without Bazel's final event leaves the invocation in progress on the backend, but was treated as a clean shutdown with no user-visible signal.

Test coverage — reviewers' call

buffer_is_full_gates_at_cap_and_reopens_on_ack covers RetryBuffer::is_full, the window semantics the select-arm guard depends on. The three behavioural changes are not covered.

I had written two tests against a new ServerMode::AckDelayed(Duration) fake-backend mode — one driving 50 events through a 4-entry buffer, one covering the half-close reset — and verified both fail with their fix reverted (BufferFull(cap=4, seq 5) and half-close deadline (60ms) elapsed with 6 events unacked). They were removed in 8d16622 because they came out of an LLM review rather than a maintainer's, and whether timing-sensitive fake-backend tests belong in this suite is a call for the people who own it. The commit records what they were; re-adding is a paste. Say the word and they come back.

Worth knowing if you want them: the half-close one is the timing-sensitive of the pair (20ms ack cadence against a 60ms deadline, ~3× margin), and would be worth widening before it lands.

Test plan

  • Covered by existing test cases — the sink's reconnect/replay/watchdog suite is unchanged and passing; bazel test //crates/... is green.
  • New test cases added — buffer_is_full_gates_at_cap_and_reopens_on_ack only; see above.
  • Manual testing; please provide instructions so we can reproduce: run aspect build --aspect-remote-cache --aspect-bes-backend -- //workflows/... against a Workflows deployment and watch ASPECT_DEBUG=1 output for outcome=BufferFull. Before: the sink exits mid-stream and the invocation is stuck "Running" in the UI. After: reads pause at the cap and the stream completes.

🤖 Generated with Claude Code

A large build (`//workflows/...` in silo: ~66k events, since the CLI always
passes `--build_event_publish_all_actions`) outruns the backend's acks. The
replay buffer hits its 10,000-entry cap, `RetryBuffer::push` returns
`BufferOverflow`, and `BufferFull` is terminal — the sink exits mid-stream,
so `BuildFinished` never ships and the backend leaves the invocation running
forever. Observed against a dev Workflows deployment:

    drive_stream returned after 35.1s: outcome=BufferFull(
      retry buffer overflowed (cap=10000) while attempting to buffer seq 51705)

Treat the cap as a flow-control window rather than a cliff: gate the
event-receive arm on `!buffer.is_full()`, so a full buffer stops pulling from
`event_rx` and the loop only reads acks until they drain it. Events wait on
the unbounded `event_rx` instead of being lost. A server that stops acking
altogether is still bounded by the existing ack-progress watchdog, so this
cannot wedge the sink. The `BufferFull` arm stays as a backstop.

Also warn on `UpstreamClosed`. It means the reader closed without Bazel's
final event, which leaves the invocation in progress on the backend, but it
was reported as a clean shutdown with no user-visible signal at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@aspect-workflows

aspect-workflows Bot commented Jul 25, 2026

Copy link
Copy Markdown

✨ Aspect Workflows Tasks

📅 Sat Jul 25 14:16:49 UTC 2026

❌ 1 failed task

  • ❌ delivery-uncacheable [delivery] · ⏱ 29.3s · 🐙 GitHub Actions
    💬 failed in deliver · Delivery failed (1 delivery fail)

⚠️ 3 flagged tasks

  • ⚠️ delivery-gha-debug [delivery] · ⏱ 49s · 🐙 GitHub Actions · ☑️ Check
    💬 Delivery complete (1 delivered · 2 warn · 3 skipped)
  • ⚠️ delivery-gha [delivery] · ⏱ 37s · 🐙 GitHub Actions · ☑️ Check
    💬 Delivery complete (1 delivered · 2 warn · 3 skipped)
  • ⚠️ delivery-uncacheable-warn [delivery] · ⏱ 12.3s · 🐙 GitHub Actions
    💬 Delivery complete (1 warn)

✅ 27 successful tasks

  • ✅ axl-smoke-gha-bootstrap [build] · ⏱ 20.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ run-axl-smoke [run] · ⏱ 25.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Ran //examples/deliverable:py_deliverable
  • ✅ run-axl-smoke-2 [run] · ⏱ 13.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Ran //examples/deliverable:sh_deliverable
  • ✅ axl-tests-gha-bootstrap [build] · ⏱ 54.8s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ build-gha-debug [build] · ⏱ 9m 33s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (166 built)
  • ✅ build-gha [build] · ⏱ 3m 21s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (166 built)
  • ✅ build-gha-ephemeral [build] · ⏱ 47.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (9 built)
  • ✅ buildifier-gha-debug [buildifier] · ⏱ 49.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ buildifier-gha [buildifier] · ⏱ 32.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-gha-debug [format] · ⏱ 1m 18s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task [format] · ⏱ 1m 35s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-2 [format] · ⏱ 14.2s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-3 [format] · ⏱ 12.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-4 [format] · ⏱ 11.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-gha [format] · ⏱ 1m 24s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ gazelle-gha-debug [gazelle] · ⏱ 34s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha-debug [gazelle] · ⏱ 2m 2s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha [gazelle] · ⏱ 2m 8s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-gha [gazelle] · ⏱ 44s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ init-shell [build] · ⏱ 1m 10s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (10 built)
  • ✅ lint-gha-debug [lint] · ⏱ 44.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ lint-gha [lint] · ⏱ 59.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ test-gha-debug [test] · ⏱ 3m 4s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (26/26 passed · 26 cached)
  • ✅ test-gha-coverage [test] · ⏱ 26.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha-target-pattern-file [test] · ⏱ 17.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha [test] · ⏱ 7m 52s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (26/26 passed · 25 cached)
  • ✅ test-gha-ephemeral [test] · ⏱ 1m 10s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed)

🔁 Reproduce

❌ delivery (delivery-uncacheable · delivery-gha-debug · delivery-gha · delivery-uncacheable-warn)

# --mode=always --track-state=false for off-runner with no state backend.
aspect delivery \
  --commit-sha=8d16622162eeb66f5eda5ff7f0ade28275a89fcd \
  --mode=always \
  --track-state=false \
  --dry-run=true

Install aspect: aspect.build/docs/cli/install


⏱ Last updated Sat Jul 25 14:31:39 UTC 2026 · 📊 GitHub API quota 274/15,000 (2% used, resets in 46m)
🚀 Powered by Aspect CLI (v0.0.0-dev)  |  Aspect Build · X · LinkedIn · YouTube

…e window

Review follow-ups on the backpressure change.

Half-close held a flat 30s to drain whatever was outstanding, with the
ack-progress watchdog explicitly disabled for the duration. That was safe when
reaching the cap was terminal; now a build that ends while backpressured
half-closes holding a full buffer, and a backend acking steadily but slower
than the deadline gets its stream torn down and fully replayed — converging on
the same give-up this change set out to remove. The deadline is a budget for
silence, not for how long a drain may take, so push it out on every ack.
Draining to empty already exits, so only a server that has actually gone quiet
spends it, and the existing 30s bound against a silent backend is unchanged.

Replay now drains acks as it goes. Reconnecting with a full buffer writes
thousands of events while never reading the response stream, which lets the
server's connection window fill until it stops reading requests and the replay
trips `send_stall_timeout` instead. Acks are collected during the loop and
applied once it releases its borrow; the few extra replayed events that costs
are deduped by sequence number server-side.

Tests: `events_beyond_buffer_cap_backpressure_rather_than_overflow` drives 50
events through a 4-entry buffer against an ack-delayed backend — `BufferFull`
before the fix, `Done` after. `half_close_drain_survives_a_backend_slower_than_
the_deadline` covers the deadline reset the same way. Both verified to fail
with their fix reverted; the previous test only exercised `is_full`'s
arithmetic, which no amount of gating mistakes would have broken.

Docs: three places still described the cap as terminal on overflow, including
the `bes_retry_max_buffer_size` flag description users configure Workflows
with. Reworded to the window semantics, and dropped the "shrink on
memory-constrained runners" guidance — the cap throttles the wire and relocates
the backlog onto the sink's input queue, so shrinking it does not bound memory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
// rather than let the build look clean and hang in the UI. A
// build that emitted nothing (Bazel failed before the BEP
// opened) is already reported by the task itself.
DriveOutcome::UpstreamClosed => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

we've lost bazel input

Removes `ServerMode::AckDelayed` and the two tests added in the previous
commit — `events_beyond_buffer_cap_backpressure_rather_than_overflow` and
`half_close_drain_survives_a_backend_slower_than_the_deadline`.

Both were written against LLM review feedback rather than a maintainer's, so
whether this behaviour is worth a timing-sensitive fake-backend test is a call
for the people who own this suite, not one to make on their behalf. The
production changes stand on their own; test coverage can be added back if
reviewers want it.

`buffer_is_full_gates_at_cap_and_reopens_on_ack` in retry.rs stays: it is not
timing-dependent, and without it `RetryBuffer::is_full` has no coverage at all.

For the record, both removed tests did fail with their fix reverted —
`BufferFull(cap=4, seq 5)` and `half-close deadline (60ms) elapsed with 6
events unacked` respectively — so re-adding them is a paste, not a rewrite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@cristifalcas
cristifalcas marked this pull request as ready for review July 25, 2026 15:47

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8d16622162

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +771 to +772
while let Some(Some(Ok(resp))) = response_stream.next().now_or_never() {
replayed_acks.push(resp.sequence_number);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve errors while draining replay acknowledgements

When the backend returns an error while buffered events are being replayed, this while let polls and consumes the ready Some(Err(status)) but retains only Some(Ok(...)). The normal response handler therefore never sees or classifies the status; for example, a non-retryable PERMISSION_DENIED is discarded and the subsequent EOF is reported as a retryable premature close, causing unnecessary reconnects and hiding the actual backend failure. Match all ready stream outcomes here and return the corresponding fatal or transient result instead of dropping non-ack items.

Useful? React with 👍 / 👎.

Comment on lines +912 to +914
if half_close_deadline.is_some() && !state.buffer.is_empty() {
half_close_deadline =
Some(tokio::time::Instant::now() + retry.half_close_timeout);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Reset the half-close timer only on advancing acknowledgements

When a reconnect leaves duplicate replay acknowledgements queued, or a backend repeatedly sends an old/out-of-order sequence number, record_ack makes no progress but this still pushes out the half-close deadline. A slow series of stale acknowledgements can therefore extend the drain far beyond half_close_timeout, and a backend continuously repeating one acknowledgement can prevent sink.wait() from ever completing despite the documented bound. Compare the acknowledged sequence or buffer length before and after record_ack and reset the deadline only when outstanding events were actually pruned.

Useful? React with 👍 / 👎.

@thesayyn

thesayyn commented Aug 4, 2026

Copy link
Copy Markdown
Member

This has some conflicts.

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.

2 participants