fix: lru local cache eviction #71 - #1481
Conversation
|
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
left a comment
There was a problem hiding this comment.
Thank you for the PR! I think this is a good approach overall, with a few comments around trash collection.
| throw new Error(`Did not expect ${absCacheDir} to already exist.`); | ||
| } | ||
| await copyEntries(absoluteFiles, script.packageDir, absCacheDir); | ||
| await this.#evictLeastRecentlyUsedEntries(script); |
There was a problem hiding this comment.
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.
| // won't do, because filesystems are commonly mounted noatime or relatime. | ||
| const now = new Date(); | ||
| try { | ||
| await fs.utimes(cacheDir, now, now); |
There was a problem hiding this comment.
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.
| * 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; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
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.
| * 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. |
There was a problem hiding this comment.
I think this should say "most recently read", not "least recently read".
e027b08 to
5b4b6bd
Compare
5b4b6bd to
f9a96ba
Compare
|
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 |
It does, pushed the fix. |
|
Somehow local prettier version drifted, just reinstalled it. |
|
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. |
Limit local cache size with per-script LRU eviction
Fixes #71
Problem
.wireit/<script>/cache/<fingerprint>/gains a full copy of the script'soutputper fingerprint, and nothing removes them. The README told users torm -rf .wireit/*/cacheby hand.Cheap when
outputisdist/, expensive when it isn't. A repo declaringnode_modulesas output reached 73 GB at ~540 MB per entry.Change
LocalCachekeeps the N most recently used entries per script — written byset(), read byget(), or relied upon by a fresh script. Eviction runs aftereach successful
set().WIREIT_CACHE_MAX_ENTRIESoverrides: positive integer, orinfinityfor the old behavior. Parsed like the existingWIREIT_PARALLEL.Recency = directory mtime
No index file, no consistency problem.
atimeis unusable: filesystems arecommonly mounted
noatimeorrelatime.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'tevicted for being old — that's LRU rather than FIFO.
markEntryRecentlyUsed(), a newCachemethod, stamps the entry a freshscript relied upon. Nothing is restored on that path, so
get()is nevercalled (see the fresh check in
standard.ts), and without this analways-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 ratherthan 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 scriptshouldn't wait for that.
set()blocks only onrename-ing each doomed entry into the package's.wireit/trash/<16 random hex>. A newsweepTrash(signal?)on theCacheinterface empties that folder:
cli.tsawaits it once the run is over (and once the watcher exits), andprints "Ctrl-C is safe" if it is still going after a second.
SIGINTandSIGTERMabort it, and anything left is picked up by the next run.session doesn't accumulate trash.
ENOENT: two Wireit processes can be sweeping the samefolder 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 stillfits, including against the Windows path limit.
Locking
None added. Eviction touches only the calling script's own cache folder, and
StandardScriptExecution#acquireSystemLockIfNeededalready holds that script'slock across
get()andset(). It skips the lock only for an emptyoutput,where entries are empty directories.
Sweeping is deliberately unlocked, per the point about
ENOENTabove.Housekeeping never fails a script
Stamping, eviction and sweeping swallow errors:
hit
script that is already cached — and the renames use
Promise.allSettled, soone stuck entry doesn't block evicting the rest
Degraded behavior is "cache larger than requested", not a failed build.
Entries are ranked with
lstat, notstat, so a broken symlink in the folder isevicted instead of throwing on every future eviction. Sweeping a symlink unlinks
it rather than following it.
Tests
src/test/local-cache.test.ts, unit-testingLocalCachewith explicit mtimes soordering doesn't depend on timestamp resolution:
get()on older entrymarkEntryRecentlyUsed()on older entrymarkEntryRecentlyUsed()with no entryinfinityget()returnsundefinedset()still succeedsPlus an end-to-end CLI test — 5 runs under
WIREIT_CACHE_MAX_ENTRIES=2, 2entries left and no trash behind — and a
cli-optionsparse test. All run undernpm run test:cache-local.Compatibility
Previously unbounded caches are now trimmed; set
WIREIT_CACHE_MAX_ENTRIES=infinityto opt out. Existing over-limit folders trimon that script's next cache write.
.wireit/trashis new, and transient — it is emptied at the end of the run thatcreates it, or by the next run if that one is interrupted.