feat(frontseat-runner): GH-250 localgrid enforces a store budget itself - #261
feat(frontseat-runner): GH-250 localgrid enforces a store budget itself#261jbadeau wants to merge 2 commits into
Conversation
Developer stores grew without bound (24 GB observed — mostly stale
build-output binaries from superseded digests) because only CI ever ran
grid gc. The grid now enforces a budget for its own lifetime: an early
check after start, then a slow tick, evicting LRU blobs down to the
budget with the same mechanism as `frontseat grid gc`. Default 10 GiB,
overridable via $FRONTSEAT_GRID_MAX_SIZE or `grid serve --max-size`
("off" disables). Both the embedded grid and `grid serve` inherit it.
`grid status` now prints the local store size and blob count so growth
is observable before it hurts. An evicted blob behind a cache hit
degrades to re-execution client-side (GH-249), so auto-gc cannot fail
a build.
Accelerator-dir policy (gobuild/, tool-cache/, gomod/) remains open on
the issue.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| return | ||
| case <-timer.C: | ||
| } | ||
| if size, _ := s.store.size(); size > s.autoGCMax { |
There was a problem hiding this comment.
The trigger and the enforcement measure different things, so the accelerator dirs go whenever this fires on an idle store.
store.size() walks only casDir. store.gc() sets BytesBefore = total + scratchTotal and, when !busy(), adds scratchTotal to total and clears gomod/, tool-cache/, gobuild/ before evicting a single blob.
Since this fires only when CAS alone already exceeds the budget, total after adding scratch is always over it too — so whenever the timer catches the store idle, all three dirs are cleared in full. On a long-lived daemon idle is the normal state between builds, so that is the common outcome, not the rare one. The 12.2 GiB across 38315 blobs in the description is the CAS figure; the accelerator dirs sit on top and are the first thing deleted, 2 minutes after start and every 30 minutes after.
That also contradicts the stated scope — the description says accelerator-dir policy "stays open" in #250, but this ships an unconditional one.
Fix: give auto-GC a CAS-only eviction path and leave the scratch dirs to explicit grid gc. Aligning the trigger to casSize + scratchTotal instead does not help — it makes the loop fire more often and still clears all three.
Two more on this loop:
- CAS eviction is ungated and an evicted input blob fails the build.
!busy()wraps only the scratch clear; the blob loop below it always runs.client.gouploads withUploadIfMissing, so a blob reported present is never re-sent; if it is evicted beforeExecute, the server returnsFailedPrecondition: missing input bloband the client makes it terminal — there is no re-upload-and-retry in the executor. Eviction also sorts onModTimewhilelinkBlobandhasBlobnevertouch, so a build own reused inputs look oldest. This contradicts both the new doc comment and "can slow a build but never fail one". busy()is a point-in-timeReadDir(execDir). A timer-driven GC can land between two actions of the same build, which is exactly the dangling-symlink failure the comment abovestore.goscratch clear warns about. Manualgrid gcruns at a known-quiet moment; this does not.
There was a problem hiding this comment.
Fixed in 952564a. Auto-gc now has a CAS-only path and never runs mid-build:
- New
gcCASOnlymode — auto-gc manages CAS alone and never touchesgomod//tool-cache//gobuild/. Their bounding stays with explicitgrid gc, so localgrid: local store grows without bound — apply the gc budget automatically #250's accelerator-dir policy is genuinely still open (the description now matches the code).gcFull(explicitgrid gc) keeps the count-then-clear-when-idle behavior. - Deferred while busy —
gcCASOnlyreturns early withDeferred: truewhenbusy(), before walking anything, so a timer tick can never evict an in-flight input blob. You're right that an evicted input is terminal (FailedPrecondition, no re-upload-retry in the executor), so "slow but never fail" needs the eviction gated, not just the scratch clear. - LRU touch —
linkBlobandhasBlobnowtouch(), so a blob hardlinked into a build or confirmed present via FindMissingBlobs gets a fresh mtime and stops looking oldest-since-first-write.
The remaining busy()-then-start-a-build race is now narrow (the check gates the whole pass before the walk) and the LRU touch means a just-started build's inputs carry fresh mtimes. Covered by autogc_internal_test.go (CAS-only leaves scratch; defers while busy; reused blob is touched).
On the CI nit: grid gc --max-size 4g (aggressive post-build trim) and the 10 GiB auto-gc default are intentionally different budgets, so I left the template alone — happy to align if you'd rather they match.
Generated by Claude Code
There was a problem hiding this comment.
Requesting changes — the loop enforces a wider budget than it measures, and a bad env value panics the daemon.
Three inline. The core one: the trigger reads CAS only (store.size()), while store.gc() counts and evicts CAS plus gomod/, tool-cache/ and gobuild/. So a fire that lands while the store is idle deletes the accelerator dirs entirely — an unconditional policy for the very thing the description says stays open in #250. On a long-lived daemon, idle between builds is the normal state.
Separately, opts.Logger.Warn for an unparseable FRONTSEAT_GRID_MAX_SIZE runs before the nil-logger default, so the daemon's embedded grid segfaults on a value the --max-size help text explicitly invites.
Two more: CAS eviction is ungated by busy() and an evicted input blob is terminal, which contradicts "can slow a build but never fail one"; and touch() is wired to getBlob but not linkBlob/hasBlob, so a blob hardlinked into every build ages on its first-write mtime.
Nit: CI's grid gc --max-size 4g runs against the 10 GiB default this introduces; if those should agree, change the template inside renderGithubWorkflow and regenerate.
This review was published with assistance from Claude.
| if n, err := ParseSize(v); err == nil { | ||
| opts.AutoGCMaxBytes = n | ||
| } else { | ||
| opts.Logger.Warn("ignoring invalid FRONTSEAT_GRID_MAX_SIZE", "value", v, "error", err) |
There was a problem hiding this comment.
Nil-logger panic: this Warn runs before the if opts.Logger == nil default a few lines below, so an unparseable FRONTSEAT_GRID_MAX_SIZE segfaults instead of warning.
Both callers that matter pass no logger — runner.go builds the daemon embedded grid as localgrid.EnsureLocal{Addr, AssetAddr} (every default-config build), and frontseat grid gc calls localgrid.New{StoreDir}. Only grid serve supplies one.
ParseSize also rejects "off", while the --max-size help reads "off" disables; default $FRONTSEAT_GRID_MAX_SIZE or 10g — so FRONTSEAT_GRID_MAX_SIZE=off, the reading that help invites, is exactly the input that panics the daemon.
t.Setenv("FRONTSEAT_GRID_MAX_SIZE", "not-a-size")
localgrid.New(localgrid.Options{StoreDir: t.TempDir()}) // SIGSEGVFix: resolve the env value after the nil-logger block, and accept "off" and 0 from the env the way the flag does.
There was a problem hiding this comment.
Fixed in 952564a. The env resolution now runs after the nil-logger default, so the warn on an unparseable value can't nil-deref the embedded-grid / grid gc paths. And "off"/"0" now disable auto-gc (set -1), mirroring --max-size off — so the FRONTSEAT_GRID_MAX_SIZE=off reading the help invites no longer panics or falls through to the 10 GiB default. TestNewInvalidEnvDoesNotPanicWithNilLogger is your exact repro; TestNewEnvDisablesAutoGC covers off/OFF/0.
Generated by Claude Code
Four fixes to the localgrid store budget, from review: - Auto-gc no longer clears the accelerator dirs. The trigger measured CAS alone (store.size) while store.gc counted and evicted gomod/, tool-cache/ and gobuild/ too, so a timer tick that caught the store idle — the normal state between builds — wiped all three. A new gcCASOnly mode manages CAS only; the accelerator-dir policy stays with explicit `grid gc` (#250). - Auto-gc defers entirely while a build runs. gcCASOnly returns Deferred without walking when busy(), so a timer tick can never evict an in-flight input blob (terminal client-side: FailedPrecondition, no re-upload-retry). - linkBlob and hasBlob now touch. Eviction is LRU on mtime; only getBlob touched, so a blob hardlinked into every build or confirmed present via FindMissingBlobs aged on its first-write mtime and looked oldest. - FRONTSEAT_GRID_MAX_SIZE resolves after the nil-logger default, so an unparseable value warns instead of segfaulting the embedded grid and `grid gc` (neither passes a logger). "off"/"0" disable, mirroring --max-size off. autogc_internal_test.go covers all four. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MYAgCD9HZHZWEuH4vF17p1
Partial for #250 (the CAS budget + observability; accelerator-dir policy for
gobuild//tool-cache//gomod/stays open there, so no closing keyword).frontseat grid gc. Both the embedded grid andfrontseat grid serveinherit it with no caller changes.Options.AutoGCMaxBytes→$FRONTSEAT_GRID_MAX_SIZE→ 10 GiB default;grid serve --max-size 10goverrides,--max-size offdisables.frontseat grid statusnow prints the local store: verified live —local store at ~/Library/Caches/frontseat/localgrid: 12.2 GiB across 38315 blobs.🤖 Generated with Claude Code