@vzn/vx-reapi 0.0.652 → 0.0.653
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 +77 -34
- package/package.json +2 -2
- package/src/cache.ts +30 -14
- package/src/executor.ts +569 -263
- package/src/index.ts +1 -1
- package/src/merkle.ts +113 -67
- package/src/wire.ts +79 -29
package/README.md
CHANGED
|
@@ -74,14 +74,15 @@ A CAS digest is the sha256 of the **content**, so it cannot be derived from a
|
|
|
74
74
|
vx cache key before the bytes exist — `has(key)` could never answer. The
|
|
75
75
|
ActionCache supplies the missing indirection:
|
|
76
76
|
|
|
77
|
-
| vx | REAPI
|
|
78
|
-
| -------------------- |
|
|
79
|
-
| cache key | synthetic action digest — `sha256("vx-reapi-v1\0" + key)`
|
|
80
|
-
| artifact (`tar.zst`) | one CAS blob, referenced by the ActionResult's `output_files`
|
|
81
|
-
| task duration | `stdout_raw` on the ActionResult
|
|
82
|
-
| cache miss | `GetActionResult` → `NOT_FOUND`
|
|
83
|
-
| cache hit | `GetActionResult` asking for stdout and the artifact inline: a server that honours it (bazel-remote, up to ~1 MiB) answers a small hit in one round trip
|
|
84
|
-
| cache save | an artifact up to 256 KiB is read once and goes in one `BatchUpdateBlobs`, unprobed; a larger one is probed with `FindMissingBlobs` and streamed only if missing
|
|
77
|
+
| vx | REAPI |
|
|
78
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
79
|
+
| cache key | synthetic action digest — `sha256("vx-reapi-v1\0" + key)` |
|
|
80
|
+
| artifact (`tar.zst`) | one CAS blob, referenced by the ActionResult's `output_files` |
|
|
81
|
+
| task duration | `stdout_raw` on the ActionResult |
|
|
82
|
+
| cache miss | `GetActionResult` → `NOT_FOUND` |
|
|
83
|
+
| cache hit | `GetActionResult` asking for stdout and the artifact inline: a server that honours it (bazel-remote, up to ~1 MiB) answers a small hit in one round trip |
|
|
84
|
+
| cache save | an artifact up to 256 KiB is read once and goes in one `BatchUpdateBlobs`, unprobed; a larger one is probed with `FindMissingBlobs` and streamed only if missing |
|
|
85
|
+
| stored key | kept: the save writes no `UpdateActionResult`, unless core says the stored bytes did not read back (`replace`); then the blob is sent unprobed and the entry rewritten |
|
|
85
86
|
|
|
86
87
|
The `vx-reapi-v1` prefix does two jobs: it keeps vx keys out of the address
|
|
87
88
|
space of real Bazel action digests on a shared server, and it makes a future
|
|
@@ -132,13 +133,19 @@ bazel-remote sends when the call's `grpc-timeout` runs out first (a
|
|
|
132
133
|
|
|
133
134
|
## Repeat runs skip the worker
|
|
134
135
|
|
|
135
|
-
Every successful remote execution writes an execution
|
|
136
|
-
task's vx key (`vx-reapi-exec-
|
|
137
|
-
its stdout (inline up to 64 KiB, a CAS blob past it), written
|
|
138
|
-
outputs come down. A later run whose vx cache missed but whose key
|
|
139
|
-
a record skips the Merkle build, the upload pass and `Execute`
|
|
140
|
-
entirely: the outputs are already in the CAS, and
|
|
141
|
-
the record.
|
|
136
|
+
Every successful remote execution writes an execution
|
|
137
|
+
record under the task's vx key (`vx-reapi-exec-v2`), listing its outputs by digest plus
|
|
138
|
+
its stdout and stderr (each inline up to 64 KiB, a CAS blob past it), written
|
|
139
|
+
while the outputs come down. A later run whose vx cache missed but whose key
|
|
140
|
+
already has a record skips the Merkle build, the upload pass and `Execute`
|
|
141
|
+
entirely: the outputs are already in the CAS, and both streams replay from
|
|
142
|
+
the record, as a vx hit replays them. A run core does not save
|
|
143
|
+
(`--no-cache`, a read-only policy, a tainted upstream) records under a key
|
|
144
|
+
only this run's executor knows instead: its dependants in this run graft
|
|
145
|
+
from it, and no later run replays it as the task's hit. A `v1` record kept no stderr and is
|
|
146
|
+
never read: its key executes once more and writes a `v2` one. A record
|
|
147
|
+
with a non-zero exit code (vx writes none, but the action cache is the
|
|
148
|
+
server's) is no record either: the task executes. `--force` bypasses it.
|
|
142
149
|
|
|
143
150
|
The input tree is read after the key was taken, so each file's bytes are
|
|
144
151
|
held to the git blob id the key hashed for it. A file edited, removed or
|
|
@@ -153,11 +160,11 @@ listed input (`@vzn/vx-lockfile` lists it on every cached task).
|
|
|
153
160
|
This matters most under `--cache-download=none`, where deferral leaves no
|
|
154
161
|
local cache entry behind, so vx's own probe misses on every later run
|
|
155
162
|
and the record is what makes the second run cheap. Records are checked
|
|
156
|
-
against the CAS first (`FindMissingBlobs`, the outputs and
|
|
163
|
+
against the CAS first (`FindMissingBlobs`, the outputs and any stream blob) — the action cache and the
|
|
157
164
|
CAS evict independently, so a record that outlived its blobs falls
|
|
158
165
|
through to a real execution rather than "succeeding" with nothing.
|
|
159
166
|
The replay itself is a cache read too: a transport failure reading the
|
|
160
|
-
record,
|
|
167
|
+
record, a stream or any output warns and executes, after removing
|
|
161
168
|
whatever the half-done replay created (core cleaned the outputs once,
|
|
162
169
|
before the replay, and the real run's result must not inherit them).
|
|
163
170
|
The probe sees a Tree by its own digest, not the blobs inside it, so
|
|
@@ -177,7 +184,10 @@ under a key asserting those bytes were present. Which upstream bytes a
|
|
|
177
184
|
command actually reads is unknowable — that is what `dependsOn`
|
|
178
185
|
declares — so the refusal is the only sound reading, and it matches
|
|
179
186
|
what core does when a deferred producer cannot be materialised.
|
|
180
|
-
Re-run the upstream (`--force`) to repopulate the store.
|
|
187
|
+
Re-run the upstream (`--force`) to repopulate the store. An upstream with
|
|
188
|
+
no record at all and its outputs nowhere on this machine (its outputs
|
|
189
|
+
listed in the local index but gone, or a deferred result no record could
|
|
190
|
+
hold) is refused the same way.
|
|
181
191
|
|
|
182
192
|
Bringing a task's OWN outputs back gets the same treatment. Core's
|
|
183
193
|
contract is that once an executor returns, the declared outputs are on
|
|
@@ -225,8 +235,10 @@ content-addressed store: a corrupt or poisoned remote degrades to a miss
|
|
|
225
235
|
(the cache invariant), never to wrong bytes under a trusted name. Uploads
|
|
226
236
|
were always server-verified; this is the mirror on the read side, the same
|
|
227
237
|
check Bazel's client performs. A streamed read (the cache artifact) is
|
|
228
|
-
hashed as its bytes pass and errors at its end on a mismatch
|
|
229
|
-
|
|
238
|
+
hashed as its bytes pass and errors at its end on a mismatch with core's
|
|
239
|
+
`CorruptArtifactError`, so the hit is a miss and the rebuild's save
|
|
240
|
+
replaces the entry and its blob: one corrupt entry is one rebuild, not
|
|
241
|
+
one on every run. The size is held as the bytes arrive:
|
|
230
242
|
a body past its digest's size is refused at the byte that passes it, a
|
|
231
243
|
zstd reply is decoded no further than that size, and a batch entry for a
|
|
232
244
|
digest not asked for is dropped.
|
|
@@ -239,8 +251,12 @@ directory that resolves out through a link, and a file or link that
|
|
|
239
251
|
lands, through one, where no declared glob names are refused before anything
|
|
240
252
|
is written through them; a link standing at an output file is replaced, never written
|
|
241
253
|
through; a Tree file's setuid and setgid bits are dropped. A Tree may name
|
|
242
|
-
one subtree many times;
|
|
243
|
-
system has free inodes is refused before anything is written
|
|
254
|
+
one subtree many times; a result whose Trees together expand to more paths
|
|
255
|
+
than the file system has free inodes is refused before anything is written,
|
|
256
|
+
as is a Tree holding a path past `PATH_MAX` (4096 bytes; 1024 on macOS).
|
|
257
|
+
A glob's matches are recorded each as a Tree of its own, a subtree named
|
|
258
|
+
under many names encoded once; a capture past the free inodes or
|
|
259
|
+
`PATH_MAX` is recorded whole, unsplit.
|
|
244
260
|
|
|
245
261
|
## Artifacts stream
|
|
246
262
|
|
|
@@ -307,19 +323,34 @@ A task placed `exec.remote: 'only'` fails with that reason instead. Unset,
|
|
|
307
323
|
the task's own `exec.timeout` bounds the queue as it bounds the run (the
|
|
308
324
|
task fails as timed out); with neither, the wait is unbounded.
|
|
309
325
|
A stream that drops with a transient status, or ends cleanly before its
|
|
310
|
-
operation is done, re-attaches with `WaitExecution`
|
|
311
|
-
|
|
326
|
+
operation is done, re-attaches with `WaitExecution` rather than running the
|
|
327
|
+
action again. Only attaches that deliver no operation spend the budget:
|
|
328
|
+
the fourth such in a row fails the task. A stream that delivered one (a
|
|
329
|
+
proxy cutting a long, progressing action) starts the count over, so a
|
|
330
|
+
long action survives any number of cuts. The wait before each re-attach
|
|
331
|
+
grows per cut in a row (100, 400, 1600, then 5000 ms) and starts over once
|
|
332
|
+
a stream advances the stage or stays open 10 s. If
|
|
312
333
|
`WaitExecution` answers NOT_FOUND — the server lost the operation, as a
|
|
313
334
|
restart does — nothing is left running to re-attach to, and the action is
|
|
314
|
-
executed again on the same budget.
|
|
335
|
+
executed again on the same budget. Past the budget the task fails and the
|
|
336
|
+
operation is cancelled, so the action does not run on unwatched.
|
|
337
|
+
An Execute answered FAILED_PRECONDITION (inputs the server lacks, evicted
|
|
338
|
+
after `FindMissingBlobs` or never stored) uploads them again — the blobs its
|
|
339
|
+
`PreconditionFailure` names MISSING, else every input, without asking
|
|
340
|
+
`FindMissingBlobs` — and executes once more; a second FAILED_PRECONDITION
|
|
341
|
+
fails the task.
|
|
315
342
|
Once a worker reports EXECUTING, the task's `exec.timeout` (or
|
|
316
|
-
`executeTimeoutMs
|
|
317
|
-
re-attach: firing during the backoff between a
|
|
318
|
-
`WaitExecution`, it ends the task there rather than
|
|
319
|
-
|
|
343
|
+
`executeTimeoutMs`, 10 minutes when neither is set) bounds the wait, and
|
|
344
|
+
the bound holds across a re-attach: firing during the backoff between a
|
|
345
|
+
dropped stream and its `WaitExecution`, it ends the task there rather than
|
|
346
|
+
being lost. It is also what ends a server that answers EXECUTING and hangs
|
|
347
|
+
up on every attach. Past it the operation is cancelled too (a refused
|
|
348
|
+
cancel is a warning).
|
|
320
349
|
The run stopping (Ctrl-C, an embedder's abort) or the task's `exec.timeout`
|
|
321
350
|
closes the stream and cancels the operation with `CancelOperation`, and an
|
|
322
|
-
action not yet submitted is not sent.
|
|
351
|
+
action not yet submitted is not sent. A record replay, an upstream record
|
|
352
|
+
read or the upload under way stops there too, and writes nothing after: what
|
|
353
|
+
a replay already created is taken back, so a retry starts clean. The task then ends as a killed local
|
|
323
354
|
one does (exit 143, `SIGTERM`): past `exec.timeout` it is `failed (timed
|
|
324
355
|
out)` with its key in `vx last`.
|
|
325
356
|
|
|
@@ -349,8 +380,9 @@ and the task executes normally, with a warning naming why. The UPSTREAM record
|
|
|
349
380
|
reads are the deliberate exception — those decide whether a dependency's bytes
|
|
350
381
|
exist at all, and carrying on past a failure there is how an action runs
|
|
351
382
|
without its inputs and caches the result. A fresh execution's stdout or
|
|
352
|
-
stderr that cannot be read
|
|
353
|
-
|
|
383
|
+
stderr that cannot be read, or whose blob the CAS no longer holds, fails
|
|
384
|
+
the task naming the digest, and no record is written: those are the
|
|
385
|
+
task's result, not a shortcut.
|
|
354
386
|
|
|
355
387
|
## Tests
|
|
356
388
|
|
|
@@ -428,7 +460,10 @@ A cache-only server (bazel-remote advertises `exec_enabled: false`) makes the
|
|
|
428
460
|
plugin **decline the executor with a warning** rather than submit work that
|
|
429
461
|
will never be answered. Only cacheable tasks are eligible — a task with no
|
|
430
462
|
`cache: true` has no described inputs, so a worker would run it against an
|
|
431
|
-
empty input root. A cacheable task's root holds its declared input files
|
|
463
|
+
empty input root. A cacheable task's root holds its declared input files
|
|
464
|
+
and its upstreams' declared outputs. An upstream with no `cache` runs on
|
|
465
|
+
this machine and leaves no record, so its outputs ship from this disk; a
|
|
466
|
+
file it writes outside its declared `outputs` never reaches the worker.
|
|
432
467
|
|
|
433
468
|
Verified end-to-end against a live NativeLink scheduler + worker: input tree
|
|
434
469
|
uploaded, QUEUED → EXECUTING → COMPLETED streamed, stdout returned inline,
|
|
@@ -480,11 +515,15 @@ worker (`tests/vx-run-e2e.test.ts`):
|
|
|
480
515
|
miss can leave the artifact store and the execution record holding
|
|
481
516
|
results of different executions under one pure-input key, and a worker
|
|
482
517
|
fed the record would see bytes this machine's own tasks do not.
|
|
518
|
+
- An upstream that writes inside `install`'s directory (a `prisma generate`
|
|
519
|
+
into `node_modules/.prisma`) is merged into it: the worker sees both. A
|
|
520
|
+
DECLARED input file under a grafted directory is replaced by the graft,
|
|
521
|
+
with a warning.
|
|
483
522
|
- With **no remote executor declared**, `install` is a local no-op and
|
|
484
523
|
dependents use whatever the machine has ambient — a laptop run behaves
|
|
485
524
|
exactly as it did before the field existed.
|
|
486
525
|
|
|
487
|
-
The execution record lives under `sha256("vx-reapi-exec-
|
|
526
|
+
The execution record lives under `sha256("vx-reapi-exec-v2\0" + key)` — a
|
|
488
527
|
second AC namespace beside the artifact mapping, listing outputs file-by-file
|
|
489
528
|
with workspace-relative paths.
|
|
490
529
|
|
|
@@ -507,6 +546,10 @@ sorted by name — the proto requires that, so equivalent Commands hash alike:
|
|
|
507
546
|
A `define` wins over an `inputs.env` entry of the same name: it is the more
|
|
508
547
|
explicit statement of intent.
|
|
509
548
|
|
|
549
|
+
One constant is vx's own: `FORCE_COLOR=1`, which every local child gets
|
|
550
|
+
too (unless the task sees a `FORCE_COLOR` or a non-empty `NO_COLOR`), so a
|
|
551
|
+
remote entry holds the same coloured bytes a local one does.
|
|
552
|
+
|
|
510
553
|
Nothing else crosses. In particular **`exec.env.passThrough` does not reach a
|
|
511
554
|
remote worker**, and neither does the essential allowlist (`PATH`, `HOME`,
|
|
512
555
|
`TMPDIR`, …) — those are the submitting machine's resolved environment, and
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-reapi",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.653",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Bazel Remote Execution API plugin for vx — remote cache (ActionCache + CAS) against NativeLink, BuildBuddy, Buildbarn or bazel-remote.",
|
|
6
6
|
"keywords": [
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@vzn/vx": "^0.0.
|
|
42
|
+
"@vzn/vx": "^0.0.653"
|
|
43
43
|
},
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
package/src/cache.ts
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import { createHash } from 'node:crypto'
|
|
12
12
|
import * as grpc from '@grpc/grpc-js'
|
|
13
13
|
import { type ActionResult, type Digest, ReapiClient, type ReapiOptions } from './wire.js'
|
|
14
|
-
import { ui } from '@vzn/vx'
|
|
14
|
+
import { CorruptArtifactError, ui } from '@vzn/vx'
|
|
15
15
|
|
|
16
16
|
/** The artifact's `output_files` path. Constant: the AC entry holds exactly one. */
|
|
17
17
|
const ARTIFACT_PATH = 'vx-artifact.tar.zst'
|
|
@@ -36,7 +36,9 @@ export function actionDigestFor(vxKey: string): Digest {
|
|
|
36
36
|
* without ever landing on the submitter's disk.
|
|
37
37
|
*/
|
|
38
38
|
export function execDigestFor(vxKey: string): Digest {
|
|
39
|
-
|
|
39
|
+
// v2 records keep stderr. A v1 record cannot tell "printed nothing" from
|
|
40
|
+
// "not kept", so it is never read: its key executes once more.
|
|
41
|
+
const payload = Buffer.from(`vx-reapi-exec-v2\0${vxKey}`, 'utf8')
|
|
40
42
|
return { hash: createHash('sha256').update(payload).digest('hex'), size_bytes: payload.length }
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -173,9 +175,16 @@ export class ReapiRemoteCache {
|
|
|
173
175
|
digestOf(file.contents).hash === file.digest.hash
|
|
174
176
|
? file.contents
|
|
175
177
|
: undefined
|
|
178
|
+
// Bytes that are not their digest are wrong on the server, not cut
|
|
179
|
+
// short: said as core's corrupt artifact, the rebuild's save replaces them.
|
|
176
180
|
const [durationMs, body] = await Promise.all([
|
|
177
181
|
this.durationOf(result),
|
|
178
|
-
inline === undefined
|
|
182
|
+
inline === undefined
|
|
183
|
+
? this.client.readBlobStream(
|
|
184
|
+
file.digest,
|
|
185
|
+
(err) => new CorruptArtifactError(hash, err.message, err),
|
|
186
|
+
)
|
|
187
|
+
: inline,
|
|
179
188
|
])
|
|
180
189
|
if (body === null) return null
|
|
181
190
|
return { body: new Response(body), durationMs }
|
|
@@ -187,7 +196,7 @@ export class ReapiRemoteCache {
|
|
|
187
196
|
}
|
|
188
197
|
// The server normalised our inline bytes into CAS (bazel-remote does).
|
|
189
198
|
// An absent digest arrives as `null` on this path (proto-loader's
|
|
190
|
-
// message default, as `
|
|
199
|
+
// message default, as `readStream` in executor.ts records).
|
|
191
200
|
// The duration is metadata: a read of it that fails (retries spent, a
|
|
192
201
|
// blob failing its digest) leaves it unknown, where it made core drop a
|
|
193
202
|
// valid hit as a miss (F-21).
|
|
@@ -213,22 +222,27 @@ export class ReapiRemoteCache {
|
|
|
213
222
|
* An artifact past `writeBlob`'s whole read takes two passes over `body`,
|
|
214
223
|
* never one in memory: the digest first (the CAS address must be known
|
|
215
224
|
* before the server is asked), then the upload from a second read of the
|
|
216
|
-
* stream.
|
|
225
|
+
* stream. `replace`: the stored entry did not read back, so the blob is
|
|
226
|
+
* sent whether the server says it holds it or not, and the entry
|
|
227
|
+
* rewritten.
|
|
217
228
|
*/
|
|
218
|
-
async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
229
|
+
async put(hash: string, body: Blob, meta: { durationMs: number; replace?: true }): Promise<void> {
|
|
219
230
|
if (this.refused !== undefined) throw this.refused
|
|
220
231
|
// An entry is immutable, and UpdateActionResult replaces one: the
|
|
221
232
|
// lookup runs beside the upload (CAS writes are idempotent), so a key
|
|
222
233
|
// stored already costs no round trip of its own and keeps its result.
|
|
223
234
|
// A lookup that fails writes, as before.
|
|
224
235
|
const action = actionDigestFor(hash)
|
|
225
|
-
const stored =
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
this.
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
236
|
+
const stored =
|
|
237
|
+
meta.replace === true
|
|
238
|
+
? undefined
|
|
239
|
+
: this.client.getActionResult(action).then(
|
|
240
|
+
(r) => r?.output_files?.find((f) => f.path === ARTIFACT_PATH)?.digest,
|
|
241
|
+
(err: unknown) => {
|
|
242
|
+
this.warn('looking up a stored entry before an upload', err)
|
|
243
|
+
return undefined
|
|
244
|
+
},
|
|
245
|
+
)
|
|
232
246
|
// Read once, and those bytes hashed and sent, wherever `writeBlob` would
|
|
233
247
|
// read the Blob whole anyway: a second writer of the key renames its
|
|
234
248
|
// artifact over the file, and two reads sent its bytes under this one's
|
|
@@ -248,7 +262,9 @@ export class ReapiRemoteCache {
|
|
|
248
262
|
} else {
|
|
249
263
|
// Upload only what the server lacks: for a large artifact the probe is
|
|
250
264
|
// cheap next to the bytes it can skip.
|
|
251
|
-
|
|
265
|
+
// A blob held under this digest may be the one that did not read back.
|
|
266
|
+
const missing =
|
|
267
|
+
meta.replace === true ? [digest] : await this.client.findMissingBlobs([digest])
|
|
252
268
|
if (missing.length > 0) await this.client.writeBlob(digest, data ?? body)
|
|
253
269
|
}
|
|
254
270
|
// The two stores are pruned apart: an entry whose blob is gone is a
|