Skip to content

fix(bes): bound the CLI's BES replay buffer by bytes, not event count - #1353

Merged
gregmagolan merged 4 commits into
mainfrom
fix/bes-retry-buffer-bytes
Jul 28, 2026
Merged

fix(bes): bound the CLI's BES replay buffer by bytes, not event count#1353
gregmagolan merged 4 commits into
mainfrom
fix/bes-retry-buffer-bytes

Conversation

@gregmagolan

@gregmagolan gregmagolan commented Jul 28, 2026

Copy link
Copy Markdown
Member

The CLI's BES sink retains unacked events so it can replay them if the stream reconnects. That buffer was capped at 10,000 events, and exceeding it was terminal — the sink stopped uploading mid-build and reported a partial upload.

An event count is the wrong bound. BEP event sizes span orders of magnitude: most are a few hundred bytes, but one carrying an action's stdout can reach tens of megabytes. A count bounds neither memory nor stream length, so it trips on builds that are using very little memory while failing to protect against the ones that aren't.

A fully-cached run hit exactly that. Bazel emitted ~20.7k analysis events in 65s (7,137 targets, 34,801 aspect applications, --build_event_publish_all_actions); acks could not keep 10,000 slots free, and the sink gave up with Uploaded 11615 of 21616 build events … before the stream failed. Nothing was wrong with the build, the network, or the backend — ASPECT_DEBUG=1 showed a single drive_stream call at attempt=0, so no disconnect and no retry, just the cap.

Changes

Bound the buffer by bytes (default 256 MiB), tracked incrementally via encoded_len().

Make overflow non-fatal. Retention exists only to enable replay, so exceeding the budget evicts the oldest entries instead of ending the stream. The cost is that a later reconnect cannot replay the evicted range; the server tolerates the resulting sequence gap, acking by position and paging forward past missing seqs. An event larger than the whole budget is sent but not retained. When a reconnect does find evicted events, the sink warns — the only signal that data may be incomplete, since BES upload never fails the build.

Allow per-runner configuration via ASPECT_CLI_BES_RETRY_MAX_BUFFER_BYTES, which accepts a plain byte count or a size suffix (512MB, 1GiB; binary multiples, case-insensitive):

export ASPECT_CLI_BES_RETRY_MAX_BUFFER_BYTES=512MB

Precedence is explicit retry_max_buffer_bytes argument → environment variable → 256 MiB. Resolution is cached per process so two sinks in one build cannot disagree, and a malformed or zero value warns and falls back rather than failing the build. The CLI in the name distinguishes this from Bazel's own BES uploader (--bes_backend), whose buffering it does not affect.

Notes for reviewers

  • Eviction is survivable because ivy-bep-etl validates only sequence_number >= 1; it does not require contiguity.
  • 256 MiB is a reasoned default, not a measured one — it holds the failing build (~20–60 MB) with room for several multi-megabyte outliers. Worth revisiting against real peak usage.
  • No integration test drives a real backend through eviction followed by a reconnect, so the warning path is covered by unit tests only.

Changes are visible to end-users: yes

  • Searched for relevant documentation and updated as needed: yes
  • Breaking change (forces users to change their own code or config): yes
  • Suggested release notes appear below: yes

Because the unit changed, retry_max_buffer_size is renamed to retry_max_buffer_bytes on bazel.build_events.grpc(), and --bes-retry-max-buffer-size to --bes-retry-max-buffer-bytes on the Workflows feature. The old names are errors rather than silent misinterpretations — 10000 bytes would be a pathologically small budget.

Suggested release notes

  • The CLI's BES upload no longer stops partway through builds that emit a large number of build events. The unacked replay buffer is now bounded by total size (default 256 MiB) instead of a fixed 10,000-event count, and exceeding it degrades reconnect replay coverage rather than ending the upload.
  • The buffer budget can be set per-runner with ASPECT_CLI_BES_RETRY_MAX_BUFFER_BYTES (accepts 512MB, 1GiB, or a plain byte count).
  • Breaking: bazel.build_events.grpc(retry_max_buffer_size = …) is now retry_max_buffer_bytes, and --bes-retry-max-buffer-size is now --bes-retry-max-buffer-bytes. The value is a byte budget, not an event count.

