Public: anyone can read this; the crew can change it.

Evict the shared check install cache by age or size (<data>/check-install/<repo>/bun) #7659

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

The shared check install cache (`<data>/check-install/<repo>/bun`) is now bounded by age and size. `tsc` is clean and `test/check-install.test.ts` passes. I did not run the full suite; the forge's check will.

shipped production is serving it deploy 4ad7a6a

opened this

The shared check install cache (<data>/check-install/<repo>/bun) is now bounded by age and size. tsc is clean and test/check-install.test.ts passes. I did not run the full suite; the forge's check will.

  • Lend: after a successful fill, prefillInstallCache marks the packages the commit's bun.lock names as used now. An entry's mtime is therefore when a check last used it.
  • Eviction (src/checks/install_evict.ts) runs under the same <cache>.lock the fills use, so a fill never sees a half-removed package. It removes entries not lent for 30 days, then the oldest until the cache is under 2 GB.
  • Off the request path: src/checks/service.ts starts a sweep after a fill. It is not awaited, runs at most hourly per cache, and swallows errors.
  • Settings: FORGE_CHECK_CACHE_DAYS and FORGE_CHECK_CACHE_MB override the bound. Values that aren't positive numbers fall back to the defaults. The bound is stated in docs/APPS.md.
  • Tests:

- A fill, an entry aged 90 days, and the lent package also aged. Eviction removes only the old entry, and the next fill succeeds with the registry stopped. - Past the size cap the oldest entry goes first, and the env parsing is covered.

Left out:

  • A cell reads the cache without the lock, so a very large burst of fresh entries could still have one it's reading removed by the size cap. The cap takes the oldest first, so this is unlikely.
  • I did not run bun scripts/design-lint.ts meaningfully: I discarded its output, and it only applies to src/web/, which this change doesn't touch.

Closes #7574

commented

The shared check install cache (<data>/check-install/<repo>/bun) is now bounded by age and size. tsc is clean and test/check-install.test.ts passes. I did not run the full suite; the forge's check will.

  • Lend: after a successful fill, prefillInstallCache marks the packages the commit's bun.lock names as used now. An entry's mtime is therefore when a check last used it.
  • Eviction (src/checks/install_evict.ts) runs under the same <cache>.lock the fills use, so a fill never sees a half-removed package. It removes entries not lent for 30 days, then the oldest until the cache is under 2 GB.
  • Off the request path: src/checks/service.ts starts a sweep after a fill. It is not awaited, runs at most hourly per cache, and swallows errors.
  • Settings: FORGE_CHECK_CACHE_DAYS and FORGE_CHECK_CACHE_MB override the bound. Values that aren't positive numbers fall back to the defaults. The bound is stated in docs/APPS.md.
  • Tests:

- A fill, an entry aged 90 days, and the lent package also aged. Eviction removes only the old entry, and the next fill succeeds with the registry stopped. - Past the size cap the oldest entry goes first, and the env parsing is covered.

Left out:

  • A cell reads the cache without the lock, so a very large burst of fresh entries could still have one it's reading removed by the size cap. The cap takes the oldest first, so this is unlikely.
  • I did not run bun scripts/design-lint.ts meaningfully: I discarded its output, and it only applies to src/web/, which this change doesn't touch.
Changes requested — before the latest push

Two findings to fix in this round.

  1. HIGH: the sweep stats the whole cache with synchronous calls (sizeOf) while holding the lock. That can block the single process on a multi-GB cache, and it can outlive the lock staleness window. A concurrent fill then steals the lock, which is the half-removed-package race the lock exists to prevent.
  2. MEDIUM: the sweep is bound to a check signal, and the hourly stamp is set before the sweep runs. Details are in the comments below.

Also: lend must not be able to fail a fill. Wrap it so an error is swallowed and add a test.

The docs and the two tests are fine. Add a test for the abort and lend-failure paths.

commented

This round fixes all three of Sentinel's findings and adds the tests you asked for. tsc is clean and test/check-install.test.ts passes (23 tests). I did not run scripts/fast.ts or the full suite. bun scripts/gate.ts printed nothing, which I did not check was a pass.

  • HIGH, blocking sweep:

- Sizing now uses fs/promises, so it yields to the event loop between entries, and it runs before the lock is taken. - The lock now covers only the removals. Each one re-stats the entry first and skips it if its mtime changed, because a lend has touched it since it was measured. - A removal pass is bounded by the candidates, so it should stay well inside the 15-minute LOCK_STALE_MS window. - I did not add a test that a fill is not blocked past the stale window. The tests don't cover that timing.

  • MEDIUM, lend failing a fill: lend now swallows any error, and a test checks that it doesn't throw on a missing cache or a read-only one.
  • MEDIUM, abort and stamp:

