Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ Versioning](https://semver.org/spec/v2.0.0.html).

## Unreleased

### Added

- Local caches are now limited to the 10 most recently used entries per script,
instead of growing without bound. Set `WIREIT_CACHE_MAX_ENTRIES` to change the
limit, or to `infinity` for the previous behavior. Evicted entries are moved
to `.wireit/trash` and deleted at the end of the run, which is safe to
interrupt. See [#71](https://github.com/google/wireit/issues/71).

## [0.14.13] - 2026-06-23

### Added
Expand Down
48 changes: 32 additions & 16 deletions README.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@
"output": []
},
"test:cache-local": {
"command": "node --test --test-reporter=dot lib/test/cache-local.test.js",
"command": "node --test --test-reporter=dot lib/test/cache-local.test.js lib/test/local-cache.test.js",
"env": {
"NODE_OPTIONS": "--enable-source-maps"
},
Expand Down
24 changes: 24 additions & 0 deletions src/caching/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ export interface Cache {
fingerprint: Fingerprint,
absoluteFiles: AbsoluteEntry[],
): Promise<boolean>;

/**
* Move the entry to the front of an evicting cache's queue without reading it
* (the local cache updates its mtime). Called when a script was fresh, so
* nothing was restored; without it, an always-fresh script's entry looks
* untouched and is eventually evicted. Does nothing if there is no entry.
*
* @param script The script whose entry was relied upon.
* @param fingerprint The string-encoded fingerprint for the script.
*/
markEntryRecentlyUsed(
script: ScriptReference,
fingerprint: Fingerprint,
): Promise<void>;

/**
* Delete everything this cache has moved aside, this run or a previous one.
* An evicted entry is a full copy of a script's output, so a script waits
* only for it to be moved out of the way; the deleting happens here.
*
* @param signal Aborts the sweep at the next entry boundary. Safe: whatever
* is left is picked up by the next run.
*/
sweepTrash(signal?: AbortSignal): Promise<void>;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions src/caching/github-actions-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,16 @@ export class GitHubActionsCache implements Cache {
);
}

/** The service decides what to evict; recency isn't ours to report. */
markEntryRecentlyUsed(): Promise<void> {
return Promise.resolve();
}

/** No entry of this cache lives on local disk. */
sweepTrash(): Promise<void> {
return Promise.resolve();
}

/**
* @returns True if we reserved, uploaded, and committed the tarball. False if
* we gave up due to a rate limit error.
Expand Down
159 changes: 149 additions & 10 deletions src/caching/local-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@

import * as fs from '../util/fs.js';
import * as pathlib from 'path';
import {createHash} from 'crypto';
import {getScriptDataDir} from '../util/script-data-dir.js';
import {createHash, randomBytes} from 'crypto';
import {getPackageDataDir, getScriptDataDir} from '../util/script-data-dir.js';
import {copyEntries} from '../util/copy.js';
import {glob} from '../util/glob.js';

Expand All @@ -18,9 +18,28 @@ import type {AbsoluteEntry} from '../util/glob.js';

/**
* 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} most recently read or written entries per script.
* Evicted entries move to the package's ".wireit/trash", which
* {@link sweepTrash} empties, so a script never waits on a large delete.
*
* Eviction needs no lock of its own: it touches only the calling script's cache
* folder, and StandardScriptExecution#acquireSystemLockIfNeeded already holds
* that script's lock, except for an empty "output", where the entries are empty
* directories. Sweeping is deliberately unlocked, so any number of Wireit
* processes can empty the same trash at once and a vanished entry is expected.
*/
export class LocalCache implements Cache {
readonly #maxEntries: number;

/** Packages used this run, whose trash {@link sweepTrash} empties. */
readonly #packageDirs = new Set<string>();

/** @param maxEntries Entries to retain per script, or Infinity for all. */
constructor(maxEntries: number) {
this.#maxEntries = maxEntries;
}

async get(
script: ScriptReference,
fingerprint: Fingerprint,
Expand All @@ -34,19 +53,32 @@ export class LocalCache implements Cache {
}
throw error;
}
await this.markEntryRecentlyUsed(script, fingerprint);
return new LocalCacheHit(cacheDir, script.packageDir);
}

async markEntryRecentlyUsed(
script: ScriptReference,
fingerprint: Fingerprint,
): Promise<void> {
this.#packageDirs.add(script.packageDir);
// Recency lives in the mtime, so there is no index file to maintain. atime
// won't do, because filesystems are commonly mounted noatime or relatime.
const now = new Date();
try {
await fs.utimes(this.#getCacheDir(script, fingerprint), now, now);
} catch {
// No entry, or one we can't stamp (read-only mount, foreign owner). A hit
// is still a hit; the entry just ages as though only ever written.
}
}

async set(
script: ScriptReference,
fingerprint: Fingerprint,
absoluteFiles: AbsoluteEntry[],
): Promise<boolean> {
// TODO(aomarks) A script's cache directory currently just grows forever.
// We'll have the "clean" command to help with manual cleanup, but we'll
// almost certainly want an automated way to limit the size of the cache
// directory (e.g. LRU capped to some number of entries).
// https://github.com/google/wireit/issues/71
this.#packageDirs.add(script.packageDir);
const absCacheDir = this.#getCacheDir(script, fingerprint);
// Note fs.mkdir returns the first created directory, or undefined if no
// directory was created.
Expand All @@ -58,13 +90,120 @@ export class LocalCache implements Cache {
throw new Error(`Did not expect ${absCacheDir} to already exist.`);
}
await copyEntries(absoluteFiles, script.packageDir, absCacheDir);
await this.#evictAllButMostRecentlyUsed(
script,
pathlib.basename(absCacheDir),
);
return true;
}

async sweepTrash(signal?: AbortSignal): Promise<void> {
await Promise.all(
[...this.#packageDirs].map((packageDir) =>
this.#sweepPackageTrash(packageDir, signal),
),
);
}

/**
* Housekeeping, so failures are swallowed: the entry just written is still
* valid, the folder is only larger than asked for.
*
* @param justWrittenName Never evicted. mtime resolution is coarse on some
* filesystems, so it can tie with an older entry and lose the sort.
*/
async #evictAllButMostRecentlyUsed(
script: ScriptReference,
justWrittenName: string,
): Promise<void> {
if (this.#maxEntries === Infinity) {
return;
}
try {
const cacheDir = this.#getScriptCacheDir(script);
const entries = await fs.readdir(cacheDir, {withFileTypes: true});
if (entries.length <= this.#maxEntries) {
return;
}
const candidates = entries
.filter((entry) => entry.name !== justWrittenName)
.map((entry) => pathlib.join(cacheDir, entry.name));
// lstat, so a broken symlink in the folder gets evicted rather than
// throwing on every future eviction.
const byRecency = await Promise.all(
candidates.map(async (path) => ({
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.

const doomed = byRecency.slice(0, entries.length - this.#maxEntries);
// allSettled, so one entry we can't move (EPERM on Windows, while
// something holds it open) doesn't block evicting the rest.
await Promise.allSettled(
doomed.map(({path}) => this.#moveToTrash(script.packageDir, path)),
);
} catch {
// See above.
}
}

async #moveToTrash(packageDir: string, path: string): Promise<void> {
const trashDir = this.#getTrashDir(packageDir);
await fs.mkdir(trashDir, {recursive: true});
// Random, not the entry's own name: the same entry can be evicted, written
// and evicted again before a sweep reaches it. Short, because every file in
// the entry is renamed onto this path.
const name = randomBytes(8).toString('hex');
await fs.rename(path, pathlib.join(trashDir, name));
}

async #sweepPackageTrash(
packageDir: string,
signal?: AbortSignal,
): Promise<void> {
const trashDir = this.#getTrashDir(packageDir);
let entries;
try {
entries = await fs.readdir(trashDir, {withFileTypes: true});
} catch {
// ENOENT: nothing evicted, or another process already swept it away.
return;
}
for (const entry of entries) {
if (signal?.aborted) {
return;
}
try {
// force, because another process may be sweeping the same folder.
await fs.rm(pathlib.join(trashDir, entry.name), {
recursive: true,
force: true,
});
} catch {
// Undeletable right now (EBUSY on Windows). The next run tries again;
// a sweep must never fail a build.
}
}
try {
await fs.rmdir(trashDir);
} catch {
// Not empty: aborted, or another process is still evicting into it.
}
}

/** Safe beside the per-script dirs: a hex script name can't spell "trash". */
#getTrashDir(packageDir: string): string {
return pathlib.join(getPackageDataDir(packageDir), 'trash');
}

#getScriptCacheDir(script: ScriptReference): string {
return pathlib.join(getScriptDataDir(script), 'cache');
}

#getCacheDir(script: ScriptReference, fingerprint: Fingerprint): string {
return pathlib.join(
getScriptDataDir(script),
'cache',
this.#getScriptCacheDir(script),
createHash('sha256').update(fingerprint.string).digest('hex'),
);
}
Expand Down
38 changes: 38 additions & 0 deletions src/cli-options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,14 @@ export const packageDir = await (async (): Promise<string | undefined> => {
}
})();

