Skip to content

fix: lru local cache eviction #71 - #1481

Merged
aomarks merged 5 commits into
google:mainfrom
asmyshlyaev177:local_cache_gc_lru
Sep 2, 2026
Merged

fix: lru local cache eviction #71#1481
aomarks merged 5 commits into
google:mainfrom
asmyshlyaev177:local_cache_gc_lru

Conversation

@asmyshlyaev177

@asmyshlyaev177 asmyshlyaev177 commented Aug 26, 2026

Copy link
Copy Markdown
Contributor

Limit local cache size with per-script LRU eviction

Fixes #71

Problem

.wireit/<script>/cache/<fingerprint>/ gains a full copy of the script's
output per fingerprint, and nothing removes them. The README told users to
rm -rf .wireit/*/cache by hand.

Cheap when output is dist/, expensive when it isn't. A repo declaring
node_modules as output reached 73 GB at ~540 MB per entry.

Change

LocalCache keeps the N most recently used entries per script — written by
set(), read by get(), or relied upon by a fresh script. Eviction runs after
each successful set().

  • Default N = 10. WIREIT_CACHE_MAX_ENTRIES overrides: positive integer, or
    infinity for the old behavior. Parsed like the existing WIREIT_PARALLEL.

Recency = directory mtime

No index file, no consistency problem. atime is unusable: filesystems are
commonly mounted noatime or relatime.

Three things stamp an entry, and the third is the one that isn't obvious:

  • set() writes it.
  • get() stamps the entry it returns, so a frequently restored entry isn't
    evicted for being old — that's LRU rather than FIFO.
  • markEntryRecentlyUsed(), a new Cache method, stamps the entry a fresh
    script relied upon. Nothing is restored on that path, so get() is never
    called (see the fresh check in standard.ts), and without this an
    always-fresh script's entry would look untouched and eventually be evicted.
    It is a no-op for GitHubActionsCache, which doesn't choose its own evictions.

The entry set() just wrote is excluded from the eviction candidates rather
than relying on its mtime to save it — mtime resolution is coarse on some
filesystems, so it can tie with an older entry and lose the sort.

Eviction is a rename; the deleting happens at the end of the run

An entry is a full copy of output, so deleting one can take a while. A script
shouldn't wait for that.

set() blocks only on rename-ing each doomed entry into the package's
.wireit/trash/<16 random hex>. A new sweepTrash(signal?) on the Cache
interface empties that folder:

  • cli.ts awaits it once the run is over (and once the watcher exits), and
    prints "Ctrl-C is safe" if it is still going after a second. SIGINT and
    SIGTERM abort it, and anything left is picked up by the next run.
  • Watch mode also sweeps in the background after each iteration, so a long
    session doesn't accumulate trash.
  • Deletes tolerate ENOENT: two Wireit processes can be sweeping the same
    folder at once, which is also why sweeping takes no lock.

The trash name is random rather than derived from the entry, for two reasons.
The same entry can be evicted, written and evicted again before a sweep reaches
it. And every file in the entry is renamed onto that path, so it needs to be
short: .wireit/trash/<16> is 59 characters shorter than the
.wireit/<script>/cache/<64> it came from, so a tree that fit before still
fits, including against the Windows path limit.

Locking

None added. Eviction touches only the calling script's own cache folder, and
StandardScriptExecution#acquireSystemLockIfNeeded already holds that script's
lock across get() and set(). It skips the lock only for an empty output,
where entries are empty directories.

Sweeping is deliberately unlocked, per the point about ENOENT above.

Housekeeping never fails a script

Stamping, eviction and sweeping swallow errors:

  • an unstampable directory (read-only mount, foreign owner) must still return a
    hit
  • an unmovable or undeletable entry (EPERM/EBUSY on Windows) must not fail a
    script that is already cached — and the renames use Promise.allSettled, so
    one stuck entry doesn't block evicting the rest

Degraded behavior is "cache larger than requested", not a failed build.

Entries are ranked with lstat, not stat, so a broken symlink in the folder is
evicted instead of throwing on every future eviction. Sweeping a symlink unlinks
it rather than following it.

Tests

src/test/local-cache.test.ts, unit-testing LocalCache with explicit mtimes so
ordering doesn't depend on timestamp resolution:

case asserts
under limit nothing evicted
over limit trimmed to limit
get() on older entry it survives, newer one evicted (LRU, not FIFO)
markEntryRecentlyUsed() on older entry same, for the fresh path
markEntryRecentlyUsed() with no entry no-op, no throw
older entry with a future mtime the just-written entry is not the one evicted
infinity nothing evicted
post-eviction survivor still restorable
evicted entry get() returns undefined
broken symlink in folder eviction not wedged
stray file in folder evicted like an entry
evicted entry waits in trash, swept on demand, path shorter than its source
trash path is a file cache stays over limit, set() still succeeds
two caches sweeping at once no error
symlinked entry swept the link goes, its target does not
aborted sweep trash left for the next run

Plus an end-to-end CLI test — 5 runs under WIREIT_CACHE_MAX_ENTRIES=2, 2
entries left and no trash behind — and a cli-options parse test. All run under
npm run test:cache-local.

Compatibility

Previously unbounded caches are now trimmed; set
WIREIT_CACHE_MAX_ENTRIES=infinity to opt out. Existing over-limit folders trim
on that script's next cache write.

.wireit/trash is new, and transient — it is emptied at the end of the run that
creates it, or by the next run if that one is interrupted.

@google-cla

google-cla Bot commented Aug 26, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@aomarks aomarks left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Thank you for the PR! I think this is a good approach overall, with a few comments around trash collection.

Comment thread src/caching/local-cache.ts Outdated
throw new Error(`Did not expect ${absCacheDir} to already exist.`);
}
await copyEntries(absoluteFiles, script.packageDir, absCacheDir);
await this.#evictLeastRecentlyUsedEntries(script);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Let's make the cache eviction a bit more asynchronous and resilient to early exit.

Instead of blocking on the full delete completing here in set, let's only block on moving the doomed entries into a new .wireit/trash/<name>-<random suffix> folder (the random suffix prevents collisions in case we find the same cache entry >1 times before cleanup completes).

Then, expose a new sweepTrash method on the cache interface which tries to delete everything in the trash folder. It would return a promise and take an abort signal for early exit. Note we should also tolerate ENOENT, because two separate wireit processes in theory could be sweeping the same trash.

If the sweep promise hasn't resolved when the run would otherwise exit, we can log what we're waiting for and tell the user that Ctrl-C is safe. Anything left in trash will get picked up on the next run.

Comment thread src/caching/local-cache.ts Outdated
// won't do, because filesystems are commonly mounted noatime or relatime.
const now = new Date();
try {
await fs.utimes(cacheDir, now, now);

@aomarks aomarks Aug 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We should also bump the mtimes for the "fresh" case (which is where we don't actually need to restore anything from cache, because the output is already in place). Otherwise "fresh" hits won't count towards the cache entrie's recency score.

Note this can't happen in get, because on the fresh path we return before get is called (see the fresh check in standard.ts). Maybe we need a new touch(script, fingerprint) method on the cache interface.

Comment thread src/cli-options.ts Outdated
* still covers what local caching is most useful for: bouncing between the
* current state and one other, like a branch you keep switching back to.
*/
const DEFAULT_CACHE_MAX_ENTRIES = 2;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think we could bump this up to maybe 10, so that the cache can survive a bit more churn when e.g. jumping between branches.

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.