Test plan

  • New test cases added
  • Covered by existing test cases

RetryBuffer: 20k small events retained without eviction, oldest-first eviction on overflow, one large event displacing several small ones, oversized events not retained, take_evicted draining so consecutive reconnects don't re-report the same losses, partial prune reclaiming budget proportionally, and an acked stream pushing 100 events through a 4-event budget without evicting.

Configuration: byte-size parsing across suffixes, case, and whitespace plus its rejections (no leading number, unknown unit, fractional, negative, overflow); override resolution for valid, absent, and invalid values; and a test that reads the variable from the real environment under its documented name.

Starlark surface: an explicit value validates, 0 is rejected, and omitting the argument — the path that consults the environment — validates.

  • cargo test --workspace — 606 passed, 0 failed
  • All 42 aspect dev test-* AXL suites pass
  • cargo fmt --check clean; cargo clippy -p axl-runtime unchanged vs main (153 warnings, none in changed files)

A CLI-streamed BES sink retains unacked events so it can replay them if the
stream reconnects. That buffer was capped at 10,000 events and overflow was
terminal: the sink stopped uploading mid-build and reported a partial upload.

An event count is the wrong bound. BEP event sizes span orders of magnitude —
most events are a few hundred bytes, but an action's captured stdout can reach
tens of megabytes — so a count caps neither memory nor stream length. A build
emitting many small events trips it while consuming little memory; a build
emitting a few huge ones stays far under it while consuming a lot.

Observed on a fully-cached silo lint run: Bazel emitted ~20.7k analysis events
in 65s (7,137 targets, 34,801 aspect applications, --build_event_publish_all_-
actions). Acks could not keep 10,000 slots free, the buffer filled, and the
sink terminated with 11,615 of 21,616 events uploaded. Nothing was wrong with
the build, the network, or the backend.

Bound the buffer by bytes (default 256 MiB) and make overflow non-fatal:
evict the oldest retained events instead of tearing the stream down. Retention
exists only to enable replay, so eviction costs replay coverage for the evicted
range and nothing else — delivery of the live stream is unaffected. The server
tolerates the resulting sequence gap (it acks by position and readers page
forward past missing seqs), and a reconnect that replays an incomplete range
now warns instead of failing silently. An event larger than the whole budget is
sent but not retained.

`retry_max_buffer_size` is renamed to `retry_max_buffer_bytes` on both the
`bazel.build_events.grpc()` Starlark surface and the Workflows feature's
`--bes-retry-max-buffer-bytes` flag, since the unit changed.

Test plan:
- 6 new RetryBuffer tests: many small events retained without eviction,
  oldest-first eviction on overflow, one large event displacing several small,
  oversized event not retained, prune reclaims bytes.
- cargo test --workspace: 597 passed, 0 failed.
- All 42 `aspect dev test-*` AXL suites pass.
- clippy warning count unchanged vs main (153 → 153).

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

aspect-workflows Bot commented Jul 28, 2026

Copy link
Copy Markdown

✨ Aspect Workflows Tasks

📅 Tue Jul 28 04:16:05 UTC 2026

❌ 1 failed task

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

⚠️ 3 flagged tasks

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