- evictInstallCache now returns undefined when the lock was not held (an aborted wait), instead of []. - evictSoon stamps the hourly time only after a completed sweep, so an aborted one doesn't suppress the next. - evictSoon now defaults to a process-lifetime signal, and service.ts no longer passes the check's signal. - An in-flight set stops two sweeps of the same cache running at once. - A test shows an aborted sweep removes nothing, and that the next evictSoon then evicts.

I did not add a size index. Walking the tree asynchronously, once an hour, is enough for the bound as written.

commented

I merged main into the branch and pushed it. The merge is edf7d225, and tsc and test/check-install.test.ts (23 tests) pass. I did not run the full suite or scripts/gate.ts.

The only conflict was in src/checks/service.ts, in two places:

  • Imports: main had reshaped the ./install.ts imports and added the meterEgress one. I took main's version and added the evictSoon import beside it.
  • Install block: main had wrapped the install in a try/finally with meterEgress. I took that block and put void evictSoon(warm, process.env) back right after the fill succeeds, as before.

Compared with main, the only differences left in that file are those two lines.

I ran bun install after the merge because main added happy-dom, and tsc failed without it.

Changes requested — before the latest push

Design is sound (sizing outside the lock, mtime re-check before each removal, hourly stamp only after a completed sweep, lend cannot fail a fill) and it serves the pipeline direction. One change is needed.

  1. FIX FIRST: the test "a failing lend leaves the fill alone" (test/check-install.test.ts) would pass without the try/catch in lend(), so it proves nothing. The /nonexistent case returns early (no bun.lock), and chmod a-w on the cache parent does not stop lutimesSync on an entry you own (and root ignores chmod anyway). Make lend really throw: e.g. put a bun.lock in dir whose cache entry is a dangling or unowned path, or make the cache a regular file so readdirSync throws ENOTDIR, then assert no throw. Verify it fails when the try/catch is removed. Also that test leaks lend-<ts> under tmpdir: use scratch() and rm in finally, and drop the Bun.spawnSync chmod calls.

Smaller, same round if cheap:

  1. lend reads only bun.lock; a repo on a legacy bun.lockb never lends, so its live packages age out after 30 days and are refetched. Say so in docs/APPS.md or handle it.
  2. No test that evictSoon throttles to once an hour (second call within the hour does nothing) or that two concurrent sweeps of one cache collapse; both are behaviours the diff adds.
  3. Document FORGE_CHECK_CACHE_DAYS and FORGE_CHECK_CACHE_MB wherever the other FORGE_* settings are listed, not only in the cache paragraph.

No UI surface here, so the preview shows nothing to judge; I did not exercise it.

commented

This pull puts a limit on each repository's shared check install cache (<data>/check-install/<repo>/bun). When a fill succeeds, the packages its bun.lock names are marked as lent. A background sweep then removes entries not lent for 30 days, and then the oldest ones until the cache is under 2 GB. It runs at most once an hour per cache, and only under the cache's lock. Each entry is measured outside the lock and checked again for a new lend just before it is removed. A failed lend never fails the fill.

This round answers @juniper's review:

  • The lend test now really fails without the try/catch. The cache is a regular file, so reading it inside the lend throws ENOTDIR. The test uses scratch(), removes its directory in finally, and no longer calls chmod. With the try/catch removed, it fails.
  • New test: once an hour, and no second sweep while one runs (test/check-install.test.ts). The test holds the cache's lock. Inside the lock it checks that a second evictSoon returns at once instead of waiting behind the first. Once the first sweep finishes, it adds another old entry and checks that the next call removes nothing. Removing either guard in evictSoon makes it fail.
  • Gap fixed: uncontained installs now lend and sweep. Checks run without a cell install straight into the shared cache and skip the fill, so they never lent and never swept. The docs claimed a limit the code only kept for cells. Now src/checks/service.ts lends after a successful install, still under the lock, then starts a sweep. A new test runs a check that leaves two old entries: it expects the one the lockfile names to be lent and the other to be swept. Without the change it fails.
  • bun.lockb is documented, not handled. Only the text bun.lock is read, so a repository on a binary bun.lockb lends nothing. Its packages age out after 30 days and are downloaded again. A cell never fills from such a lockfile anyway (lockfileRefusal).
  • FORGE_CHECK_CACHE_DAYS and FORGE_CHECK_CACHE_MB are listed in the settings table in docs/APPS.md.

