bazel l1: LRU observation flusher + LRU index entry cap - #38
Conversation
Buffer AC-access closures (cache.LRUObserver) per tenant storage prefix and periodically flush them as JSONL artifacts to the tenant's S3 backend, using the artifact schema/key layout the web-side retention sweep already consumes. s3proxy gains a narrow PutArtifact surface that routes like cache operations but consults the data-plane breaker read-only: advisory artifact traffic can fail fast on a sick shard yet can never trip or heal the breaker customer traffic depends on. The flusher is deliberately minimal: one aggregation map, one serial flush goroutine, direct PUTs, no retries. Under any pressure (full buffer, slow or failing backend) it drops observations and counts the drop - the recency signal re-establishes itself on the next access. Co-authored-by: Cursor <cursoragent@cursor.com>
e5bad9b to
4fdb83c
Compare
Three review P1s. RecordACAccess now admits via TryLock: it runs synchronously on the cache hit path, so lock contention (a concurrent wide merge) becomes a metered drop instead of queueing a cache request behind advisory bookkeeping. A flush pass (including the shutdown drain) gets a 2m wall-clock bound plus per-backend failure suppression, so a stalled artifact backend costs one PUT deadline, never N of them, and can never hold a node roll hostage. The feature flips to explicit opt-in (BAZEL_REMOTE_LRU_ARTIFACTS=1) so a routine binary roll cannot activate it fleet-wide. Also: closures-lost counter (per closure, so loss ratios are computable), detached-objects gauge and pass-duration histogram, breaker no-trip/no-heal regression tests, and a go test -race CI workflow. Co-authored-by: Cursor <cursoragent@cursor.com>
Validation performedUnit / race / build (head
Behavioral pins (all in-tree tests):
Staging E2E (2-node staging L1 fleet, real Bazel traffic via cold-cache smoke builds):
Rollout posture: binary is opt-in ( |
|
Exact-head staging gate complete ( |
The byte budget does not bound entry count: zero-byte blobs charge nothing and tiny blobs at most one 4 KiB block, while each resident entry costs a measured ~268 B of index metadata (key string, entry struct, list node, map slot). A byte-full 10 GiB cache can therefore hold ~2.6M entries (~670 MiB of metadata). WithMaxEntries adds an opt-in entry-count cap with the same eviction semantics as the byte budget, enforced on Add and during the startup scan, plus a counter separating count-cap evictions from byte-cap evictions. Co-authored-by: Cursor <cursoragent@cursor.com>
Exposes the entry-count bound to the standalone binary: --max_entries / BAZEL_REMOTE_MAX_ENTRIES / yaml max_entries, default 0 (no bound), forwarded to disk.WithMaxEntries. The L1 role will set 200M. Co-authored-by: Cursor <cursoragent@cursor.com>
Summary
L1-resident half of the Bazel LRU accounting move: the L1 observes AC accesses and persists them as advisory artifacts; the web-side retention sweep (unchanged) consumes them.
cache/lruflush: buffers AC-access closures (cache.LRUObserver) per tenant storage prefix, dedupes by AC hash, and flushes one JSONL artifact per prefix every 5 minutes plus a synchronous drain at shutdown. One aggregation map, one serial flush goroutine, direct PUTs, no retries.cache/s3proxy.PutArtifact: writes one artifact under a fully-composed key, routed by the request-scoped backend/bucket selection exactly like cache entries (so the sweep, which follows the namespace shard pin, can find it), taggedlru=truefor the bucket ILM backstop. The data-plane circuit breaker is consulted read-only: artifact traffic fails fast on a sick shard but can never trip or heal the breaker customer traffic depends on (both directions regression-tested).main.go: explicitly opt-in — the flusher runs only on trusted-mode nodes with an S3 proxy ANDBAZEL_REMOTE_LRU_ARTIFACTS=1. Dark by default so a routine binary roll can never activate it fleet-wide; rollout enables per node (staging first) and widens with evidence.Failure model
Everything here is advisory: under any pressure the correct response is to drop observations, count the drop, and move on — nothing can stall or fail a cache request.
RecordACAccessadmits viaTryLock; lock contention is a metered drop (contention), never a queued cache request. Full buffer (250k object refs process-wide) drops rather than flushing early. Oversized closures (>50k leaves) drop whole, never truncate.(endpoint, bucket)failure suppression — a stalled backend costs one 30s PUT deadline per pass, never N of them. Abandoned/failed windows are counted per closure.Metrics
bazel_remote_lru_artifact_flush_total{trigger,result},bazel_remote_lru_flush_observations_dropped_total{reason},bazel_remote_lru_flush_closures_lost_total{reason},bazel_remote_lru_flush_buffered_objects,bazel_remote_lru_flush_detached_objects,bazel_remote_lru_flush_pass_duration_seconds, flush size/entry histograms.Deliberately not in this PR (trimmed after review): gRPC transport tuning (stream quotas, recv caps, keepalives), GetTree guards, and tree-validation byte caps. The L1 keeps stock inbound/outbound transport behavior; defensive limits return only with evidence they're needed.
Test plan
go test -raceon all touched packages; fullgo test ./...; repeated flusher race runsgo test -raceworkflow green on this headAlso bundled: LRU index entry cap (
--max_entries)The other half of the L1 memory story: the in-memory LRU index costs ~270 bytes per resident entry regardless of blob size (key string, entry struct, list node, map slot), and the byte budget alone does not bound it — zero-byte blobs charge nothing and tiny blobs charge at most one 4 KiB block, so a byte-full cache's index metadata is effectively unbounded on small-blob workloads.
disk.WithMaxEntries(n)/--max_entries/BAZEL_REMOTE_MAX_ENTRIES/ yamlmax_entries: when positive,SizedLRUevicts least-recently-used entries past the count cap, exactly like the byte budget but counting entries. Applied during startup index load too, so a directory holding more files than the cap is trimmed oldest-first on boot.0= no bound; the binary roll changes nothing until the L1 role sets the flag (planned:--max_entries 200000000≈ 54 GiB index ceiling on 128 GiB nodes).bazel_remote_disk_cache_max_entries_evictions_totaldistinguishes count-cap evictions from byte-budget ones.Both features in this PR are inert by default (flusher:
BAZEL_REMOTE_LRU_ARTIFACTS=1; cap:--max_entries > 0), so one binary release carries both and each is enabled/tuned/killed independently via config.