Skip to content

BLA-4200: LRU observation capture (producer 1/3)#8

Merged
shreyas-blacksmith merged 5 commits into
mainfrom
shreyaskalyan/bla-4200-bazel-cache-lru-artifacts
Jul 9, 2026
Merged

BLA-4200: LRU observation capture (producer 1/3)#8
shreyas-blacksmith merged 5 commits into
mainfrom
shreyaskalyan/bla-4200-bazel-cache-lru-artifacts

Conversation

@shreyas-blacksmith

@shreyas-blacksmith shreyas-blacksmith commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

BLA-4200: LRU observation capture (producer, part 1 of 3)

Repo: useblacksmith/bazel-remote, branch shreyaskalyan/bla-4200-bazel-cache-lru-artifacts -> merge FIRST, then re-pin fa (FastActions/fa#4428). Full feature narrative (flows, decisions, E2E) lives in the web PR: useblacksmith/web#8767.

Changes in this repo

  • cache/lru_observer.go - the LRUObserver contract: a nil/panic-safe observation seam mirroring the existing OperationObserver pattern, plus the JSONL artifact schema (ACClosure / LRUObject / LRUArtifactHeader, schema_version 1) and the zero-padded chronological object-key layout. This file is the single source of truth shared with fa (serialization) and the web sweep (consumption).
  • AC-closure capture in diskCache - observations fire on validated AC hits (via GetValidatedActionResult, where the closure is already expanded) and on AC writes via Put under complete-or-drop: writes with output_directories or inlined leaves defer to the first read, and a closure with any unresolvable leaf size is dropped whole - no partial record is ever emitted. Plain CAS traffic is never observed on its own.
  • LeafSizeSink size harvesting - leaf sizeOnDisk is recorded at the existing validation touch points (local index lookups + the proxy StatObject.Size that was previously discarded), context-threaded per request: zero extra round trips on the request path (D5/D11). Tree blobs are emitted as closure leaves (the sweep must keep a kept AC's Tree blob or the entry breaks).
  • Bounded capture memory - AC buffering for capture is capped at 4 MiB per request (the gRPC max-message default, so only the HTTP path can exceed it) and 64 MiB in aggregate via a process-wide semaphore. Either bound skips the advisory observation only (ac_too_large / capture_budget drops); the write proceeds on the streaming path. Bodies longer than the declared size are rejected as malformed (400) instead of buffered unboundedly.
  • Capture-side metrics - bazel_remote_lru_leaf_size_source_total{source} (the field validator for the zero-round-trip assumption: a non-trivial missing rate means the design must be revisited) and bazel_remote_lru_observations_dropped_total{reason}.
  • Microbenchmarks - capture overhead on hit/write paths with observer on/off (lru_capture_bench_test.go).

Design doc

docs/bazel-cache-lru-artifacts-plan.md - decisions D1-D17 (Slices 1+2 of section 9).

Validation

  • Golden JSONL round-trip + key-format tests; complete-or-drop, nil-observer no-op, no-extra-StatObject tests. go test ./cache/... green.
  • Live on the staging fleet since Jul 2: artifacts verified well-formed, tagged, chronologically sorted against this schema.
  • E2E merge-gate green on staging Jul 6: https://github.com/useblacksmith/web/actions/runs/28827808000 (details in the web PR).

Reviewer note

No production observer is wired here - fa injects it (part 2/3); with a nil observer the cache path is unchanged.

shreyas-blacksmith and others added 3 commits July 1, 2026 12:06
Introduce the producer<->retention-sweep contract for LRU artifacts with
no runtime behavior yet:

- LRUObserver interface (RecordACAccess) plus a nil-safe, panic-swallowing
  ObserveACAccess helper, mirroring the existing OperationObserver pattern.
- ACClosure/LRUObject types and LRUArtifactHeader matching the design doc
  artifact schema (short h/s keys, sizeOnDisk unit, per-entry ts_ms).
- JSONL encode/decode (single source of truth FA must reuse) and a
  chronologically-sortable object-key helper (zero-padded window-end ms).

Golden-byte, round-trip, key-sort, and observer nil/panic tests included.

Co-authored-by: Cursor <cursoragent@cursor.com>
Produce one ACClosure per touched AC to a (possibly nil) LRUObserver, with
sizes gathered at existing validation touch points (zero extra round trips):

- LeafSizeSink (cache pkg, context-threaded) records leaf sizeOnDisk from the
  local LRU index (findMissingLocalCAS) and from the proxy StatObject.Size that
  s3proxy.Contains currently discards for CAS/v2 (returned value unchanged).
- GetValidatedActionResult assembles the hit closure (output files, expanded
  Tree files, the Tree blobs themselves, stdout/stderr) under complete-or-drop;
  capture is gated on an observer so the default path is unchanged.
- AC writes emit only when the closure is fully resolvable now: writes with
  output directories or inlined leaves emit nothing and defer to the read path
  (D10), so no partial closure ever reaches an artifact.
- WithLRUObserver option; leaf-size-source and dropped-observation counters.

Tests: hit closure + dedup + tree leaves, local-vs-proxy leaf size source,
no extra proxy stat under capture, write with/without directories,
complete-or-drop on unresolved leaf, nil-observer no-op, and an s3proxy
StatObject->sink test backed by gofakes3.

Co-authored-by: Cursor <cursoragent@cursor.com>
Record output_directories Tree blobs as closure leaves using a
local-index size lookup instead of re-adding them to pendingValidations,
so capture can never turn a proven AC hit into a miss. Emit the
lru_leaf_size_source{missing} counter on complete-or-drop so the
zero-extra-round-trip size assumption (D11) is observable in the field.
Add capture-overhead microbenchmarks and widen the put helpers to
testing.TB so benches can reuse the existing test seam.

Co-authored-by: Cursor <cursoragent@cursor.com>
@linear-code

linear-code Bot commented Jul 6, 2026

Copy link
Copy Markdown

BLA-4200

@shreyas-blacksmith shreyas-blacksmith marked this pull request as ready for review July 8, 2026 02:17

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using default effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 82ddb3c. Configure here.

Comment thread cache/disk/lru_capture.go
lookupSizeOnDisk resolved leaf sizes via SizedLRU.Get, which moves each
entry to the MRU side. The AC write capture path calls it for every
referenced CAS digest without reading those blobs, so enabling an LRU
observer perturbed on-disk eviction order. Add SizedLRU.Peek (no
MoveToFront) and use it, with a regression test on eviction order.

Co-authored-by: Codesmith Staging <codesmith-bot@users.noreply.github.com>
Comment thread cache/disk/disk.go Outdated
// blobs are small and this only runs when an observer is configured.
var acForObserver *pb.ActionResult
if kind == cache.AC && c.lruObserver != nil {
acBytes, rErr := io.ReadAll(r)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

do we want to bound the size here? Like if somebody for whatever reason sends a very large action result should we just fail? Maybe use a limit reader at 512k or something?

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.

Landed in a816d00, with two bounds instead of one:

  • Per-request: 4 MiB cap on the declared size. Went above 512k because 4 MiB is the gRPC max-message default — every AC arriving via UpdateActionResult fits under it by construction, so the gate only bites the HTTP path, where no transport limit exists. Oversized ACs are still cached normally via the streaming path (constant memory); they just skip observation. Capture is advisory, so it shouldn't gate the write.
  • Aggregate: 64 MiB process-wide semaphore across concurrent captures — the per-request cap alone leaves a worst case of (concurrent AC writes x 4 MiB), which scales with stream count. Non-blocking TryAcquire: no budget means skip the observation, never delay the write. Both skips are counted in bazel_remote_lru_observations_dropped_total (ac_too_large / capture_budget).

On "should we just fail": the declared-size gate alone bounds nothing if the client lies, since ReadAll trusted the body. The read now goes through LimitReader(size+1), and a body longer than declared fails with a 400 — that request is malformed, so failing it is correct. Honest-but-large just isn't observed.

The capture path buffered AC writes with io.ReadAll on a client-declared
size. Bound it two ways: a 4 MiB per-request cap (gRPC max-message
default, so only the HTTP path can exceed it) and a 64 MiB process-wide
semaphore across concurrent captures. Either bound skips the advisory
observation only - the write proceeds on the streaming path. Bodies
longer than the declared size are now rejected as malformed instead of
buffered unboundedly.

Co-authored-by: Cursor <cursoragent@cursor.com>
@shreyas-blacksmith shreyas-blacksmith merged commit e74b379 into main Jul 9, 2026
4 checks passed
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