✅ 27 successful tasks

  • ✅ axl-smoke-gha-bootstrap [build] · ⏱ 55.8s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ run-axl-smoke [run] · ⏱ 19.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Ran //examples/deliverable:py_deliverable
  • ✅ run-axl-smoke-2 [run] · ⏱ 19s · 🐙 GitHub Actions · ☑️ Check
    💬 Ran //examples/deliverable:sh_deliverable
  • ✅ axl-tests-gha-bootstrap [build] · ⏱ 25.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (1 built)
  • ✅ build-gha-debug [build] · ⏱ 6m 47s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (166 built)
  • ✅ build-gha [build] · ⏱ 6m 57s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (166 built)
  • ✅ build-gha-ephemeral [build] · ⏱ 47.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (9 built)
  • ✅ buildifier-gha-debug [buildifier] · ⏱ 38.6s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ buildifier-gha [buildifier] · ⏱ 31.1s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-gha-debug [format] · ⏱ 1m 32s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task [format] · ⏱ 1m 32s · 🐙 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] · ⏱ 13.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-format-repeat-task-4 [format] · ⏱ 12.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ format-gha [format] · ⏱ 1m 25s · 🐙 GitHub Actions · ☑️ Check
    💬 Format complete (clean)
  • ✅ gazelle-gha-debug [gazelle] · ⏱ 58.7s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha-debug [gazelle] · ⏱ 2m 39s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-from-source-gha [gazelle] · ⏱ 2m 36s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ gazelle-gha [gazelle] · ⏱ 1m 25s · 🐙 GitHub Actions · ☑️ Check
    💬 Gazelle complete (clean)
  • ✅ init-shell [build] · ⏱ 56s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel build complete (10 built)
  • ✅ lint-gha-debug [lint] · ⏱ 50.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ lint-gha [lint] · ⏱ 1m 5s · 🐙 GitHub Actions · ☑️ Check
    💬 Lint complete (clean)
  • ✅ test-gha-debug [test] · ⏱ 10m 52s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (26/26 passed · 23 cached)
  • ✅ test-gha-coverage [test] · ⏱ 30.9s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha-target-pattern-file [test] · ⏱ 26.3s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (1/1 passed · 1 cached)
  • ✅ test-gha [test] · ⏱ 11m 2s · 🐙 GitHub Actions · ☑️ Check
    💬 Bazel test complete (26/26 passed · 26 cached)
  • ✅ test-gha-ephemeral [test] · ⏱ 38.3s · 🐙 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=beca79218f1cf9f2e749bcfdd412e1d1c12fa749 \
  --mode=always \
  --track-state=false \
  --dry-run=true

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


⏱ Last updated Tue Jul 28 04:33:22 UTC 2026 · 📊 GitHub API quota 2,142/15,000 (14% used, resets in 0s)
🚀 Powered by Aspect CLI (v0.0.0-dev)  |  Aspect Build · X · LinkedIn · YouTube

@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: 0ec147656e