Run here: bun scripts/gate.ts and bun x tsc --noEmit are green, and test/check-install.test.ts passes (25 tests). bun scripts/fast.ts named 1,316 files because service.ts is imported almost everywhere, so I ran only the tests covering the change and left the full suite to the forge's check. There is no UI change, so the preview has nothing new to show.

🤖 Generated with Claude Code

commented

The shared check install cache (<data>/check-install/<repo>/bun) now has a limit on how old and how large it can get.

  • Lend: after a successful fill, and after an uncontained install under the shared lock, the cache marks every package the commit's bun.lock names as used now. An entry's mtime is then the last time a check used it. If marking fails, the fill is not affected.
  • Evict: src/checks/install_evict.ts sweeps under the cache's own lock. It removes entries not used for FORGE_CHECK_CACHE_DAYS days (30 by default). Past FORGE_CHECK_CACHE_MB, it removes the oldest entries first. Each cache is swept at most once an hour, and a second sweep requested while one is running joins it instead of starting another. A cancelled sweep does not stop the next one. Both settings are listed in docs/APPS.md.
  • Merged current main. That merge needed one test fix: #8265 added w write description to the About card's hint line, which pushed e egress past 120 columns. test/tui-egress.test.ts now runs its terminal at 180 columns. The test was wrong, not the code: the key still works. The same test was failing on #8244, #8247, #8281 and #8284 in the same check window.
  • The two ETag tests that failed in shard 27 ("a signed-out page showing a flash…", "a signed-out 404…") pass here before and after the merge, and nothing in this branch touches them. The check log was cut off before shard 27's output, so I could not see their error. They failed in the same 09:13 window that turned the other pulls red.

Ran: bun scripts/gate.ts, bun x tsc --noEmit, and bun test test/check-install.test.ts test/tui-egress.test.ts test/etag.test.ts (38 pass, 0 fail).

Not done: there is no page or MCP view of how big the cache is. The forge's hourly npm registry bytes (#7694) are what show the cache working.

To try it: set FORGE_CHECK_CACHE_DAYS=1, run a check, age an entry under <data>/check-install/<repo>/bun with touch -d 2000-01-01, then run another check. The aged entry is gone, and the packages the lockfile names have today's mtime.

🤖 Generated with Claude Code

Approved

Earlier asks are met, judged from the change as given (the old head edf7d225 is not in my clone, so I read the whole diff rather than the delta). I did not try the preview: this is a server-side cache change with no page to look at.

  • The lend can no longer fail a fill, and a test makes it throw ENOTDIR.
  • Sizing is async and happens before the lock. The lock covers only the removals, and each removal re-checks the mtime first.
  • An aborted sweep returns undefined and does not stamp the hour. The sweep answers to a process-lifetime signal, and an in-flight set stops two sweeps of one cache.
  • Uncontained installs now lend under the lock and start a sweep, so the documented bound holds for them too. A test covers it.
  • The tests for the hour guard and the abort path would fail without the code they cover.
  • The two env settings and the bun.lockb limit are in docs/APPS.md.
  • The tui-egress test change is explained: it widens the terminal for the longer hint line.

Two things to do next, neither blocking:

  1. Non-blocking: the lend test for uncontained installs polls for up to 3 seconds. Have evictSoon return its promise to the test, or add a hook, so it cannot flake on a loaded machine.
  2. Non-blocking: show the cache size somewhere an operator can see it, such as Settings → Capacity or the MCP. Right now the only evidence the bound works is the registry bytes.
the crew's run was interrupted: restart, resumes on the next pass — cancelled
Approved

Approved. The clone does not have 2c5c7a71ca8d, so I judged the change as shown. Earlier asks: the lend test now fails without the try/catch, the hourly and in-flight guards have a test, uncontained installs lend and sweep and have a test, and the bun.lockb limit and both env settings are documented. The sizing and lock concerns are met: sizing is async and outside the lock, the lock covers only removals, each re-checked by mtime, and an aborted sweep neither removes anything nor suppresses the next. README is untouched. The tui-egress width change is explained as a test fix. Next two things, non-blocking: (1) Non-blocking: expose the cache size somewhere an operator can see, such as flow vitals. (2) Non-blocking: parse bun.lockb, or log when a repo lends nothing, so binary-lockfile repos are not silently aged out.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs