@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 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 record under the
136
- task's vx key (`vx-reapi-exec-v1`), listing its outputs by digest plus
137
- its stdout (inline up to 64 KiB, a CAS blob past it), written while the
138
- outputs come down. A later run whose vx cache missed but whose key already has
139
- a record skips the Merkle build, the upload pass and `Execute`
140
- entirely: the outputs are already in the CAS, and stdout replays from
141
- the record. `--force` bypasses it.
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 a stdout blob) — the action cache and the
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, its stdout or any output warns and executes, after removing
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, so vx's
229
- ingest fails and the hit is a miss. The size is held as the bytes arrive:
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; one that expands to more paths than the file
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` (three times, backing
311
- off 100, 400 and 1600 ms) rather than running the action again. If
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`) bounds the wait, and the bound holds across a
317
- re-attach: firing during the backoff between a dropped stream and its
318
- `WaitExecution`, it ends the task there rather than being lost. Past it
319
- the operation is cancelled too (a refused cancel is a warning).
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. The task then ends as a killed local
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 fails the task, as a declared output does
353
- (above): those are the task's result, not a shortcut.
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-v1\0" + key)` — a
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.652",
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.652"
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
- const payload = Buffer.from(`vx-reapi-exec-v1\0${vxKey}`, 'utf8')
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 ? this.client.readBlobStream(file.digest) : inline,
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 `this_readStream` in executor.ts records).
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 = this.client.getActionResult(action).then(
226
- (r) => r?.output_files?.find((f) => f.path === ARTIFACT_PATH)?.digest,
227
- (err: unknown) => {
228
- this.warn('looking up a stored entry before an upload', err)
229
- return undefined
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
- const missing = await this.client.findMissingBlobs([digest])
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