ℹ️ 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 +167 to +170
if size > self.cap_bytes {
self.evict_all();
self.evicted += 1;
return;

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 Track oversized events as unacked

When retry_max_buffer_bytes is configured below a single BES request size, this branch evicts the existing buffer and returns without retaining the oversized request. The gRPC state machine uses state.buffer.is_empty() to decide that there are no outstanding acks, including the half-close path that returns Done when the buffer is empty, so an oversized last_message sent to a backend that never acks will be reported as a successful upload after half_close_timeout instead of surfacing a retry/loss condition. Please keep separate outstanding-ack accounting, or otherwise avoid treating a non-retained sent event as already acked.

Useful? React with 👍 / 👎.

gregmagolan and others added 3 commits July 27, 2026 20:34
The right buffer budget is a property of the machine, not of the build: a
memory-constrained runner may want less than 256 MiB, and a fleet that streams
unusually large events may want more. Threading a flag through every task
definition to express that is awkward, so read it from the environment.

`ASPECT_BES_RETRY_MAX_BUFFER_BYTES` accepts a plain byte count or a suffixed
size (`512MB`, `1GiB`; all binary multiples, case-insensitive). It sets the
*default*, so an explicit `retry_max_buffer_bytes` on `bazel.build_events.grpc()`
still wins — precedence is explicit arg > env var > built-in 256 MiB.

Resolution is cached per-process so two sinks in one build cannot disagree, and
a malformed or zero value warns and falls back rather than failing: BES upload
is best-effort, and a typo'd tuning knob should not lose a CI run.

The Workflows `--bes-retry-max-buffer-bytes` flag now defaults to 0 meaning
"unset" and is omitted from the `grpc()` call in that case; previously its
hardcoded default would have silently outranked the environment.

Test plan:
- New tests: byte-size parsing (plain, all suffixes, case, whitespace) and its
  rejections (no leading number, unknown unit, fractional, negative, overflow);
  override resolution for valid / absent / invalid values, asserting the
  fallback warns and names the variable.
- New Starlark test that omitting the knob validates — that is the path which
  now consults the environment.
- cargo test --workspace: 605 passed, 0 failed.
- All 42 `aspect dev test-*` AXL suites pass.
- clippy warning count unchanged vs main (153).

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

The `CLI_` infix marks which uploader this tunes. Bazel has its own BES
uploader driven by `--bes_backend`, with its own buffering that this setting
does not affect; the runner also already exports `ASPECT_WORKFLOWS_BES_BACKEND`
and `ASPECT_WORKFLOWS_BES_RESULTS_URL`, so an unqualified `ASPECT_BES_*` would
read as applying to BES generally rather than to the CLI-streamed sink alone.

Also adds a test that reads the variable through the real environment under its
documented name. The existing tests cover the fallback rules but pass a string
directly to the resolver, so they would not catch a misspelled constant or a
`default_retry_max_buffer_bytes` that stopped consulting the environment.

Test plan:
- cargo test --workspace: 604 passed, 0 failed.
- All 42 `aspect dev test-*` AXL suites pass.
- clippy warning count unchanged vs main (153).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review pass over the byte-bounded buffer.

Bug: the eviction warning fired on every reconnect. `drive_stream` runs once
per attempt and read a cumulative counter, so a build that evicted once and
then reconnected three times warned three times, each with a growing count and
each claiming the events "could not be replayed after reconnecting" — including
ones a previous warning had already reported. `evicted()` becomes
`take_evicted()`, draining the counter so each reconnect reports only what was
lost since the last one.

The warning text also overstated the loss: an evicted event was streamed, it
just cannot be re-sent. Reworded, and it now names the environment variable so
the message carries its own remedy.

Other cleanups:
- `parse_byte_size` and `parse_duration` shared a number/suffix split; extracted
  `split_scalar_unit`. `parse_duration` picks up case-insensitivity and internal
  whitespace as a side effect, both now covered by tests.
- `push` no longer needs a separate `evict_all` path or an unreachable match
  arm; one `evict_oldest` helper drives both cases.
- Dropped `#[allow(dead_code)]` from `len()` (used by six debug-log sites) and
  `bytes()` (now used by one).
- `drive_stream`'s entry log reports buffered bytes alongside the event count —
  bytes are the bound, so a user tuning the budget can see the headroom.
- Trimmed doc comments that argued for the design rather than describing the
  code, and repaired a module docstring paragraph split mid-sentence.

New tests: `take_evicted` drain semantics, partial prune reclaiming budget
proportionally, and an acked stream pushing 100 events through a 4-event budget
without evicting.

Test plan:
- cargo test --workspace: 606 passed, 0 failed.
- All 42 `aspect dev test-*` AXL suites pass.
- cargo fmt --check clean; clippy unchanged vs main (153, none in changed files).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@gregmagolan
gregmagolan merged commit 460989c into main Jul 28, 2026
71 checks passed
@gregmagolan
gregmagolan deleted the fix/bes-retry-buffer-bytes branch July 28, 2026 04:35
cristifalcas added a commit that referenced this pull request Aug 8, 2026
A reconnect replays every retained event before resuming the live stream.
That loop never reads the response side, so on a large buffer the server's
flow-control window fills, it stops reading requests, and the replay trips
`send_stall_timeout` — reconnecting into the same wall each time. Acks are
now drained as they arrive and applied once the loop releases its borrow of
the buffer; the handful of extra replayed events that costs are deduped by
sequence number server-side.

Separately, half-close held a flat 30s to drain whatever was outstanding.
That deadline is a budget for *silence*, not for how long a drain may take:
a build that ends holding a large unacked backlog, against a backend acking
steadily but slower than 30s, gets its stream torn down and fully replayed
at the end of the build. It is now pushed out on every ack. Draining to
empty already exits, so only a backend that has actually gone quiet spends
it, and the 30s bound against a silent one is unchanged.

Both paths got more exposed with #1353, which replaced the 10,000-event cap
with a 256 MiB byte budget — the replay these guard is now up to two orders
of magnitude larger.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

1 participant