@vzn/vx 0.0.21 → 0.0.23
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 +622 -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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// LayeredCache — composes the local cache with a remote HTTP cache.
|
|
2
2
|
//
|
|
3
|
-
// Read path: try local. On miss, try remote; on remote hit,
|
|
4
|
-
// the artifact
|
|
3
|
+
// Read path: try local. On miss, try remote; on remote hit, stream
|
|
4
|
+
// the artifact body into local so the next read is a local hit.
|
|
5
5
|
//
|
|
6
6
|
// Write path: write to local synchronously. Upload the local artifact
|
|
7
7
|
// to remote as a fire-and-forget background task; failures log a
|
|
@@ -45,8 +45,20 @@ import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
|
|
|
45
45
|
* local `<hash>.tar.zst` verbatim. The wires live in plugin packages
|
|
46
46
|
* (`@vzn/vx-migrate`'s `turboCache()` and `nxCache()`, `@vzn/vx-reapi`); see
|
|
47
47
|
* docs/modules/layered-cache.md.
|
|
48
|
+
*
|
|
49
|
+
* Core awaits every call and bounds none: a `get` that never settles
|
|
50
|
+
* holds its task and a `put` the run's upload drain, so each request
|
|
51
|
+
* carries the layer's own deadline (every first-party layer has one; the
|
|
52
|
+
* plugin guide's example shows it).
|
|
48
53
|
*/
|
|
49
54
|
export interface RemoteCacheLayer {
|
|
55
|
+
/**
|
|
56
|
+
* Where the layer's artifacts live, named in every degrade warning
|
|
57
|
+
* (`upload <hash> to <endpoint> failed: …`): a base URL or `host:port`.
|
|
58
|
+
* Never a credential: a URL's `user:pass@`, query and fragment are
|
|
59
|
+
* dropped before it is printed.
|
|
60
|
+
*/
|
|
61
|
+
readonly endpoint?: string
|
|
50
62
|
/** Existence probe (drives the plan path's `--dry` remote prediction). */
|
|
51
63
|
has(hash: string): Promise<boolean>
|
|
52
64
|
/**
|
|
@@ -58,11 +70,47 @@ export interface RemoteCacheLayer {
|
|
|
58
70
|
* info; use per-hash".
|
|
59
71
|
*/
|
|
60
72
|
hasMany?(hashes: readonly string[]): Promise<Set<string> | null>
|
|
61
|
-
/**
|
|
62
|
-
*
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
73
|
+
/**
|
|
74
|
+
* Fetch an artifact; `null` = miss. `body` is read once, by core,
|
|
75
|
+
* straight into the local cache — an HTTP wire returns its `fetch`
|
|
76
|
+
* `Response`, a chunked wire `new Response(stream)`, bytes in hand
|
|
77
|
+
* `new Blob([bytes])`. `durationMs` is the producing task's duration
|
|
78
|
+
* when the wire carries it.
|
|
79
|
+
*/
|
|
80
|
+
get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null>
|
|
81
|
+
/**
|
|
82
|
+
* Store an artifact (fire-and-forget from LayeredCache's perspective).
|
|
83
|
+
* `body` is file-backed (`Bun.file`) when the local store holds the
|
|
84
|
+
* artifact, so a plugin that streams it never holds it whole.
|
|
85
|
+
*/
|
|
86
|
+
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* What a remote layer resolves is a plugin's, so its shape is a boundary
|
|
91
|
+
* (item 252): a `get` that resolved `{ body: 'abc' }` was reported as
|
|
92
|
+
* "corrupt artifact … not a readable archive" — the bytes blamed for the
|
|
93
|
+
* plugin's shape — and a `hasMany` that resolved an array reached the
|
|
94
|
+
* prefetch pass's `.has()`. Named here and degraded to a miss, like every
|
|
95
|
+
* other remote failure.
|
|
96
|
+
*/
|
|
97
|
+
function invalidRemoteResult(what: string): Error {
|
|
98
|
+
return new Error(
|
|
99
|
+
`remote cache layer returned an invalid result: ${what} — a plugin bug, degraded to a miss`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function isBody(value: unknown): value is Blob | Response {
|
|
104
|
+
return value instanceof Blob || value instanceof Response
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeValue(value: unknown): string {
|
|
108
|
+
if (value === null) return 'null'
|
|
109
|
+
if (Array.isArray(value)) return 'an array'
|
|
110
|
+
// The pre-stream contract's shape, named so an unported layer reads its fix.
|
|
111
|
+
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
|
|
112
|
+
return `a ${value.constructor.name}`
|
|
113
|
+
return typeof value === 'object' ? 'an object' : typeof value
|
|
66
114
|
}
|
|
67
115
|
|
|
68
116
|
/**
|
|
@@ -71,8 +119,51 @@ export interface RemoteCacheLayer {
|
|
|
71
119
|
*/
|
|
72
120
|
const UPLOAD_CONCURRENCY = 4
|
|
73
121
|
|
|
122
|
+
/** The seam call a failure happened in, as the warning names it. */
|
|
123
|
+
type RemoteOp = 'probe' | 'download' | 'upload'
|
|
124
|
+
|
|
125
|
+
const PREPOSITION: Record<RemoteOp, string> = { probe: 'at', download: 'from', upload: 'to' }
|
|
126
|
+
|
|
127
|
+
/** The layer's `endpoint` as it may be printed: no userinfo, query or fragment. */
|
|
128
|
+
function printableEndpoint(endpoint: unknown): string | undefined {
|
|
129
|
+
if (typeof endpoint !== 'string' || endpoint === '') return undefined
|
|
130
|
+
if (!URL.canParse(endpoint)) return endpoint
|
|
131
|
+
const u = new URL(endpoint)
|
|
132
|
+
if (u.username === '' && u.password === '' && u.search === '' && u.hash === '') return endpoint
|
|
133
|
+
u.username = ''
|
|
134
|
+
u.password = ''
|
|
135
|
+
u.search = ''
|
|
136
|
+
u.hash = ''
|
|
137
|
+
return u.toString()
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* What makes two failures "the same way": the error's `code` when it
|
|
142
|
+
* carries one (a gRPC status, an errno, Bun's `ConnectionRefused`), because
|
|
143
|
+
* gRPC writes the elapsed time into its message and no two deadline messages
|
|
144
|
+
* match; else the message with the artifact's hash taken out, so a 500 on
|
|
145
|
+
* one hash and a 500 on the next are one class. The operation is not part of
|
|
146
|
+
* it: an unreachable server fails the probe, the download and the upload
|
|
147
|
+
* alike, and that is one fact.
|
|
148
|
+
*/
|
|
149
|
+
function failureClass(err: Error, hash: string | undefined): string {
|
|
150
|
+
const code = (err as { code?: unknown }).code
|
|
151
|
+
if (typeof code === 'string' || typeof code === 'number') return `${err.name}\0${code}`
|
|
152
|
+
return hash === undefined ? err.message : err.message.replaceAll(hash, '\0')
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** A batch probe names no one hash, so its line counts them. */
|
|
156
|
+
function batchOf(hashes: readonly string[]): { batch: string } {
|
|
157
|
+
return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
|
|
158
|
+
}
|
|
159
|
+
|
|
74
160
|
export interface LayeredCacheOptions {
|
|
75
|
-
/**
|
|
161
|
+
/**
|
|
162
|
+
* Called for the remote failures the layer degrades to a miss, once per
|
|
163
|
+
* failure class per layer (`upload <hash> to <endpoint> failed: <cause>`,
|
|
164
|
+
* with the original error as `cause`); the repeats are counted and said
|
|
165
|
+
* once more at `close()` (`<n> more requests failed the same way: <cause>`).
|
|
166
|
+
*/
|
|
76
167
|
onRemoteError?: (err: Error) => void
|
|
77
168
|
/**
|
|
78
169
|
* The 4-axis read/write policy. The local slice (read/write) is
|
|
@@ -121,6 +212,15 @@ export class LayeredCache implements CacheLayer {
|
|
|
121
212
|
private drainWaiters: Array<() => void> = []
|
|
122
213
|
|
|
123
214
|
private readonly policy: CachePolicy
|
|
215
|
+
private readonly endpoint: string | undefined
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* One entry per failure class said: its first line went out, `repeats`
|
|
219
|
+
* counts the ones held back. A run's requests are concurrent, so an
|
|
220
|
+
* unreachable server otherwise printed its bare runtime message once per
|
|
221
|
+
* request (three lines for one task over the Turbo wire, item 749).
|
|
222
|
+
*/
|
|
223
|
+
private readonly reported = new Map<string, { cause: string; repeats: number }>()
|
|
124
224
|
|
|
125
225
|
constructor(
|
|
126
226
|
readonly local: Cache,
|
|
@@ -128,6 +228,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
128
228
|
private readonly options: LayeredCacheOptions = {},
|
|
129
229
|
) {
|
|
130
230
|
this.policy = options.policy ?? FULL_CACHE_POLICY
|
|
231
|
+
this.endpoint = printableEndpoint(remote.endpoint)
|
|
131
232
|
}
|
|
132
233
|
|
|
133
234
|
key(input: CacheKeyInput): Promise<string> {
|
|
@@ -157,9 +258,20 @@ export class LayeredCache implements CacheLayer {
|
|
|
157
258
|
try {
|
|
158
259
|
// `return await`, deliberately: the catch below is the never-fail
|
|
159
260
|
// contract, and a returned promise's rejection would sail past it.
|
|
160
|
-
|
|
261
|
+
const found: unknown = await this.remote.hasMany(hashes)
|
|
262
|
+
if (found !== null && found !== undefined && !(found instanceof Set)) {
|
|
263
|
+
this.reportRemoteError(
|
|
264
|
+
'probe',
|
|
265
|
+
batchOf(hashes),
|
|
266
|
+
invalidRemoteResult(
|
|
267
|
+
`hasMany() resolved ${describeValue(found)} (expected a Set of the hashes present, or null)`,
|
|
268
|
+
),
|
|
269
|
+
)
|
|
270
|
+
return null
|
|
271
|
+
}
|
|
272
|
+
return found ?? null
|
|
161
273
|
} catch (err) {
|
|
162
|
-
this.reportRemoteError(err)
|
|
274
|
+
this.reportRemoteError('probe', batchOf(hashes), err)
|
|
163
275
|
return null
|
|
164
276
|
}
|
|
165
277
|
}
|
|
@@ -227,7 +339,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
227
339
|
try {
|
|
228
340
|
return (await this.remote.has(hash)) ? 'remote' : null
|
|
229
341
|
} catch (err) {
|
|
230
|
-
this.reportRemoteError(err)
|
|
342
|
+
this.reportRemoteError('probe', hash, err)
|
|
231
343
|
return null
|
|
232
344
|
}
|
|
233
345
|
}
|
|
@@ -259,16 +371,31 @@ export class LayeredCache implements CacheLayer {
|
|
|
259
371
|
// or remote if a concurrent prefetch set it).
|
|
260
372
|
if ((await this.local.has(hash)) === 'local') return true
|
|
261
373
|
|
|
262
|
-
let remoteResult
|
|
374
|
+
let remoteResult: unknown
|
|
263
375
|
try {
|
|
264
376
|
remoteResult = await this.remote.get(hash)
|
|
265
377
|
} catch (err) {
|
|
266
|
-
this.reportRemoteError(err)
|
|
378
|
+
this.reportRemoteError('download', hash, err)
|
|
267
379
|
return false
|
|
268
380
|
}
|
|
269
381
|
if (!remoteResult) return false
|
|
382
|
+
if (typeof remoteResult !== 'object' || !isBody((remoteResult as { body?: unknown }).body)) {
|
|
383
|
+
const shape =
|
|
384
|
+
typeof remoteResult !== 'object'
|
|
385
|
+
? describeValue(remoteResult)
|
|
386
|
+
: `body is ${describeValue((remoteResult as { body?: unknown }).body)}`
|
|
387
|
+
this.reportRemoteError(
|
|
388
|
+
'download',
|
|
389
|
+
hash,
|
|
390
|
+
invalidRemoteResult(
|
|
391
|
+
`get() resolved ${shape} (expected { body: Blob | Response, durationMs } or null)`,
|
|
392
|
+
),
|
|
393
|
+
)
|
|
394
|
+
return false
|
|
395
|
+
}
|
|
396
|
+
const remoteBody = remoteResult as { body: Blob | Response; durationMs?: number }
|
|
270
397
|
|
|
271
|
-
// Ingest the remote
|
|
398
|
+
// Ingest the remote body into local using the caller-supplied
|
|
272
399
|
// taskId/command plus the remote-reported durationMs. The remote
|
|
273
400
|
// layer carries durationMs as an HTTP header (x-artifact-duration);
|
|
274
401
|
// taskId + command come from the orchestrator's TaskNode in scope.
|
|
@@ -278,16 +405,21 @@ export class LayeredCache implements CacheLayer {
|
|
|
278
405
|
const meta: IngestMeta = {
|
|
279
406
|
taskId: ctx?.taskId ?? `${hash}#unknown`,
|
|
280
407
|
command: ctx?.command ?? '',
|
|
281
|
-
|
|
408
|
+
// A layer's number is taken only as a finite non-negative one: a NaN
|
|
409
|
+
// failed the entry row's NOT NULL after the artifact was renamed into
|
|
410
|
+
// place, which threw a valid hit away and orphaned its bytes, and a
|
|
411
|
+
// negative or infinite one was stored and replayed (item 944).
|
|
412
|
+
durationMs: durationOf(remoteBody.durationMs),
|
|
413
|
+
...(ctx?.outputs !== undefined ? { outputs: ctx.outputs } : {}),
|
|
282
414
|
}
|
|
283
415
|
try {
|
|
284
|
-
await this.local.ingest(hash,
|
|
416
|
+
await this.local.ingest(hash, remoteBody.body, meta)
|
|
285
417
|
} catch (err) {
|
|
286
418
|
// The bytes came off the network — a corrupt/truncated remote
|
|
287
419
|
// artifact must degrade to a cache miss (task re-executes), not
|
|
288
420
|
// crash the run. Local-layer reads outside this block still
|
|
289
421
|
// propagate: local corruption is a real fault, not a network one.
|
|
290
|
-
this.reportRemoteError(err)
|
|
422
|
+
this.reportRemoteError('download', hash, err)
|
|
291
423
|
return false
|
|
292
424
|
}
|
|
293
425
|
this.remoteSourced.add(hash)
|
|
@@ -318,16 +450,13 @@ export class LayeredCache implements CacheLayer {
|
|
|
318
450
|
// cache. Errors are logged, not propagated: the task already
|
|
319
451
|
// succeeded; we don't fail it on cache-server issues.
|
|
320
452
|
//
|
|
321
|
-
// The
|
|
322
|
-
//
|
|
323
|
-
//
|
|
324
|
-
// RSS scaled with a run's total miss artifact bytes rather than with
|
|
325
|
-
// the pool — any cold monorepo run whose remote uploads slower than the
|
|
326
|
-
// build produces artifacts held every pending one at once. Reading in
|
|
327
|
-
// the job caps resident artifact bytes at UPLOAD_CONCURRENCY. The
|
|
453
|
+
// The job hands the plugin a file-backed Blob over the local artifact,
|
|
454
|
+
// opened when the plugin reads it: a queued job holds a path, not a
|
|
455
|
+
// buffer, and a plugin that streams the Blob never holds it whole. The
|
|
328
456
|
// artifact is content-addressed and immutable, so a deferred read sees
|
|
329
457
|
// the same bytes; if a concurrent `vx cache prune` removed it first the
|
|
330
|
-
// read throws and this upload is skipped — the never-fail
|
|
458
|
+
// plugin's read throws and this upload is skipped — the never-fail
|
|
459
|
+
// contract.
|
|
331
460
|
const hash = args.hash
|
|
332
461
|
const durationMs = args.entry.durationMs
|
|
333
462
|
// Local writes disabled (`--cache=local:,remote:rw`): there is no
|
|
@@ -341,16 +470,17 @@ export class LayeredCache implements CacheLayer {
|
|
|
341
470
|
try {
|
|
342
471
|
packed = await this.local.packArtifactBytes(args)
|
|
343
472
|
} catch (err) {
|
|
344
|
-
this.reportRemoteError(err)
|
|
473
|
+
this.reportRemoteError('upload', hash, err)
|
|
345
474
|
return
|
|
346
475
|
}
|
|
347
476
|
}
|
|
348
477
|
this.enqueueUpload(async () => {
|
|
349
478
|
try {
|
|
350
|
-
const
|
|
351
|
-
|
|
479
|
+
const body =
|
|
480
|
+
packed !== undefined ? new Blob([packed]) : Bun.file(this.local.outputsPath(hash))
|
|
481
|
+
await this.remote.put(hash, body, { durationMs })
|
|
352
482
|
} catch (err) {
|
|
353
|
-
this.reportRemoteError(err)
|
|
483
|
+
this.reportRemoteError('upload', hash, err)
|
|
354
484
|
}
|
|
355
485
|
})
|
|
356
486
|
}
|
|
@@ -387,8 +517,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
387
517
|
}
|
|
388
518
|
}
|
|
389
519
|
|
|
390
|
-
async ingest(hash: string,
|
|
391
|
-
await this.local.ingest(hash,
|
|
520
|
+
async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
521
|
+
await this.local.ingest(hash, body, meta)
|
|
392
522
|
}
|
|
393
523
|
|
|
394
524
|
loadOutputFilesBatch(hashes: readonly string[]): Map<string, OutputFileRow[]> {
|
|
@@ -404,6 +534,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
404
534
|
recordOutputDirs(hash: string, projectDir: string, prefixes: readonly string[]): Promise<void> {
|
|
405
535
|
return this.local.recordOutputDirs(hash, projectDir, prefixes)
|
|
406
536
|
}
|
|
537
|
+
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
538
|
+
this.local.recordOutputStamps(hash, projectDir, workspaceRoot)
|
|
539
|
+
}
|
|
407
540
|
|
|
408
541
|
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
409
542
|
return this.local.loadOutputDirsBatch(hashes)
|
|
@@ -425,12 +558,42 @@ export class LayeredCache implements CacheLayer {
|
|
|
425
558
|
return this.local.prune(options)
|
|
426
559
|
}
|
|
427
560
|
|
|
561
|
+
/** `run()` drains the uploads and prefetches first, so the repeat counts are final here. */
|
|
428
562
|
close(): void {
|
|
563
|
+
for (const { cause, repeats } of this.reported.values()) {
|
|
564
|
+
if (repeats === 0) continue
|
|
565
|
+
this.emit(
|
|
566
|
+
new Error(
|
|
567
|
+
`${repeats} more request${repeats === 1 ? '' : 's'} failed the same way: ${cause}`,
|
|
568
|
+
),
|
|
569
|
+
)
|
|
570
|
+
}
|
|
571
|
+
this.reported.clear()
|
|
429
572
|
this.local.close()
|
|
430
573
|
}
|
|
431
574
|
|
|
432
|
-
|
|
575
|
+
/**
|
|
576
|
+
* `subject` is the artifact's hash, or the batch for a `hasMany`. The
|
|
577
|
+
* wire's own message says what went wrong but not on what or where: an
|
|
578
|
+
* upload deadline read `The operation timed out.` with no upload, hash or
|
|
579
|
+
* server in it (item 749).
|
|
580
|
+
*/
|
|
581
|
+
private reportRemoteError(op: RemoteOp, subject: string | { batch: string }, err: unknown): void {
|
|
433
582
|
const e = err instanceof Error ? err : new Error(String(err))
|
|
583
|
+
const hash = typeof subject === 'string' ? subject : undefined
|
|
584
|
+
const cls = failureClass(e, hash)
|
|
585
|
+
const seen = this.reported.get(cls)
|
|
586
|
+
if (seen !== undefined) {
|
|
587
|
+
seen.repeats++
|
|
588
|
+
return
|
|
589
|
+
}
|
|
590
|
+
this.reported.set(cls, { cause: e.message, repeats: 0 })
|
|
591
|
+
const what = typeof subject === 'string' ? subject : subject.batch
|
|
592
|
+
const where = this.endpoint === undefined ? '' : ` ${PREPOSITION[op]} ${this.endpoint}`
|
|
593
|
+
this.emit(new Error(`${op} ${what}${where} failed: ${e.message}`, { cause: e }))
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
private emit(e: Error): void {
|
|
434
597
|
// The remote cache is fully optional: NO remote failure — a 500, a
|
|
435
598
|
// network drop, a corrupt artifact, even a throwing onRemoteError
|
|
436
599
|
// callback — may ever fail the run. We report and degrade to a
|
|
@@ -447,3 +610,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
447
610
|
}
|
|
448
611
|
}
|
|
449
612
|
}
|
|
613
|
+
|
|
614
|
+
function durationOf(ms: unknown): number {
|
|
615
|
+
return typeof ms === 'number' && ms >= 0 && ms <= Number.MAX_SAFE_INTEGER ? Math.round(ms) : 0
|
|
616
|
+
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
OUTPUT_DIRS_RACY_MS,
|
|
18
18
|
type OutputDirRow,
|
|
19
19
|
type OutputFileRow,
|
|
20
|
+
WORKSPACE_OUTPUT_PREFIX,
|
|
20
21
|
} from './layer.js'
|
|
21
22
|
|
|
22
23
|
export class OutputIndex {
|
|
@@ -24,6 +25,19 @@ export class OutputIndex {
|
|
|
24
25
|
private readonly deleteOutputFiles: ReturnType<Database['prepare']>
|
|
25
26
|
private readonly insertOutputDir: ReturnType<Database['prepare']>
|
|
26
27
|
private readonly deleteOutputDirs: ReturnType<Database['prepare']>
|
|
28
|
+
private readonly entryExists: ReturnType<Database['prepare']>
|
|
29
|
+
private readonly stampOutputFile: ReturnType<Database['prepare']>
|
|
30
|
+
/**
|
|
31
|
+
* Snapshots taken and not yet written, the last per hash. A snapshot is
|
|
32
|
+
* read by the NEXT run's hit check, never by the task that took it, so
|
|
33
|
+
* the rows land in one transaction at the first read, at prune, at
|
|
34
|
+
* stats or at close — the way `accessed_at` bumps do — instead of one
|
|
35
|
+
* commit per task: 1,000 of them were the whole `output dir snapshots`
|
|
36
|
+
* stage, 47–72 ms at run end (item 622).
|
|
37
|
+
*/
|
|
38
|
+
private readonly pendingDirs = new Map<string, Array<[string, number]> | null>()
|
|
39
|
+
/** Stamps taken and not yet written, flushed with the directory snapshots. */
|
|
40
|
+
private readonly pendingStamps = new Map<string, Array<[string, number, number]>>()
|
|
27
41
|
|
|
28
42
|
constructor(private readonly db: Database) {
|
|
29
43
|
this.insertOutputFile = this.db.prepare(`
|
|
@@ -39,6 +53,10 @@ export class OutputIndex {
|
|
|
39
53
|
'INSERT INTO output_dirs(entry_hash, path, mtime_ms) VALUES (?, ?, ?)',
|
|
40
54
|
)
|
|
41
55
|
this.deleteOutputDirs = this.db.prepare('DELETE FROM output_dirs WHERE entry_hash = ?')
|
|
56
|
+
this.entryExists = this.db.prepare('SELECT 1 FROM entries WHERE hash = ?')
|
|
57
|
+
this.stampOutputFile = this.db.prepare(
|
|
58
|
+
'UPDATE output_files SET ino = ?, ctime_ms = ? WHERE entry_hash = ? AND path = ?',
|
|
59
|
+
)
|
|
42
60
|
}
|
|
43
61
|
|
|
44
62
|
/**
|
|
@@ -62,9 +80,12 @@ export class OutputIndex {
|
|
|
62
80
|
// compiled statement keyed by the SQL text — so the dominant single-hash
|
|
63
81
|
// warm-hit path (called up to 3× per hit) reuses one statement instead of
|
|
64
82
|
// recompiling on every call.
|
|
83
|
+
// A reader in the same process (`vx watch`'s next cycle, a test) sees
|
|
84
|
+
// the stamps taken, not only the ones flushed.
|
|
85
|
+
this.flushOutputDirs()
|
|
65
86
|
const placeholders = hashes.map(() => '?').join(',')
|
|
66
87
|
const stmt = this.db.query(
|
|
67
|
-
`SELECT entry_hash, path, size_bytes, mode, mtime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
|
|
88
|
+
`SELECT entry_hash, path, size_bytes, mode, mtime_ms, ino, ctime_ms FROM output_files WHERE entry_hash IN (${placeholders})`,
|
|
68
89
|
)
|
|
69
90
|
const rows = stmt.all(...(hashes as readonly SQLQueryBindings[])) as Array<{
|
|
70
91
|
entry_hash: string
|
|
@@ -72,6 +93,8 @@ export class OutputIndex {
|
|
|
72
93
|
size_bytes: number
|
|
73
94
|
mode: number
|
|
74
95
|
mtime_ms: number
|
|
96
|
+
ino: number | null
|
|
97
|
+
ctime_ms: number | null
|
|
75
98
|
}>
|
|
76
99
|
for (const r of rows) {
|
|
77
100
|
let list = out.get(r.entry_hash)
|
|
@@ -79,7 +102,17 @@ export class OutputIndex {
|
|
|
79
102
|
list = []
|
|
80
103
|
out.set(r.entry_hash, list)
|
|
81
104
|
}
|
|
82
|
-
|
|
105
|
+
const row: OutputFileRow = {
|
|
106
|
+
path: r.path,
|
|
107
|
+
size: r.size_bytes,
|
|
108
|
+
mode: r.mode,
|
|
109
|
+
mtimeMs: r.mtime_ms,
|
|
110
|
+
}
|
|
111
|
+
if (r.ino !== null && r.ctime_ms !== null) {
|
|
112
|
+
row.ino = r.ino
|
|
113
|
+
row.ctimeMs = r.ctime_ms
|
|
114
|
+
}
|
|
115
|
+
list.push(row)
|
|
83
116
|
}
|
|
84
117
|
return out
|
|
85
118
|
}
|
|
@@ -107,11 +140,21 @@ export class OutputIndex {
|
|
|
107
140
|
// restoreOutputs re-syncs restored files to the row value,
|
|
108
141
|
// so equality holds exactly in steady state; legacy
|
|
109
142
|
// second-precision rows converge on their first restore.
|
|
110
|
-
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
|
|
143
|
+
Math.abs(s.mtimeMs - e.mtimeMs) < 1 &&
|
|
144
|
+
// And the file is the one THIS machine last saw under this
|
|
145
|
+
// entry: two entries whose outputs carry one fixed mtime (a
|
|
146
|
+
// `tar -x`, `cp -p`, SOURCE_DATE_EPOCH) and one size matched
|
|
147
|
+
// each other's rows, and a hit left the other entry's bytes in
|
|
148
|
+
// place under a green run (item 886). No task sets a ctime; the
|
|
149
|
+
// inode is weaker than it looks, since ext4 gives a restore's new
|
|
150
|
+
// file the inode the clean freed (item 941). A row without a stamp
|
|
151
|
+
// (an ingest, a changed file at stamping) is never current. The
|
|
152
|
+
// residual: a same-size rewrite, in place or as a new file on the
|
|
153
|
+
// same inode, inside the coarse clock tick of the stamp, with the
|
|
154
|
+
// mtime forged back.
|
|
155
|
+
e.ino !== undefined &&
|
|
156
|
+
s.ino === e.ino &&
|
|
157
|
+
Math.floor(s.ctimeMs) === e.ctimeMs
|
|
115
158
|
)
|
|
116
159
|
} catch {
|
|
117
160
|
return false
|
|
@@ -177,16 +220,72 @@ export class OutputIndex {
|
|
|
177
220
|
// parent trusted while an addition inside it bumps only the dropped one.
|
|
178
221
|
const youngest = Date.now() - OUTPUT_DIRS_RACY_MS
|
|
179
222
|
if (rows.some(([, mtime]) => mtime > youngest)) ok = false
|
|
223
|
+
this.pendingDirs.set(hash, ok ? rows : null)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Stamp `hash`'s rows with each file's inode and ctime, once a save or
|
|
228
|
+
* a restore has left the tree equal to the entry. A file whose size,
|
|
229
|
+
* mode or mtime no longer matches its row is left unstamped, so a
|
|
230
|
+
* write that landed since is never vouched for; the next hit restores.
|
|
231
|
+
* Written with the directory snapshots, in one transaction.
|
|
232
|
+
*/
|
|
233
|
+
recordOutputStamps(hash: string, projectDir: string, workspaceRoot: string): void {
|
|
234
|
+
const rows = this.loadOutputFilesBatch([hash]).get(hash) ?? []
|
|
235
|
+
const stamps: Array<[string, number, number]> = []
|
|
236
|
+
for (const e of rows) {
|
|
237
|
+
const ws = e.path.startsWith(WORKSPACE_OUTPUT_PREFIX)
|
|
238
|
+
const abs = ws
|
|
239
|
+
? path.join(workspaceRoot, e.path.slice(WORKSPACE_OUTPUT_PREFIX.length))
|
|
240
|
+
: path.join(projectDir, e.path)
|
|
241
|
+
try {
|
|
242
|
+
const s = statSync(abs)
|
|
243
|
+
if (
|
|
244
|
+
s.size === e.size &&
|
|
245
|
+
(s.mode & 0o777) === (e.mode & 0o777) &&
|
|
246
|
+
Math.abs(s.mtimeMs - e.mtimeMs) < 1
|
|
247
|
+
) {
|
|
248
|
+
stamps.push([e.path, s.ino, Math.floor(s.ctimeMs)])
|
|
249
|
+
}
|
|
250
|
+
} catch {
|
|
251
|
+
// gone: left unstamped
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (stamps.length > 0) this.pendingStamps.set(hash, stamps)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Land every pending stamp and snapshot in one transaction each; a null snapshot clears its rows. */
|
|
258
|
+
flushOutputDirs(): void {
|
|
259
|
+
if (this.pendingStamps.size > 0) {
|
|
260
|
+
const stamps = [...this.pendingStamps]
|
|
261
|
+
this.pendingStamps.clear()
|
|
262
|
+
this.db.transaction(() => {
|
|
263
|
+
for (const [hash, rows] of stamps) {
|
|
264
|
+
for (const [rel, ino, ctime] of rows) this.stampOutputFile.run(ino, ctime, hash, rel)
|
|
265
|
+
}
|
|
266
|
+
})()
|
|
267
|
+
}
|
|
268
|
+
if (this.pendingDirs.size === 0) return
|
|
269
|
+
const pending = [...this.pendingDirs]
|
|
270
|
+
this.pendingDirs.clear()
|
|
180
271
|
this.db.transaction(() => {
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
272
|
+
for (const [hash, rows] of pending) {
|
|
273
|
+
this.deleteOutputDirs.run(hash)
|
|
274
|
+
// The rows reference the entry: one pruned by another process
|
|
275
|
+
// between the snapshot and this flush has nothing to describe,
|
|
276
|
+
// and its insert would fail the whole transaction on the FK.
|
|
277
|
+
if (rows === null || this.entryExists.get(hash) === null) continue
|
|
278
|
+
for (const [rel, mtime] of rows) this.insertOutputDir.run(hash, rel, mtime)
|
|
279
|
+
}
|
|
184
280
|
})()
|
|
185
281
|
}
|
|
186
282
|
|
|
187
283
|
loadOutputDirsBatch(hashes: readonly string[]): Map<string, OutputDirRow[]> {
|
|
188
284
|
const out = new Map<string, OutputDirRow[]>()
|
|
189
285
|
if (hashes.length === 0) return out
|
|
286
|
+
// A reader in the same process (`vx watch`'s next cycle, a test) sees
|
|
287
|
+
// what was snapshotted, not what was flushed.
|
|
288
|
+
this.flushOutputDirs()
|
|
190
289
|
const placeholders = hashes.map(() => '?').join(',')
|
|
191
290
|
const rows = this.db
|
|
192
291
|
.query(
|
package/src/cache/run-history.ts
CHANGED
|
@@ -16,9 +16,10 @@ export class RunHistory {
|
|
|
16
16
|
hash, project, task, status, exit_code, duration_ms, forward_args,
|
|
17
17
|
started_at, ended_at,
|
|
18
18
|
run_id, cpu_ms, peak_rss_bytes, wallclock_start_ns, wallclock_end_ns,
|
|
19
|
-
cache_hit, attempts, cached
|
|
19
|
+
cache_hit, attempts, cached,
|
|
20
|
+
blocked_by, timed_out, sandbox_violations, not_ready
|
|
20
21
|
)
|
|
21
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
22
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
22
23
|
`)
|
|
23
24
|
this.insertInvocation = this.db.prepare(`
|
|
24
25
|
INSERT INTO invocations(
|
|
@@ -84,7 +85,7 @@ export class RunHistory {
|
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
87
|
* Bind a RunRecord to the positional parameters expected by the
|
|
87
|
-
* `insertRun` prepared statement (
|
|
88
|
+
* `insertRun` prepared statement (21 columns). Shared between the
|
|
88
89
|
* single and batched record paths.
|
|
89
90
|
*/
|
|
90
91
|
function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
@@ -108,6 +109,10 @@ function bindRun(run: RunRecord): SQLQueryBindings[] {
|
|
|
108
109
|
run.cacheHit === undefined ? null : run.cacheHit ? 1 : 0,
|
|
109
110
|
run.attempts ?? null,
|
|
110
111
|
run.cached === undefined ? null : run.cached ? 1 : 0,
|
|
112
|
+
run.blockedBy ?? null,
|
|
113
|
+
run.timedOut === true ? 1 : null,
|
|
114
|
+
run.sandboxViolations ?? null,
|
|
115
|
+
run.notReady ?? null,
|
|
111
116
|
]
|
|
112
117
|
}
|
|
113
118
|
|