@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.
Files changed (54) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +1062 -0
  3. package/bin/wireit.js +9 -0
  4. package/lib/analyzer.js +1600 -0
  5. package/lib/caching/cache.js +7 -0
  6. package/lib/caching/github-actions-cache.js +832 -0
  7. package/lib/caching/local-cache.js +78 -0
  8. package/lib/caching/shared-cache.js +256 -0
  9. package/lib/cli-options.js +495 -0
  10. package/lib/cli.js +177 -0
  11. package/lib/config.js +18 -0
  12. package/lib/error.js +160 -0
  13. package/lib/event.js +7 -0
  14. package/lib/execution/base.js +108 -0
  15. package/lib/execution/no-command.js +32 -0
  16. package/lib/execution/service.js +1017 -0
  17. package/lib/execution/standard.js +683 -0
  18. package/lib/executor.js +249 -0
  19. package/lib/fingerprint.js +164 -0
  20. package/lib/ide.js +583 -0
  21. package/lib/language-server.js +135 -0
  22. package/lib/logging/combination-logger.js +41 -0
  23. package/lib/logging/debug-logger.js +43 -0
  24. package/lib/logging/logger.js +38 -0
  25. package/lib/logging/metrics-logger.js +108 -0
  26. package/lib/logging/quiet/run-tracker.js +597 -0
  27. package/lib/logging/quiet/stack-map.js +41 -0
  28. package/lib/logging/quiet/writeover-line.js +197 -0
  29. package/lib/logging/quiet-logger.js +78 -0
  30. package/lib/logging/simple-logger.js +296 -0
  31. package/lib/logging/watch-logger.js +81 -0
  32. package/lib/script-child-process.js +270 -0
  33. package/lib/util/ast.js +71 -0
  34. package/lib/util/async-cache.js +24 -0
  35. package/lib/util/copy.js +120 -0
  36. package/lib/util/deferred.js +35 -0
  37. package/lib/util/delete.js +120 -0
  38. package/lib/util/dispose.js +16 -0
  39. package/lib/util/fs.js +258 -0
  40. package/lib/util/glob.js +255 -0
  41. package/lib/util/line-monitor.js +69 -0
  42. package/lib/util/manifest.js +31 -0
  43. package/lib/util/optimize-mkdirs.js +55 -0
  44. package/lib/util/package-json-reader.js +61 -0
  45. package/lib/util/package-json.js +179 -0
  46. package/lib/util/script-data-dir.js +19 -0
  47. package/lib/util/shuffle.js +16 -0
  48. package/lib/util/unreachable.js +12 -0
  49. package/lib/util/windows.js +87 -0
  50. package/lib/util/worker-pool.js +61 -0
  51. package/lib/watcher.js +396 -0
  52. package/package.json +470 -0
  53. package/schema.json +132 -0
  54. 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