Ok, makes sense. Problem that I hit locally that whole node_modules were in cache, so better to have it configurable. Possibly that I just didn't read docs good enough.

return {path, mtimeMs: (await fs.lstat(path)).mtimeMs};
}),
);
byRecency.sort((a, b) => a.mtimeMs - b.mtimeMs);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

In theory (e.g. with coarse mtime and a low max entries), this could end up deleting a cache entry that we just wrote. Let's pass in any just-created entry and exclude it from the candidates so that this can't happen.

Comment thread src/caching/local-cache.ts Outdated
* Caches script output to each package's
* ".wireit/<script-name-hex>/cache/<cache-key-sha256-hex>" folder.
* ".wireit/<script-name-hex>/cache/<cache-key-sha256-hex>" folder, keeping only
* the {@link maxEntries} least recently read or written entries per script.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this should say "most recently read", not "least recently read".

@asmyshlyaev177
asmyshlyaev177 requested a review from aomarks August 29, 2026 19:26
@aomarks

aomarks commented Sep 2, 2026

Copy link
Copy Markdown
Member

Sorry for the very pedantic format check!

@asmyshlyaev177

Copy link
Copy Markdown
Contributor Author

Sorry for the very pedantic format check!

It's ok, probably need to adjust it for markdown, something isn't right.

@aomarks

aomarks commented Sep 2, 2026

Copy link
Copy Markdown
Member

Sorry for the very pedantic format check!

It's ok, probably need to adjust it for markdown, something isn't right.

does it pass locally with npm run lint? do you have the same exact prettier version as the checked-in package lock dictates? npm ci && npm run format:fix should work, and if it doesn't there must be something wrong with our configuration.

@asmyshlyaev177

Copy link
Copy Markdown
Contributor Author

Sorry for the very pedantic format check!

It's ok, probably need to adjust it for markdown, something isn't right.

does it pass locally with npm run lint? do you have the same exact prettier version as the checked-in package lock dictates? npm ci && npm run format:fix should work, and if it doesn't there must be something wrong with our configuration.

It does, pushed the fix.

@asmyshlyaev177

Copy link
Copy Markdown
Contributor Author

Somehow local prettier version drifted, just reinstalled it.

@aomarks
aomarks merged commit fcb5391 into google:main Sep 2, 2026
20 checks passed
@asmyshlyaev177
asmyshlyaev177 deleted the local_cache_gc_lru branch September 2, 2026 18:13
@aomarks

aomarks commented Sep 2, 2026

Copy link
Copy Markdown
Member

Great, merged! Thanks for the PR. I have a few other small tasks to do before the next release, will post here when the release is out, probably in a couple days.

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.

Garbage collection for cache directory

2 participants