@vzn/vx 0.0.21 → 0.0.22
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/README.md +21 -18
- package/package.json +6 -6
- package/src/bin.ts +52 -3
- package/src/cache/archive.ts +170 -21
- package/src/cache/cache.ts +667 -288
- package/src/cache/chained-cache.ts +14 -2
- package/src/cache/config-evals.ts +29 -6
- package/src/cache/file-hashes.ts +63 -35
- package/src/cache/git-inputs.ts +398 -69
- package/src/cache/index.ts +8 -1
- package/src/cache/inputs.ts +403 -102
- package/src/cache/key-fold.ts +232 -0
- package/src/cache/layer.ts +81 -11
- package/src/cache/layered-cache.ts +199 -32
- package/src/cache/output-index.ts +109 -10
- package/src/cache/run-history.ts +8 -3
- package/src/cache/zstd.ts +53 -30
- package/src/cli/cache.ts +51 -21
- package/src/cli/format.ts +4 -15
- package/src/cli/help.ts +13 -3
- package/src/cli/index.ts +35 -9
- package/src/cli/info.ts +49 -7
- package/src/cli/init.ts +17 -5
- package/src/cli/last.ts +96 -21
- package/src/cli/lock.ts +34 -17
- package/src/cli/plan-format.ts +4 -4
- package/src/cli/plugin-commands.ts +20 -5
- package/src/cli/run.ts +55 -12
- package/src/cli/select.ts +23 -13
- package/src/cli/show.ts +9 -4
- package/src/cli/upgrade.ts +134 -14
- package/src/cli/watch.ts +611 -102
- package/src/cli/why.ts +8 -16
- package/src/cli/workspace-config.ts +21 -9
- package/src/config.ts +46 -9
- package/src/exec/executor.ts +51 -5
- package/src/exec/index.ts +10 -0
- package/src/exec/kill-tree.ts +281 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/runner.ts +327 -82
- package/src/exec/sandbox-binds.ts +34 -5
- package/src/exec/sandbox-paths.ts +22 -2
- package/src/exec/sandbox-runtime.ts +627 -102
- package/src/exec/sandbox-violations.ts +36 -23
- package/src/graph/dependency-spec.ts +14 -14
- package/src/graph/index.ts +12 -2
- package/src/graph/priorities.ts +130 -0
- package/src/graph/scheduler.ts +182 -134
- package/src/graph/task-graph.ts +552 -188
- package/src/index.ts +16 -2
- package/src/orchestrator/admission.ts +41 -17
- package/src/orchestrator/colors.ts +8 -3
- package/src/orchestrator/deferred-outputs.ts +22 -7
- package/src/orchestrator/doctor.ts +122 -14
- package/src/orchestrator/events.ts +71 -2
- package/src/orchestrator/excluded-keys.ts +155 -0
- package/src/orchestrator/execute-task.ts +331 -37
- package/src/orchestrator/failure-recap.ts +121 -0
- package/src/orchestrator/fingerprint-watch.ts +89 -0
- package/src/orchestrator/framed-output.ts +81 -14
- package/src/orchestrator/history.ts +6 -7
- package/src/orchestrator/hit-restore.ts +82 -18
- package/src/orchestrator/index.ts +7 -1
- package/src/orchestrator/keyed-projects.ts +89 -0
- package/src/orchestrator/local-shortcircuit.ts +83 -23
- package/src/orchestrator/lockfile-claim.ts +152 -32
- package/src/orchestrator/logger.ts +116 -20
- package/src/orchestrator/metrics.ts +156 -60
- package/src/orchestrator/miss-save.ts +106 -43
- package/src/orchestrator/options.ts +30 -4
- package/src/orchestrator/persistent.ts +41 -21
- package/src/orchestrator/placement.ts +55 -25
- package/src/orchestrator/plan.ts +24 -11
- package/src/orchestrator/plugin-host.ts +17 -23
- package/src/orchestrator/plugin.ts +31 -8
- package/src/orchestrator/prepare.ts +99 -13
- package/src/orchestrator/projects.ts +19 -8
- package/src/orchestrator/remote-prefetch.ts +21 -1
- package/src/orchestrator/run-artifacts.ts +9 -0
- package/src/orchestrator/run-context.ts +19 -4
- package/src/orchestrator/run-lock.ts +349 -0
- package/src/orchestrator/run-records.ts +6 -23
- package/src/orchestrator/run-report.ts +4 -2
- package/src/orchestrator/run.ts +264 -57
- package/src/orchestrator/sandbox-request.ts +388 -62
- package/src/orchestrator/save-lane.ts +4 -7
- package/src/orchestrator/shell-verdict.ts +117 -0
- package/src/orchestrator/signals.ts +86 -30
- package/src/orchestrator/stable-keys.ts +202 -10
- package/src/orchestrator/status-line.ts +33 -1
- package/src/orchestrator/summary.ts +49 -2
- package/src/orchestrator/task-hash.ts +87 -7
- package/src/orchestrator/task-log-buffer.ts +7 -7
- package/src/orchestrator/telemetry-host.ts +16 -7
- package/src/orchestrator/telemetry.ts +69 -28
- package/src/orchestrator/upstream.ts +91 -45
- package/src/util/bun-version.ts +43 -0
- package/src/util/errors.ts +86 -0
- package/src/util/hash.ts +10 -1
- package/src/util/index.ts +32 -3
- package/src/util/paths.ts +147 -5
- package/src/util/procfs.ts +20 -0
- package/src/util/settle.ts +4 -2
- package/src/util/size.ts +33 -0
- package/src/util/task-id.ts +20 -0
- package/src/util/timing.ts +11 -1
- package/src/util/which.ts +48 -0
- package/src/workspace/affected.ts +272 -64
- package/src/workspace/config-cache.ts +262 -32
- package/src/workspace/config-eval.ts +49 -12
- package/src/workspace/config-imports.ts +126 -5
- package/src/workspace/config-schema.ts +237 -41
- package/src/workspace/filter.ts +33 -18
- package/src/workspace/fingerprint.ts +29 -11
- package/src/workspace/index.ts +7 -0
- package/src/workspace/json-data.ts +90 -0
- package/src/workspace/load-reads.ts +32 -0
- package/src/workspace/lockfile.ts +33 -11
- package/src/workspace/migrate-scripts.ts +60 -7
- package/src/workspace/migration.ts +40 -1
- package/src/workspace/package-graph.ts +154 -16
- package/src/workspace/project-loader.ts +227 -77
- package/src/workspace/workspace.ts +173 -23
package/src/cache/cache.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// metadata — taskId, command, exitCode, durationMs, storedAt — lives
|
|
13
13
|
// in the SQLite `entries` row. The same tar.zst bytes ship to a remote
|
|
14
14
|
// cache server unchanged; on remote-hit, the caller supplies metadata
|
|
15
|
-
// via the `ingest(hash,
|
|
15
|
+
// via the `ingest(hash, body, meta)` API so the local SQL index gets
|
|
16
16
|
// populated without sniffing the artifact.
|
|
17
17
|
//
|
|
18
18
|
// We never cache failed runs, so stderr is dropped from the cached
|
|
@@ -26,12 +26,23 @@
|
|
|
26
26
|
// layer speaks is `CacheLayer` in layer.ts; `plugin-host.ts` enforces it.
|
|
27
27
|
|
|
28
28
|
import { Database, type SQLQueryBindings } from 'bun:sqlite'
|
|
29
|
-
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
30
|
-
import {
|
|
29
|
+
import { accessSync, constants, existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
30
|
+
import { readdir, rename, rm, stat, unlink, writeFile } from 'node:fs/promises'
|
|
31
31
|
import path from 'node:path'
|
|
32
|
-
import {
|
|
32
|
+
import {
|
|
33
|
+
UserError,
|
|
34
|
+
asTrees,
|
|
35
|
+
formatBytes,
|
|
36
|
+
isDiskFull,
|
|
37
|
+
isFsRefusal,
|
|
38
|
+
relPosix,
|
|
39
|
+
span,
|
|
40
|
+
splitTaskId,
|
|
41
|
+
taskGlob,
|
|
42
|
+
} from '../util/index.js'
|
|
33
43
|
import {
|
|
34
44
|
ArchiveSecurityError,
|
|
45
|
+
type ArtifactPlan,
|
|
35
46
|
type ExecUsage,
|
|
36
47
|
extractArtifactStream,
|
|
37
48
|
scanArtifact,
|
|
@@ -40,6 +51,7 @@ import {
|
|
|
40
51
|
planArtifact,
|
|
41
52
|
} from './archive.js'
|
|
42
53
|
import {
|
|
54
|
+
ArtifactVanishedError,
|
|
43
55
|
type CacheEntry,
|
|
44
56
|
type CacheGetContext,
|
|
45
57
|
type CacheKeyInput,
|
|
@@ -47,6 +59,7 @@ import {
|
|
|
47
59
|
type CacheStats,
|
|
48
60
|
type CacheStatsOptions,
|
|
49
61
|
CorruptArtifactError,
|
|
62
|
+
type DeclaredOutputs,
|
|
50
63
|
type IngestMeta,
|
|
51
64
|
type InvocationRecord,
|
|
52
65
|
type OutputDirRow,
|
|
@@ -56,72 +69,20 @@ import {
|
|
|
56
69
|
type RunRecord,
|
|
57
70
|
type SaveArgs,
|
|
58
71
|
type TaskInputRow,
|
|
72
|
+
WORKSPACE_OUTPUT_PREFIX,
|
|
59
73
|
} from './layer.js'
|
|
60
|
-
import {
|
|
74
|
+
import {
|
|
75
|
+
bytesOf,
|
|
76
|
+
decodedTar,
|
|
77
|
+
MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
78
|
+
STREAM_DECODE_FROM,
|
|
79
|
+
zstdEncoder,
|
|
80
|
+
} from './zstd.js'
|
|
61
81
|
import { ConfigEvalTable } from './config-evals.js'
|
|
62
82
|
import { FileHashStore } from './file-hashes.js'
|
|
63
83
|
import { OutputIndex } from './output-index.js'
|
|
64
84
|
import { RunHistory } from './run-history.js'
|
|
65
|
-
|
|
66
|
-
// v17: artifact carries only logs + outputs (stdout + outputs/<rel>).
|
|
67
|
-
// Local and remote layers transport the SAME tar.zst bytes — no
|
|
68
|
-
// separate stage/meta.json/tar.gz dance for remote, no
|
|
69
|
-
// `cache-archive.ts`. stderr is no longer cached: we only cache
|
|
70
|
-
// successful runs and stderr is rarely meaningful on success.
|
|
71
|
-
// v19: '^task' dependsOn expansion switched from transitive-deps to
|
|
72
|
-
// nearest-holder frontier — upstream-hash sets shrink, so keys change.
|
|
73
|
-
// v20: input-file content hashes switched from xxh3 to git blob OIDs
|
|
74
|
-
// (Turbo parity). Clean tracked files take their OID straight from
|
|
75
|
-
// the index (harvested by the bulk `git ls-files -s`); dirty /
|
|
76
|
-
// untracked files get the identical OID computed in-process. Every
|
|
77
|
-
// file's hash bytes change → bump. SCHEMA_VERSION moves with it:
|
|
78
|
-
// pre-v20 `file_hashes.content_hash` rows hold xxh3 digests that
|
|
79
|
-
// must not leak into the OID domain via the mtime+size memo.
|
|
80
|
-
// v22: reverted the v21 output-fold "early cutoff". Downstream keys
|
|
81
|
-
// fold the upstream's INPUT key (its task hash) again — pure-input
|
|
82
|
-
// transitive hashing (Turbo/Nx model). No output content participates
|
|
83
|
-
// in any cache key. SCHEMA v21 drops the now-unused outputs_hash
|
|
84
|
-
// column. Early cutoff removed (an upstream that re-emits identical
|
|
85
|
-
// output still re-runs dependents) — rare, not worth the cascade.
|
|
86
|
-
// v23: fold cache.inputs.runtime / workspaceRuntime command output into
|
|
87
|
-
// the key (two namespaced sections after env-values). Command strings
|
|
88
|
-
// live in the resolved config (frozen by `vx lock`); the OUTPUT is
|
|
89
|
-
// resolved live every run, so it stays correct under --frozen. No
|
|
90
|
-
// SCHEMA bump — only Cache.key derivation changed.
|
|
91
|
-
// v24: vx-lock.json added to the always-ignored input set (cache/
|
|
92
|
-
// inputs.ts ALWAYS_IGNORE). A task whose globs matched the root
|
|
93
|
-
// lockfile (broad `**/*` on the root project) drops it from the hashed
|
|
94
|
-
// file set, so those keys change; tasks that never matched it are
|
|
95
|
-
// byte-identical. No SCHEMA bump — only the input file set changed.
|
|
96
|
-
//
|
|
97
|
-
// CACHE_VERSION stays v24 through the Tier-3 schema roll (SCHEMA v22).
|
|
98
|
-
// Tier 3 persists the very components already fed to `Cache.key()` —
|
|
99
|
-
// it adds nothing to, reorders nothing in, and reweights nothing
|
|
100
|
-
// inside the key fold (the new `captureInto` sink is a pure
|
|
101
|
-
// side-channel). A task's hash is byte-identical before and after, so
|
|
102
|
-
// existing artifacts stay valid and there is no cache-version bump.
|
|
103
|
-
// v25: the ARTIFACT BYTES are wrong in every existing entry, while the
|
|
104
|
-
// key that addresses them is unchanged — the one situation a version
|
|
105
|
-
// bump exists for. Two defects: (a) `packArtifact` staged outputs with
|
|
106
|
-
// `Bun.write`, which does not carry the source mode, so every artifact
|
|
107
|
-
// records 0644 and a cache hit strips the executable bit off any binary
|
|
108
|
-
// or generated script; (b) it packed `--format=ustar`, which splits a
|
|
109
|
-
// name over 100 bytes into prefix+name, and the reader read only `name`
|
|
110
|
-
// — so those entries lost their `outputs/` prefix and were silently
|
|
111
|
-
// dropped from both the restore and the output_files index. Neither is
|
|
112
|
-
// self-healing: the stored bytes/headers are already wrong, so without a
|
|
113
|
-
// new namespace the fixed code would keep replaying them forever.
|
|
114
|
-
// v26: same situation, different producer. A task whose child was killed by
|
|
115
|
-
// a shutdown signal reported `aborted`, but `aborted` did not propagate to
|
|
116
|
-
// dependents — so a dependent ran against PARTIAL outputs, succeeded, and
|
|
117
|
-
// cached what it built. The fold takes the upstream's INPUT key, which a
|
|
118
|
-
// signal does not change, so that entry sits under exactly the key a healthy
|
|
119
|
-
// run derives and replays forever as a green hit. Fixing the propagation
|
|
120
|
-
// stops new poison but cannot reach entries already written, and a
|
|
121
|
-
// `LayeredCache` uploads them, so the reach is a whole team's shared cache
|
|
122
|
-
// rather than one developer's disk. Pre-alpha, so one cold rebuild is the
|
|
123
|
-
// cheap side of that trade.
|
|
124
|
-
export const CACHE_VERSION = 'vx-cache-v27'
|
|
85
|
+
import { CACHE_VERSION, foldKey } from './key-fold.js'
|
|
125
86
|
|
|
126
87
|
/**
|
|
127
88
|
* An artifact or temp file without an `entries` row is reaped by
|
|
@@ -132,13 +93,38 @@ export const CACHE_VERSION = 'vx-cache-v27'
|
|
|
132
93
|
*/
|
|
133
94
|
const ORPHAN_GRACE_MS = 60 * 60 * 1000
|
|
134
95
|
|
|
96
|
+
/**
|
|
97
|
+
* The only names the orphan sweep may unlink: an artifact under a key
|
|
98
|
+
* `foldKey` prints (16 lowercase hex) and the temp `tempPath` makes of
|
|
99
|
+
* one. `cacheDir` is the user's to point anywhere (`build/`, a shared
|
|
100
|
+
* directory), and the sweep once took any `*.tar.zst` an hour old there,
|
|
101
|
+
* the user's own release tarball included (item 968).
|
|
102
|
+
*/
|
|
103
|
+
const VX_ARTIFACT_NAME = /^([0-9a-f]{16})\.tar\.zst(\.tmp-\d+-\d+-[0-9a-z]*)?$/
|
|
104
|
+
|
|
105
|
+
/** The `schema_meta` key holding when the orphan sweep last ran (ms epoch). */
|
|
106
|
+
const SWEPT_AT = 'orphans_swept_at'
|
|
107
|
+
|
|
135
108
|
export interface SchemaReset {
|
|
136
109
|
from: string
|
|
137
110
|
to: string
|
|
138
111
|
}
|
|
139
112
|
|
|
113
|
+
/** SQLite's answer for a file that is not a readable database: corrupt, or not one at all. */
|
|
114
|
+
function unreadableIndex(err: unknown): boolean {
|
|
115
|
+
const code = (err as { code?: unknown }).code
|
|
116
|
+
return typeof code === 'string' && (code.startsWith('SQLITE_CORRUPT') || code === 'SQLITE_NOTADB')
|
|
117
|
+
}
|
|
118
|
+
|
|
140
119
|
/** Say once, on the channel the opener has, that an upgrade emptied the index. */
|
|
141
120
|
export function noteSchemaReset(cache: Cache, warn: (message: string) => void): void {
|
|
121
|
+
if (cache.formatChange !== null) {
|
|
122
|
+
const { from, to } = cache.formatChange
|
|
123
|
+
warn(
|
|
124
|
+
`[vx] cache format changed: ${from} → ${to} (vx upgraded); every cached task misses once and re-saves, and the old entries, never read again, age out under \`vx cache prune --older-than\` or \`cacheRetention\``,
|
|
125
|
+
)
|
|
126
|
+
return
|
|
127
|
+
}
|
|
142
128
|
if (cache.schemaReset === null) return
|
|
143
129
|
const { from, to } = cache.schemaReset
|
|
144
130
|
warn(
|
|
@@ -187,7 +173,20 @@ export function noteSchemaReset(cache: Cache, warn: (message: string) => void):
|
|
|
187
173
|
// tell the history what the task needs. The cache KEY and the
|
|
188
174
|
// artifact container are unchanged (no CACHE_VERSION bump: an
|
|
189
175
|
// artifact without the field reads as before).
|
|
190
|
-
|
|
176
|
+
// v28: output_files.ino + .ctime_ms — the skip-restore check's guard
|
|
177
|
+
// against another entry's bytes. Two entries whose outputs carry
|
|
178
|
+
// one fixed mtime (`tar -x`, `cp -p`, SOURCE_DATE_EPOCH) and one
|
|
179
|
+
// size matched each other's (size, mode, mtime) rows, so a hit left
|
|
180
|
+
// the last entry's bytes on disk under a green run (item 886). A row
|
|
181
|
+
// is current only with the inode and ctime recorded after the save
|
|
182
|
+
// or restore that wrote it. The cache KEY is unchanged.
|
|
183
|
+
export const SCHEMA_VERSION = 'v28'
|
|
184
|
+
|
|
185
|
+
/** A schema version's number (`v28` → 28); one that is not `v<n>` is older than any. */
|
|
186
|
+
function schemaOrdinal(version: string): number {
|
|
187
|
+
const m = /^v(\d+)$/.exec(version)
|
|
188
|
+
return m === null ? -1 : Number(m[1])
|
|
189
|
+
}
|
|
191
190
|
|
|
192
191
|
/**
|
|
193
192
|
* SQL predicate selecting `runs` rows that record an EXECUTION.
|
|
@@ -214,17 +213,6 @@ export const EXECUTED_RUNS_SQL = "status <> 'skipped'"
|
|
|
214
213
|
*/
|
|
215
214
|
export const KEYED_RUNS_SQL = "hash <> ''"
|
|
216
215
|
|
|
217
|
-
/**
|
|
218
|
-
* Artifact + `output_files` namespace prefix for workspace-root-
|
|
219
|
-
* anchored outputs (`cache.outputs.workspaceFiles`). Project outputs
|
|
220
|
-
* keep their bare project-relative `path` rows; workspace rows store
|
|
221
|
-
* the full `workspace-outputs/<rel-to-root>` tar entry name as the
|
|
222
|
-
* discriminator — least-invasive row format, no schema change. A
|
|
223
|
-
* project output dir literally named `workspace-outputs/` would
|
|
224
|
-
* collide with the namespace; the name is reserved.
|
|
225
|
-
*/
|
|
226
|
-
export const WORKSPACE_OUTPUT_PREFIX = 'workspace-outputs/'
|
|
227
|
-
|
|
228
216
|
// The contract, the policy grammar and the zstd framing live beside this
|
|
229
217
|
// file; they are re-exported here so an importer of `./cache.js` — the
|
|
230
218
|
// module index, the sibling layers, the tests — keeps one path.
|
|
@@ -273,6 +261,93 @@ function usageOfEntry(entry: { cpuMs?: number; peakRssBytes?: number }): ExecUsa
|
|
|
273
261
|
}
|
|
274
262
|
}
|
|
275
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Make `cacheDir` exist and say why this process cannot write into it, or
|
|
266
|
+
* `null` when it can: the directory must take new files (the WAL, the
|
|
267
|
+
* artifacts) and `cache.db`, when it exists, must take pages. The file
|
|
268
|
+
* system's answer, not a trial write — under WAL a rolled-back write never
|
|
269
|
+
* reaches the disk, so `BEGIN IMMEDIATE … ROLLBACK` passes on a handle
|
|
270
|
+
* SQLite opened read-only (proven as an unprivileged user, 2026-09-16).
|
|
271
|
+
*
|
|
272
|
+
* Every verb opens the cache, so each question here is one call whose
|
|
273
|
+
* failure is another question's answer: `access` on the directory is also
|
|
274
|
+
* "does it exist" (made only on ENOENT), `access` on the database also "is
|
|
275
|
+
* it there", and the ignore file is created exclusively instead of probed
|
|
276
|
+
* first. An existing cache costs three calls; it cost six.
|
|
277
|
+
*/
|
|
278
|
+
function openCacheDir(cacheDir: string): string | null {
|
|
279
|
+
const ignore = path.join(cacheDir, '.gitignore')
|
|
280
|
+
try {
|
|
281
|
+
accessSync(cacheDir, constants.W_OK)
|
|
282
|
+
} catch (err) {
|
|
283
|
+
makeCacheDir(cacheDir)
|
|
284
|
+
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') return errorText(err)
|
|
285
|
+
writeFileSync(ignore, IGNORE_ALL)
|
|
286
|
+
return null
|
|
287
|
+
}
|
|
288
|
+
try {
|
|
289
|
+
accessSync(path.join(cacheDir, 'cache.db'), constants.W_OK)
|
|
290
|
+
} catch (err) {
|
|
291
|
+
const code = (err as NodeJS.ErrnoException).code
|
|
292
|
+
// A FILE at `cacheDir` passed the `access` above; mkdir names it.
|
|
293
|
+
if (code === 'ENOTDIR') makeCacheDir(cacheDir)
|
|
294
|
+
if (code !== 'ENOENT') return errorText(err)
|
|
295
|
+
refuseManifestDir(cacheDir)
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
writeFileSync(ignore, IGNORE_ALL, { flag: 'wx' })
|
|
299
|
+
} catch (err) {
|
|
300
|
+
if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
|
|
301
|
+
}
|
|
302
|
+
return null
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Make the cache dir invisible to git, every time it is created: a `*`
|
|
306
|
+
// .gitignore inside it (the Cargo / Nx convention). Two reasons, both
|
|
307
|
+
// measured. A cache nobody ignored gets COMMITTED by the next `git add -A`;
|
|
308
|
+
// and vx's own `git status -uall` walks it — 1000 artifacts doubled the
|
|
309
|
+
// enumeration on the bench workspace before its generator ignored `.vx`. An
|
|
310
|
+
// ignored directory is skipped by the walk entirely. Only written when
|
|
311
|
+
// absent, so a user's own file wins.
|
|
312
|
+
const IGNORE_ALL = '*\n'
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* A first index in a directory that holds a manifest is a cache pointed at
|
|
316
|
+
* the workspace or a project (`cacheDir: ''`, `'.'`, `'packages/a'`): the
|
|
317
|
+
* `*` ignore file above hid every file in it from git, so its inputs
|
|
318
|
+
* matched nothing and a changed source replayed the old output, and the
|
|
319
|
+
* artifacts landed among the sources (item 997). Asked only when there is
|
|
320
|
+
* no index yet, so an open cache pays nothing for it.
|
|
321
|
+
*/
|
|
322
|
+
function refuseManifestDir(cacheDir: string): void {
|
|
323
|
+
for (const manifest of ['package.json', 'pnpm-workspace.yaml']) {
|
|
324
|
+
if (!existsSync(path.join(cacheDir, manifest))) continue
|
|
325
|
+
throw new UserError(
|
|
326
|
+
`cache directory ${cacheDir} holds a ${manifest}: it is the workspace's or a project's own ` +
|
|
327
|
+
`directory, and vx would keep its index there under a \`*\` .gitignore that hides every file ` +
|
|
328
|
+
`in it from git and from the cache keys. Point \`cacheDir\` in vx.workspace.ts (or ` +
|
|
329
|
+
`--cache-dir) at a directory of its own, such as .vx/cache.`,
|
|
330
|
+
)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function makeCacheDir(cacheDir: string): void {
|
|
335
|
+
try {
|
|
336
|
+
mkdirSync(cacheDir, { recursive: true })
|
|
337
|
+
} catch (err) {
|
|
338
|
+
// A read-only checkout with no cache yet: every verb opens the cache,
|
|
339
|
+
// so this is the first thing any of them says there.
|
|
340
|
+
throw new UserError(
|
|
341
|
+
`cannot create cache directory ${cacheDir} (${errorText(err)}) — vx keeps its cache there; make ` +
|
|
342
|
+
`the workspace writable, set \`cacheDir\` in vx.workspace.ts, or pass --cache-dir <path>`,
|
|
343
|
+
)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function errorText(err: unknown): string {
|
|
348
|
+
return err instanceof Error ? err.message : String(err)
|
|
349
|
+
}
|
|
350
|
+
|
|
276
351
|
export class Cache implements CacheLayer {
|
|
277
352
|
/** This IS the local layer — there is nothing slower behind it. */
|
|
278
353
|
readonly hasRemote = false
|
|
@@ -298,6 +373,10 @@ export class Cache implements CacheLayer {
|
|
|
298
373
|
*/
|
|
299
374
|
private readonly read: boolean
|
|
300
375
|
private readonly write: boolean
|
|
376
|
+
/** A reading verb's handle (`Cache.inspect`): it prunes no history. */
|
|
377
|
+
private readonly inspecting: boolean
|
|
378
|
+
/** Why this process cannot write into `cacheDir`, or `null`; decided at open. */
|
|
379
|
+
private readonly writeBlocked: string | null
|
|
301
380
|
|
|
302
381
|
/**
|
|
303
382
|
* Set when THIS open found an index written by another `SCHEMA_VERSION`
|
|
@@ -309,42 +388,92 @@ export class Cache implements CacheLayer {
|
|
|
309
388
|
*/
|
|
310
389
|
readonly schemaReset: SchemaReset | null = null
|
|
311
390
|
|
|
391
|
+
/**
|
|
392
|
+
* Set when THIS open found entries written under another
|
|
393
|
+
* `CACHE_VERSION`: the index survives, but no old key is derived again,
|
|
394
|
+
* so every cached task misses once. Reported by `noteSchemaReset`.
|
|
395
|
+
*/
|
|
396
|
+
readonly formatChange: SchemaReset | null = null
|
|
397
|
+
|
|
398
|
+
/** A reading verb's open: it never resets the index (the constructor's `mode`). */
|
|
399
|
+
static inspect(cacheDir: string): Cache {
|
|
400
|
+
return new Cache(cacheDir, undefined, undefined, undefined, 'inspect')
|
|
401
|
+
}
|
|
402
|
+
|
|
312
403
|
constructor(
|
|
313
404
|
private readonly cacheDir: string,
|
|
314
405
|
localPolicy: { read: boolean; write: boolean } = { read: true, write: true },
|
|
406
|
+
/** The workspace root, where the file hasher asks git for the object format (file-hashes.ts). */
|
|
407
|
+
repoDir?: string,
|
|
408
|
+
/**
|
|
409
|
+
* The largest artifact, decoded, this cache saves, ingests or
|
|
410
|
+
* restores (zstd.ts). Lowered only by a test, through
|
|
411
|
+
* `RunOptions.artifactCeiling`: 2 GiB of output is out of a test's reach.
|
|
412
|
+
*/
|
|
413
|
+
private readonly artifactCeiling: number = MAX_DECOMPRESSED_ARTIFACT_BYTES,
|
|
414
|
+
/**
|
|
415
|
+
* `'inspect'`: a reading verb (`why`, `last`, `info`, a dry prune). It
|
|
416
|
+
* never resets the index: a schema it cannot read is refused, named,
|
|
417
|
+
* and left as it was.
|
|
418
|
+
*/
|
|
419
|
+
mode: 'open' | 'inspect' = 'open',
|
|
315
420
|
) {
|
|
421
|
+
this.inspecting = mode === 'inspect'
|
|
316
422
|
this.read = localPolicy.read
|
|
317
|
-
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
//
|
|
326
|
-
//
|
|
327
|
-
//
|
|
328
|
-
//
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
this.
|
|
333
|
-
this.db
|
|
334
|
-
this.db.exec('PRAGMA synchronous = NORMAL')
|
|
335
|
-
this.db.exec('PRAGMA foreign_keys = ON')
|
|
423
|
+
// The directory exists before the DB opens — bun:sqlite won't create
|
|
424
|
+
// parent dirs for us. A directory this user cannot write into is a
|
|
425
|
+
// read-only cache for every reader (`vx show`, `why`, `last`, the
|
|
426
|
+
// doctor, the watch sweep): the write axis goes off, so the
|
|
427
|
+
// config-evaluation store and the file-hash memo skip their upserts
|
|
428
|
+
// instead of dying in SQLite on the first miss (an unprivileged user on
|
|
429
|
+
// a root-owned `.vx`, 2026-09-16). A run wants more than a quiet
|
|
430
|
+
// read-only cache — `assertWritable()`.
|
|
431
|
+
// A reading verb over a directory with no index yet reads an empty one
|
|
432
|
+
// in memory and makes nothing on disk: `vx last --cache-dir .vx/cahce`
|
|
433
|
+
// created the typo's directory, a `.gitignore` and a database, then
|
|
434
|
+
// said "no recorded runs yet" (item 900).
|
|
435
|
+
const dbFile = path.join(cacheDir, 'cache.db')
|
|
436
|
+
const absent = mode === 'inspect' && !existsSync(dbFile)
|
|
437
|
+
this.writeBlocked = absent ? 'no index there yet' : openCacheDir(cacheDir)
|
|
438
|
+
this.write = localPolicy.write && this.writeBlocked === null
|
|
439
|
+
this.db = new Database(absent ? ':memory:' : dbFile, { create: true })
|
|
336
440
|
// busy_timeout makes concurrent writers wait for the lock instead of
|
|
337
441
|
// failing immediately with SQLITE_BUSY. Two parallel `vx run`
|
|
338
442
|
// invocations in CI is a normal pattern; without this the second one
|
|
339
|
-
// crashes in recordRun().
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`)
|
|
443
|
+
// crashes in recordRun(). FIRST, before the journal-mode switch: that
|
|
444
|
+
// pragma takes a lock of its own, and set after it the timeout did not
|
|
445
|
+
// cover it — two CLI runs opening one cache at the same instant met
|
|
446
|
+
// `database is locked` right here, on macOS's slower disk first
|
|
447
|
+
// (2026-09-16, the run lock's own e2e; the lock is taken after the
|
|
448
|
+
// cache opens, so the open is the one moment two runs still overlap).
|
|
449
|
+
// A file SQLite cannot read as a database answers the first statement
|
|
450
|
+
// that reads it, and reached every verb as `SQLiteError: file is not a
|
|
451
|
+
// database` and a stack, `vx run` and the doctor included (item 1005).
|
|
452
|
+
const readable = <T>(read: () => T): T => {
|
|
453
|
+
try {
|
|
454
|
+
return read()
|
|
455
|
+
} catch (err) {
|
|
456
|
+
if (!unreadableIndex(err)) throw err
|
|
457
|
+
this.db.close()
|
|
458
|
+
throw new UserError(
|
|
459
|
+
`the cache index ${dbFile} is not a database SQLite can read (${(err as Error).message}) — ` +
|
|
460
|
+
`it holds nothing a run cannot rebuild: remove it with its -wal and -shm files and ` +
|
|
461
|
+
`re-run, and \`vx cache prune\` reclaims the artifacts it indexed`,
|
|
462
|
+
)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
readable(() => {
|
|
466
|
+
this.db.exec('PRAGMA busy_timeout = 5000')
|
|
467
|
+
this.db.exec('PRAGMA journal_mode = WAL')
|
|
468
|
+
this.db.exec('PRAGMA synchronous = NORMAL')
|
|
469
|
+
this.db.exec('PRAGMA foreign_keys = ON')
|
|
470
|
+
this.db.exec(`
|
|
471
|
+
CREATE TABLE IF NOT EXISTS schema_meta (
|
|
472
|
+
key TEXT PRIMARY KEY,
|
|
473
|
+
value TEXT NOT NULL
|
|
474
|
+
);
|
|
475
|
+
`)
|
|
476
|
+
})
|
|
348
477
|
|
|
349
478
|
// Schema-version gate runs BEFORE the rest of the schema lands so
|
|
350
479
|
// a column rename (e.g. v15's `sha256` → `content_hash`) actually
|
|
@@ -353,19 +482,62 @@ export class Cache implements CacheLayer {
|
|
|
353
482
|
// sees them (rows first, then the file), the next save of the same
|
|
354
483
|
// key renames over them, and `prune()`'s orphan sweep unlinks the
|
|
355
484
|
// rest.
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
485
|
+
//
|
|
486
|
+
// The write half runs under the write lock and reads the row again: two
|
|
487
|
+
// processes opening one NEW cache both read no row, and the second
|
|
488
|
+
// insert died on the primary key (upstream survey, nx#28608). A warm
|
|
489
|
+
// open reads the current version and takes no lock.
|
|
490
|
+
const readVersion = (): string | undefined =>
|
|
491
|
+
(
|
|
492
|
+
this.db.prepare("SELECT value FROM schema_meta WHERE key = 'version'").get() as
|
|
493
|
+
| { value: string }
|
|
494
|
+
| undefined
|
|
495
|
+
)?.value
|
|
496
|
+
// Only an EARLIER schema is reset, and only by an opener that may write
|
|
497
|
+
// it. A newer one is another vx's index and history: an older binary
|
|
498
|
+
// (a global install beside a workspace's own) dropped every table of it
|
|
499
|
+
// and announced "vx upgraded", and so did a reading verb, a dry prune
|
|
500
|
+
// among them (item 896).
|
|
501
|
+
const refuseUnreadable = (found: string): void => {
|
|
502
|
+
if (schemaOrdinal(found) > schemaOrdinal(SCHEMA_VERSION)) {
|
|
503
|
+
throw new UserError(
|
|
504
|
+
`the cache at ${cacheDir} holds index schema ${found}, written by a newer vx; this vx reads ${SCHEMA_VERSION} and leaves it untouched. Run the newer vx, or give this one another --cache-dir`,
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
if (mode === 'inspect') {
|
|
508
|
+
throw new UserError(
|
|
509
|
+
`the cache at ${cacheDir} holds index schema ${found} from an earlier vx; this vx reads ${SCHEMA_VERSION}, so nothing in it is readable here. The next \`vx run\` resets it; a reading verb leaves it untouched`,
|
|
510
|
+
)
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
const current = readable(readVersion)
|
|
514
|
+
if (current !== SCHEMA_VERSION) {
|
|
515
|
+
try {
|
|
516
|
+
if (current !== undefined) refuseUnreadable(current)
|
|
517
|
+
this.schemaReset = this.db
|
|
518
|
+
.transaction((): SchemaReset | null => {
|
|
519
|
+
const found = readVersion()
|
|
520
|
+
if (found !== undefined && found !== SCHEMA_VERSION) refuseUnreadable(found)
|
|
521
|
+
if (found === undefined) {
|
|
522
|
+
this.db
|
|
523
|
+
.prepare("INSERT INTO schema_meta(key, value) VALUES ('version', ?)")
|
|
524
|
+
.run(SCHEMA_VERSION)
|
|
525
|
+
return null
|
|
526
|
+
}
|
|
527
|
+
if (found === SCHEMA_VERSION) return null
|
|
528
|
+
this.db.exec(
|
|
529
|
+
'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals;',
|
|
530
|
+
)
|
|
531
|
+
this.db
|
|
532
|
+
.prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
|
|
533
|
+
.run(SCHEMA_VERSION)
|
|
534
|
+
return { from: found, to: SCHEMA_VERSION }
|
|
535
|
+
})
|
|
536
|
+
.immediate()
|
|
537
|
+
} catch (err) {
|
|
538
|
+
this.db.close()
|
|
539
|
+
throw err
|
|
540
|
+
}
|
|
369
541
|
}
|
|
370
542
|
|
|
371
543
|
// Cached config evaluations (workspace/config-cache.ts): the validated
|
|
@@ -426,7 +598,16 @@ export class Cache implements CacheLayer {
|
|
|
426
598
|
attempts INTEGER,
|
|
427
599
|
-- v25: 1 when the task declared a cache block, 0 when it runs every
|
|
428
600
|
-- time by design; NULL on rows older than the column.
|
|
429
|
-
cached INTEGER
|
|
601
|
+
cached INTEGER,
|
|
602
|
+
-- v27: why a task failed or was skipped, as the run's own footer
|
|
603
|
+
-- said it (items 267–270): a skip's root blocker (a task id), vx's
|
|
604
|
+
-- own timeout (1), the sandbox's violation count, and why a
|
|
605
|
+
-- persistent task never became ready ('timeout' | 'exited' |
|
|
606
|
+
-- 'spawn'). NULL where the reason does not apply.
|
|
607
|
+
blocked_by TEXT,
|
|
608
|
+
timed_out INTEGER,
|
|
609
|
+
sandbox_violations INTEGER,
|
|
610
|
+
not_ready TEXT
|
|
430
611
|
);
|
|
431
612
|
-- Two whole-table indexes only, and both APPEND: every row of a run carries the
|
|
432
613
|
-- same run_id and a started_at newer than everything before it, so
|
|
@@ -483,14 +664,13 @@ export class Cache implements CacheLayer {
|
|
|
483
664
|
size_bytes INTEGER NOT NULL,
|
|
484
665
|
mode INTEGER NOT NULL,
|
|
485
666
|
mtime_ms INTEGER NOT NULL,
|
|
667
|
+
-- v28: the inode and ctime this machine saw after the save or
|
|
668
|
+
-- restore that left the file equal to the entry; NULL until then.
|
|
669
|
+
ino INTEGER,
|
|
670
|
+
ctime_ms INTEGER,
|
|
486
671
|
PRIMARY KEY (entry_hash, path),
|
|
487
672
|
FOREIGN KEY (entry_hash) REFERENCES entries(hash) ON DELETE CASCADE
|
|
488
673
|
);
|
|
489
|
-
-- Every directory under a whole-subtree output glob, with its mtime
|
|
490
|
-
-- as of the last save/restore on THIS machine (2026-09-03). On a warm
|
|
491
|
-
-- hit, unchanged mtimes prove the output SET is unchanged, replacing
|
|
492
|
-
-- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
|
|
493
|
-
-- ingest writes none, and the first hit after it walks and records.
|
|
494
674
|
-- Each config's ORDERED import closure (the config first), so a warm
|
|
495
675
|
-- load keys it by stat-hashing the list (the file_hashes memo) instead
|
|
496
676
|
-- of reading and scanning every file. Machine-local; pruned with
|
|
@@ -500,6 +680,11 @@ export class Cache implements CacheLayer {
|
|
|
500
680
|
files_json TEXT NOT NULL,
|
|
501
681
|
created_at INTEGER NOT NULL
|
|
502
682
|
);
|
|
683
|
+
-- Every directory under a whole-subtree output glob, with its mtime
|
|
684
|
+
-- as of the last save/restore on THIS machine (2026-09-03). On a warm
|
|
685
|
+
-- hit, unchanged mtimes prove the output SET is unchanged, replacing
|
|
686
|
+
-- the glob walk that cost 0.36 ms per hit. Machine-local: a remote
|
|
687
|
+
-- ingest writes none, and the first hit after it walks and records.
|
|
503
688
|
CREATE TABLE IF NOT EXISTS output_dirs (
|
|
504
689
|
entry_hash TEXT NOT NULL,
|
|
505
690
|
path TEXT NOT NULL,
|
|
@@ -588,10 +773,29 @@ export class Cache implements CacheLayer {
|
|
|
588
773
|
`)
|
|
589
774
|
// The slices: each owns its statements over this handle and its table(s);
|
|
590
775
|
// the schema above is the one place every table is declared.
|
|
591
|
-
this.files = new FileHashStore(this.db, cacheDir)
|
|
592
|
-
this.configEvals = new ConfigEvalTable(this.db,
|
|
776
|
+
this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
|
|
777
|
+
this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
|
|
593
778
|
this.outputs = new OutputIndex(this.db)
|
|
594
779
|
this.history = new RunHistory(this.db)
|
|
780
|
+
|
|
781
|
+
// A CACHE_VERSION bump keeps the index but moves every key, so the run
|
|
782
|
+
// after an upgrade misses everything. Roadmap 3.3: that is announced,
|
|
783
|
+
// never silent. A store with no recorded version and no entries is
|
|
784
|
+
// new; one with entries predates the record (item 671).
|
|
785
|
+
const format = this.db
|
|
786
|
+
.prepare("SELECT value FROM schema_meta WHERE key = 'cache_version'")
|
|
787
|
+
.get() as { value: string } | null
|
|
788
|
+
if (format?.value !== CACHE_VERSION && this.writeBlocked === null) {
|
|
789
|
+
const hasEntries = this.db.prepare('SELECT 1 FROM entries LIMIT 1').get() != null
|
|
790
|
+
if (this.schemaReset === null && (format !== null || hasEntries)) {
|
|
791
|
+
this.formatChange = { from: format?.value ?? 'an earlier format', to: CACHE_VERSION }
|
|
792
|
+
}
|
|
793
|
+
this.db
|
|
794
|
+
.prepare(
|
|
795
|
+
"INSERT INTO schema_meta(key, value) VALUES ('cache_version', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value",
|
|
796
|
+
)
|
|
797
|
+
.run(CACHE_VERSION)
|
|
798
|
+
}
|
|
595
799
|
}
|
|
596
800
|
|
|
597
801
|
// --- config evaluations: `ConfigEvalStore`, delegated to `ConfigEvalTable` ---
|
|
@@ -610,113 +814,47 @@ export class Cache implements CacheLayer {
|
|
|
610
814
|
putConfigEval(key: string, json: string): void {
|
|
611
815
|
this.configEvals.putConfigEval(key, json)
|
|
612
816
|
}
|
|
817
|
+
putConfigEvals(entries: ReadonlyArray<readonly [string, string]>): void {
|
|
818
|
+
this.configEvals.putConfigEvals(entries)
|
|
819
|
+
}
|
|
820
|
+
putConfigClosures(entries: ReadonlyArray<readonly [string, readonly string[]]>): void {
|
|
821
|
+
this.configEvals.putConfigClosures(entries)
|
|
822
|
+
}
|
|
613
823
|
// --- input file hashes: delegated to `FileHashStore` (see file-hashes.ts) ---
|
|
614
824
|
hashFile(filePath: string): Promise<string> {
|
|
615
825
|
return this.files.hashFile(filePath)
|
|
616
826
|
}
|
|
827
|
+
hashBytes(bytes: Uint8Array, nearPath: string): string {
|
|
828
|
+
return this.files.hashBytes(bytes, nearPath)
|
|
829
|
+
}
|
|
617
830
|
hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
|
|
618
831
|
return this.files.hashFiles(paths)
|
|
619
832
|
}
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
h = xxh3(`pkg:${input.projectPackageJsonHash}`, h)
|
|
634
|
-
h = xxh3(`config:${input.taskConfigHash}`, h)
|
|
635
|
-
if (cap) {
|
|
636
|
-
cap.push({ kind: 'workspace', name: 'fingerprint', hash: input.workspaceFingerprint })
|
|
637
|
-
cap.push({ kind: 'package', name: 'package.json', hash: input.projectPackageJsonHash })
|
|
638
|
-
cap.push({ kind: 'config', name: 'config', hash: input.taskConfigHash })
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
// NOTE on `cap` (entry_inputs capture): the `hash` field stores a
|
|
642
|
-
// DIGEST of each component, never the raw value. For value-bearing kinds
|
|
643
|
-
// (env, runtime, ws-runtime, forward) the payload is a secret or
|
|
644
|
-
// sensitive string (API keys via cache.inputs.env, runtime-command
|
|
645
|
-
// output, args after `--`), so we push `xxh3hex(v)` — the diff consumer
|
|
646
|
-
// only needs to know whether a component CHANGED, which a digest
|
|
647
|
-
// preserves losslessly. cache.db must never hold plaintext secrets at
|
|
648
|
-
// rest. The cache KEY (`h`) folds the plaintext separately below and is
|
|
649
|
-
// unaffected.
|
|
650
|
-
const forwarded = input.forwardArgs ?? []
|
|
651
|
-
h = xxh3(`forward-args:${forwarded.length}`, h)
|
|
652
|
-
for (const a of forwarded) h = xxh3(a, h)
|
|
653
|
-
if (cap && forwarded.length > 0) {
|
|
654
|
-
cap.push({ kind: 'forward', name: 'argv', hash: xxh3hex(JSON.stringify(forwarded)) })
|
|
655
|
-
}
|
|
656
|
-
|
|
657
|
-
h = xxh3(`env-values:${input.envValues.length}`, h)
|
|
658
|
-
// \0 delimiter, not `=`: names and values may themselves contain
|
|
659
|
-
// `=`, and `A` + `B=C` must never fold the same bytes as `A=B` + `C`.
|
|
660
|
-
for (const [n, v] of input.envValues) h = xxh3(`${n}\0${v}`, h)
|
|
661
|
-
if (cap)
|
|
662
|
-
for (const [n, v] of input.envValues) cap.push({ kind: 'env', name: n, hash: xxh3hex(v) })
|
|
663
|
-
|
|
664
|
-
const runtimeValues = input.runtimeValues ?? []
|
|
665
|
-
h = xxh3(`runtime-values:${runtimeValues.length}`, h)
|
|
666
|
-
for (const [c, o] of runtimeValues) h = xxh3(`${c}\0${o}`, h)
|
|
667
|
-
if (cap)
|
|
668
|
-
for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: xxh3hex(o) })
|
|
669
|
-
|
|
670
|
-
const wsRuntimeValues = input.workspaceRuntimeValues ?? []
|
|
671
|
-
h = xxh3(`ws-runtime-values:${wsRuntimeValues.length}`, h)
|
|
672
|
-
for (const [c, o] of wsRuntimeValues) h = xxh3(`${c}\0${o}`, h)
|
|
673
|
-
if (cap)
|
|
674
|
-
for (const [c, o] of wsRuntimeValues)
|
|
675
|
-
cap.push({ kind: 'ws-runtime', name: c, hash: xxh3hex(o) })
|
|
676
|
-
|
|
677
|
-
const upstream = [...input.upstreamHashes].sort()
|
|
678
|
-
h = xxh3(`upstream:${upstream.length}`, h)
|
|
679
|
-
for (const u of upstream) h = xxh3(u, h)
|
|
680
|
-
if (cap) {
|
|
681
|
-
for (const u of upstream) {
|
|
682
|
-
cap.push({ kind: 'upstream', name: input.upstreamIds?.get(u) ?? u, hash: u })
|
|
683
|
-
}
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
const pluginParts = input.pluginParts ?? []
|
|
687
|
-
if (pluginParts.length > 0) {
|
|
688
|
-
h = xxh3(`plugin:${pluginParts.length}`, h)
|
|
689
|
-
for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
|
|
690
|
-
if (cap) {
|
|
691
|
-
for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: xxh3hex(v) })
|
|
692
|
-
}
|
|
833
|
+
/**
|
|
834
|
+
* `relPosix` against the run's workspace root, memoized: the same three
|
|
835
|
+
* thousand files are re-relativized for every task of the project, which
|
|
836
|
+
* is 132,000 calls for 3,000 answers on this repo's own gate. Cleared
|
|
837
|
+
* when a caller arrives with a different root, so the memo can never
|
|
838
|
+
* answer for a workspace it did not measure.
|
|
839
|
+
*/
|
|
840
|
+
private relMemo = new Map<string, string>()
|
|
841
|
+
private relMemoRoot: string | undefined
|
|
842
|
+
private relFor(root: string, file: string): string {
|
|
843
|
+
if (root !== this.relMemoRoot) {
|
|
844
|
+
this.relMemoRoot = root
|
|
845
|
+
this.relMemo.clear()
|
|
693
846
|
}
|
|
847
|
+
let rel = this.relMemo.get(file)
|
|
848
|
+
if (rel === undefined) this.relMemo.set(file, (rel = relPosix(root, file)))
|
|
849
|
+
return rel
|
|
850
|
+
}
|
|
694
851
|
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
// All three describe the WORKTREE bytes, which is what the task
|
|
701
|
-
// reads — the index-OID map is only populated for paths whose
|
|
702
|
-
// worktree form matches their index form, because a clean filter
|
|
703
|
-
// (`text`/`eol`/`ident`) makes the index blob a DIFFERENT sequence
|
|
704
|
-
// of bytes and folding it would let two distinct worktree contents
|
|
705
|
-
// share a key. The fold order is locked to `sortedInputs` so
|
|
706
|
-
// results are stable across runs.
|
|
707
|
-
const fileHashes = await Promise.all(
|
|
708
|
-
sortedInputs.map((f) => input.fileHashes?.get(f) ?? this.hashFile(f)),
|
|
852
|
+
key(input: CacheKeyInput): Promise<string> {
|
|
853
|
+
return foldKey(
|
|
854
|
+
input,
|
|
855
|
+
(f) => this.hashFile(f),
|
|
856
|
+
(f) => this.relFor(input.workspaceRoot, f),
|
|
709
857
|
)
|
|
710
|
-
for (let i = 0; i < sortedInputs.length; i++) {
|
|
711
|
-
const file = sortedInputs[i]!
|
|
712
|
-
const rel = relPosix(input.workspaceRoot, file)
|
|
713
|
-
const oid = fileHashes[i]!
|
|
714
|
-
h = xxh3(`${rel}\0${oid}`, h)
|
|
715
|
-
// The OID is already awaited — file capture is zero extra I/O.
|
|
716
|
-
if (cap) cap.push({ kind: 'file', name: rel, hash: oid })
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
return h.toString(16).padStart(16, '0')
|
|
720
858
|
}
|
|
721
859
|
|
|
722
860
|
// ctx is accepted but ignored — the local layer reads metadata from
|
|
@@ -841,6 +979,9 @@ export class Cache implements CacheLayer {
|
|
|
841
979
|
): Promise<void> {
|
|
842
980
|
await this.outputs.recordOutputDirs(hash, projectDir, prefixes)
|
|
843
981
|
}
|
|
982
|
+
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
983
|
+
this.outputs.recordOutputStamps(hash, projectDir, workspaceRoot)
|
|
984
|
+
}
|
|
844
985
|
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
845
986
|
return this.outputs.loadOutputDirsBatch(hashes)
|
|
846
987
|
}
|
|
@@ -861,16 +1002,22 @@ export class Cache implements CacheLayer {
|
|
|
861
1002
|
// replay through the logger.
|
|
862
1003
|
const src = this.tarPath(hash)
|
|
863
1004
|
// The caller already committed to this hit and WIPED the declared
|
|
864
|
-
// outputs, so
|
|
865
|
-
// an emptied output tree. The artifact existed when `get()`
|
|
866
|
-
// its absence now means something removed it underneath us
|
|
867
|
-
// `vx cache prune`
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
1005
|
+
// outputs, so a restore that did not fail here would report a green cache
|
|
1006
|
+
// hit over an emptied output tree. The artifact existed when `get()`
|
|
1007
|
+
// probed it, so its absence now means something removed it underneath us
|
|
1008
|
+
// (a `vx cache prune` in another shell, or another workspace's retention
|
|
1009
|
+
// on a shared `--cache-dir`) — `ArtifactVanishedError`, which the caller
|
|
1010
|
+
// turns into a miss: the task runs.
|
|
1011
|
+
//
|
|
1012
|
+
// The FAILING is held twice: the decode below reaches the missing file
|
|
1013
|
+
// and throws `CorruptArtifactError` from the extract catch either way.
|
|
1014
|
+
// What the `ENOENT` mapping in that catch carries alone is the CLASS,
|
|
1015
|
+
// and the two ask for opposite answers — a prune raced this run (run
|
|
1016
|
+
// the task) versus the cache holds bad bytes (fail, and say so). The
|
|
1017
|
+
// roundtrip row asserts the class (item 481); a row that only asserts
|
|
1018
|
+
// "the restore fails" passes with the mapping deleted. It was a
|
|
1019
|
+
// separate `exists()` probe until item 627: one thread-pool round trip
|
|
1020
|
+
// per restore, spent to learn what the read reports itself.
|
|
874
1021
|
const endRows = span('restore: rows')
|
|
875
1022
|
// The index says exactly which files this entry materializes. If the
|
|
876
1023
|
// archive cannot produce one of them, restoring "successfully" leaves a
|
|
@@ -909,14 +1056,25 @@ export class Cache implements CacheLayer {
|
|
|
909
1056
|
// 0.62 ms sequential, 2026-09-10) and 30% slower in the run, where
|
|
910
1057
|
// four workers overlap their round trips and a blocking one stalls
|
|
911
1058
|
// the other three (1,000-project restore row 1.1–1.2 s → 1.5 s).
|
|
912
|
-
const tar = await decodedTar(Bun.file(src), hash)
|
|
1059
|
+
const tar = await decodedTar(Bun.file(src), hash, this.artifactCeiling)
|
|
913
1060
|
await extractArtifactStream(tar, projectDir, workspaceRoot, verify)
|
|
914
1061
|
} catch (err) {
|
|
915
|
-
|
|
1062
|
+
// A UserError here is the extractor naming the tree's fault (an
|
|
1063
|
+
// output directory that links out of the project).
|
|
1064
|
+
if (
|
|
1065
|
+
err instanceof ArchiveSecurityError ||
|
|
1066
|
+
err instanceof CorruptArtifactError ||
|
|
1067
|
+
err instanceof UserError
|
|
1068
|
+
) {
|
|
1069
|
+
throw err
|
|
1070
|
+
}
|
|
916
1071
|
// What is on disk, not what is in the archive: a directory standing
|
|
917
1072
|
// where the entry holds a file, or a file where it needs a directory.
|
|
918
1073
|
// The clean removes everything the output globs cover, so this is a
|
|
919
|
-
// stray they do not — name it as such, not as a corrupt artifact.
|
|
1074
|
+
// stray they do not — name it as such, not as a corrupt artifact. A
|
|
1075
|
+
// link on the way never lands here: the extractor writes through one
|
|
1076
|
+
// that stays in the project, dangling or not, and names one that
|
|
1077
|
+
// leaves it or loops (item 748).
|
|
920
1078
|
const code = (err as NodeJS.ErrnoException).code
|
|
921
1079
|
if (code === 'EISDIR' || code === 'ENOTDIR' || code === 'EEXIST' || code === 'ENOTEMPTY') {
|
|
922
1080
|
throw new UserError(
|
|
@@ -924,12 +1082,65 @@ export class Cache implements CacheLayer {
|
|
|
924
1082
|
`Declared outputs are wiped before a restore, so this is a path the output globs do not cover — remove it and re-run.`,
|
|
925
1083
|
)
|
|
926
1084
|
}
|
|
1085
|
+
// A legal name under a destination deep enough that the two together
|
|
1086
|
+
// pass PATH_MAX: the artifact is fine, the workspace's location is
|
|
1087
|
+
// not (the parity audit's last open archive row, item 670).
|
|
1088
|
+
if (code === 'ENAMETOOLONG') {
|
|
1089
|
+
throw new UserError(
|
|
1090
|
+
`restore of ${hash} into ${projectDir} could not write its outputs (${code}: ${(err as Error).message}). ` +
|
|
1091
|
+
`An output path under this directory is longer than the file system allows — move the workspace to a shorter path.`,
|
|
1092
|
+
)
|
|
1093
|
+
}
|
|
1094
|
+
// Same distinction for a tree the process cannot write into — a
|
|
1095
|
+
// `dist/` another user owns, a read-only checkout, a full disk: the
|
|
1096
|
+
// artifact is intact, the tree is not the process's to change.
|
|
1097
|
+
if (isFsRefusal(err)) {
|
|
1098
|
+
const remedy = isDiskFull(err)
|
|
1099
|
+
? 'Free space on that disk and re-run.'
|
|
1100
|
+
: 'Make the declared output paths writable by this user, or stop declaring them as outputs.'
|
|
1101
|
+
throw new UserError(
|
|
1102
|
+
`restore of ${hash} into ${projectDir} could not write its outputs (${code}: ${err.message}). ${remedy}`,
|
|
1103
|
+
)
|
|
1104
|
+
}
|
|
1105
|
+
// The artifact itself is gone: the read names its path (`bytes()`
|
|
1106
|
+
// opens it; a staged file's ENOENT names the temp below).
|
|
1107
|
+
if (code === 'ENOENT' && (err as NodeJS.ErrnoException).path === src) {
|
|
1108
|
+
throw new ArtifactVanishedError(hash)
|
|
1109
|
+
}
|
|
1110
|
+
// A staged file the restore itself just wrote is gone before its
|
|
1111
|
+
// commit: another process cleaned the same outputs under us (two runs
|
|
1112
|
+
// on one workspace, a `vx watch` beside a `vx run` — a clean landing
|
|
1113
|
+
// 4–32 ms into a 2,000-file restore reproduces it, 2026-09-16). The
|
|
1114
|
+
// artifact is intact; the tree was not ours alone.
|
|
1115
|
+
if (code === 'ENOENT' && (err as Error).message.includes('.vx-tmp-')) {
|
|
1116
|
+
throw new UserError(
|
|
1117
|
+
`restore of ${hash} into ${projectDir} was interrupted: a file it had just written vanished (${(err as Error).message}). ` +
|
|
1118
|
+
`Another vx run is using this workspace — re-run once it is done.`,
|
|
1119
|
+
)
|
|
1120
|
+
}
|
|
927
1121
|
throw new CorruptArtifactError(hash, 'artifact is not a readable archive', err)
|
|
928
1122
|
} finally {
|
|
929
1123
|
endExtract()
|
|
930
1124
|
}
|
|
931
1125
|
}
|
|
932
1126
|
|
|
1127
|
+
/**
|
|
1128
|
+
* A run writes here on every path — the run record at its end, an
|
|
1129
|
+
* entry's `accessed_at` on a hit, the artifact on a miss — so a cache
|
|
1130
|
+
* directory this user cannot write into fails the run before the graph
|
|
1131
|
+
* starts, once, with the directory named, rather than every task at
|
|
1132
|
+
* 0 ms (or the run at its very end) with SQLite's "attempt to write a
|
|
1133
|
+
* readonly database" as an internal error. The readers open the same
|
|
1134
|
+
* directory read-only and go on; a run refuses.
|
|
1135
|
+
*/
|
|
1136
|
+
assertWritable(): void {
|
|
1137
|
+
if (this.writeBlocked === null) return
|
|
1138
|
+
throw new UserError(
|
|
1139
|
+
`cache directory ${this.cacheDir} is not writable (${this.writeBlocked}) — every run records its ` +
|
|
1140
|
+
`history there; make it writable by this user, or pass --cache-dir <path>`,
|
|
1141
|
+
)
|
|
1142
|
+
}
|
|
1143
|
+
|
|
933
1144
|
async save(args: {
|
|
934
1145
|
hash: string
|
|
935
1146
|
/** See `CacheLayer.save` — `exitCode` is not a caller's to supply. */
|
|
@@ -961,7 +1172,10 @@ export class Cache implements CacheLayer {
|
|
|
961
1172
|
// the bytes, since there's no local artifact to read off disk.
|
|
962
1173
|
if (!this.write) return
|
|
963
1174
|
if (args.skipLocalWrite === true) return
|
|
964
|
-
|
|
1175
|
+
// The cache directory is the constructor's (`mkdirSync`, with a
|
|
1176
|
+
// refusal recorded for `assertWritable`); re-creating it here and
|
|
1177
|
+
// again in `writeArtifactAndIndex` cost every save two thread-pool
|
|
1178
|
+
// round trips for an EEXIST and a stat (item 630).
|
|
965
1179
|
const endPack = span('save: pack')
|
|
966
1180
|
const compressed = await this.packArtifactToTemp(this.tempPath(args.hash), args)
|
|
967
1181
|
endPack()
|
|
@@ -991,20 +1205,26 @@ export class Cache implements CacheLayer {
|
|
|
991
1205
|
return this.packArtifact(args)
|
|
992
1206
|
}
|
|
993
1207
|
|
|
994
|
-
async ingest(hash: string, compressed: Uint8Array, meta: IngestMeta): Promise<void> {
|
|
995
|
-
await this.writeArtifactAndIndex(hash, compressed, meta)
|
|
996
|
-
}
|
|
997
|
-
|
|
998
1208
|
/**
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
*
|
|
1003
|
-
*
|
|
1004
|
-
* Entries are named directly into the archive, so there is no staging
|
|
1005
|
-
* copy of every output byte and no `tar` subprocess (see
|
|
1006
|
-
* `archive.ts`).
|
|
1209
|
+
* The remote body goes to the temp by `Bun.write`, which streams a
|
|
1210
|
+
* `Response` and copies a file `Blob` without collecting either, so a
|
|
1211
|
+
* pull never holds the artifact. A body that fails mid-stream (a dropped
|
|
1212
|
+
* socket) leaves a partial temp behind it, removed here; validation
|
|
1213
|
+
* removes its own.
|
|
1007
1214
|
*/
|
|
1215
|
+
async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
1216
|
+
const tmpPath = this.tempPath(hash)
|
|
1217
|
+
try {
|
|
1218
|
+
// Split only for the typings: Bun.write's Response and Blob overloads
|
|
1219
|
+
// do not accept their union.
|
|
1220
|
+
await (body instanceof Response ? Bun.write(tmpPath, body) : Bun.write(tmpPath, body))
|
|
1221
|
+
} catch (err) {
|
|
1222
|
+
await unlink(tmpPath).catch(() => undefined)
|
|
1223
|
+
throw err
|
|
1224
|
+
}
|
|
1225
|
+
await this.writeArtifactAndIndex(hash, { tmpPath }, meta)
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1008
1228
|
/** Archive name → absolute source path for every declared output. */
|
|
1009
1229
|
private outputsOf(args: {
|
|
1010
1230
|
projectDir: string
|
|
@@ -1027,9 +1247,17 @@ export class Cache implements CacheLayer {
|
|
|
1027
1247
|
/**
|
|
1028
1248
|
* The compressed artifact, in memory: the remote upload when local
|
|
1029
1249
|
* writes are off. A large artifact is packed and compressed as a
|
|
1030
|
-
* stream and collected — the
|
|
1250
|
+
* stream and collected — with no local artifact the bytes must be
|
|
1251
|
+
* captured while the outputs are still on disk.
|
|
1252
|
+
*
|
|
1253
|
+
* Both packers name entries directly into the archive — no staging
|
|
1254
|
+
* copy of the outputs, no `tar` subprocess (`archive.ts`) — and
|
|
1255
|
+
* neither writes the final cache path, which is the index step's job:
|
|
1256
|
+
* `ingest()` packs nothing and hands its remote-supplied temp straight
|
|
1257
|
+
* to `writeArtifactAndIndex`.
|
|
1031
1258
|
*/
|
|
1032
1259
|
private async packArtifact(args: {
|
|
1260
|
+
hash: string
|
|
1033
1261
|
entry: Omit<CacheEntry, 'hash' | 'storedAt' | 'outputFiles' | 'exitCode'>
|
|
1034
1262
|
projectDir: string
|
|
1035
1263
|
outputFiles: string[]
|
|
@@ -1039,14 +1267,33 @@ export class Cache implements CacheLayer {
|
|
|
1039
1267
|
// stdout is ALWAYS present in the artifact, even if empty, so the
|
|
1040
1268
|
// layout is predictable: a successful read finds `stdout` and
|
|
1041
1269
|
// zero-or-more `outputs/<rel>` / `workspace-outputs/<rel>` entries.
|
|
1270
|
+
const plan = await this.planWithin(args)
|
|
1271
|
+
if (plan.size <= STREAM_DECODE_FROM)
|
|
1272
|
+
return await Bun.zstdCompress(await packArtifactBytes(plan))
|
|
1273
|
+
return bytesOf(packArtifactStream(plan).pipeThrough(zstdEncoder()))
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The artifact's plan, refused when its tar — exactly the bytes a restore
|
|
1278
|
+
* decodes — is past the ceiling every restore enforces. Refused here, it
|
|
1279
|
+
* costs a stat per output; left to the scan, it cost the whole compress
|
|
1280
|
+
* and a decode of a 2.2 GB output (6 to 14 s) to learn the same thing
|
|
1281
|
+
* and call the task's own outputs a "corrupt artifact".
|
|
1282
|
+
*/
|
|
1283
|
+
private async planWithin(args: Parameters<Cache['packArtifact']>[0]): Promise<ArtifactPlan> {
|
|
1042
1284
|
const plan = await planArtifact({
|
|
1285
|
+
key: args.hash,
|
|
1043
1286
|
stdout: args.entry.stdout ?? '',
|
|
1044
1287
|
outputs: this.outputsOf(args),
|
|
1045
1288
|
exec: usageOfEntry(args.entry),
|
|
1046
1289
|
})
|
|
1047
|
-
if (plan.size
|
|
1048
|
-
|
|
1049
|
-
|
|
1290
|
+
if (plan.size > this.artifactCeiling) {
|
|
1291
|
+
throw new Error(
|
|
1292
|
+
`${args.entry.taskId} is not cached: its outputs pack to ${formatBytes(plan.size)}, past the ` +
|
|
1293
|
+
`${formatBytes(this.artifactCeiling)} artifact ceiling a restore enforces — narrow cache.outputs.files`,
|
|
1294
|
+
)
|
|
1295
|
+
}
|
|
1296
|
+
return plan
|
|
1050
1297
|
}
|
|
1051
1298
|
|
|
1052
1299
|
/**
|
|
@@ -1062,11 +1309,7 @@ export class Cache implements CacheLayer {
|
|
|
1062
1309
|
tmpPath: string,
|
|
1063
1310
|
args: Parameters<Cache['packArtifact']>[0],
|
|
1064
1311
|
): Promise<Uint8Array | { tmpPath: string }> {
|
|
1065
|
-
const plan = await
|
|
1066
|
-
stdout: args.entry.stdout ?? '',
|
|
1067
|
-
outputs: this.outputsOf(args),
|
|
1068
|
-
exec: usageOfEntry(args.entry),
|
|
1069
|
-
})
|
|
1312
|
+
const plan = await this.planWithin(args)
|
|
1070
1313
|
if (plan.size <= STREAM_DECODE_FROM)
|
|
1071
1314
|
return await Bun.zstdCompress(await packArtifactBytes(plan))
|
|
1072
1315
|
const sink = Bun.file(tmpPath).writer()
|
|
@@ -1085,13 +1328,6 @@ export class Cache implements CacheLayer {
|
|
|
1085
1328
|
return { tmpPath }
|
|
1086
1329
|
}
|
|
1087
1330
|
|
|
1088
|
-
/**
|
|
1089
|
-
* Atomically write `compressed` to `<hash>.tar.zst` and (re)build the
|
|
1090
|
-
* entries + output_files SQL rows from the archive itself. Shared by
|
|
1091
|
-
* `save()` (we just packed the bytes) and `ingest()` (we got them from
|
|
1092
|
-
* the remote layer) — both index the identical values, because both
|
|
1093
|
-
* read them out of the artifact.
|
|
1094
|
-
*/
|
|
1095
1331
|
/** tmp suffix mixes pid + hrtime + a random hex chunk so two saves of
|
|
1096
1332
|
* the same hash from the same process (or from two forked workers that
|
|
1097
1333
|
* happen to share a wall-clock ms) don't pick the same tmp filename and
|
|
@@ -1100,13 +1336,20 @@ export class Cache implements CacheLayer {
|
|
|
1100
1336
|
return `${this.tarPath(hash)}.tmp-${process.pid}-${process.hrtime.bigint()}-${Math.random().toString(36).slice(2, 10)}`
|
|
1101
1337
|
}
|
|
1102
1338
|
|
|
1339
|
+
/**
|
|
1340
|
+
* Atomically write `compressed` to `<hash>.tar.zst` and (re)build the
|
|
1341
|
+
* entries + output_files SQL rows from the archive itself. Shared by
|
|
1342
|
+
* `save()` (we just packed the bytes) and `ingest()` (it streamed them
|
|
1343
|
+
* from the remote layer into a temp) — both index the identical values, because both
|
|
1344
|
+
* read them out of the artifact.
|
|
1345
|
+
*/
|
|
1103
1346
|
private async writeArtifactAndIndex(
|
|
1104
1347
|
hash: string,
|
|
1105
1348
|
compressed: Uint8Array | { tmpPath: string },
|
|
1106
1349
|
meta: IngestMeta,
|
|
1107
1350
|
): Promise<void> {
|
|
1108
1351
|
// Validate BEFORE anything touches the final path. `ingest()` feeds
|
|
1109
|
-
// us network bytes; a truncated/garbage body that went live first
|
|
1352
|
+
// us a temp of network bytes; a truncated/garbage body that went live first
|
|
1110
1353
|
// would leave a corrupt `<hash>.tar.zst` behind (with no SQL row,
|
|
1111
1354
|
// since the decompress throw aborted indexing) for every later
|
|
1112
1355
|
// reader to trip over. Decompress + parse also produce the
|
|
@@ -1117,7 +1360,6 @@ export class Cache implements CacheLayer {
|
|
|
1117
1360
|
let tmpPath: string
|
|
1118
1361
|
if (compressed instanceof Uint8Array) {
|
|
1119
1362
|
tmpPath = this.tempPath(hash)
|
|
1120
|
-
await mkdir(this.cacheDir, { recursive: true })
|
|
1121
1363
|
// The temp is written BEFORE validation so a large artifact can be
|
|
1122
1364
|
// scanned from the file as it decodes — a file stream reads in
|
|
1123
1365
|
// bounded pieces; the bytes in memory would not (see `decodedTar`).
|
|
@@ -1128,12 +1370,12 @@ export class Cache implements CacheLayer {
|
|
|
1128
1370
|
await writeFile(tmpPath, compressed)
|
|
1129
1371
|
endWrite()
|
|
1130
1372
|
} else {
|
|
1131
|
-
// `save` already streamed the artifact into its temp.
|
|
1373
|
+
// `save` or `ingest` already streamed the artifact into its temp.
|
|
1132
1374
|
tmpPath = compressed.tmpPath
|
|
1133
1375
|
}
|
|
1134
1376
|
let scanned: Awaited<ReturnType<typeof scanArtifact>>
|
|
1135
1377
|
try {
|
|
1136
|
-
// ingest() is the UNTRUSTED boundary —
|
|
1378
|
+
// ingest() is the UNTRUSTED boundary — its temp holds bytes just
|
|
1137
1379
|
// pulled from a remote. Refuse a bomb (declared or sizeless) before it
|
|
1138
1380
|
// can expand into memory.
|
|
1139
1381
|
const source =
|
|
@@ -1141,13 +1383,22 @@ export class Cache implements CacheLayer {
|
|
|
1141
1383
|
? compressed
|
|
1142
1384
|
: Bun.file(tmpPath)
|
|
1143
1385
|
const endScan = span('save: scan')
|
|
1144
|
-
scanned = await scanArtifact(await decodedTar(source, hash))
|
|
1386
|
+
scanned = await scanArtifact(await decodedTar(source, hash, this.artifactCeiling))
|
|
1145
1387
|
endScan()
|
|
1146
1388
|
// v17 invariant: every artifact carries a `stdout` entry. Its
|
|
1147
1389
|
// absence means the bytes decompressed but aren't a vx artifact.
|
|
1148
1390
|
if (scanned.stdout === null) {
|
|
1149
1391
|
throw new CorruptArtifactError(hash, 'missing stdout entry')
|
|
1150
1392
|
}
|
|
1393
|
+
if (scanned.key !== hash) {
|
|
1394
|
+
throw new CorruptArtifactError(
|
|
1395
|
+
hash,
|
|
1396
|
+
scanned.key === undefined
|
|
1397
|
+
? 'artifact records no cache key'
|
|
1398
|
+
: `artifact was stored under another key (${scanned.key})`,
|
|
1399
|
+
)
|
|
1400
|
+
}
|
|
1401
|
+
assertArtifactNames(hash, scanned.entries, meta.outputs)
|
|
1151
1402
|
} catch (err) {
|
|
1152
1403
|
await unlink(tmpPath).catch(() => undefined)
|
|
1153
1404
|
if (err instanceof ArchiveSecurityError || err instanceof CorruptArtifactError) throw err
|
|
@@ -1280,6 +1531,7 @@ export class Cache implements CacheLayer {
|
|
|
1280
1531
|
}
|
|
1281
1532
|
stats(opts: CacheStatsOptions = {}): CacheStats {
|
|
1282
1533
|
this.flushAccessed()
|
|
1534
|
+
this.outputs.flushOutputDirs()
|
|
1283
1535
|
const project = opts.project
|
|
1284
1536
|
const scoped = project !== undefined
|
|
1285
1537
|
const scopeParams: string[] = project === undefined ? [] : [project]
|
|
@@ -1306,8 +1558,57 @@ export class Cache implements CacheLayer {
|
|
|
1306
1558
|
}
|
|
1307
1559
|
}
|
|
1308
1560
|
|
|
1561
|
+
/**
|
|
1562
|
+
* The workspace's `cacheRetention`, applied at the end of a run: `prune()`
|
|
1563
|
+
* with the same policy, but only when it would evict something, else the
|
|
1564
|
+
* orphan sweep alone when an hour has passed since the last one. A run
|
|
1565
|
+
* with nothing due pays the accessed-at flush it owed at close anyway, one
|
|
1566
|
+
* scan of the index and one read of the sweep's clock — never the
|
|
1567
|
+
* sweep's readdir. Null when nothing was due or this handle does not write.
|
|
1568
|
+
*/
|
|
1569
|
+
async evictIfDue(
|
|
1570
|
+
policy: { maxAgeMs?: number; maxBytes?: number },
|
|
1571
|
+
now: number = Date.now(),
|
|
1572
|
+
): Promise<PruneResult | null> {
|
|
1573
|
+
if (!this.write) return null
|
|
1574
|
+
// First: an entry this run restored still carries its old `accessed_at`
|
|
1575
|
+
// until the deferred bump lands, and would read as due for eviction.
|
|
1576
|
+
this.flushAccessed()
|
|
1577
|
+
const olderThanMs = policy.maxAgeMs === undefined ? undefined : now - policy.maxAgeMs
|
|
1578
|
+
const { oldest, bytes } = this.db
|
|
1579
|
+
.prepare(
|
|
1580
|
+
'SELECT MIN(accessed_at) AS oldest, COALESCE(SUM(size_bytes), 0) AS bytes FROM entries',
|
|
1581
|
+
)
|
|
1582
|
+
.get() as { oldest: number | null; bytes: number }
|
|
1583
|
+
const ageDue = olderThanMs !== undefined && oldest !== null && oldest < olderThanMs
|
|
1584
|
+
const sizeDue = policy.maxBytes !== undefined && bytes > policy.maxBytes
|
|
1585
|
+
if (ageDue || sizeDue) {
|
|
1586
|
+
return this.prune({
|
|
1587
|
+
...(olderThanMs !== undefined ? { olderThanMs } : {}),
|
|
1588
|
+
...(policy.maxBytes !== undefined ? { maxBytes: policy.maxBytes } : {}),
|
|
1589
|
+
})
|
|
1590
|
+
}
|
|
1591
|
+
// Row-less artifacts are bytes the index cannot count, so the policy
|
|
1592
|
+
// above never sees them (upstream survey, nx#35483: 9 MiB of them sat
|
|
1593
|
+
// under a 1 MB limit). Listing the directory to find them costs 0.5 ms
|
|
1594
|
+
// per 1,000 entries (4.9 ms at 10,000), every run; the sweep's own
|
|
1595
|
+
// clock in `schema_meta` costs one indexed read. An orphan is reapable
|
|
1596
|
+
// only an hour after its last write anyway, so an hourly sweep reaps
|
|
1597
|
+
// it within two.
|
|
1598
|
+
const swept = this.db.prepare('SELECT value FROM schema_meta WHERE key = ?').get(SWEPT_AT) as {
|
|
1599
|
+
value: string
|
|
1600
|
+
} | null
|
|
1601
|
+
if (swept !== null && now - Number(swept.value) < ORPHAN_GRACE_MS) return null
|
|
1602
|
+
return { evicted: 0, bytesFreed: 0, ...(await this.reapOrphans(now)) }
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1309
1605
|
async prune(options: PruneOptions): Promise<PruneResult> {
|
|
1310
1606
|
this.flushAccessed()
|
|
1607
|
+
// Before any entry is deleted, so a kept entry's pending snapshot is on
|
|
1608
|
+
// disk whatever the eviction does next. A pruned hash's snapshot cannot
|
|
1609
|
+
// orphan rows in either order: landed first, the cascade takes them;
|
|
1610
|
+
// landed after, the flush's own entry check drops them (item 628).
|
|
1611
|
+
this.outputs.flushOutputDirs()
|
|
1311
1612
|
const { olderThanMs, maxBytes, dryRun = false } = options
|
|
1312
1613
|
if (olderThanMs === undefined && maxBytes === undefined) {
|
|
1313
1614
|
throw new Error('prune: pass at least one of `olderThanMs` or `maxBytes`')
|
|
@@ -1315,12 +1616,18 @@ export class Cache implements CacheLayer {
|
|
|
1315
1616
|
|
|
1316
1617
|
const victims = new Set<string>()
|
|
1317
1618
|
let bytesFreed = 0
|
|
1619
|
+
// Rows whose artifact is gone (deleted by hand, or by a disk cleaner):
|
|
1620
|
+
// never a hit, and their bytes are on no disk, but `--max-size` counted
|
|
1621
|
+
// them and evicted real entries to make room for them (item 975). They
|
|
1622
|
+
// are dropped, neither evicted nor freed.
|
|
1623
|
+
const phantoms = await this.phantomRows()
|
|
1318
1624
|
|
|
1319
1625
|
if (olderThanMs !== undefined) {
|
|
1320
1626
|
const rows = this.db
|
|
1321
1627
|
.prepare('SELECT hash, size_bytes FROM entries WHERE accessed_at < ?')
|
|
1322
1628
|
.all(olderThanMs) as Array<{ hash: string; size_bytes: number }>
|
|
1323
1629
|
for (const r of rows) {
|
|
1630
|
+
if (phantoms.has(r.hash)) continue
|
|
1324
1631
|
victims.add(r.hash)
|
|
1325
1632
|
bytesFreed += r.size_bytes
|
|
1326
1633
|
}
|
|
@@ -1330,7 +1637,9 @@ export class Cache implements CacheLayer {
|
|
|
1330
1637
|
const totalRow = this.db
|
|
1331
1638
|
.prepare('SELECT COALESCE(SUM(size_bytes), 0) AS bytes FROM entries')
|
|
1332
1639
|
.get() as { bytes: number }
|
|
1333
|
-
let
|
|
1640
|
+
let phantomBytes = 0
|
|
1641
|
+
for (const b of phantoms.values()) phantomBytes += b
|
|
1642
|
+
let remaining = totalRow.bytes - phantomBytes - bytesFreed
|
|
1334
1643
|
if (remaining > maxBytes) {
|
|
1335
1644
|
// Exclude already-picked victims in JS, not via a SQL NOT-IN —
|
|
1336
1645
|
// an IN-list over tens of thousands of TTL victims would blow
|
|
@@ -1339,7 +1648,7 @@ export class Cache implements CacheLayer {
|
|
|
1339
1648
|
this.db
|
|
1340
1649
|
.prepare('SELECT hash, size_bytes FROM entries ORDER BY accessed_at ASC')
|
|
1341
1650
|
.all() as Array<{ hash: string; size_bytes: number }>
|
|
1342
|
-
).filter((row) => !victims.has(row.hash))
|
|
1651
|
+
).filter((row) => !victims.has(row.hash) && !phantoms.has(row.hash))
|
|
1343
1652
|
for (const row of candidates) {
|
|
1344
1653
|
if (remaining <= maxBytes) break
|
|
1345
1654
|
victims.add(row.hash)
|
|
@@ -1364,11 +1673,12 @@ export class Cache implements CacheLayer {
|
|
|
1364
1673
|
orphanBytes: orphans.orphanBytes,
|
|
1365
1674
|
}
|
|
1366
1675
|
}
|
|
1367
|
-
if (victims.size > 0) {
|
|
1676
|
+
if (victims.size > 0 || phantoms.size > 0) {
|
|
1368
1677
|
const hashes = [...victims]
|
|
1678
|
+
const rows = [...hashes, ...phantoms.keys()]
|
|
1369
1679
|
this.db.transaction(() => {
|
|
1370
|
-
for (let i = 0; i <
|
|
1371
|
-
const chunk =
|
|
1680
|
+
for (let i = 0; i < rows.length; i += 900) {
|
|
1681
|
+
const chunk = rows.slice(i, i + 900)
|
|
1372
1682
|
this.db
|
|
1373
1683
|
.prepare(`DELETE FROM entries WHERE hash IN (${chunk.map(() => '?').join(',')})`)
|
|
1374
1684
|
.run(...(chunk as readonly SQLQueryBindings[]))
|
|
@@ -1392,7 +1702,12 @@ export class Cache implements CacheLayer {
|
|
|
1392
1702
|
* exists while the bytes are still being written, so a fresh file
|
|
1393
1703
|
* without a row is a save in flight, not an orphan.
|
|
1394
1704
|
*/
|
|
1395
|
-
private async reapOrphans(
|
|
1705
|
+
private async reapOrphans(
|
|
1706
|
+
now: number = Date.now(),
|
|
1707
|
+
): Promise<{ orphans: number; orphanBytes: number }> {
|
|
1708
|
+
this.db
|
|
1709
|
+
.prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
|
|
1710
|
+
.run(SWEPT_AT, String(now))
|
|
1396
1711
|
let orphans = 0
|
|
1397
1712
|
let orphanBytes = 0
|
|
1398
1713
|
await Promise.all(
|
|
@@ -1411,6 +1726,29 @@ export class Cache implements CacheLayer {
|
|
|
1411
1726
|
return { orphans, orphanBytes }
|
|
1412
1727
|
}
|
|
1413
1728
|
|
|
1729
|
+
/**
|
|
1730
|
+
* Index rows whose artifact is not in the directory, with their recorded
|
|
1731
|
+
* sizes. Only rows last written or used before the grace window: a save
|
|
1732
|
+
* renames its artifact in before its row commits, so a fresh row without
|
|
1733
|
+
* one is a listing that raced a save, not a phantom. An unreadable
|
|
1734
|
+
* directory judges nothing.
|
|
1735
|
+
*/
|
|
1736
|
+
private async phantomRows(): Promise<Map<string, number>> {
|
|
1737
|
+
let names: string[]
|
|
1738
|
+
try {
|
|
1739
|
+
names = await readdir(this.cacheDir)
|
|
1740
|
+
} catch {
|
|
1741
|
+
return new Map()
|
|
1742
|
+
}
|
|
1743
|
+
const present = new Set(names)
|
|
1744
|
+
const rows = this.db
|
|
1745
|
+
.prepare('SELECT hash, size_bytes FROM entries WHERE accessed_at < ?')
|
|
1746
|
+
.all(Date.now() - ORPHAN_GRACE_MS) as Array<{ hash: string; size_bytes: number }>
|
|
1747
|
+
const out = new Map<string, number>()
|
|
1748
|
+
for (const r of rows) if (!present.has(`${r.hash}.tar.zst`)) out.set(r.hash, r.size_bytes)
|
|
1749
|
+
return out
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1414
1752
|
/** What `prune()` would reap right now, for `vx info` to say before anyone prunes. */
|
|
1415
1753
|
async orphanStats(): Promise<{ orphans: number; orphanBytes: number }> {
|
|
1416
1754
|
const found = await this.scanOrphans()
|
|
@@ -1435,11 +1773,9 @@ export class Cache implements CacheLayer {
|
|
|
1435
1773
|
const cutoff = Date.now() - ORPHAN_GRACE_MS
|
|
1436
1774
|
const candidates: string[] = []
|
|
1437
1775
|
for (const name of names) {
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
candidates.push(name)
|
|
1442
|
-
}
|
|
1776
|
+
const m = VX_ARTIFACT_NAME.exec(name)
|
|
1777
|
+
if (m === null) continue
|
|
1778
|
+
if (m[2] !== undefined || !indexed.has(m[1]!)) candidates.push(name)
|
|
1443
1779
|
}
|
|
1444
1780
|
const found: Array<{ file: string; size: number }> = []
|
|
1445
1781
|
await Promise.all(
|
|
@@ -1466,15 +1802,21 @@ export class Cache implements CacheLayer {
|
|
|
1466
1802
|
// header would outlive its `runs` rows, so `vx info`/`vx last` would
|
|
1467
1803
|
// list an invocation whose task detail is already gone, and the
|
|
1468
1804
|
// table would grow unbounded on a long-lived checkout.
|
|
1805
|
+
// A reading verb and a dry run delete nothing: `vx last --list` over
|
|
1806
|
+
// month-old runs emptied the history it listed, and `vx cache prune
|
|
1807
|
+
// --dry-run` pruned it (item 1004).
|
|
1469
1808
|
try {
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1809
|
+
if (!this.inspecting) {
|
|
1810
|
+
const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000
|
|
1811
|
+
this.history.pruneOlderThan(cutoff)
|
|
1812
|
+
this.configEvals.pruneOlderThan(cutoff)
|
|
1813
|
+
}
|
|
1473
1814
|
} catch {
|
|
1474
1815
|
// Retention is best-effort; never block closing the handle.
|
|
1475
1816
|
}
|
|
1476
1817
|
try {
|
|
1477
1818
|
this.flushAccessed()
|
|
1819
|
+
this.outputs.flushOutputDirs()
|
|
1478
1820
|
} catch {
|
|
1479
1821
|
// Same contract as the retention prune above, which was already
|
|
1480
1822
|
// guarded while this sibling was not: `accessed_at` is LRU
|
|
@@ -1493,8 +1835,45 @@ export class Cache implements CacheLayer {
|
|
|
1493
1835
|
}
|
|
1494
1836
|
}
|
|
1495
1837
|
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
1838
|
+
/**
|
|
1839
|
+
* Refuse an artifact whose file names no restore can materialise as one
|
|
1840
|
+
* tree, or (given the task's declared outputs) that names a file outside
|
|
1841
|
+
* them. Both reached the tree before: `outputs/out.txt` beside
|
|
1842
|
+
* `outputs/out.txt/x` failed every later run from the local copy, blaming
|
|
1843
|
+
* what was on disk, and an undeclared name overwrote an input or planted a
|
|
1844
|
+
* git hook under a green remote hit (item 942). Thrown at ingest, so the
|
|
1845
|
+
* bytes never reach the local store and the remote read is a miss.
|
|
1846
|
+
*/
|
|
1847
|
+
function assertArtifactNames(
|
|
1848
|
+
hash: string,
|
|
1849
|
+
entries: ReadonlyArray<{ name: string }>,
|
|
1850
|
+
declared: DeclaredOutputs | undefined,
|
|
1851
|
+
): void {
|
|
1852
|
+
const names = new Set(entries.map((e) => e.name))
|
|
1853
|
+
for (const name of names) {
|
|
1854
|
+
for (let sep = name.indexOf('/'); sep !== -1; sep = name.indexOf('/', sep + 1)) {
|
|
1855
|
+
if (names.has(name.slice(0, sep))) {
|
|
1856
|
+
throw new CorruptArtifactError(
|
|
1857
|
+
hash,
|
|
1858
|
+
`artifact holds ${name.slice(0, sep)} as a file and as a directory`,
|
|
1859
|
+
)
|
|
1860
|
+
}
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
if (declared === undefined) return
|
|
1864
|
+
const files = asTrees(declared.files).map(taskGlob)
|
|
1865
|
+
const wsFiles = asTrees(declared.workspaceFiles).map(taskGlob)
|
|
1866
|
+
for (const name of names) {
|
|
1867
|
+
const ok = name.startsWith('outputs/')
|
|
1868
|
+
? files.some((g) => g.match(name.slice('outputs/'.length)))
|
|
1869
|
+
: name.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
1870
|
+
? wsFiles.some((g) => g.match(name.slice(WORKSPACE_OUTPUT_PREFIX.length)))
|
|
1871
|
+
: true
|
|
1872
|
+
if (!ok) {
|
|
1873
|
+
throw new CorruptArtifactError(
|
|
1874
|
+
hash,
|
|
1875
|
+
`artifact carries ${name}, which is not one of the task's declared outputs`,
|
|
1876
|
+
)
|
|
1877
|
+
}
|
|
1878
|
+
}
|
|
1500
1879
|
}
|