Skip to content

fix(bes): keep the stream alive through long replays and slow drains - #1344

Open
cristifalcas wants to merge 1 commit into
mainfrom
cf/bes-sink-backpressure
Open

fix(bes): keep the stream alive through long replays and slow drains#1344
cristifalcas wants to merge 1 commit into
mainfrom
cf/bes-sink-backpressure

Conversation

@cristifalcas

@cristifalcas cristifalcas commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Two ways the BES sink tears down a stream that is working fine.

Replay deadlocks itself. A reconnect replays the retained buffer without
reading the response side, so the server's flow-control window fills, it stops
reading requests, and the replay trips send_stall_timeout — then reconnects
into the same wall. Acks are now read as they land.

Half-close mistakes slow for silent. The 30s post-half-close deadline was a
flat budget for the whole drain, so a build ending with a large backlog gets
torn down and fully replayed at the end, even though the backend is acking
steadily. The deadline now resets on each ack, bounding silence rather than
drain time.


Changes are visible to end-users: no

Test plan

  • Covered by existing test cases

cargo test -p axl-runtime — 377 passed.

@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

Comment thread crates/axl-runtime/src/engine/bazel/sink/grpc.rs Outdated
@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 👍 / 👎.

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.

Valid, and it is ours — that drain loop is new in this PR.

now_or_never() resolves the future, so a ready Some(Some(Err(status))) is consumed and then dropped by the failed pattern match. The stream is finished after that, the main loop reads None, and the existing handling classifies it as a premature close. A non-retryable status would be retried four times and never reported.

Fix follows the shape already there: stash the outcome like replay_failure does — Err classified through is_retryable, Some(None) treated as a closed stream — break out, and act on it once the state.buffer borrow is released.

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 👍 / 👎.

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.

Valid, and also introduced here.

Duplicate or out-of-order acks prune nothing, so !state.buffer.is_empty() still holds and each one buys another full half_close_timeout. With bes_timeout defaulting to 0s there is no outer bound, so a backend repeating a single ack keeps sink.wait() alive indefinitely — precisely what half_close_timeout exists to prevent. It needs a misbehaving backend to trigger, but it punches a hole in a bound documented as absolute.

Fix: snapshot max_acked around record_ack and push the deadline out only when it advances.

Worth noting the ack_deadline assignment directly above resets unconditionally in the same way and predates this PR. Milder consequence, same weakness — it should probably get the same treatment while we are in here.

@thesayyn

thesayyn commented Aug 4, 2026

Copy link
Copy Markdown
Member

This has some conflicts.

@cristifalcas
cristifalcas force-pushed the cf/bes-sink-backpressure branch from 8d16622 to e3cf514 Compare August 8, 2026 14:52
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>
@cristifalcas
cristifalcas force-pushed the cf/bes-sink-backpressure branch from c44a27e to 072de45 Compare August 8, 2026 15:08
@cristifalcas cristifalcas changed the title fix(bes): backpressure the sink instead of dying when acks fall behind fix(bes): keep the stream alive through long replays and slow drains Aug 8, 2026
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