/**
* Bounded, because an entry is a full copy of a script's output, and scripts
* with large outputs are what make an unbounded cache painful. Ten is enough to
* survive a fair amount of churn, like jumping between a few branches, without
* the folder growing without limit.
*/
const DEFAULT_CACHE_MAX_ENTRIES = 10;

export type Agent = 'npm' | 'nodeRun' | 'pnpm' | 'yarnClassic' | 'yarnBerry';

export interface Options {
Expand All @@ -54,6 +62,7 @@ export interface Options {
extraArgs: string[] | undefined;
numWorkers: number;
cache: 'local' | 'github' | 'none';
cacheMaxEntries: number;
failureMode: FailureMode;
agent: Agent;
logger: Logger;
Expand Down Expand Up @@ -161,6 +170,34 @@ export const getOptions = async (): Promise<Result<Options>> => {
return cacheResult;
}

const cacheMaxEntriesResult = ((): Result<number> => {
const str = process.env['WIREIT_CACHE_MAX_ENTRIES'] ?? '';
if (str === '') {
return {ok: true, value: DEFAULT_CACHE_MAX_ENTRIES};
}
if (str.match(/^infinity$/i)) {
return {ok: true, value: Infinity};
}
const parsedInt = parseInt(str, 10);
if (Number.isNaN(parsedInt) || parsedInt <= 0) {
return {
ok: false,
error: {
reason: 'invalid-usage',
message:
`Expected the WIREIT_CACHE_MAX_ENTRIES env variable to be ` +
`a positive integer or "infinity", got ${JSON.stringify(str)}`,
script,
type: 'failure',
},
};
}
return {ok: true, value: parsedInt};
})();
if (!cacheMaxEntriesResult.ok) {
return cacheMaxEntriesResult;
}

const failureModeResult = ((): Result<FailureMode> => {
const str = process.env['WIREIT_FAILURES'];
if (!str) {
Expand Down Expand Up @@ -247,6 +284,7 @@ export const getOptions = async (): Promise<Result<Options>> => {
script,
numWorkers: numWorkersResult.value,
cache: cacheResult.value,
cacheMaxEntries: cacheMaxEntriesResult.value,
failureMode: failureModeResult.value,
agent,
logger,
Expand Down
Loading