@vzn/vx-reapi 0.0.0 → 0.0.485
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/LICENSE +21 -0
- package/README.md +502 -0
- package/index.ts +4 -0
- package/package.json +45 -6
- package/protos/build/bazel/remote/execution/v2/remote_execution.proto +2516 -0
- package/protos/build/bazel/semver/semver.proto +41 -0
- package/protos/google/api/annotations.proto +31 -0
- package/protos/google/api/client.proto +598 -0
- package/protos/google/api/field_behavior.proto +104 -0
- package/protos/google/api/http.proto +370 -0
- package/protos/google/api/launch_stage.proto +72 -0
- package/protos/google/bytestream/bytestream.proto +178 -0
- package/protos/google/longrunning/operations.proto +265 -0
- package/protos/google/rpc/code.proto +186 -0
- package/protos/google/rpc/status.proto +48 -0
- package/src/cache.ts +199 -0
- package/src/executor.ts +1805 -0
- package/src/index.ts +277 -0
- package/src/merkle.ts +867 -0
- package/src/wire.ts +1658 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 vx contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
# `@vzn/vx-reapi`
|
|
2
|
+
|
|
3
|
+
A vx **remote cache** backed by any server speaking Bazel's
|
|
4
|
+
[Remote Execution API](https://github.com/bazelbuild/remote-apis) — NativeLink,
|
|
5
|
+
BuildBuddy, Buildbarn, bazel-remote: mature server implementations, none of
|
|
6
|
+
which we had to write, because a REAPI server is deliberately dumb.
|
|
7
|
+
|
|
8
|
+
```sh
|
|
9
|
+
npm install -D @vzn/vx @vzn/vx-reapi # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```ts
|
|
13
|
+
// vx.workspace.ts
|
|
14
|
+
import { defineWorkspace } from '@vzn/vx/config'
|
|
15
|
+
import { reapi } from '@vzn/vx-reapi'
|
|
16
|
+
|
|
17
|
+
export default defineWorkspace({
|
|
18
|
+
// Reads try the local cache first, then the remote; a remote hit is copied to local.
|
|
19
|
+
plugins: [reapi({ endpoint: 'grpcs://cache.example.com:443' })],
|
|
20
|
+
})
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
`ReapiRemoteCache` is the layer class behind `reapi()`, for a workspace that
|
|
24
|
+
composes cache layers by hand. The package exports `reapi`, `ReapiPluginOptions`,
|
|
25
|
+
`ReapiRemoteCache` and its `ReapiOptions`; the wire client, the Merkle
|
|
26
|
+
encoders and the executor are internal. `ReapiOptions` is the connection
|
|
27
|
+
as the plugin resolves it: the same fields, with the PEM text itself
|
|
28
|
+
(`tlsCaPem`, `tlsClientCertPem`, `tlsClientKeyPem`) in place of the files,
|
|
29
|
+
and `onWarn` for a degraded-but-recovered call; `reapi()` refuses those four,
|
|
30
|
+
reading files and warning through vx. With no endpoint configured (or a blank
|
|
31
|
+
one) the plugin **declines** and costs nothing, so it is
|
|
32
|
+
safe to leave declared. An endpoint that is not `host[:port]`, with an
|
|
33
|
+
optional `grpc(s)://` or `http(s)://` scheme, or a gRPC resolver target
|
|
34
|
+
(`unix:`, `unix-abstract:`, `dns:`, `ipv4:`, `ipv6:`), is refused at startup
|
|
35
|
+
with a line naming the setting. `VX_REAPI_ENDPOINT` / `VX_REAPI_INSTANCE` configure it
|
|
36
|
+
from the environment, and `VX_REAPI_EXECUTE=1` turns on remote execution the
|
|
37
|
+
way `execute: true` does (off by default: a plugin must not move where a
|
|
38
|
+
build runs merely by being configured for caching). `execute` is a
|
|
39
|
+
boolean: a string (`process.env.X`) is refused.
|
|
40
|
+
`instanceName` is the option form of `VX_REAPI_INSTANCE`, and `headers`
|
|
41
|
+
adds gRPC metadata to every call: a hosted server's API key goes there
|
|
42
|
+
(`headers: { 'x-buildbuddy-api-key': process.env.BB_KEY! }`).
|
|
43
|
+
`toolName` and `toolVersion` (default `vx`, `0.0.0`) fill each call's
|
|
44
|
+
`RequestMetadata.tool_details`, and `correlatedInvocationsId` groups several
|
|
45
|
+
runs as one build in a server's UI.
|
|
46
|
+
|
|
47
|
+
TLS is on for a `grpcs://` or `https://` endpoint, or with any PEM below;
|
|
48
|
+
a bare `host:port` is plaintext, unless `tls: true` turns it on (`tls: false`
|
|
49
|
+
turns it off). It uses the system roots unless told otherwise. A server behind a private
|
|
50
|
+
CA takes `tlsCertificate` (or `VX_REAPI_TLS_CERTIFICATE`), a PEM file of
|
|
51
|
+
that CA; one that asks for mutual TLS takes `tlsClientCertificate` and
|
|
52
|
+
`tlsClientKey` (`VX_REAPI_TLS_CLIENT_CERTIFICATE` / `VX_REAPI_TLS_CLIENT_KEY`)
|
|
53
|
+
— Bazel's `--tls_certificate`, `--tls_client_certificate` and
|
|
54
|
+
`--tls_client_key`. Any of them turns TLS on unless `tls: false` is set; a file that cannot be read
|
|
55
|
+
is refused at startup, naming the setting.
|
|
56
|
+
|
|
57
|
+
## How a vx cache key becomes a REAPI entry
|
|
58
|
+
|
|
59
|
+
A CAS digest is the sha256 of the **content**, so it cannot be derived from a
|
|
60
|
+
vx cache key before the bytes exist — `has(key)` could never answer. The
|
|
61
|
+
ActionCache supplies the missing indirection:
|
|
62
|
+
|
|
63
|
+
| vx | REAPI |
|
|
64
|
+
| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
65
|
+
| cache key | synthetic action digest — `sha256("vx-reapi-v1\0" + key)` |
|
|
66
|
+
| artifact (`tar.zst`) | one CAS blob, referenced by the ActionResult's `output_files` |
|
|
67
|
+
| task duration | `stdout_raw` on the ActionResult |
|
|
68
|
+
| cache miss | `GetActionResult` → `NOT_FOUND` |
|
|
69
|
+
| 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 |
|
|
70
|
+
| 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 |
|
|
71
|
+
|
|
72
|
+
The `vx-reapi-v1` prefix does two jobs: it keeps vx keys out of the address
|
|
73
|
+
space of real Bazel action digests on a shared server, and it makes a future
|
|
74
|
+
change to this mapping **miss cleanly** rather than read bytes written under
|
|
75
|
+
different rules.
|
|
76
|
+
|
|
77
|
+
Servers may normalise an inline `stdout_raw` into a CAS blob and hand back a
|
|
78
|
+
`stdout_digest` instead (bazel-remote does). The read path accepts either.
|
|
79
|
+
|
|
80
|
+
## Bun and chunk size
|
|
81
|
+
|
|
82
|
+
`chunkBytes` defaults to **65535 and is not a throughput knob.** Bun's
|
|
83
|
+
`node:http2` client _hangs_ — it does not error — when a request carries more
|
|
84
|
+
than one message and any single message exceeds a ceiling that **the server's
|
|
85
|
+
flow-control behaviour decides**. Go's gRPC servers grow their window
|
|
86
|
+
dynamically (a `WINDOW_UPDATE` then a `SETTINGS` raise) and Bun mishandles the
|
|
87
|
+
tail of that sequence; a `node:http2` server, which does not do it, accepts
|
|
88
|
+
4 MB writes happily.
|
|
89
|
+
|
|
90
|
+
See Bun [#30342](https://github.com/oven-sh/bun/issues/30342) and
|
|
91
|
+
[#26915](https://github.com/oven-sh/bun/issues/26915), largely fixed by
|
|
92
|
+
[#31584](https://github.com/oven-sh/bun/pull/31584) — which is why the ceiling
|
|
93
|
+
_rose_ from ~64 KB on Bun 1.3.x to ~216 KB on 1.4.0 rather than the hang going
|
|
94
|
+
away. Hence **Bun >= 1.4** is required, and the plugin refuses to start on
|
|
95
|
+
anything older with a named error: the alternative is a wedged upload with
|
|
96
|
+
nothing for a user to act on.
|
|
97
|
+
|
|
98
|
+
The default is the one size with no peer-dependence — 65535, the RFC 7540
|
|
99
|
+
default initial window every peer must honour with no `WINDOW_UPDATE` at
|
|
100
|
+
all (`SAFE_CHUNK_BYTES`). 128 KB was the default until it stalled a 1 MiB
|
|
101
|
+
write against bazel-remote in 2 of 12 fresh runs (Bun 1.4.2), each costing
|
|
102
|
+
the call's 30 s deadline; 65535 stalled in none, and costs ~51% on a 32 MiB
|
|
103
|
+
upload (390 → 590 ms on loopback). A larger `chunkBytes` is still accepted:
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
reapi({ endpoint: '…', chunkBytes: 128 * 1024 })
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The stall is a RACE, not a boundary. So above the safe size the client
|
|
110
|
+
**downgrades adaptively** — a deadline on a multi-message write retries once at
|
|
111
|
+
`SAFE_CHUNK_BYTES` with a warning, turning a lost coin-flip into a logged
|
|
112
|
+
retry instead of a failed task. The deadline counts in either spelling: the
|
|
113
|
+
client's own `DEADLINE_EXCEEDED`, or the `CANCELLED` a grpc-go server such as
|
|
114
|
+
bazel-remote sends when the call's `grpc-timeout` runs out first (a
|
|
115
|
+
`CANCELLED` before the deadline is the server's own and is not retried). The full probe matrix is in
|
|
116
|
+
`packages/vx/docs/design/plugin-executor-reapi-2026-08.md` §14.
|
|
117
|
+
|
|
118
|
+
## Repeat runs skip the worker
|
|
119
|
+
|
|
120
|
+
Every successful remote execution writes an execution record under the
|
|
121
|
+
task's vx key (`vx-reapi-exec-v1`), listing its outputs by digest plus
|
|
122
|
+
its stdout (inline up to 64 KiB, a CAS blob past it), written while the
|
|
123
|
+
outputs come down. A later run whose vx cache missed but whose key already has
|
|
124
|
+
a record skips the Merkle build, the upload pass and `Execute`
|
|
125
|
+
entirely: the outputs are already in the CAS, and stdout replays from
|
|
126
|
+
the record. `--force` bypasses it.
|
|
127
|
+
|
|
128
|
+
The input tree is read after the key was taken, so each file's bytes are
|
|
129
|
+
held to the git blob id the key folded for it. A file edited or removed
|
|
130
|
+
in between (an edit mid-run under `vx watch`) still runs, but the
|
|
131
|
+
execution is not recorded under a key that no longer describes it; the
|
|
132
|
+
record once replayed the edited outputs on every machine after the file
|
|
133
|
+
was restored. The project's own `package.json`, which the key folds
|
|
134
|
+
whether or not a glob lists it, is always in the input root.
|
|
135
|
+
|
|
136
|
+
This matters most under `--download=none`, where deferral leaves no
|
|
137
|
+
local cache entry behind, so vx's own probe misses on every later run
|
|
138
|
+
and the record is what makes the second run cheap. Records are checked
|
|
139
|
+
against the CAS first (`FindMissingBlobs`) — the action cache and the
|
|
140
|
+
CAS evict independently, so a record that outlived its blobs falls
|
|
141
|
+
through to a real execution rather than "succeeding" with nothing.
|
|
142
|
+
The replay itself is a cache read too: a transport failure reading the
|
|
143
|
+
record, its stdout or any output warns and executes, after removing
|
|
144
|
+
whatever the half-done replay created (core cleaned the outputs once,
|
|
145
|
+
before the replay, and the real run's result must not inherit them).
|
|
146
|
+
The probe sees a Tree by its own digest, not the blobs inside it, so
|
|
147
|
+
one of those gone is caught by the replay: under any capture shape it
|
|
148
|
+
fails the replay, and the task executes. (A fresh result under a
|
|
149
|
+
whole-tree capture still only warns, since the capture holds more
|
|
150
|
+
than the outputs.)
|
|
151
|
+
|
|
152
|
+
An UPSTREAM's evicted blobs get the opposite answer, because there is
|
|
153
|
+
nothing to fall through to. When a dependency's outputs live only in
|
|
154
|
+
the CAS — vx grafts them by reference precisely because no local copy
|
|
155
|
+
exists — and those blobs are gone, the action cannot be built with the
|
|
156
|
+
inputs its key claims. vx fails the task and names the upstream rather
|
|
157
|
+
than shipping the action without them: a command that tolerates the
|
|
158
|
+
absence exits 0, and that successful-but-wrong result would be cached
|
|
159
|
+
under a key asserting those bytes were present. Which upstream bytes a
|
|
160
|
+
command actually reads is unknowable — that is what `dependsOn`
|
|
161
|
+
declares — so the refusal is the only sound reading, and it matches
|
|
162
|
+
what core does when a deferred producer cannot be materialised.
|
|
163
|
+
Re-run the upstream (`--force`) to repopulate the store.
|
|
164
|
+
|
|
165
|
+
Bringing a task's OWN outputs back gets the same treatment. Core's
|
|
166
|
+
contract is that once an executor returns, the declared outputs are on
|
|
167
|
+
disk, because the ordinary save path then tars whatever it finds — so
|
|
168
|
+
an output blob that cannot be fetched is a hole that would be cached
|
|
169
|
+
under a key claiming a complete build. Under a literal capture the
|
|
170
|
+
worker returns only what `output_paths` named, so every returned file
|
|
171
|
+
is a declared output and an unfetchable one fails the task. The
|
|
172
|
+
exception is a glob whose FIRST segment is a wildcard (`*.js`): it has
|
|
173
|
+
no REAPI spelling, so it is sent as `''` — whole-working-directory
|
|
174
|
+
capture — and inputs and undeclared siblings come back too. Those
|
|
175
|
+
cannot be told apart from real outputs, so a missing blob there only
|
|
176
|
+
warns, unless a declared glob names it: that is a hole in a declared
|
|
177
|
+
output and fails the task under either shape. Inline output bytes are
|
|
178
|
+
checked against their digest like fetched ones; a mismatch is fetched.
|
|
179
|
+
|
|
180
|
+
A capture cut at a wildcard holds more than the outputs: `src/*.gen.js`
|
|
181
|
+
is sent as `src`, and the worker returns the sources beside the
|
|
182
|
+
generated files. Only what a declared glob names (or what sits under a
|
|
183
|
+
directory it names) is written back; the rest stays as it is on disk.
|
|
184
|
+
Writing all of it once put the worker's copy of the sources over the
|
|
185
|
+
user's, so an edit made while the action ran was lost. A directory a
|
|
186
|
+
literal glob names is written whole.
|
|
187
|
+
|
|
188
|
+
## The existence probe confirms the artifact, not just the entry
|
|
189
|
+
|
|
190
|
+
`has()` — what `vx run --dry` and `--graph` use to predict hit vs miss —
|
|
191
|
+
reads the ActionCache entry AND checks the artifact blob is still in the
|
|
192
|
+
CAS. The second call is not redundant, because servers disagree: measured
|
|
193
|
+
against both, bazel-remote validates an ActionResult's referenced blobs
|
|
194
|
+
and hides a dangling entry, while NativeLink serves it. Without the
|
|
195
|
+
check, a plan would report `cache hit (remote)` for a task that then
|
|
196
|
+
executes for real. It costs one extra round trip and only for a PREDICTED
|
|
197
|
+
HIT — a miss still answers in a single call.
|
|
198
|
+
|
|
199
|
+
## Downloads are verified
|
|
200
|
+
|
|
201
|
+
Every blob read — ByteStream and batch alike, compressed or not — is
|
|
202
|
+
re-hashed with the negotiated digest function and length-checked against
|
|
203
|
+
the digest it was requested under. Bytes that don't match are refused with
|
|
204
|
+
a named integrity error instead of being written into the local
|
|
205
|
+
content-addressed store: a corrupt or poisoned remote degrades to a miss
|
|
206
|
+
(the cache invariant), never to wrong bytes under a trusted name. Uploads
|
|
207
|
+
were always server-verified; this is the mirror on the read side, the same
|
|
208
|
+
check Bazel's client performs. A streamed read (the cache artifact) is
|
|
209
|
+
hashed as its bytes pass and errors at its end on a mismatch, so vx's
|
|
210
|
+
ingest fails and the hit is a miss. The size is held as the bytes arrive:
|
|
211
|
+
a body past its digest's size is refused at the byte that passes it, a
|
|
212
|
+
zstd reply is decoded no further than that size, and a batch entry for a
|
|
213
|
+
digest not asked for is dropped.
|
|
214
|
+
|
|
215
|
+
A verified blob still lands where the server's ActionResult says, so its
|
|
216
|
+
paths are held to the workspace: an output path or Tree name that climbs
|
|
217
|
+
out (`..`, absolute), a link whose target leaves the workspace (read as
|
|
218
|
+
the OS follows it, through the links the result placed), and a
|
|
219
|
+
directory that resolves out through a link are refused before anything
|
|
220
|
+
is written through them; a link standing at an output file is replaced, never written
|
|
221
|
+
through; a Tree file's setuid and setgid bits are dropped.
|
|
222
|
+
|
|
223
|
+
## Artifacts stream
|
|
224
|
+
|
|
225
|
+
An artifact up to 256 KiB is read whole and sent in one batch, with no
|
|
226
|
+
probe first. Past that the cache layer never holds an artifact whole: `put` digests the file-backed
|
|
227
|
+
`Blob` vx hands it in one pass over its stream, asks `FindMissingBlobs`, and
|
|
228
|
+
uploads from a second pass: past the batch limit (about 4 MiB) the file is
|
|
229
|
+
read `chunkBytes` at a time as the ByteStream write drains, identity-encoded
|
|
230
|
+
(the artifact is zstd already). `get` returns the ByteStream read as a
|
|
231
|
+
`Response`, each message taken from the call as vx writes the previous one
|
|
232
|
+
to disk.
|
|
233
|
+
|
|
234
|
+
## Deadlines: a wedged server degrades, it does not hang
|
|
235
|
+
|
|
236
|
+
Every call carries a deadline, because the killer case is not a server that is
|
|
237
|
+
DOWN — that is an instant `UNAVAILABLE` the cache layer degrades to a miss —
|
|
238
|
+
but one that accepts TCP and never answers. Without a deadline no error ever
|
|
239
|
+
happens and the first probe hangs the whole run.
|
|
240
|
+
|
|
241
|
+
There are TWO deadlines, and the split matters:
|
|
242
|
+
|
|
243
|
+
| option | covers | default |
|
|
244
|
+
| --------------- | ------------------------------------------------------------------------------------- | ---------------------------- |
|
|
245
|
+
| `metaTimeoutMs` | Capabilities, GetActionResult, UpdateActionResult, FindMissingBlobs, QueryWriteStatus | `min(callTimeoutMs, 15 000)` |
|
|
246
|
+
| `callTimeoutMs` | ByteStream transfers, Batch{Read,Update}Blobs, Split/SpliceBlob | `30 000` |
|
|
247
|
+
|
|
248
|
+
A control-plane message is small and bounded: a healthy server answers in
|
|
249
|
+
single-digit milliseconds. A bulk transfer is size-proportional and
|
|
250
|
+
legitimately slow — capturing a `node_modules` tree is what pushes real
|
|
251
|
+
deployments to raise `callTimeoutMs` into the minutes. With one knob for both,
|
|
252
|
+
buying headroom for that upload also buys every metadata probe the same
|
|
253
|
+
minutes before it can degrade, which is the opposite of what the deadline is
|
|
254
|
+
for.
|
|
255
|
+
|
|
256
|
+
Each deadline must be a positive number of ms; anything else is refused when
|
|
257
|
+
the plugin starts. `executeTimeoutMs` and `queueTimeoutMs` are held to the
|
|
258
|
+
same rule when the executor starts, so with `execute` off they are not
|
|
259
|
+
checked.
|
|
260
|
+
|
|
261
|
+
That is not hypothetical. A NativeLink instance degraded into a state where it
|
|
262
|
+
answered every ActionCache MISS in 3 ms and every HIT never — idle CPU,
|
|
263
|
+
nothing in its logs, cleared by a restart with identical on-disk data. With a
|
|
264
|
+
single 180 s deadline, every task burned three minutes on a lookup before
|
|
265
|
+
failing. Now the probe gives up in 15 s and the run re-executes.
|
|
266
|
+
|
|
267
|
+
What the run prints is one line per kind of failure:
|
|
268
|
+
`vx/reapi: probe <key> at <endpoint> failed: 4 DEADLINE_EXCEEDED: …`.
|
|
269
|
+
Core's layered cache names the request, the vx key and the server (a
|
|
270
|
+
gRPC status carries none of them), and a later request that fails with
|
|
271
|
+
the same status is counted, not repeated: the run ends with
|
|
272
|
+
`vx/reapi: N more requests failed the same way: …`.
|
|
273
|
+
|
|
274
|
+
Execution streams are deliberately NOT bounded by either: queueing behind a
|
|
275
|
+
busy worker pool is legitimate. A wedged server still cannot reach Execute,
|
|
276
|
+
because the deadline-bounded Capabilities call runs first. The wait for a
|
|
277
|
+
worker is bounded by `queueTimeoutMs` when set: no EXECUTING within it, the
|
|
278
|
+
Execute stream is closed, the operation is cancelled with
|
|
279
|
+
`Operations.CancelOperation` (one attempt on `metaTimeoutMs`; a server
|
|
280
|
+
that refuses it, or lacks the service, may still run the action, and its
|
|
281
|
+
result lands in the action cache) and the task is given back to vx, which
|
|
282
|
+
runs it here and says so once:
|
|
283
|
+
`[vx] <task>: vx/reapi: no worker started the action within queueTimeoutMs (…ms); its operation was cancelled — running it here`.
|
|
284
|
+
A task placed `exec.remote: 'only'` fails with that reason instead. Unset,
|
|
285
|
+
the task's own `exec.timeout` bounds the queue as it bounds the run (the
|
|
286
|
+
task fails as timed out); with neither, the wait is unbounded.
|
|
287
|
+
A stream that drops with a transient status, or ends cleanly before its
|
|
288
|
+
operation is done, re-attaches with `WaitExecution` (three times, backing
|
|
289
|
+
off 100, 400 and 1600 ms) rather than running the action again. If
|
|
290
|
+
`WaitExecution` answers NOT_FOUND — the server lost the operation, as a
|
|
291
|
+
restart does — nothing is left running to re-attach to, and the action is
|
|
292
|
+
executed again on the same budget.
|
|
293
|
+
Once a worker reports EXECUTING, the task's `exec.timeout` (or
|
|
294
|
+
`executeTimeoutMs`) bounds the wait, and the bound holds across a
|
|
295
|
+
re-attach: firing during the backoff between a dropped stream and its
|
|
296
|
+
`WaitExecution`, it ends the task there rather than being lost.
|
|
297
|
+
The run stopping (Ctrl-C, an embedder's abort) cancels the operation
|
|
298
|
+
stream the same way, and an action not yet submitted is not sent.
|
|
299
|
+
|
|
300
|
+
A ByteStream Read, like every unary call, retries UNAVAILABLE,
|
|
301
|
+
RESOURCE_EXHAUSTED and INTERNAL three times (100, 400 and 1600 ms) before it
|
|
302
|
+
counts as failed. A streamed read (the artifact of a remote hit) spends
|
|
303
|
+
the same budget across the whole blob: a cut after bytes have reached the
|
|
304
|
+
reader re-opens the Read at `read_offset` = what the reader has, and the
|
|
305
|
+
digest is checked over the joined bytes as before. INTERNAL is on the list because it is how the gRPC client
|
|
306
|
+
reports a call cut in transit: the RST_STREAM(INTERNAL_ERROR) a proxy sends
|
|
307
|
+
when the server behind it goes away, or a stream that ends with no gRPC
|
|
308
|
+
status. The same three statuses are what re-attach a dropped execution
|
|
309
|
+
stream.
|
|
310
|
+
|
|
311
|
+
A server that stays down pays that backoff once: after a call spends its
|
|
312
|
+
retries on UNAVAILABLE, the cache path's calls (unary, Read, Write) give up
|
|
313
|
+
at their first UNAVAILABLE until the server answers again, so a refused port
|
|
314
|
+
costs a five-task run 2.6 s, not 24.
|
|
315
|
+
|
|
316
|
+
A status the server answers with and no retry heals (PERMISSION_DENIED on
|
|
317
|
+
Execute, a refused upload) fails the task with a line naming the task and
|
|
318
|
+
the status, never as a vx "internal error".
|
|
319
|
+
|
|
320
|
+
A failed READ is never a failed task. The execution-record lookup is a
|
|
321
|
+
shortcut past the worker, so a transport error there means "no usable record"
|
|
322
|
+
and the task executes normally, with a warning naming why. The UPSTREAM record
|
|
323
|
+
reads are the deliberate exception — those decide whether a dependency's bytes
|
|
324
|
+
exist at all, and carrying on past a failure there is how an action runs
|
|
325
|
+
without its inputs and caches the result.
|
|
326
|
+
|
|
327
|
+
## Tests
|
|
328
|
+
|
|
329
|
+
`bun test` runs the unit suite anywhere. The round-trip suite needs a real
|
|
330
|
+
server:
|
|
331
|
+
|
|
332
|
+
```sh
|
|
333
|
+
docker run -d -p 19092:9092 buchgr/bazel-remote-cache:latest \
|
|
334
|
+
--dir /data --max_size 1 --grpc_address 0.0.0.0:9092 --http_address 0.0.0.0:8080
|
|
335
|
+
|
|
336
|
+
VX_REAPI_TEST_ENDPOINT=127.0.0.1:19092 bun test
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Without an endpoint those tests skip; CI sets `VX_REQUIRE_REAPI=1`, which turns
|
|
340
|
+
an absent endpoint into a failure so the suite cannot silently vanish. The
|
|
341
|
+
remote-execution suites need an execution server in `VX_REAPI_EXEC_ENDPOINT`;
|
|
342
|
+
CI sets it with `VX_REQUIRE_REAPI_EXEC=1`.
|
|
343
|
+
|
|
344
|
+
## Protocol coverage
|
|
345
|
+
|
|
346
|
+
All **14 RPCs** across the five services, not a working subset:
|
|
347
|
+
|
|
348
|
+
| Service | RPCs |
|
|
349
|
+
| --------------------------- | ---------------------------------------------------------------------------------------------- |
|
|
350
|
+
| `Execution` | `Execute`, `WaitExecution` |
|
|
351
|
+
| `ActionCache` | `GetActionResult`, `UpdateActionResult` |
|
|
352
|
+
| `ContentAddressableStorage` | `FindMissingBlobs`, `BatchUpdateBlobs`, `BatchReadBlobs`, `GetTree`, `SplitBlob`, `SpliceBlob` |
|
|
353
|
+
| `Capabilities` | `GetCapabilities` |
|
|
354
|
+
| `ByteStream` | `Read`, `Write`, `QueryWriteStatus` |
|
|
355
|
+
|
|
356
|
+
Protocol features in use, not just reachable:
|
|
357
|
+
|
|
358
|
+
- **Digest negotiation** — SHA256 by default (the universal baseline; the
|
|
359
|
+
Merkle encoders must hash with the SAME function as every upload, so
|
|
360
|
+
auto-upgrading would mix functions inside one action). The plugin
|
|
361
|
+
offers no other.
|
|
362
|
+
- **zstd compression** — `compressed-blobs/zstd/…` resource names on
|
|
363
|
+
ByteStream and `compressor: ZSTD` on batch updates, enabled only when
|
|
364
|
+
`supported_compressors` says so.
|
|
365
|
+
- **`RequestMetadata`** in the well-known binary header (tool name/version,
|
|
366
|
+
action id, correlated invocations id) — how a server groups an action's
|
|
367
|
+
dozens of CAS/AC calls into one build in its UI.
|
|
368
|
+
- **Inline stdout/stderr** on `ExecuteRequest`, sparing two CAS round trips
|
|
369
|
+
per finished action. A failed execution status that carries a partial
|
|
370
|
+
result (a worker past its timeout) still prints what the command wrote.
|
|
371
|
+
- **Execution stages** — `QUEUED` / `EXECUTING` / `COMPLETED` decoded from
|
|
372
|
+
`ExecuteOperationMetadata`, so a queued action is distinguishable from a
|
|
373
|
+
hung one.
|
|
374
|
+
- **`Action.platform`** (v2.2) alongside `Command.platform` for older
|
|
375
|
+
servers.
|
|
376
|
+
- **`NodeProperties`** — `unix_mode` and `mtime` on tree nodes.
|
|
377
|
+
- **Output directories** via the `Tree` blob an `OutputDirectory.tree_digest`
|
|
378
|
+
addresses, plus **output symlinks** (a v2.0 server's
|
|
379
|
+
`output_file_symlinks` / `output_directory_symlinks` when it sends only
|
|
380
|
+
those). A tree's small files are fetched
|
|
381
|
+
together across its directories (`BatchReadBlobs`, 64 MiB at a time),
|
|
382
|
+
not one call per directory.
|
|
383
|
+
- **Upload minimality** — `FindMissingBlobs` first (split so no request
|
|
384
|
+
passes a 4 MiB message), then batched blobs while they fit the server's
|
|
385
|
+
budget and ByteStream beyond it.
|
|
386
|
+
|
|
387
|
+
## Remote execution
|
|
388
|
+
|
|
389
|
+
Off by default. Remote execution changes where a user's build runs, which is
|
|
390
|
+
not something a plugin should switch on merely by being configured for
|
|
391
|
+
caching:
|
|
392
|
+
|
|
393
|
+
```ts
|
|
394
|
+
reapi({
|
|
395
|
+
endpoint: 'grpcs://grpc.example.com:443',
|
|
396
|
+
execute: true,
|
|
397
|
+
platform: { 'container-image': 'docker://alpine:3.20', OSFamily: 'Linux' },
|
|
398
|
+
capacity: 64, // concurrent remote tasks; becomes the scheduler's pool
|
|
399
|
+
})
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
A cache-only server (bazel-remote advertises `exec_enabled: false`) makes the
|
|
403
|
+
plugin **decline the executor with a warning** rather than submit work that
|
|
404
|
+
will never be answered. Only cacheable tasks are eligible — a task with no
|
|
405
|
+
`cache` block has no described inputs, so a worker would run it against an
|
|
406
|
+
empty input root. A cacheable task's root holds its declared input files
|
|
407
|
+
and its project's `package.json`.
|
|
408
|
+
|
|
409
|
+
Verified end-to-end against a live NativeLink scheduler + worker: input tree
|
|
410
|
+
uploaded, QUEUED → EXECUTING → COMPLETED streamed, stdout returned inline,
|
|
411
|
+
declared outputs materialised byte-correct, and the worker attributed. Every
|
|
412
|
+
hand-rolled encoder AND decoder is pinned byte-for-byte against protobufjs
|
|
413
|
+
over the same vendored protos — the decoder tests exist because a wrong field
|
|
414
|
+
number parses garbage without ever erroring (`tests/encoding.test.ts`).
|
|
415
|
+
|
|
416
|
+
One environmental note for NativeLink specifically: its official image is
|
|
417
|
+
distroless, so a worker inside it has no `/bin/sh` and cannot run any vx task.
|
|
418
|
+
`tests/helpers/nativelink.md` has the three-command busybox rehost.
|
|
419
|
+
|
|
420
|
+
## node_modules: install as an action
|
|
421
|
+
|
|
422
|
+
REAPI workers are stateless, and vx deliberately treats `node_modules` as
|
|
423
|
+
ambient environment rather than a cache input — so a remote task cannot see
|
|
424
|
+
the packages a build needs. The answer is the design doc's §7.4 recipe,
|
|
425
|
+
`exec.remote: 'only'`:
|
|
426
|
+
|
|
427
|
+
```ts
|
|
428
|
+
install: {
|
|
429
|
+
exec: { command: 'pnpm install --frozen-lockfile', remote: 'only' },
|
|
430
|
+
cache: {
|
|
431
|
+
inputs: { files: ['package.json', 'pnpm-lock.yaml'] },
|
|
432
|
+
outputs: { files: ['node_modules/**'] },
|
|
433
|
+
},
|
|
434
|
+
},
|
|
435
|
+
build: {
|
|
436
|
+
dependsOn: ['install'],
|
|
437
|
+
exec: { command: 'tsc -p .' },
|
|
438
|
+
cache: { inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] } },
|
|
439
|
+
},
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
What actually happens, all verified live against a NativeLink scheduler +
|
|
443
|
+
worker (`tests/vx-run-e2e.test.ts`):
|
|
444
|
+
|
|
445
|
+
- `install` executes **on a worker** — so platform binaries build for the
|
|
446
|
+
worker's platform, not the laptop's — and runs once per lockfile change,
|
|
447
|
+
ever: repeats are satisfied from an execution record the plugin keeps under
|
|
448
|
+
the task's vx cache key.
|
|
449
|
+
- Its outputs **never land on the submitter's disk**: not materialised, not
|
|
450
|
+
restored, and the local `node_modules` a dev installed is never cleaned.
|
|
451
|
+
- A dependent task's input tree grafts the install outputs **by reference**
|
|
452
|
+
(per-file digests from the execution record; whole directories as
|
|
453
|
+
re-canonicalised REAPI `Tree`s), so the bytes flow worker→CAS→worker and
|
|
454
|
+
never transit the submitter. The graft applies ONLY to outputs that exist
|
|
455
|
+
nowhere locally: when an upstream's outputs are materialised on this
|
|
456
|
+
machine, **local disk is truth** — two machines racing a nondeterministic
|
|
457
|
+
miss can leave the artifact store and the execution record holding
|
|
458
|
+
results of different executions under one pure-input key, and a worker
|
|
459
|
+
fed the record would see bytes this machine's own tasks do not.
|
|
460
|
+
- With **no remote executor declared**, `install` is a local no-op and
|
|
461
|
+
dependents use whatever the machine has ambient — a laptop run behaves
|
|
462
|
+
exactly as it did before the field existed.
|
|
463
|
+
|
|
464
|
+
The execution record lives under `sha256("vx-reapi-exec-v1\0" + key)` — a
|
|
465
|
+
second AC namespace beside the artifact mapping, listing outputs file-by-file
|
|
466
|
+
with workspace-relative paths.
|
|
467
|
+
|
|
468
|
+
## What the worker's environment contains
|
|
469
|
+
|
|
470
|
+
An action's `Command` carries exactly two of vx's three environment lists,
|
|
471
|
+
sorted by name — the proto requires that, so equivalent Commands hash alike:
|
|
472
|
+
|
|
473
|
+
- **`exec.env.define`** — literal `name: value` pairs from the task config.
|
|
474
|
+
They read the same on every machine, so they are safe to put into the
|
|
475
|
+
action identity, and they are already in the vx cache key.
|
|
476
|
+
- **`cache.inputs.env`** — the values this machine resolved for those names,
|
|
477
|
+
for each name the task's local child would get too (it is also in
|
|
478
|
+
`exec.env.passThrough`). They are in the vx cache key by definition, so a
|
|
479
|
+
change to one already produces a different action. A name unset here, or
|
|
480
|
+
one the config only tracks, is left out of the `Command`, so the worker
|
|
481
|
+
sees what a local run would: shipping a tracked-only value ran the worker
|
|
482
|
+
on something a local run never saw, under the same key (item 1092).
|
|
483
|
+
|
|
484
|
+
A `define` wins over an `inputs.env` entry of the same name: it is the more
|
|
485
|
+
explicit statement of intent.
|
|
486
|
+
|
|
487
|
+
Nothing else crosses. In particular **`exec.env.passThrough` does not reach a
|
|
488
|
+
remote worker**, and neither does the essential allowlist (`PATH`, `HOME`,
|
|
489
|
+
`TMPDIR`, …) — those are the submitting machine's resolved environment, and
|
|
490
|
+
the worker has its own. Two reasons, and both matter:
|
|
491
|
+
|
|
492
|
+
- Host values in the `Command` would enter the action digest, so a laptop and
|
|
493
|
+
a CI runner would never share a remote entry — the same reason the vx key
|
|
494
|
+
excludes them.
|
|
495
|
+
- A `Command` blob lives in the CAS. `passThrough` is where secrets go, and a
|
|
496
|
+
token written to a shared content-addressed store is readable by anyone who
|
|
497
|
+
can name its digest.
|
|
498
|
+
|
|
499
|
+
So a task that needs a value on a worker must `define` it (config literal) or
|
|
500
|
+
list it in both `cache.inputs.env` and `exec.env.passThrough` (host value,
|
|
501
|
+
keyed, and seen by a local run the same way). A task whose command reads
|
|
502
|
+
a `passThrough` secret is one to keep local with `exec: { remote: false }`.
|
package/index.ts
ADDED
package/package.json
CHANGED
|
@@ -1,13 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-reapi",
|
|
3
|
-
"version": "0.0.
|
|
4
|
-
"description": "Placeholder; real releases are published by CI.",
|
|
3
|
+
"version": "0.0.485",
|
|
5
4
|
"license": "MIT",
|
|
6
|
-
"
|
|
7
|
-
|
|
8
|
-
"
|
|
5
|
+
"description": "Bazel Remote Execution API plugin for vx — remote cache (ActionCache + CAS) against NativeLink, BuildBuddy, Buildbarn or bazel-remote.",
|
|
6
|
+
"keywords": [
|
|
7
|
+
"vx",
|
|
8
|
+
"vx-plugin",
|
|
9
|
+
"monorepo",
|
|
10
|
+
"bazel",
|
|
11
|
+
"remote-execution",
|
|
12
|
+
"reapi",
|
|
13
|
+
"remote-cache"
|
|
14
|
+
],
|
|
15
|
+
"type": "module",
|
|
16
|
+
"main": "./src/index.ts",
|
|
17
|
+
"types": "./src/index.ts",
|
|
18
|
+
"exports": {
|
|
19
|
+
".": {
|
|
20
|
+
"types": "./src/index.ts",
|
|
21
|
+
"import": "./src/index.ts"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"files": [
|
|
25
|
+
"index.ts",
|
|
26
|
+
"src",
|
|
27
|
+
"protos",
|
|
28
|
+
"README.md",
|
|
29
|
+
"LICENSE"
|
|
30
|
+
],
|
|
31
|
+
"engines": {
|
|
32
|
+
"bun": ">=1.4"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"@grpc/grpc-js": "^1.12.0",
|
|
36
|
+
"@grpc/proto-loader": "^0.7.13",
|
|
37
|
+
"protobufjs": "^7.4.0"
|
|
9
38
|
},
|
|
10
39
|
"publishConfig": {
|
|
11
40
|
"access": "public"
|
|
12
|
-
}
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"@vzn/vx": "^0.0.485"
|
|
44
|
+
},
|
|
45
|
+
"repository": {
|
|
46
|
+
"type": "git",
|
|
47
|
+
"url": "git+https://github.com/vznjs/vx.git",
|
|
48
|
+
"directory": "packages/vx-reapi"
|
|
49
|
+
},
|
|
50
|
+
"homepage": "https://github.com/vznjs/vx/tree/main/packages/vx-reapi#readme",
|
|
51
|
+
"bugs": "https://github.com/vznjs/vx/issues"
|
|
13
52
|
}
|