@vzn/vx 0.0.638 → 0.0.640
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 +1 -1
- package/docs/caching.md +152 -95
- package/docs/cli.md +225 -372
- package/docs/execution.md +22 -47
- package/docs/features.md +14 -16
- package/docs/patterns.md +6 -8
- package/docs/schema.md +268 -215
- package/docs/security.md +1 -1
- package/package.json +5 -5
- package/schemas/last.json +7 -4
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +10 -7
- package/src/cache/cache.ts +44 -11
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +8 -2
- package/src/cache/index.ts +5 -2
- package/src/cache/inputs.ts +154 -83
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +33 -18
- package/src/cache/layered-cache.ts +108 -315
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/plugin-cache.ts +2 -10
- package/src/cache/remote-lane.ts +383 -0
- package/src/cache/run-history.ts +24 -8
- package/src/cache/run-store.ts +4 -4
- package/src/cache/schema.ts +6 -4
- package/src/cache/scoped-cache.ts +7 -0
- package/src/cli/completions.ts +2 -2
- package/src/cli/foreign-flags.ts +11 -23
- package/src/cli/help.ts +6 -31
- package/src/cli/info.ts +2 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +30 -4
- package/src/cli/plan-format.ts +5 -58
- package/src/cli/plugin-commands.ts +2 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +23 -185
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +15 -6
- package/src/cli/upgrade.ts +215 -55
- package/src/cli/watch-cycle.ts +2 -1
- package/src/cli/watch-filter.ts +22 -6
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +103 -16
- package/src/cli/why.ts +9 -7
- package/src/config.ts +19 -21
- package/src/exec/executor.ts +5 -5
- package/src/exec/sandbox-runtime.ts +1 -1
- package/src/exec/sandbox-violations.ts +3 -1
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +17 -2
- package/src/graph/priorities.ts +1 -1
- package/src/graph/scheduler.ts +45 -31
- package/src/graph/task-graph.ts +190 -58
- package/src/graph/task-status.ts +95 -0
- package/src/{orchestrator → history}/failure-mode.ts +1 -1
- package/src/{orchestrator → history}/history.ts +1 -1
- package/src/history/index.ts +48 -0
- package/src/{orchestrator → history}/metrics.ts +7 -20
- package/src/{orchestrator → history}/miss-reason.ts +1 -1
- package/src/index.ts +11 -29
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-args.ts +80 -0
- package/src/orchestrator/execute-task.ts +50 -411
- package/src/orchestrator/fingerprint-watch.ts +2 -2
- package/src/orchestrator/index.ts +2 -73
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +1 -1
- package/src/orchestrator/miss-save.ts +33 -3
- package/src/orchestrator/options.ts +1 -36
- package/src/orchestrator/output-host.ts +4 -3
- package/src/orchestrator/persistent-task.ts +321 -0
- package/src/orchestrator/persistent.ts +130 -3
- package/src/orchestrator/placement.ts +5 -5
- package/src/orchestrator/plan.ts +19 -6
- package/src/orchestrator/plugin-host.ts +129 -69
- package/src/orchestrator/plugin.ts +18 -8
- package/src/orchestrator/prepare.ts +40 -34
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/remote-prefetch.ts +14 -1
- package/src/orchestrator/run-artifacts.ts +5 -177
- package/src/orchestrator/run-context.ts +55 -0
- package/src/orchestrator/run-failures.ts +15 -3
- package/src/orchestrator/run-records.ts +35 -3
- package/src/orchestrator/run-report.ts +151 -171
- package/src/orchestrator/run-settings.ts +1 -16
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run-stop.ts +153 -0
- package/src/orchestrator/run.ts +293 -604
- package/src/orchestrator/sandbox-fix.ts +1 -17
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +14 -6
- package/src/orchestrator/stable-keys.ts +164 -27
- package/src/orchestrator/steps.ts +70 -0
- package/src/orchestrator/task-hash.ts +34 -25
- package/src/orchestrator/task-log-buffer.ts +1 -1
- package/src/orchestrator/task-process.ts +94 -0
- package/src/orchestrator/telemetry-host.ts +4 -4
- package/src/orchestrator/telemetry.ts +14 -86
- package/src/orchestrator/upstream.ts +4 -181
- package/src/{orchestrator → report}/events.ts +41 -8
- package/src/{orchestrator → report}/forecast.ts +35 -2
- package/src/{orchestrator → report}/framed-output.ts +40 -34
- package/src/report/index.ts +36 -0
- package/src/{orchestrator → report}/logger.ts +158 -20
- package/src/{orchestrator → report}/status-line.ts +88 -7
- package/src/{orchestrator → report}/summary.ts +126 -35
- package/src/report/tally.ts +83 -0
- package/src/settings.ts +12 -39
- package/src/util/colors.ts +44 -4
- package/src/util/git-ignored.ts +143 -0
- package/src/util/index.ts +19 -4
- package/src/util/paths.ts +1 -1
- package/src/util/report.ts +13 -5
- package/src/util/settle.ts +14 -39
- package/src/util/tail.ts +1 -1
- package/src/util/ui.ts +17 -6
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/schemas/summary.json +0 -133
- package/src/cache/chained-cache.ts +0 -280
- package/src/orchestrator/tally.ts +0 -112
- /package/src/{orchestrator → report}/path-links.ts +0 -0
- /package/src/{orchestrator → report}/plain-output.ts +0 -0
- /package/src/{orchestrator → report}/terminal-signals.ts +0 -0
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
// LayeredCache —
|
|
1
|
+
// LayeredCache — the local cache with the declared remotes behind it.
|
|
2
2
|
//
|
|
3
|
-
// Read path: try local. On miss,
|
|
4
|
-
// the
|
|
3
|
+
// Read path: try local. On miss, ask each readable remote in declared
|
|
4
|
+
// order; the first hit streams its body into local so the next read is a
|
|
5
|
+
// local hit.
|
|
5
6
|
//
|
|
6
7
|
// Write path: write to local synchronously. Upload the local artifact
|
|
7
|
-
// to remote as a fire-and-forget background task; failures
|
|
8
|
-
// warning but never fail the user's run (the task already
|
|
9
|
-
// the only loss is the remote cache entry).
|
|
8
|
+
// to every writable remote as a fire-and-forget background task; failures
|
|
9
|
+
// log a warning but never fail the user's run (the task already
|
|
10
|
+
// succeeded; the only loss is the remote cache entry). Each remote's
|
|
11
|
+
// boundary, upload pool and warnings are its `RemoteLane`.
|
|
10
12
|
//
|
|
11
13
|
// The local and remote layers share the SAME artifact format — the
|
|
12
14
|
// `<hash>.tar.zst` bytes ship across the wire verbatim. Metadata
|
|
@@ -21,7 +23,6 @@ import type {
|
|
|
21
23
|
CacheGetContext,
|
|
22
24
|
CacheKeyInput,
|
|
23
25
|
CacheLayer,
|
|
24
|
-
CachePolicy,
|
|
25
26
|
CacheStats,
|
|
26
27
|
CacheStatsOptions,
|
|
27
28
|
IngestMeta,
|
|
@@ -32,7 +33,8 @@ import type {
|
|
|
32
33
|
RunRecord,
|
|
33
34
|
SaveArgs,
|
|
34
35
|
} from './cache.js'
|
|
35
|
-
import {
|
|
36
|
+
import type { OutputDirRow } from './cache.js'
|
|
37
|
+
import { RemoteLane, type ResolvedRemote } from './remote-lane.js'
|
|
36
38
|
|
|
37
39
|
/**
|
|
38
40
|
* What a remote cache layer must provide — THE plugin seam for remote
|
|
@@ -40,8 +42,8 @@ import { FULL_CACHE_POLICY, type OutputDirRow } from './cache.js'
|
|
|
40
42
|
* (or an embedder passes one as `RunOptions.remoteCache`), speaking
|
|
41
43
|
* whatever protocol it wants, and `LayeredCache` owns everything else:
|
|
42
44
|
* policy gating, in-flight dedup, remote provenance, and the never-fail
|
|
43
|
-
* contract (implementations THROW on failure;
|
|
44
|
-
*
|
|
45
|
+
* contract (implementations THROW on failure; every throw degrades to a
|
|
46
|
+
* cache miss, reported to the remote's `onError`). The artifact bytes are the
|
|
45
47
|
* local `<hash>.tar.zst` verbatim. The wires live in plugin packages
|
|
46
48
|
* (`@vzn/vx-migrate`'s `turboCache()` and `nxCache()`, `@vzn/vx-reapi`); see
|
|
47
49
|
* docs/modules/layered-cache.md.
|
|
@@ -64,15 +66,15 @@ export interface RemoteCacheLayer {
|
|
|
64
66
|
* token): core clears the run's remote axis it lacks. Omitted → both.
|
|
65
67
|
*/
|
|
66
68
|
readonly access?: { readonly read?: boolean; readonly write?: boolean }
|
|
67
|
-
/** Existence probe
|
|
69
|
+
/** Existence probe: `--dry` and `--force` ask it per key when the remote cannot batch. */
|
|
68
70
|
has(hash: string): Promise<boolean>
|
|
69
71
|
/**
|
|
70
72
|
* Optional batch existence probe: given N hashes, return the subset stored
|
|
71
|
-
* remotely in ONE round-trip.
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* info; use per-hash".
|
|
73
|
+
* remotely in ONE round-trip. Its presence says the wire can batch: a run
|
|
74
|
+
* asks it once for its keys, `--dry` before its per-key answers, and
|
|
75
|
+
* `--force` in the background to skip uploads of held keys. Without it a
|
|
76
|
+
* run GETs each key directly (no `has`). Never throws for control flow:
|
|
77
|
+
* `null` means "no batch info; use per-hash".
|
|
76
78
|
*/
|
|
77
79
|
hasMany?(hashes: readonly string[]): Promise<Set<string> | null>
|
|
78
80
|
/**
|
|
@@ -89,155 +91,50 @@ export interface RemoteCacheLayer {
|
|
|
89
91
|
* Store an artifact (fire-and-forget from LayeredCache's perspective).
|
|
90
92
|
* `body` is file-backed (`Bun.file`) when the local store holds the
|
|
91
93
|
* artifact, so a plugin that streams it never holds it whole, and every
|
|
92
|
-
* read of it sees the same bytes.
|
|
94
|
+
* read of it sees the same bytes. An entry is immutable: a `put` of a
|
|
95
|
+
* key the remote holds keeps the stored bytes. Core never asks `has`
|
|
96
|
+
* first; a wire that would overwrite checks inside.
|
|
93
97
|
*/
|
|
94
98
|
put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
/**
|
|
98
|
-
* What a remote layer resolves is a plugin's, so its shape is a boundary
|
|
99
|
-
* (item 252): a `get` that resolved `{ body: 'abc' }` was reported as
|
|
100
|
-
* "corrupt artifact … not a readable archive" — the bytes blamed for the
|
|
101
|
-
* plugin's shape — and a `hasMany` that resolved an array reached the
|
|
102
|
-
* prefetch pass's `.has()`. Named here and degraded to a miss, like every
|
|
103
|
-
* other remote failure.
|
|
104
|
-
*/
|
|
105
|
-
function invalidRemoteResult(what: string): Error {
|
|
106
|
-
return new Error(
|
|
107
|
-
`remote cache layer returned an invalid result: ${what} — a plugin bug, degraded to a miss`,
|
|
108
|
-
)
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function isBody(value: unknown): value is Blob | Response {
|
|
112
|
-
return value instanceof Blob || value instanceof Response
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function describeValue(value: unknown): string {
|
|
116
|
-
if (value === null) return 'null'
|
|
117
|
-
if (Array.isArray(value)) return 'an array'
|
|
118
|
-
// The pre-stream contract's shape, named so an unported layer reads its fix.
|
|
119
|
-
if (value instanceof ArrayBuffer || ArrayBuffer.isView(value))
|
|
120
|
-
return `a ${value.constructor.name}`
|
|
121
|
-
return typeof value === 'object' ? 'an object' : typeof value
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* Cap on concurrent background PUTs. Keeps a burst of cache misses from
|
|
126
|
-
* opening one socket per task; excess uploads queue and drain FIFO.
|
|
127
|
-
*/
|
|
128
|
-
const UPLOAD_CONCURRENCY = 4
|
|
129
|
-
|
|
130
|
-
/** The seam call a failure happened in, as the warning names it. */
|
|
131
|
-
type RemoteOp = 'probe' | 'download' | 'upload'
|
|
132
|
-
|
|
133
|
-
const PREPOSITION: Record<RemoteOp, string> = { probe: 'at', download: 'from', upload: 'to' }
|
|
134
|
-
|
|
135
|
-
/** The layer's `endpoint` as it may be printed: no userinfo, query or fragment. */
|
|
136
|
-
function printableEndpoint(endpoint: unknown): string | undefined {
|
|
137
|
-
if (typeof endpoint !== 'string' || endpoint === '') return undefined
|
|
138
|
-
if (!URL.canParse(endpoint)) return endpoint
|
|
139
|
-
const u = new URL(endpoint)
|
|
140
|
-
if (u.username === '' && u.password === '' && u.search === '' && u.hash === '') return endpoint
|
|
141
|
-
u.username = ''
|
|
142
|
-
u.password = ''
|
|
143
|
-
u.search = ''
|
|
144
|
-
u.hash = ''
|
|
145
|
-
return u.toString()
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
/**
|
|
149
|
-
* What makes two failures "the same way": the error's `code` when it
|
|
150
|
-
* carries one (a gRPC status, an errno, Bun's `ConnectionRefused`), because
|
|
151
|
-
* gRPC writes the elapsed time into its message and no two deadline messages
|
|
152
|
-
* match; else the message with the artifact's hash taken out, so a 500 on
|
|
153
|
-
* one hash and a 500 on the next are one class. The operation is not part of
|
|
154
|
-
* it: an unreachable server fails the probe, the download and the upload
|
|
155
|
-
* alike, and that is one fact.
|
|
156
|
-
*/
|
|
157
|
-
function failureClass(err: Error, hash: string | undefined): string {
|
|
158
|
-
const code = (err as { code?: unknown }).code
|
|
159
|
-
if (typeof code === 'string' || typeof code === 'number') return `${err.name}\0${code}`
|
|
160
|
-
return hash === undefined ? err.message : err.message.replaceAll(hash, '\0')
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/** A batch probe names no one hash, so its line counts them. */
|
|
164
|
-
function batchOf(hashes: readonly string[]): { batch: string } {
|
|
165
|
-
return { batch: `of ${hashes.length} artifact${hashes.length === 1 ? '' : 's'}` }
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
export interface LayeredCacheOptions {
|
|
169
|
-
/**
|
|
170
|
-
* Called for the remote failures the layer degrades to a miss, once per
|
|
171
|
-
* failure class per layer (`upload <hash> to <endpoint> failed: <cause>`,
|
|
172
|
-
* with the original error as `cause`); the repeats are counted and said
|
|
173
|
-
* once more at `close()` (`<n> more requests failed the same way: <cause>`).
|
|
174
|
-
*/
|
|
175
|
-
onRemoteError?: (err: Error) => void
|
|
176
|
-
/**
|
|
177
|
-
* The 4-axis read/write policy. The local slice (read/write) is
|
|
178
|
-
* already applied to the inner `Cache` by the caller; this layer reads
|
|
179
|
-
* `remoteRead` / `remoteWrite` to gate its OWN remote operations.
|
|
180
|
-
* Default: everything on.
|
|
181
|
-
*/
|
|
182
|
-
policy?: CachePolicy
|
|
183
|
-
}
|
|
184
|
-
|
|
185
101
|
export class LayeredCache implements CacheLayer {
|
|
186
102
|
/** A remote layer is composed in by construction — see `CacheLayer.hasRemote`. */
|
|
187
103
|
readonly hasRemote = true
|
|
188
104
|
|
|
105
|
+
private readonly lanes: readonly RemoteLane[]
|
|
106
|
+
private readonly readable: readonly RemoteLane[]
|
|
107
|
+
|
|
189
108
|
/**
|
|
190
109
|
* In-flight remote pulls keyed by hash. `prefetch` and `get` both go
|
|
191
110
|
* through here, so a key probed concurrently by both resolves a
|
|
192
|
-
* SINGLE
|
|
193
|
-
* was successfully ingested into local. Entries are retained
|
|
194
|
-
* run's lifetime: a settled `false` records "remote
|
|
195
|
-
* such artifact (or it was corrupt)", which lets `get` skip a
|
|
196
|
-
* lazy probe of the same dead hash. The map is bounded by the
|
|
197
|
-
* of distinct task keys in a run — negligible.
|
|
111
|
+
* SINGLE walk of the remotes. Each promise resolves `true` iff the
|
|
112
|
+
* artifact was successfully ingested into local. Entries are retained
|
|
113
|
+
* for the run's lifetime: a settled `false` records "no remote had
|
|
114
|
+
* such an artifact (or it was corrupt)", which lets `get` skip a
|
|
115
|
+
* second lazy probe of the same dead hash. The map is bounded by the
|
|
116
|
+
* number of distinct task keys in a run — negligible.
|
|
198
117
|
*/
|
|
199
118
|
private readonly inflight = new Map<string, Promise<boolean>>()
|
|
200
119
|
|
|
201
120
|
/**
|
|
202
|
-
* Hashes whose local artifact was materialized FROM
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
206
|
-
*
|
|
207
|
-
*
|
|
208
|
-
*
|
|
121
|
+
* Hashes whose local artifact was materialized FROM a remote this run
|
|
122
|
+
* (by `prefetch` or `get`'s read-through). A later `get` finds them as
|
|
123
|
+
* a local hit, but the work was still saved by the remote cache — so
|
|
124
|
+
* we flip `source` to `'remote'` and the orchestrator reports
|
|
125
|
+
* `cache-hit-remote`. Without this, a prefetch followed by a `get`
|
|
126
|
+
* would mislabel a genuine remote hit as local. Each maps to how long
|
|
127
|
+
* its download and ingest took.
|
|
209
128
|
*/
|
|
210
129
|
private readonly remoteSourced = new Map<string, number>()
|
|
211
130
|
|
|
212
|
-
/**
|
|
213
|
-
* Background write-through uploads. `save()` returns after the local
|
|
214
|
-
* write + byte capture; the PUT runs here so a cache-miss task never
|
|
215
|
-
* holds its scheduler worker slot for the upload round-trip. Bounded
|
|
216
|
-
* at UPLOAD_CONCURRENCY; `run()` awaits `drainUploads()` before
|
|
217
|
-
* `cache.close()`.
|
|
218
|
-
*/
|
|
219
|
-
private readonly uploadQueue: Array<() => Promise<void>> = []
|
|
220
|
-
private activeUploads = 0
|
|
221
|
-
private drainWaiters: Array<() => void> = []
|
|
222
|
-
|
|
223
|
-
private readonly policy: CachePolicy
|
|
224
|
-
private readonly endpoint: string | undefined
|
|
225
|
-
|
|
226
|
-
/**
|
|
227
|
-
* One entry per failure class said: its first line went out, `repeats`
|
|
228
|
-
* counts the ones held back. A run's requests are concurrent, so an
|
|
229
|
-
* unreachable server otherwise printed its bare runtime message once per
|
|
230
|
-
* request (three lines for one task over the Turbo wire, item 749).
|
|
231
|
-
*/
|
|
232
|
-
private readonly reported = new Map<string, { cause: string; repeats: number }>()
|
|
233
|
-
|
|
131
|
+
/** Remotes in declared order: the first readable one holding a hash answers it. */
|
|
234
132
|
constructor(
|
|
235
133
|
readonly local: Cache,
|
|
236
|
-
|
|
237
|
-
private readonly options: LayeredCacheOptions = {},
|
|
134
|
+
remotes: readonly ResolvedRemote[],
|
|
238
135
|
) {
|
|
239
|
-
this.
|
|
240
|
-
this.
|
|
136
|
+
this.lanes = remotes.map((r) => new RemoteLane(r, local.traffic))
|
|
137
|
+
this.readable = this.lanes.filter((l) => l.read)
|
|
241
138
|
}
|
|
242
139
|
|
|
243
140
|
key(input: CacheKeyInput): Promise<string> {
|
|
@@ -245,8 +142,8 @@ export class LayeredCache implements CacheLayer {
|
|
|
245
142
|
}
|
|
246
143
|
|
|
247
144
|
async prefetch(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
248
|
-
// No-op when remote
|
|
249
|
-
if (
|
|
145
|
+
// No-op when no remote may be read — there's nothing to warm from.
|
|
146
|
+
if (this.readable.length === 0) return false
|
|
250
147
|
// The local-first skip lives in doPullFromRemote (the shared choke
|
|
251
148
|
// point): pullFromRemote registers the `inflight` entry SYNCHRONOUSLY,
|
|
252
149
|
// so a concurrent markRemoteAbsent can't clobber a pending pull — a
|
|
@@ -259,43 +156,34 @@ export class LayeredCache implements CacheLayer {
|
|
|
259
156
|
}
|
|
260
157
|
|
|
261
158
|
/**
|
|
262
|
-
* Batch existence probe over the
|
|
263
|
-
*
|
|
264
|
-
* batch (no `hasMany`,
|
|
265
|
-
*
|
|
266
|
-
*
|
|
267
|
-
*
|
|
159
|
+
* Batch existence probe over the readable remotes — the subset of
|
|
160
|
+
* `hashes` some remote holds, one round-trip each, or `null` when any
|
|
161
|
+
* readable remote can't batch (no `hasMany`, or an error), or none may
|
|
162
|
+
* be read. The prefetch pass treats a non-null answer as complete: it
|
|
163
|
+
* fetches only the hits and pre-marks the rest (`markRemoteAbsent`) so
|
|
164
|
+
* their lazy `get` skips the network, which is sound only when every
|
|
165
|
+
* readable remote answered. Each answering remote keeps its own
|
|
166
|
+
* absences either way, so a hash only a sibling holds costs it no GET.
|
|
167
|
+
* Never throws.
|
|
268
168
|
*/
|
|
269
169
|
async remoteHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
|
|
270
|
-
if (
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
if (
|
|
276
|
-
this.reportRemoteError(
|
|
277
|
-
'probe',
|
|
278
|
-
batchOf(hashes),
|
|
279
|
-
invalidRemoteResult(
|
|
280
|
-
`hasMany() resolved ${describeValue(found)} (expected a Set of the hashes present, or null)`,
|
|
281
|
-
),
|
|
282
|
-
)
|
|
283
|
-
return null
|
|
284
|
-
}
|
|
285
|
-
return found ?? null
|
|
286
|
-
} catch (err) {
|
|
287
|
-
this.reportRemoteError('probe', batchOf(hashes), err)
|
|
288
|
-
return null
|
|
170
|
+
if (this.readable.length === 0) return null
|
|
171
|
+
let out: Set<string> | null = new Set()
|
|
172
|
+
for (const lane of this.readable) {
|
|
173
|
+
const found = await lane.hasMany(hashes)
|
|
174
|
+
if (found === null) out = null
|
|
175
|
+
else if (out !== null) for (const h of found) out.add(h)
|
|
289
176
|
}
|
|
177
|
+
return out
|
|
290
178
|
}
|
|
291
179
|
|
|
292
180
|
/**
|
|
293
|
-
* Record that
|
|
294
|
-
* batch probe), so a later `get`/`prefetch` short-circuits to a miss
|
|
295
|
-
* a network round-trip. Byte-for-byte equivalent to a
|
|
296
|
-
* having resolved `false` into `inflight` — same
|
|
297
|
-
* point-in-time staleness window — but
|
|
298
|
-
* overwrites an entry already in flight.
|
|
181
|
+
* Record that no remote has an artifact for each of `hashes` (from a
|
|
182
|
+
* batch probe), so a later `get`/`prefetch` short-circuits to a miss
|
|
183
|
+
* WITHOUT a network round-trip. Byte-for-byte equivalent to a
|
|
184
|
+
* background prefetch having resolved `false` into `inflight` — same
|
|
185
|
+
* at-most-once semantics, same point-in-time staleness window — but
|
|
186
|
+
* without spending the GET. Never overwrites an entry already in flight.
|
|
299
187
|
*/
|
|
300
188
|
markRemoteAbsent(hashes: Iterable<string>): void {
|
|
301
189
|
for (const hash of hashes) {
|
|
@@ -303,6 +191,10 @@ export class LayeredCache implements CacheLayer {
|
|
|
303
191
|
}
|
|
304
192
|
}
|
|
305
193
|
|
|
194
|
+
probeBeforeUpload(hashes: Promise<readonly string[]>): void {
|
|
195
|
+
for (const lane of this.lanes) lane.probeBeforeUpload(hashes)
|
|
196
|
+
}
|
|
197
|
+
|
|
306
198
|
async get(hash: string, ctx?: CacheGetContext): Promise<CacheEntry | null> {
|
|
307
199
|
const localHit = await this.local.get(hash, ctx)
|
|
308
200
|
if (localHit) {
|
|
@@ -313,12 +205,12 @@ export class LayeredCache implements CacheLayer {
|
|
|
313
205
|
}
|
|
314
206
|
|
|
315
207
|
// Remote reads disabled (e.g. `--cache-policy=remote:`): a local miss is a
|
|
316
|
-
// real miss; never touch
|
|
317
|
-
if (
|
|
208
|
+
// real miss; never touch a remote.
|
|
209
|
+
if (this.readable.length === 0) return null
|
|
318
210
|
|
|
319
211
|
// A prefetch may already have probed this hash (resolved) or be
|
|
320
212
|
// mid-flight. Awaiting the shared promise guarantees AT MOST ONE
|
|
321
|
-
// remote
|
|
213
|
+
// remote walk per key: a settled `false` means no remote had it
|
|
322
214
|
// (no second probe), and a settled/in-flight `true` means the
|
|
323
215
|
// artifact was/will-be ingested locally — re-read below.
|
|
324
216
|
const ingested = await this.pullFromRemote(hash, ctx)
|
|
@@ -348,27 +240,23 @@ export class LayeredCache implements CacheLayer {
|
|
|
348
240
|
return fetchMs === undefined ? entry : { ...entry, source: 'remote', fetchMs }
|
|
349
241
|
}
|
|
350
242
|
|
|
351
|
-
// Existence probe: local first, then
|
|
243
|
+
// Existence probe: local first, then each remote's HEAD — no body
|
|
352
244
|
// transfer, no ingest, so the plan path stays read-only even against
|
|
353
245
|
// a remote cache. Remote errors degrade to a miss (never throw).
|
|
354
246
|
async has(hash: string): Promise<'local' | 'remote' | null> {
|
|
355
247
|
if ((await this.local.has(hash)) === 'local') return 'local'
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return (await this.remote.has(hash)) ? 'remote' : null
|
|
359
|
-
} catch (err) {
|
|
360
|
-
this.reportRemoteError('probe', hash, err)
|
|
361
|
-
return null
|
|
362
|
-
}
|
|
248
|
+
for (const lane of this.readable) if (await lane.has(hash)) return 'remote'
|
|
249
|
+
return null
|
|
363
250
|
}
|
|
364
251
|
|
|
365
252
|
/**
|
|
366
253
|
* Single implementation of the remote read-through, shared by
|
|
367
254
|
* `prefetch` and `get`. Idempotent per hash via `inflight`: the first
|
|
368
|
-
* caller starts the
|
|
255
|
+
* caller starts the walk + validate + ingest; concurrent and later
|
|
369
256
|
* callers await the same promise. Resolves `true` when the artifact
|
|
370
|
-
* ends up in local, `false` on a remote
|
|
371
|
-
* (degrades to a cache miss; the error is reported, never
|
|
257
|
+
* ends up in local, `false` on a miss in every remote / error /
|
|
258
|
+
* corruption (degrades to a cache miss; the error is reported, never
|
|
259
|
+
* thrown).
|
|
372
260
|
*/
|
|
373
261
|
private pullFromRemote(hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
374
262
|
const existing = this.inflight.get(hash)
|
|
@@ -388,31 +276,16 @@ export class LayeredCache implements CacheLayer {
|
|
|
388
276
|
// caller re-reads it, keeping whatever provenance it already had (local,
|
|
389
277
|
// or remote if a concurrent prefetch set it).
|
|
390
278
|
if ((await this.local.has(hash)) === 'local') return true
|
|
279
|
+
for (const lane of this.readable) {
|
|
280
|
+
if (await this.pullFrom(lane, hash, ctx)) return true
|
|
281
|
+
}
|
|
282
|
+
return false
|
|
283
|
+
}
|
|
391
284
|
|
|
285
|
+
private async pullFrom(lane: RemoteLane, hash: string, ctx?: CacheGetContext): Promise<boolean> {
|
|
392
286
|
const fetchStart = performance.now()
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
remoteResult = await this.remote.get(hash)
|
|
396
|
-
} catch (err) {
|
|
397
|
-
this.reportRemoteError('download', hash, err)
|
|
398
|
-
return false
|
|
399
|
-
}
|
|
400
|
-
if (!remoteResult) return false
|
|
401
|
-
if (typeof remoteResult !== 'object' || !isBody((remoteResult as { body?: unknown }).body)) {
|
|
402
|
-
const shape =
|
|
403
|
-
typeof remoteResult !== 'object'
|
|
404
|
-
? describeValue(remoteResult)
|
|
405
|
-
: `body is ${describeValue((remoteResult as { body?: unknown }).body)}`
|
|
406
|
-
this.reportRemoteError(
|
|
407
|
-
'download',
|
|
408
|
-
hash,
|
|
409
|
-
invalidRemoteResult(
|
|
410
|
-
`get() resolved ${shape} (expected { body: Blob | Response, durationMs } or null)`,
|
|
411
|
-
),
|
|
412
|
-
)
|
|
413
|
-
return false
|
|
414
|
-
}
|
|
415
|
-
const remoteBody = remoteResult as { body: Blob | Response; durationMs?: number }
|
|
287
|
+
const remoteBody = await lane.get(hash)
|
|
288
|
+
if (remoteBody === null) return false
|
|
416
289
|
|
|
417
290
|
// Ingest the remote body into local using the caller-supplied
|
|
418
291
|
// taskId/command plus the remote-reported durationMs, which `get`
|
|
@@ -438,7 +311,7 @@ export class LayeredCache implements CacheLayer {
|
|
|
438
311
|
// artifact must degrade to a cache miss (task re-executes), not
|
|
439
312
|
// crash the run. A corrupt LOCAL artifact is a miss too, one level
|
|
440
313
|
// down: `Cache.restoreOutputs` drops it (A-52).
|
|
441
|
-
|
|
314
|
+
lane.report('download', hash, err)
|
|
442
315
|
return false
|
|
443
316
|
}
|
|
444
317
|
this.remoteSourced.set(hash, Math.round(performance.now() - fetchStart))
|
|
@@ -463,17 +336,17 @@ export class LayeredCache implements CacheLayer {
|
|
|
463
336
|
|
|
464
337
|
async save(args: SaveArgs): Promise<void> {
|
|
465
338
|
// Local write honors its own gates inside Cache.save (no-op when local
|
|
466
|
-
// writes are disabled
|
|
467
|
-
// to a shared local handle — see `skipLocalWrite` on the contract).
|
|
339
|
+
// writes are disabled).
|
|
468
340
|
await this.local.save(args)
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
//
|
|
472
|
-
//
|
|
473
|
-
//
|
|
474
|
-
//
|
|
341
|
+
const writable = this.lanes.filter((l) => l.write)
|
|
342
|
+
if (writable.length === 0) return
|
|
343
|
+
// Write-through upload, OFF the task's critical path: each PUT runs in
|
|
344
|
+
// its remote's bounded background pool so the task's worker slot is
|
|
345
|
+
// released immediately, and `run()` awaits `drainUploads()` before
|
|
346
|
+
// closing the cache. Errors are logged, not propagated: the task
|
|
347
|
+
// already succeeded; we don't fail it on cache-server issues.
|
|
475
348
|
//
|
|
476
|
-
//
|
|
349
|
+
// Each job hands the plugin a file-backed Blob over the local artifact,
|
|
477
350
|
// pinned under a private name when the job starts (`pinArtifact`): a
|
|
478
351
|
// queued job holds a path, not a buffer, a plugin that streams the Blob
|
|
479
352
|
// never holds it whole, and every read of it sees the same bytes though
|
|
@@ -493,61 +366,27 @@ export class LayeredCache implements CacheLayer {
|
|
|
493
366
|
try {
|
|
494
367
|
packed = await this.local.packArtifactBytes(args)
|
|
495
368
|
} catch (err) {
|
|
496
|
-
|
|
369
|
+
for (const lane of writable) lane.report('upload', hash, err)
|
|
497
370
|
return
|
|
498
371
|
}
|
|
499
372
|
}
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
let release: (() => Promise<void>) | undefined
|
|
504
|
-
try {
|
|
505
|
-
let body: Blob
|
|
506
|
-
if (packed !== undefined) body = new Blob([packed])
|
|
507
|
-
else ({ body, release } = this.local.pinArtifact(hash))
|
|
508
|
-
await this.remote.put(hash, body, { durationMs })
|
|
509
|
-
tally.count++
|
|
510
|
-
tally.bytes += body.size
|
|
511
|
-
tally.ms += Math.round(performance.now() - start)
|
|
512
|
-
} catch (err) {
|
|
513
|
-
tally.failed++
|
|
514
|
-
this.reportRemoteError('upload', hash, err)
|
|
515
|
-
} finally {
|
|
516
|
-
await release?.()
|
|
517
|
-
}
|
|
518
|
-
})
|
|
373
|
+
const body = (): { body: Blob; release?: () => Promise<void> } =>
|
|
374
|
+
packed !== undefined ? { body: new Blob([packed]) } : this.local.pinArtifact(hash)
|
|
375
|
+
for (const lane of writable) lane.upload(hash, durationMs, body, this.local.uploads)
|
|
519
376
|
}
|
|
520
377
|
|
|
521
378
|
/**
|
|
522
|
-
* Resolves once every queued + in-flight background upload
|
|
523
|
-
* `run()` calls this next to the prefetch drain, before
|
|
379
|
+
* Resolves once every remote's queued + in-flight background upload
|
|
380
|
+
* settles. `run()` calls this next to the prefetch drain, before
|
|
524
381
|
* `cache.close()` — an upload reading state after close would race.
|
|
525
382
|
*/
|
|
526
383
|
async drainUploads(): Promise<void> {
|
|
527
|
-
|
|
528
|
-
await new Promise<void>((resolve) => this.drainWaiters.push(resolve))
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
private enqueueUpload(job: () => Promise<void>): void {
|
|
532
|
-
this.uploadQueue.push(job)
|
|
533
|
-
this.pumpUploads()
|
|
384
|
+
await Promise.all(this.lanes.map((l) => l.drain()))
|
|
534
385
|
}
|
|
535
386
|
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
this.activeUploads++
|
|
540
|
-
// Jobs never reject (each wraps its PUT in the never-fail guard).
|
|
541
|
-
void job().finally(() => {
|
|
542
|
-
this.activeUploads--
|
|
543
|
-
this.pumpUploads()
|
|
544
|
-
})
|
|
545
|
-
}
|
|
546
|
-
if (this.activeUploads === 0 && this.uploadQueue.length === 0 && this.drainWaiters.length > 0) {
|
|
547
|
-
const waiters = this.drainWaiters
|
|
548
|
-
this.drainWaiters = []
|
|
549
|
-
for (const w of waiters) w()
|
|
550
|
-
}
|
|
387
|
+
/** A stopped run starts no more uploads and stops waiting on those in flight. */
|
|
388
|
+
stopUploads(): void {
|
|
389
|
+
for (const lane of this.lanes) lane.stop()
|
|
551
390
|
}
|
|
552
391
|
|
|
553
392
|
async ingest(hash: string, body: Blob | Response, meta: IngestMeta): Promise<void> {
|
|
@@ -598,55 +437,9 @@ export class LayeredCache implements CacheLayer {
|
|
|
598
437
|
|
|
599
438
|
/** `run()` drains the uploads and prefetches first, so the repeat counts are final here. */
|
|
600
439
|
close(): void {
|
|
601
|
-
for (const
|
|
602
|
-
if (repeats === 0) continue
|
|
603
|
-
this.emit(
|
|
604
|
-
new Error(
|
|
605
|
-
`${repeats} more request${repeats === 1 ? '' : 's'} failed the same way: ${cause}`,
|
|
606
|
-
),
|
|
607
|
-
)
|
|
608
|
-
}
|
|
609
|
-
this.reported.clear()
|
|
440
|
+
for (const lane of this.lanes) lane.close()
|
|
610
441
|
this.local.close()
|
|
611
442
|
}
|
|
612
|
-
|
|
613
|
-
/**
|
|
614
|
-
* `subject` is the artifact's hash, or the batch for a `hasMany`. The
|
|
615
|
-
* wire's own message says what went wrong but not on what or where: an
|
|
616
|
-
* upload deadline read `The operation timed out.` with no upload, hash or
|
|
617
|
-
* server in it (item 749).
|
|
618
|
-
*/
|
|
619
|
-
private reportRemoteError(op: RemoteOp, subject: string | { batch: string }, err: unknown): void {
|
|
620
|
-
const e = err instanceof Error ? err : new Error(String(err))
|
|
621
|
-
const hash = typeof subject === 'string' ? subject : undefined
|
|
622
|
-
const cls = failureClass(e, hash)
|
|
623
|
-
const seen = this.reported.get(cls)
|
|
624
|
-
if (seen !== undefined) {
|
|
625
|
-
seen.repeats++
|
|
626
|
-
return
|
|
627
|
-
}
|
|
628
|
-
this.reported.set(cls, { cause: e.message, repeats: 0 })
|
|
629
|
-
const what = typeof subject === 'string' ? subject : subject.batch
|
|
630
|
-
const where = this.endpoint === undefined ? '' : ` ${PREPOSITION[op]} ${this.endpoint}`
|
|
631
|
-
this.emit(new Error(`${op} ${what}${where} failed: ${e.message}`, { cause: e }))
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
private emit(e: Error): void {
|
|
635
|
-
// The remote cache is fully optional: NO remote failure — a 500, a
|
|
636
|
-
// network drop, a corrupt artifact, even a throwing onRemoteError
|
|
637
|
-
// callback — may ever fail the run. We report and degrade to a
|
|
638
|
-
// cache miss. The callback is guarded so a buggy reporter can't
|
|
639
|
-
// turn an optional-cache hiccup into a run failure.
|
|
640
|
-
try {
|
|
641
|
-
if (this.options.onRemoteError) {
|
|
642
|
-
this.options.onRemoteError(e)
|
|
643
|
-
} else {
|
|
644
|
-
process.stderr.write(`[vx] remote cache: ${e.message}\n`)
|
|
645
|
-
}
|
|
646
|
-
} catch {
|
|
647
|
-
// swallow — reporting must never escalate
|
|
648
|
-
}
|
|
649
|
-
}
|
|
650
443
|
}
|
|
651
444
|
|
|
652
445
|
function durationOf(ms: unknown): number {
|