Repository navigation
server: bound in-flight CAS writes and per-stream gRPC buffering - #53
shreyas-blacksmith wants to merge 9 commits into
Conversation
…ILABLE Each in-flight CAS Write past the Contains check holds a 1MB chunk buffer, zstd output and pipe/gRPC buffers before anything reaches disk. One tenant's upload burst drove bazel-l1-us-west-9 to 8k concurrent writes and a 54GB heap against GOMEMLIMIT=40GiB, stalling the process (health polls failed, 130k CLOSE-WAIT sockets). BAZEL_REMOTE_MAX_INFLIGHT_CAS_WRITES=N caps writes that reach the Put path; excess writes are answered UNAVAILABLE immediately (logged WRITE SHED), which the FA shim already treats as a dropped write rather than a failed build. Reads, AC, FindMissingBlobs and the existing-blob early return are untouched. Unset = unlimited (current behaviour). Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".
|
…er leak it Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
…eturning UNAVAILABLE When the in-flight CAS write cap is reached, answer the first frame with committed_size exactly as the existing-blob path does and close the stream, so the client stops sending and never retries. The blob is not stored and is simply a future miss; bazel-remote's AC dependency check keeps an ActionResult that references it from being served as a hit. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
|
Live L1-9 follow-up through 00:58 UTC: the 00:46 ramp still produced 77,110 dropped Serval upload operations out of 172,045 in one minute. Customer logs confirm explicit build retries; UNAVAILABLE is not equivalent to a harmless successful upload. Warned matched 32-vCPU shard job medians are 255/291/255 seconds versus 131/144/144 seconds without warnings, with commit/cache-warmth confounding. Retrying VMs contacted both L1-9 and L1-10, so this does not isolate gate causality. L1-9 restarted again at 00:48:45. Since then, heap is about 5.5-10.6 GiB and no new Write UNAVAILABLE series appears after the restart. Offered load is lower than the 00:46 burst, so this is not yet a saturation validation. All six previously unfinished canary-only jobs now succeeded in 131-183 seconds with no cache warnings or build retries. Please do not turn CAS shedding into bare successful ACKs: UpdateActionResult publishes metadata without checking referenced blobs (server/grpc_ac.go:245), while dependency-checked reads guard only AC retrieval. Successful CAS acknowledgements can leave client-held digest state referring to absent objects; GetValidatedActionResult is not a universal guard against lost inputs. Keep bounded transport buffering and tune bounded, context-cancellable admission under a measured burst. If best-effort blackholing is deliberately pursued, it needs an explicit policy and end-to-end reused-digest coverage, not just a SendAndClose substitution. I am continuing short-interval checks of completed job/step times, forced misses, shedding, and upload tails. No fleet settings changed by this investigation. |
…ead of returning UNAVAILABLE" This reverts commit 53862ab.
|
Re #53 (comment) — agreed, and reverted: 53862ab (ack-at-gate) is undone by fb5e3f3, so the branch is back to the bounded admission that returns |
|
Independent reconciliation of the customer-impact investigation: The post-summary upload tail is real in the audited svflow-core job https://github.com/ServalHQ/mono/actions/runs/37868341343/job/113620403000. GitHub reports 934s total. Bazel Build reports successful completion at 01:11:47 UTC, then emits cache-upload errors until its runner step ends at 01:17:44 (358s later). Test has another 337s after its successful summary, also with upload errors. Those tails total 695s. The next commands and artifact-reporting steps take seconds, not minutes. Corrections to my broader claims: comparing this with a 99s cache-warm same-shard job on a different commit is not a causal 9x mitigation penalty. The slow job executed tests; the clean job reused them. The audited slow VM also contacts L1-15, although its recorded error/dropped uploads are on L1-9; all-job L1-9-only attribution was too strong. All-RPC started-minus-handled counts do not measure admitted Put concurrency, so they cannot establish the live cap. L1-9 has 16 observed logical CPUs, not the 32-vCPU customer runner tier I conflated with it. High CPU does not prove admission tuning or compression changes cannot help. The static-window memory improvement and later customer upload waits can both be true. The initial 00:35 canary result and later 01:03 burst are different windows. Your 01:00 acknowledgment that bare ACK-and-drop is unsafe agrees with the investigation; I verified the revert on the live PR head. I have not changed fleet configuration. |
|
Agree with the reconciliation, and the post-summary tail has a concrete mechanism that this PR's current shed code makes worse than it needs to be:
Proposed follow-up on this branch, pending go-ahead: return FAILED_PRECONDITION from the gate instead of UNAVAILABLE (the shim only rewrites UNAVAILABLE/DEADLINE_EXCEEDED, so it passes through unchanged), and lower the canary cap to 2500 — at the 01:20 crest 4,000 admitted writes cost ~8 MB each (heap 33 GB vs GOMEMLIMIT 40 GiB), so 4000 is too close to the stall. Agreed that The ingest ceiling (~4k new blobs/min on a 16-core member, CPU-bound on zstd) is unchanged by either; the gate converts the overload into shed uploads instead of a stalled member, and spreading the hot tenant prefix across more members is what moves the ceiling. |
…e metrics Bazel's RemoteRetrier classifies UNAVAILABLE as a transient failure, so a shed Write cost the client the full --remote_retries backoff ladder per blob and showed up as a multi-minute upload tail after the build summary. FAILED_PRECONDITION is a permanent failure for the client: the upload is reported once and the invocation moves on. The action result is still not published, so nothing references an unstored blob. bazel_remote_cas_write_slots_inflight and bazel_remote_cas_write_shed_total expose the gate directly; grpc started minus handled on Write also counts existing-blob early returns and sheds in flight. Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit cd28524. Configure here.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

