@laurence79/wireit 0.14.13-shared-cache.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/README.md +1062 -0
- package/bin/wireit.js +9 -0
- package/lib/analyzer.js +1600 -0
- package/lib/caching/cache.js +7 -0
- package/lib/caching/github-actions-cache.js +832 -0
- package/lib/caching/local-cache.js +78 -0
- package/lib/caching/shared-cache.js +256 -0
- package/lib/cli-options.js +495 -0
- package/lib/cli.js +177 -0
- package/lib/config.js +18 -0
- package/lib/error.js +160 -0
- package/lib/event.js +7 -0
- package/lib/execution/base.js +108 -0
- package/lib/execution/no-command.js +32 -0
- package/lib/execution/service.js +1017 -0
- package/lib/execution/standard.js +683 -0
- package/lib/executor.js +249 -0
- package/lib/fingerprint.js +164 -0
- package/lib/ide.js +583 -0
- package/lib/language-server.js +135 -0
- package/lib/logging/combination-logger.js +41 -0
- package/lib/logging/debug-logger.js +43 -0
- package/lib/logging/logger.js +38 -0
- package/lib/logging/metrics-logger.js +108 -0
- package/lib/logging/quiet/run-tracker.js +597 -0
- package/lib/logging/quiet/stack-map.js +41 -0
- package/lib/logging/quiet/writeover-line.js +197 -0
- package/lib/logging/quiet-logger.js +78 -0
- package/lib/logging/simple-logger.js +296 -0
- package/lib/logging/watch-logger.js +81 -0
- package/lib/script-child-process.js +270 -0
- package/lib/util/ast.js +71 -0
- package/lib/util/async-cache.js +24 -0
- package/lib/util/copy.js +120 -0
- package/lib/util/deferred.js +35 -0
- package/lib/util/delete.js +120 -0
- package/lib/util/dispose.js +16 -0
- package/lib/util/fs.js +258 -0
- package/lib/util/glob.js +255 -0
- package/lib/util/line-monitor.js +69 -0
- package/lib/util/manifest.js +31 -0
- package/lib/util/optimize-mkdirs.js +55 -0
- package/lib/util/package-json-reader.js +61 -0
- package/lib/util/package-json.js +179 -0
- package/lib/util/script-data-dir.js +19 -0
- package/lib/util/shuffle.js +16 -0
- package/lib/util/unreachable.js +12 -0
- package/lib/util/windows.js +87 -0
- package/lib/util/worker-pool.js +61 -0
- package/lib/watcher.js +396 -0
- package/package.json +470 -0
- package/schema.json +132 -0
- package/wireit.svg +1 -0
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2022 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import * as fs from '../util/fs.js';
|
|
7
|
+
import * as pathlib from 'path';
|
|
8
|
+
import { createHash } from 'crypto';
|
|
9
|
+
import { getScriptDataDir } from '../util/script-data-dir.js';
|
|
10
|
+
import { copyEntries } from '../util/copy.js';
|
|
11
|
+
import { glob } from '../util/glob.js';
|
|
12
|
+
/**
|
|
13
|
+
* Caches script output to each package's
|
|
14
|
+
* ".wireit/<script-name-hex>/cache/<cache-key-sha256-hex>" folder.
|
|
15
|
+
*/
|
|
16
|
+
export class LocalCache {
|
|
17
|
+
async get(script, fingerprint) {
|
|
18
|
+
const cacheDir = this.#getCacheDir(script, fingerprint);
|
|
19
|
+
try {
|
|
20
|
+
await fs.access(cacheDir);
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (error.code === 'ENOENT') {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
throw error;
|
|
27
|
+
}
|
|
28
|
+
return new LocalCacheHit(cacheDir, script.packageDir);
|
|
29
|
+
}
|
|
30
|
+
async set(script, fingerprint, absoluteFiles) {
|
|
31
|
+
// TODO(aomarks) A script's cache directory currently just grows forever.
|
|
32
|
+
// We'll have the "clean" command to help with manual cleanup, but we'll
|
|
33
|
+
// almost certainly want an automated way to limit the size of the cache
|
|
34
|
+
// directory (e.g. LRU capped to some number of entries).
|
|
35
|
+
// https://github.com/google/wireit/issues/71
|
|
36
|
+
const absCacheDir = this.#getCacheDir(script, fingerprint);
|
|
37
|
+
// Note fs.mkdir returns the first created directory, or undefined if no
|
|
38
|
+
// directory was created.
|
|
39
|
+
const existed = (await fs.mkdir(absCacheDir, { recursive: true })) === undefined;
|
|
40
|
+
if (existed) {
|
|
41
|
+
// This is an unexpected error because the Executor should already have
|
|
42
|
+
// checked for an existing cache hit.
|
|
43
|
+
throw new Error(`Did not expect ${absCacheDir} to already exist.`);
|
|
44
|
+
}
|
|
45
|
+
await copyEntries(absoluteFiles, script.packageDir, absCacheDir);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
#getCacheDir(script, fingerprint) {
|
|
49
|
+
return pathlib.join(getScriptDataDir(script), 'cache', createHash('sha256').update(fingerprint.string).digest('hex'));
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
class LocalCacheHit {
|
|
53
|
+
/**
|
|
54
|
+
* The folder where the cached output is stored. Assumed to exist.
|
|
55
|
+
*/
|
|
56
|
+
#source;
|
|
57
|
+
/**
|
|
58
|
+
* The folder where the cached output should be written when {@link apply} is
|
|
59
|
+
* called.
|
|
60
|
+
*/
|
|
61
|
+
#destination;
|
|
62
|
+
constructor(source, destination) {
|
|
63
|
+
this.#source = source;
|
|
64
|
+
this.#destination = destination;
|
|
65
|
+
}
|
|
66
|
+
async apply() {
|
|
67
|
+
const entries = await glob(['**'], {
|
|
68
|
+
cwd: this.#source,
|
|
69
|
+
followSymlinks: false,
|
|
70
|
+
includeDirectories: true,
|
|
71
|
+
expandDirectories: true,
|
|
72
|
+
// Shouldn't ever happen, but would be really weird.
|
|
73
|
+
throwIfOutsideCwd: true,
|
|
74
|
+
});
|
|
75
|
+
await copyEntries(entries, this.#source, this.#destination);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=local-cache.js.map
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* Copyright 2025 Google LLC
|
|
4
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
5
|
+
*/
|
|
6
|
+
import * as pathlib from 'path';
|
|
7
|
+
import { createHash } from 'crypto';
|
|
8
|
+
import * as fs from '../util/fs.js';
|
|
9
|
+
import { copyEntries } from '../util/copy.js';
|
|
10
|
+
import { glob } from '../util/glob.js';
|
|
11
|
+
/**
|
|
12
|
+
* On-disk format version. A future incompatible layout change can bump this and
|
|
13
|
+
* coexist with older entries in a cache that outlives wireit versions.
|
|
14
|
+
*/
|
|
15
|
+
const FORMAT_VERSION = 'v1';
|
|
16
|
+
/**
|
|
17
|
+
* Sub-directory of the format-version root where in-flight writes live before
|
|
18
|
+
* being atomically renamed into place. Kept under the same root so that the
|
|
19
|
+
* rename stays on one filesystem (and is therefore atomic).
|
|
20
|
+
*/
|
|
21
|
+
const TEMP_DIR_NAME = '.tmp';
|
|
22
|
+
/**
|
|
23
|
+
* A content-addressed, multi-writer-safe cache that lives at a stable path
|
|
24
|
+
* outside any working tree, so entries survive across builds and can be shared
|
|
25
|
+
* between multiple agents on a long-lived machine.
|
|
26
|
+
*
|
|
27
|
+
* Unlike {@link LocalCache}, entries are keyed purely by fingerprint hash (no
|
|
28
|
+
* per-package/per-script namespacing), which gives cross-package/cross-agent
|
|
29
|
+
* dedup. Writes are published with a temp-dir-then-atomic-rename, which makes
|
|
30
|
+
* concurrent writers safe (first writer wins; the content is identical by
|
|
31
|
+
* construction) and means readers never observe a partial entry.
|
|
32
|
+
*
|
|
33
|
+
* A shared-cache problem can make builds slower, never broken: all runtime
|
|
34
|
+
* read/write errors are non-fatal and degrade to "no cache" or "read-only".
|
|
35
|
+
*/
|
|
36
|
+
export class SharedCache {
|
|
37
|
+
/** The format-version root, i.e. `<root>/v1`. */
|
|
38
|
+
#versionRoot;
|
|
39
|
+
/** Where in-flight writes are staged, i.e. `<root>/v1/.tmp`. */
|
|
40
|
+
#tempDir;
|
|
41
|
+
#logger;
|
|
42
|
+
/**
|
|
43
|
+
* Whether we should skip all writes. Set initially from config, and flipped
|
|
44
|
+
* on if we hit a permission error while writing (degrade-to-read-only safety
|
|
45
|
+
* net).
|
|
46
|
+
*/
|
|
47
|
+
#readonly;
|
|
48
|
+
/**
|
|
49
|
+
* Whether we've already logged a write failure. Used to avoid flooding the
|
|
50
|
+
* output when the same problem recurs for every script in a run.
|
|
51
|
+
*/
|
|
52
|
+
#writeWarningLogged = false;
|
|
53
|
+
constructor(versionRoot, readonly, logger) {
|
|
54
|
+
this.#versionRoot = versionRoot;
|
|
55
|
+
this.#tempDir = pathlib.join(versionRoot, TEMP_DIR_NAME);
|
|
56
|
+
this.#readonly = readonly;
|
|
57
|
+
this.#logger = logger;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Create a {@link SharedCache} rooted at {@link rootDir}, probing whether the
|
|
61
|
+
* directory is usable and emitting a startup health line (never silent).
|
|
62
|
+
*
|
|
63
|
+
* @returns A cache instance, or `undefined` if the cache is unavailable (in
|
|
64
|
+
* which case caching is disabled for the run, but the build still proceeds).
|
|
65
|
+
*/
|
|
66
|
+
static async create(rootDir, options, logger) {
|
|
67
|
+
const versionRoot = pathlib.join(rootDir, FORMAT_VERSION);
|
|
68
|
+
const tempDir = pathlib.join(versionRoot, TEMP_DIR_NAME);
|
|
69
|
+
if (options.readonly) {
|
|
70
|
+
// We only ever read, so there's nothing to set up. A missing directory
|
|
71
|
+
// simply means every lookup is a miss.
|
|
72
|
+
console.error(`ℹ️ Shared cache: ${rootDir} (read-only)`);
|
|
73
|
+
return new SharedCache(versionRoot, true, logger);
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
// Eagerly create the temp directory (and therefore the version root). If
|
|
77
|
+
// this works, we know we can write.
|
|
78
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
79
|
+
}
|
|
80
|
+
catch (error) {
|
|
81
|
+
const code = error.code;
|
|
82
|
+
// We couldn't set up for writing. Fall back to read-only if we can still
|
|
83
|
+
// read existing entries, otherwise disable caching entirely. Either way
|
|
84
|
+
// the build proceeds.
|
|
85
|
+
if (await exists(rootDir)) {
|
|
86
|
+
console.error(`⚠️ Shared cache: ${rootDir} not writable — running read-only ` +
|
|
87
|
+
`(${code ?? String(error)})`);
|
|
88
|
+
return new SharedCache(versionRoot, true, logger);
|
|
89
|
+
}
|
|
90
|
+
console.error(`⚠️ Shared cache: ${rootDir} unavailable — caching disabled ` +
|
|
91
|
+
`(${code ?? String(error)})`);
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
console.error(`ℹ️ Shared cache: ${rootDir} (read-write)`);
|
|
95
|
+
return new SharedCache(versionRoot, false, logger);
|
|
96
|
+
}
|
|
97
|
+
async get(script, fingerprint) {
|
|
98
|
+
const entryDir = this.#entryDir(fingerprint);
|
|
99
|
+
try {
|
|
100
|
+
// The only way an entry directory can appear is a completed rename, so its
|
|
101
|
+
// mere existence means it's a complete, readable hit. No done-marker or
|
|
102
|
+
// partial-read window to worry about.
|
|
103
|
+
await fs.access(entryDir);
|
|
104
|
+
}
|
|
105
|
+
catch (error) {
|
|
106
|
+
const code = error.code;
|
|
107
|
+
if (code === 'ENOENT') {
|
|
108
|
+
// Miss.
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
// Unexpected read error. Treat as a miss so the build proceeds, but let
|
|
112
|
+
// the user know.
|
|
113
|
+
this.#logger.log({
|
|
114
|
+
script,
|
|
115
|
+
type: 'info',
|
|
116
|
+
detail: 'cache-info',
|
|
117
|
+
message: `Shared cache read error, treating as a miss: ${String(error)}`,
|
|
118
|
+
});
|
|
119
|
+
return undefined;
|
|
120
|
+
}
|
|
121
|
+
return new SharedCacheHit(entryDir, script.packageDir);
|
|
122
|
+
}
|
|
123
|
+
async set(script, fingerprint, absoluteFiles) {
|
|
124
|
+
if (this.#readonly) {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
const entryDir = this.#entryDir(fingerprint);
|
|
128
|
+
let tempEntryDir;
|
|
129
|
+
try {
|
|
130
|
+
// The temp directory could have been reaped externally since startup, so
|
|
131
|
+
// ensure it exists before staging into it.
|
|
132
|
+
await fs.mkdir(this.#tempDir, { recursive: true });
|
|
133
|
+
tempEntryDir = await fs.mkdtemp(pathlib.join(this.#tempDir, 'set-'));
|
|
134
|
+
}
|
|
135
|
+
catch (error) {
|
|
136
|
+
return this.#handleWriteError(error, script);
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
// copyEntries already attempts a reflink (COPYFILE_FICLONE), so staging is
|
|
140
|
+
// copy-on-write when the cache and working tree share a filesystem.
|
|
141
|
+
await copyEntries(absoluteFiles, script.packageDir, tempEntryDir);
|
|
142
|
+
// Create the shard directory (`<root>/v1/<aa>/`) before publishing.
|
|
143
|
+
await fs.mkdir(pathlib.dirname(entryDir), { recursive: true });
|
|
144
|
+
// Atomic publish.
|
|
145
|
+
await fs.rename(tempEntryDir, entryDir);
|
|
146
|
+
return true;
|
|
147
|
+
}
|
|
148
|
+
catch (error) {
|
|
149
|
+
// The rename failed (or a step before it did). Clean up our temp dir;
|
|
150
|
+
// a crash-orphaned temp dir is harmless (never read by get()) but we
|
|
151
|
+
// tidy up on the happy-ish path anyway.
|
|
152
|
+
await deleteQuietly(tempEntryDir);
|
|
153
|
+
// If the destination now exists, another agent published the identical
|
|
154
|
+
// content first. Entries are content-addressed, so theirs is
|
|
155
|
+
// interchangeable with ours — this is the expected outcome of a publish
|
|
156
|
+
// race, not an error. (This is the explicit fix for LocalCache's
|
|
157
|
+
// "Did not expect ... to already exist" throw under concurrency.)
|
|
158
|
+
if (await exists(entryDir)) {
|
|
159
|
+
return true;
|
|
160
|
+
}
|
|
161
|
+
return this.#handleWriteError(error, script);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
#entryDir(fingerprint) {
|
|
165
|
+
const hash = createHash('sha256').update(fingerprint.string).digest('hex');
|
|
166
|
+
// 2-char shard prefix keeps any one directory from accumulating tens of
|
|
167
|
+
// thousands of entries on filesystems that handle that poorly.
|
|
168
|
+
return pathlib.join(this.#versionRoot, hash.slice(0, 2), hash);
|
|
169
|
+
}
|
|
170
|
+
/**
|
|
171
|
+
* Handle a write-path error: degrade to read-only on permission problems,
|
|
172
|
+
* otherwise just skip caching this entry. Always non-fatal.
|
|
173
|
+
*/
|
|
174
|
+
#handleWriteError(error, script) {
|
|
175
|
+
const code = error.code;
|
|
176
|
+
if (code === 'EACCES' || code === 'EPERM') {
|
|
177
|
+
// Permission problem. Stop trying to write for the rest of this process
|
|
178
|
+
// so builds keep working without repeated failures.
|
|
179
|
+
this.#readonly = true;
|
|
180
|
+
if (!this.#writeWarningLogged) {
|
|
181
|
+
this.#writeWarningLogged = true;
|
|
182
|
+
console.warn(`⚠️ Shared cache: not writable — continuing read-only (${code})`);
|
|
183
|
+
}
|
|
184
|
+
return false;
|
|
185
|
+
}
|
|
186
|
+
// Other I/O error (ENOSPC, EIO, a flaky mount, ...). Best-effort: skip
|
|
187
|
+
// caching this entry and let the build proceed. Warn once to avoid noise.
|
|
188
|
+
if (!this.#writeWarningLogged) {
|
|
189
|
+
this.#writeWarningLogged = true;
|
|
190
|
+
this.#logger.log({
|
|
191
|
+
script,
|
|
192
|
+
type: 'info',
|
|
193
|
+
detail: 'cache-info',
|
|
194
|
+
message: `Shared cache write error, not caching: ${String(error)}`,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
return false;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Apply a {@link SharedCache} hit by copying the entry's payload into the
|
|
202
|
+
* script's package directory.
|
|
203
|
+
*/
|
|
204
|
+
class SharedCacheHit {
|
|
205
|
+
/** The cache entry directory. Assumed to exist. */
|
|
206
|
+
#source;
|
|
207
|
+
/** Where the cached output should be written when {@link apply} is called. */
|
|
208
|
+
#destination;
|
|
209
|
+
constructor(source, destination) {
|
|
210
|
+
this.#source = source;
|
|
211
|
+
this.#destination = destination;
|
|
212
|
+
}
|
|
213
|
+
async apply() {
|
|
214
|
+
const entries = await glob(['**'], {
|
|
215
|
+
cwd: this.#source,
|
|
216
|
+
followSymlinks: false,
|
|
217
|
+
includeDirectories: true,
|
|
218
|
+
expandDirectories: true,
|
|
219
|
+
// Shouldn't ever happen, but would be really weird.
|
|
220
|
+
throwIfOutsideCwd: true,
|
|
221
|
+
});
|
|
222
|
+
// Restore is always a copy, never a hardlink: a later in-place write to a
|
|
223
|
+
// restored file would otherwise corrupt the shared entry and poison every
|
|
224
|
+
// future restore. copyEntries passes COPYFILE_FICLONE, so this is
|
|
225
|
+
// copy-on-write automatically when the cache and working tree share a
|
|
226
|
+
// filesystem, and a plain copy otherwise.
|
|
227
|
+
await copyEntries(entries, this.#source, this.#destination);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* Whether a path exists (best-effort; any error other than a clean "yes" is
|
|
232
|
+
* treated as "no").
|
|
233
|
+
*/
|
|
234
|
+
async function exists(path) {
|
|
235
|
+
try {
|
|
236
|
+
await fs.access(path);
|
|
237
|
+
return true;
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
return false;
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Delete a path recursively, swallowing any error. Used for best-effort cleanup
|
|
245
|
+
* of temp directories.
|
|
246
|
+
*/
|
|
247
|
+
async function deleteQuietly(path) {
|
|
248
|
+
try {
|
|
249
|
+
await fs.rm(path, { recursive: true, force: true });
|
|
250
|
+
}
|
|
251
|
+
catch {
|
|
252
|
+
// Best-effort: an orphaned temp dir is harmless (never read by get()) and
|
|
253
|
+
// is reaped externally.
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
//# sourceMappingURL=shared-cache.js.map
|