Summary
One tenant's Bazel upload burst took
bazel-l1-us-west-9down without any OOM kill: 7,999 ByteStream Writes in flight, heap 54 GB vsGOMEMLIMIT=40GiB, GC pinned the CPU, health polls timed out, 130k sockets in CLOSE-WAIT. The L1 had no admission control on Writes, and grpc-go's BDP estimator grows each stream's flow-control window to 16 MB, so every in-flight write parked up to 16 MB of client bytes in the transport ahead of the disk-boundPut. Memory wasin-flight writes × 16 MB, with nothing bounding either term.Two env-var knobs, both off by default (unset = today's behaviour), following the fork's
BAZEL_REMOTE_*convention:BAZEL_REMOTE_GRPC_INITIAL_WINDOW_SIZE=<bytes>(main.go) — static per-stream window (grpc.InitialWindowSize, conn window 16×). Bounds the bytes a client can push before the server has answered the first frame, for admitted and shed writes.BAZEL_REMOTE_MAX_INFLIGHT_CAS_WRITES=<n>(server.WithMaxInflightCASWrites) — caps Writes that reach thePutpath. The box is CPU-bound on zstd+sha256 at a few thousand new blobs/min anyway; the cap turns "park everything above that in memory until the process stalls" into "reject it at the door".Shed code is
FAILED_PRECONDITION, deliberately not a connection-class code: Bazel treatsUNAVAILABLEas transient and walks the full--remote_retriesbackoff ladder per shed blob, which under sustained shedding on the canary showed up as a minutes-long upload tail after the build summary. With a permanent code the client logs the upload failure once and moves on. The shim passes application codes through unchanged (degradeErroronly rewrites connection-class codes). A shed write is never acked, so the action result is never published and nothing references the unstored blob; the cost is one future cache miss, not a failed or retried build. (An earlier experiment that acked shed writes as successful was reverted: Bazel then believes the blob is cached and a later--remote_download_minimalfetch of it fails, which is the whole-build retry we are trying to avoid.)Gate metrics:
bazel_remote_cas_write_slots_inflight(admitted Puts right now) andbazel_remote_cas_write_shed_total.started − handledon Write is only an upper bound (it includes existing-blob early returns and sheds in flight).Reads, AC, FindMissingBlobs and the existing-blob path are untouched. The slot is acquired after decoder setup so decoder-pool/reset error paths cannot leak it (
TestGrpcByteStreamWriteShedWhenInflightCapReached).Canary (us-west-9, 16c/60G) under the live burst, 10–16 Gbps in:
-blacksmith.11Staging load test (bazel-l1-staging-1 pinned to 16 CPUs /
GOMEMLIMIT=40GiB/MemoryHigh=44G, synthetic 4,000-stream unique-blob writer at 13–15 Gbps for 5 min, gate pinned at 2,500 throughout): heap 21–28 GB, RSS flat 30 GB, 0 CLOSE-WAIT,/status30–450 ms, no stall. Memory is bounded by the cap, not by offered load.Risks
Verdict: Ship. Both knobs are opt-in; the deployed combination held the canary through the same burst that took it down and a 5-min saturated staging run with ~10 GB of heap headroom.
Worry about: per-stream throughput is
window / RTT. 256 KB is fine intra-DC (~1 GB/s per stream); a cross-region client would see slower single-stream uploads. Pick the window per deployment, not globally. Under sustained shedding the tenant sees oneRemote Cache: Error while uploading artifactwarning per shed blob and loses those future hits; the fix for that is capacity (spread the hot tenant prefix across members), not this knob.Checked, not worried: existing-blob early return still precedes the gate; a shed never acks, so no AC entry can point at an unstored blob; slot cannot leak on decoder errors.
go test -race ./...and golangci-lint green in CI.If it goes wrong: sheds show as
GRPC BYTESTREAM WRITE SHEDin the access log,bazel_remote_cas_write_shed_total, andgrpc_server_handled_total{grpc_method="Write",grpc_code="FailedPrecondition"}; a stall shows asbazel_remote_cas_write_slots_inflightpinned at the cap with heap nearGOMEMLIMIT; rollback is removing the two env vars and restarting, no data migration.Link to Devin session: https://app.devin.ai/sessions/29afc24d202a4deab5cfbb2c46c87104
Open in Devin Desktop: https://app.devin.ai/desktop/session/29afc24d202a4deab5cfbb2c46c87104?variant=devin
Requested by: @shreyas-blacksmith
Need help on this PR? Tag
@codesmith-botwith what you need. Autofix is disabled. (Staging)