@vzn/vx-reapi 0.0.646 → 0.0.648
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 +52 -40
- package/package.json +3 -3
- package/src/cache.ts +89 -23
- package/src/executor.ts +289 -109
- package/src/index.ts +67 -51
- package/src/merkle.ts +57 -18
- package/src/wire.ts +106 -32
package/README.md
CHANGED
|
@@ -25,7 +25,9 @@ export default defineWorkspace({
|
|
|
25
25
|
Off CI a run reads the remote and does not write it unless its policy names
|
|
26
26
|
the remote. Give the write credential only to the default branch's CI and a
|
|
27
27
|
read-only one to laptops and pull request jobs: the server's token is the
|
|
28
|
-
boundary (`docs/security.md` § Cache poisoning).
|
|
28
|
+
boundary (`docs/security.md` § Cache poisoning). A server that refuses a
|
|
29
|
+
save's `UpdateActionResult` (`PERMISSION_DENIED`, `UNIMPLEMENTED`) is taken
|
|
30
|
+
at its word for the run: later saves fail the same way without uploading.
|
|
29
31
|
|
|
30
32
|
`ReapiRemoteCache` is the layer class behind `reapi()`, for a workspace that
|
|
31
33
|
composes cache layers by hand. The package exports `reapi`, `ReapiPluginOptions`,
|
|
@@ -37,19 +39,22 @@ and `onWarn` for a degraded-but-recovered call; `reapi()` refuses those four,
|
|
|
37
39
|
reading files and warning through vx. With no endpoint configured (or a blank
|
|
38
40
|
one) the plugin **declines** and costs nothing, so it is
|
|
39
41
|
safe to leave declared. An endpoint that is not `host[:port]`, with an
|
|
40
|
-
optional `grpc(s)://` or `http(s)://` scheme, or a gRPC resolver target
|
|
42
|
+
optional `grpc(s)://` or `http(s)://` scheme (a trailing `/` is dropped), or a gRPC resolver target
|
|
41
43
|
(`unix:`, `unix-abstract:`, `dns:`, `ipv4:`, `ipv6:`), is refused at startup
|
|
42
44
|
with a line naming the setting. So is a TLS endpoint at an IP address:
|
|
43
45
|
Bun's TLS refuses an IP as the server name, so name the server by host
|
|
44
|
-
name, or use `grpc://` for plaintext.
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
`
|
|
50
|
-
|
|
46
|
+
name, or use `grpc://` for plaintext. The plugin reads no environment
|
|
47
|
+
variable: every setting is an option, and a value from CI is named in
|
|
48
|
+
`.vx/workspace.ts` (`endpoint: process.env.REAPI_ENDPOINT`). `execute:
|
|
49
|
+
true` turns on remote execution (off by default: a plugin must not move
|
|
50
|
+
where a build runs merely by being configured for caching). `execute` is
|
|
51
|
+
a boolean: a string (`process.env.X`) is refused. `instanceName`, like
|
|
52
|
+
the endpoint, is trimmed, and a blank one is unset. `headers`
|
|
51
53
|
adds gRPC metadata to every call: a hosted server's API key goes there
|
|
52
|
-
(`headers: { 'x-buildbuddy-api-key': process.env.BB_KEY! }`)
|
|
54
|
+
(`headers: { 'x-buildbuddy-api-key': process.env.BB_KEY! }`); a name
|
|
55
|
+
gRPC metadata cannot carry (a space, a `-bin` suffix) is refused at startup,
|
|
56
|
+
and a header or `platform` value that is no string (an unset variable) as
|
|
57
|
+
`reapi()` is called.
|
|
53
58
|
`toolName` and `toolVersion` (default `vx`, `0.0.0`) fill each call's
|
|
54
59
|
`RequestMetadata.tool_details`, and `correlatedInvocationsId` groups several
|
|
55
60
|
runs as one build in a server's UI.
|
|
@@ -58,10 +63,8 @@ TLS is on unless asked off, as in Bazel: a bare `host:port`, a `grpcs://`
|
|
|
58
63
|
or `https://` endpoint, and a `dns:`/`ipv4:`/`ipv6:` target all use it.
|
|
59
64
|
Plaintext takes a `grpc://` or `http://` endpoint or `tls: false`; a `unix:`
|
|
60
65
|
or `unix-abstract:` socket is plaintext (`tls: true` turns it on). It uses the system roots unless told otherwise. A server behind a private
|
|
61
|
-
CA takes `tlsCertificate
|
|
62
|
-
|
|
63
|
-
`tlsClientKey` (`VX_REAPI_TLS_CLIENT_CERTIFICATE` / `VX_REAPI_TLS_CLIENT_KEY`)
|
|
64
|
-
— Bazel's `--tls_certificate`, `--tls_client_certificate` and
|
|
66
|
+
CA takes `tlsCertificate`, a PEM file of that CA; one that asks for
|
|
67
|
+
mutual TLS takes `tlsClientCertificate` and `tlsClientKey` — Bazel's `--tls_certificate`, `--tls_client_certificate` and
|
|
65
68
|
`--tls_client_key`. Any of them turns TLS on unless `tls: false` is set; a file that cannot be read
|
|
66
69
|
is refused at startup, naming the setting.
|
|
67
70
|
|
|
@@ -102,9 +105,10 @@ See Bun [#30342](https://github.com/oven-sh/bun/issues/30342) and
|
|
|
102
105
|
[#26915](https://github.com/oven-sh/bun/issues/26915), largely fixed by
|
|
103
106
|
[#31584](https://github.com/oven-sh/bun/pull/31584) — which is why the ceiling
|
|
104
107
|
_rose_ from ~64 KB on Bun 1.3.x to ~216 KB on 1.4.0 rather than the hang going
|
|
105
|
-
away. Hence **Bun >= 1.4** is required
|
|
106
|
-
|
|
107
|
-
nothing for a user to
|
|
108
|
+
away. Hence **Bun >= 1.4.4** is required (core's own minimum, the package's
|
|
109
|
+
`engines.bun`), and the plugin refuses to start on anything older with a
|
|
110
|
+
named error: the alternative is a wedged upload with nothing for a user to
|
|
111
|
+
act on.
|
|
108
112
|
|
|
109
113
|
The default is the one size with no peer-dependence — 65535, the RFC 7540
|
|
110
114
|
default initial window every peer must honour with no `WINDOW_UPDATE` at
|
|
@@ -114,7 +118,7 @@ the call's 30 s deadline; 65535 stalled in none, and costs ~51% on a 32 MiB
|
|
|
114
118
|
upload (390 → 590 ms on loopback). A larger `chunkBytes` is still accepted:
|
|
115
119
|
|
|
116
120
|
```ts
|
|
117
|
-
reapi({ endpoint: '
|
|
121
|
+
reapi({ endpoint: 'grpcs://cache.example.com:443', chunkBytes: 128 * 1024 })
|
|
118
122
|
```
|
|
119
123
|
|
|
120
124
|
The stall is a RACE, not a boundary. So above the safe size the client
|
|
@@ -137,18 +141,19 @@ entirely: the outputs are already in the CAS, and stdout replays from
|
|
|
137
141
|
the record. `--force` bypasses it.
|
|
138
142
|
|
|
139
143
|
The input tree is read after the key was taken, so each file's bytes are
|
|
140
|
-
held to the git blob id the key
|
|
144
|
+
held to the git blob id the key hashed for it. A file edited, removed or
|
|
145
|
+
replaced by a directory
|
|
141
146
|
in between (an edit mid-run under `vx watch`) still runs, but the
|
|
142
147
|
execution is not recorded under a key that no longer describes it; the
|
|
143
148
|
record once replayed the edited outputs on every machine after the file
|
|
144
|
-
was restored. The input root holds exactly the files the key
|
|
149
|
+
was restored. The input root holds exactly the files the key hashed: core
|
|
145
150
|
keys no package file, so a project's `package.json` is in it only as a
|
|
146
151
|
listed input (`@vzn/vx-lockfile` lists it on every cached task).
|
|
147
152
|
|
|
148
153
|
This matters most under `--cache-download=none`, where deferral leaves no
|
|
149
154
|
local cache entry behind, so vx's own probe misses on every later run
|
|
150
155
|
and the record is what makes the second run cheap. Records are checked
|
|
151
|
-
against the CAS first (`FindMissingBlobs
|
|
156
|
+
against the CAS first (`FindMissingBlobs`, the outputs and a stdout blob) — the action cache and the
|
|
152
157
|
CAS evict independently, so a record that outlived its blobs falls
|
|
153
158
|
through to a real execution rather than "succeeding" with nothing.
|
|
154
159
|
The replay itself is a cache read too: a transport failure reading the
|
|
@@ -165,7 +170,7 @@ An UPSTREAM's evicted blobs get the opposite answer, because there is
|
|
|
165
170
|
nothing to fall through to. When a dependency's outputs live only in
|
|
166
171
|
the CAS — vx grafts them by reference precisely because no local copy
|
|
167
172
|
exists — and those blobs are gone, the action cannot be built with the
|
|
168
|
-
inputs its key
|
|
173
|
+
inputs its key names. vx fails the task and names the upstream rather
|
|
169
174
|
than shipping the action without them: a command that tolerates the
|
|
170
175
|
absence exits 0, and that successful-but-wrong result would be cached
|
|
171
176
|
under a key asserting those bytes were present. Which upstream bytes a
|
|
@@ -178,7 +183,7 @@ Bringing a task's OWN outputs back gets the same treatment. Core's
|
|
|
178
183
|
contract is that once an executor returns, the declared outputs are on
|
|
179
184
|
disk, because the ordinary save path then tars whatever it finds — so
|
|
180
185
|
an output blob that cannot be fetched is a hole that would be cached
|
|
181
|
-
under a key
|
|
186
|
+
under a key that stands for a complete build. Under a literal capture the
|
|
182
187
|
worker returns only what `output_paths` named, so every returned file
|
|
183
188
|
is a declared output and an unfetchable one fails the task. The
|
|
184
189
|
exception is a glob whose FIRST segment is a wildcard (`*.js`): it has
|
|
@@ -229,10 +234,13 @@ digest not asked for is dropped.
|
|
|
229
234
|
A verified blob still lands where the server's ActionResult says, so its
|
|
230
235
|
paths are held to the workspace: an output path or Tree name that climbs
|
|
231
236
|
out (`..`, absolute), a link whose target leaves the workspace (read as
|
|
232
|
-
the OS follows it, through the links the result placed),
|
|
233
|
-
directory that resolves out through a link
|
|
237
|
+
the OS follows it, through the links the result placed), a
|
|
238
|
+
directory that resolves out through a link, and a file or link that
|
|
239
|
+
lands, through one, where no declared glob names are refused before anything
|
|
234
240
|
is written through them; a link standing at an output file is replaced, never written
|
|
235
|
-
through; a Tree file's setuid and setgid bits are dropped.
|
|
241
|
+
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.
|
|
236
244
|
|
|
237
245
|
## Artifacts stream
|
|
238
246
|
|
|
@@ -268,10 +276,9 @@ buying headroom for that upload also buys every metadata probe the same
|
|
|
268
276
|
minutes before it can degrade, which is the opposite of what the deadline is
|
|
269
277
|
for.
|
|
270
278
|
|
|
271
|
-
Each deadline
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
checked.
|
|
279
|
+
Each deadline, `executeTimeoutMs` and `queueTimeoutMs` among them, must be
|
|
280
|
+
a positive number of ms up to 2147483647 (a longer timer fires at once);
|
|
281
|
+
anything else is refused at the `reapi()` call.
|
|
275
282
|
|
|
276
283
|
That is not hypothetical. A NativeLink instance degraded into a state where it
|
|
277
284
|
answered every ActionCache MISS in 3 ms and every HIT never — idle CPU,
|
|
@@ -280,11 +287,11 @@ single 180 s deadline, every task burned three minutes on a lookup before
|
|
|
280
287
|
failing. Now the probe gives up in 15 s and the run re-executes.
|
|
281
288
|
|
|
282
289
|
What the run prints is one line per kind of failure:
|
|
283
|
-
`vx/reapi: probe <key> at <endpoint> failed: 4 DEADLINE_EXCEEDED: …`.
|
|
290
|
+
`[vx] plugin '@vzn/vx-reapi': probe <key> at <endpoint> failed: 4 DEADLINE_EXCEEDED: …`.
|
|
284
291
|
Core's layered cache names the request, the vx key and the server (a
|
|
285
292
|
gRPC status carries none of them), and a later request that fails with
|
|
286
293
|
the same status is counted, not repeated: the run ends with
|
|
287
|
-
`vx/reapi: N more requests failed the same way: …`.
|
|
294
|
+
`[vx] plugin '@vzn/vx-reapi': N more requests failed the same way: …`.
|
|
288
295
|
|
|
289
296
|
Execution streams are deliberately NOT bounded by either: queueing behind a
|
|
290
297
|
busy worker pool is legitimate. A wedged server still cannot reach Execute,
|
|
@@ -293,9 +300,9 @@ worker is bounded by `queueTimeoutMs` when set: no EXECUTING within it, the
|
|
|
293
300
|
Execute stream is closed, the operation is cancelled with
|
|
294
301
|
`Operations.CancelOperation` (one attempt on `metaTimeoutMs`; a server
|
|
295
302
|
that refuses it, or lacks the service, may still run the action, and its
|
|
296
|
-
result lands in the action cache) and the task is given back to vx, which
|
|
303
|
+
result lands in the action cache; that refusal is a warning) and the task is given back to vx, which
|
|
297
304
|
runs it here and says so once:
|
|
298
|
-
`[vx] <task>: vx/reapi: no worker started the action within queueTimeoutMs (…ms); its operation was cancelled — running it here`.
|
|
305
|
+
`[vx] <task>: plugin '@vzn/vx-reapi' (executor 'vx/reapi') gave it back: no worker started the action within queueTimeoutMs (…ms); its operation was cancelled — running it here`.
|
|
299
306
|
A task placed `exec.remote: 'only'` fails with that reason instead. Unset,
|
|
300
307
|
the task's own `exec.timeout` bounds the queue as it bounds the run (the
|
|
301
308
|
task fails as timed out); with neither, the wait is unbounded.
|
|
@@ -308,7 +315,8 @@ executed again on the same budget.
|
|
|
308
315
|
Once a worker reports EXECUTING, the task's `exec.timeout` (or
|
|
309
316
|
`executeTimeoutMs`) bounds the wait, and the bound holds across a
|
|
310
317
|
re-attach: firing during the backoff between a dropped stream and its
|
|
311
|
-
`WaitExecution`, it ends the task there rather than being lost.
|
|
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).
|
|
312
320
|
The run stopping (Ctrl-C, an embedder's abort) or the task's `exec.timeout`
|
|
313
321
|
closes the stream and cancels the operation with `CancelOperation`, and an
|
|
314
322
|
action not yet submitted is not sent. The task then ends as a killed local
|
|
@@ -335,12 +343,14 @@ A status the server answers with and no retry heals (PERMISSION_DENIED on
|
|
|
335
343
|
Execute, a refused upload) fails the task with a line naming the task and
|
|
336
344
|
the status, never as a vx "internal error".
|
|
337
345
|
|
|
338
|
-
A failed
|
|
346
|
+
A failed RECORD read is never a failed task. The execution-record lookup is a
|
|
339
347
|
shortcut past the worker, so a transport error there means "no usable record"
|
|
340
348
|
and the task executes normally, with a warning naming why. The UPSTREAM record
|
|
341
349
|
reads are the deliberate exception — those decide whether a dependency's bytes
|
|
342
350
|
exist at all, and carrying on past a failure there is how an action runs
|
|
343
|
-
without its inputs and caches the result.
|
|
351
|
+
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.
|
|
344
354
|
|
|
345
355
|
## Tests
|
|
346
356
|
|
|
@@ -348,7 +358,7 @@ without its inputs and caches the result.
|
|
|
348
358
|
servers, which `vx run test.live --filter @vzn/vx-reapi` brings with Docker:
|
|
349
359
|
`services.cache` (bazel-remote on `:19092`) and `services.exec` (NativeLink on
|
|
350
360
|
`:51051`) run in the foreground until the run ends. CI runs it in the Linux
|
|
351
|
-
|
|
361
|
+
DTE run, where they start on the agent that runs `test.live`.
|
|
352
362
|
|
|
353
363
|
Without an endpoint those tests skip; `test.live` sets `VX_REQUIRE_REAPI=1` and
|
|
354
364
|
`VX_REQUIRE_REAPI_EXEC=1`, which turn an absent endpoint into a failure so the
|
|
@@ -386,13 +396,15 @@ Protocol features in use, not just reachable:
|
|
|
386
396
|
hung one.
|
|
387
397
|
- **`Action.platform`** (v2.2) alongside `Command.platform` for older
|
|
388
398
|
servers.
|
|
389
|
-
- **`NodeProperties`** — `unix_mode` and `mtime` on tree nodes
|
|
399
|
+
- **`NodeProperties`** — `unix_mode` and `mtime` on tree nodes; a mode (and
|
|
400
|
+
`is_executable`) lands as its permission bits minus the process umask.
|
|
390
401
|
- **Output directories** via the `Tree` blob an `OutputDirectory.tree_digest`
|
|
391
402
|
addresses, plus **output symlinks** (a v2.0 server's
|
|
392
403
|
`output_file_symlinks` / `output_directory_symlinks` when it sends only
|
|
393
404
|
those). A tree's small files are fetched
|
|
394
405
|
together across its directories (`BatchReadBlobs`, 64 MiB at a time),
|
|
395
|
-
not one call per directory
|
|
406
|
+
not one call per directory; the result's own output files, 64 MiB at a
|
|
407
|
+
time too.
|
|
396
408
|
- **Upload minimality** — `FindMissingBlobs` first (split so no request
|
|
397
409
|
passes a 4 MiB message), then batched blobs while they fit the server's
|
|
398
410
|
budget and ByteStream beyond it.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-reapi",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.648",
|
|
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": [
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"LICENSE"
|
|
30
30
|
],
|
|
31
31
|
"engines": {
|
|
32
|
-
"bun": ">=1.4"
|
|
32
|
+
"bun": ">=1.4.4"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@grpc/grpc-js": "^1.12.0",
|
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
"access": "public"
|
|
40
40
|
},
|
|
41
41
|
"peerDependencies": {
|
|
42
|
-
"@vzn/vx": "^0.0.
|
|
42
|
+
"@vzn/vx": "^0.0.648"
|
|
43
43
|
},
|
|
44
44
|
"repository": {
|
|
45
45
|
"type": "git",
|
package/src/cache.ts
CHANGED
|
@@ -9,7 +9,9 @@
|
|
|
9
9
|
// as a general key/value cache.
|
|
10
10
|
|
|
11
11
|
import { createHash } from 'node:crypto'
|
|
12
|
+
import * as grpc from '@grpc/grpc-js'
|
|
12
13
|
import { type ActionResult, type Digest, ReapiClient, type ReapiOptions } from './wire.js'
|
|
14
|
+
import { ui } from '@vzn/vx'
|
|
13
15
|
|
|
14
16
|
/** The artifact's `output_files` path. Constant: the AC entry holds exactly one. */
|
|
15
17
|
const ARTIFACT_PATH = 'vx-artifact.tar.zst'
|
|
@@ -61,16 +63,19 @@ function encodeDuration(durationMs: number): Uint8Array {
|
|
|
61
63
|
return new TextEncoder().encode(JSON.stringify({ durationMs }))
|
|
62
64
|
}
|
|
63
65
|
|
|
66
|
+
/** The duration a record carries; throws on bytes that are not one. */
|
|
64
67
|
function decodeDuration(raw: Uint8Array | undefined): number | undefined {
|
|
65
68
|
if (raw === undefined || raw.length === 0) return undefined
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return typeof parsed.durationMs === 'number' ? parsed.durationMs : undefined
|
|
69
|
-
} catch {
|
|
70
|
-
return undefined
|
|
71
|
-
}
|
|
69
|
+
const parsed = JSON.parse(new TextDecoder().decode(raw)) as { durationMs?: number }
|
|
70
|
+
return typeof parsed.durationMs === 'number' ? parsed.durationMs : undefined
|
|
72
71
|
}
|
|
73
72
|
|
|
73
|
+
/**
|
|
74
|
+
* A duration record vx writes is some 20 bytes; a blob past this is not
|
|
75
|
+
* one, and is never read: a 256 MiB one was held whole for its number.
|
|
76
|
+
*/
|
|
77
|
+
const DURATION_BYTES = 1024
|
|
78
|
+
|
|
74
79
|
/** Artifacts up to this size are batch-uploaded without a FindMissingBlobs probe. */
|
|
75
80
|
const SMALL_PUT_BYTES = 256 * 1024
|
|
76
81
|
|
|
@@ -78,10 +83,39 @@ export class ReapiRemoteCache {
|
|
|
78
83
|
private readonly client: ReapiClient
|
|
79
84
|
/** Named in core's degrade line: a gRPC status carries no server. */
|
|
80
85
|
readonly endpoint: string
|
|
86
|
+
private readonly onWarn: (message: string) => void
|
|
87
|
+
/** What was said already: a fault every hit meets is said once a run. */
|
|
88
|
+
private readonly said = new Set<string>()
|
|
89
|
+
/**
|
|
90
|
+
* The action cache's refusal of a write, which stands for the run: a
|
|
91
|
+
* read-only key or an AC with updates off refuses every save the same
|
|
92
|
+
* way, and each one uploaded its whole artifact before it was refused.
|
|
93
|
+
*/
|
|
94
|
+
private refused: Error | undefined
|
|
81
95
|
|
|
82
96
|
constructor(opts: ReapiOptions) {
|
|
83
97
|
this.client = new ReapiClient(opts)
|
|
84
98
|
this.endpoint = opts.endpoint
|
|
99
|
+
this.onWarn = opts.onWarn ?? ((m) => ui.say('warn', m))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** A fault the cache works around, said once per kind (2026-10-23: none is silent). */
|
|
103
|
+
private warn(kind: string, err: unknown): void {
|
|
104
|
+
if (this.said.has(kind)) return
|
|
105
|
+
this.said.add(kind)
|
|
106
|
+
this.onWarn(
|
|
107
|
+
`[vx] vx-reapi: ${kind} at ${this.endpoint} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The duration a hit carries; one that cannot be read is unknown, and said. */
|
|
112
|
+
private duration(raw: Uint8Array | undefined): number | undefined {
|
|
113
|
+
try {
|
|
114
|
+
return decodeDuration(raw)
|
|
115
|
+
} catch (err) {
|
|
116
|
+
this.warn('reading a task duration', err)
|
|
117
|
+
return undefined
|
|
118
|
+
}
|
|
85
119
|
}
|
|
86
120
|
|
|
87
121
|
/**
|
|
@@ -92,7 +126,7 @@ export class ReapiRemoteCache {
|
|
|
92
126
|
* ActionResult's referenced blobs and hides a dangling entry, NativeLink
|
|
93
127
|
* serves it. Without the second call, `has` on a NativeLink-style server
|
|
94
128
|
* promises a hit that `get` then cannot honour — and the only consumer of
|
|
95
|
-
* `has` is the `--dry`
|
|
129
|
+
* `has` is the `--dry` plan, whose entire job is predicting
|
|
96
130
|
* hit vs miss. Costs one extra round trip, and only for a PREDICTED HIT:
|
|
97
131
|
* a miss still answers in one call.
|
|
98
132
|
*/
|
|
@@ -103,7 +137,7 @@ export class ReapiRemoteCache {
|
|
|
103
137
|
if (file === undefined) return null
|
|
104
138
|
if ((await this.client.findMissingBlobs([file.digest])).length > 0) return null
|
|
105
139
|
// The duration only when it rode inline: a CAS read for it is a round trip.
|
|
106
|
-
const durationMs =
|
|
140
|
+
const durationMs = this.duration(result.stdout_raw)
|
|
107
141
|
return {
|
|
108
142
|
size: Number(file.digest.size_bytes),
|
|
109
143
|
...(durationMs === undefined ? {} : { durationMs }),
|
|
@@ -149,7 +183,7 @@ export class ReapiRemoteCache {
|
|
|
149
183
|
|
|
150
184
|
private async durationOf(result: ActionResult): Promise<number | undefined> {
|
|
151
185
|
if (result.stdout_raw !== undefined && result.stdout_raw.length > 0) {
|
|
152
|
-
return
|
|
186
|
+
return this.duration(result.stdout_raw)
|
|
153
187
|
}
|
|
154
188
|
// The server normalised our inline bytes into CAS (bazel-remote does).
|
|
155
189
|
// An absent digest arrives as `null` on this path (proto-loader's
|
|
@@ -157,9 +191,20 @@ export class ReapiRemoteCache {
|
|
|
157
191
|
// The duration is metadata: a read of it that fails (retries spent, a
|
|
158
192
|
// blob failing its digest) leaves it unknown, where it made core drop a
|
|
159
193
|
// valid hit as a miss (F-21).
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
194
|
+
const size = Number(result.stdout_digest?.size_bytes ?? 0)
|
|
195
|
+
if (size > DURATION_BYTES) {
|
|
196
|
+
this.warn(
|
|
197
|
+
'reading a task duration',
|
|
198
|
+
new Error(`its record is ${size} bytes, over ${DURATION_BYTES}: not read`),
|
|
199
|
+
)
|
|
200
|
+
return undefined
|
|
201
|
+
}
|
|
202
|
+
if (size > 0) {
|
|
203
|
+
const raw = await this.client.readBlob(result.stdout_digest!).catch((err: unknown) => {
|
|
204
|
+
this.warn('reading a task duration', err)
|
|
205
|
+
return null
|
|
206
|
+
})
|
|
207
|
+
return this.duration(raw ?? undefined)
|
|
163
208
|
}
|
|
164
209
|
return undefined
|
|
165
210
|
}
|
|
@@ -171,14 +216,18 @@ export class ReapiRemoteCache {
|
|
|
171
216
|
* stream.
|
|
172
217
|
*/
|
|
173
218
|
async put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
|
|
219
|
+
if (this.refused !== undefined) throw this.refused
|
|
174
220
|
// An entry is immutable, and UpdateActionResult replaces one: the
|
|
175
221
|
// lookup runs beside the upload (CAS writes are idempotent), so a key
|
|
176
222
|
// stored already costs no round trip of its own and keeps its result.
|
|
177
223
|
// A lookup that fails writes, as before.
|
|
178
224
|
const action = actionDigestFor(hash)
|
|
179
225
|
const stored = this.client.getActionResult(action).then(
|
|
180
|
-
(r) => r?.output_files?.
|
|
181
|
-
() =>
|
|
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
|
+
},
|
|
182
231
|
)
|
|
183
232
|
// Read once, and those bytes hashed and sent, wherever `writeBlob` would
|
|
184
233
|
// read the Blob whole anyway: a second writer of the key renames its
|
|
@@ -192,21 +241,38 @@ export class ReapiRemoteCache {
|
|
|
192
241
|
// it (102 → 71 ms per save at 15 ms one-way, F-26). Content-addressed,
|
|
193
242
|
// so a re-send is harmless.
|
|
194
243
|
// A server whose batch limit is smaller refuses it; stream instead.
|
|
195
|
-
await this.client
|
|
196
|
-
.
|
|
197
|
-
|
|
244
|
+
await this.client.batchUpdateBlobs([{ digest, data }]).catch((err: unknown) => {
|
|
245
|
+
this.warn('a batch upload (streamed instead)', err)
|
|
246
|
+
return this.client.writeBlob(digest, data)
|
|
247
|
+
})
|
|
198
248
|
} else {
|
|
199
249
|
// Upload only what the server lacks: for a large artifact the probe is
|
|
200
250
|
// cheap next to the bytes it can skip.
|
|
201
251
|
const missing = await this.client.findMissingBlobs([digest])
|
|
202
252
|
if (missing.length > 0) await this.client.writeBlob(digest, data ?? body)
|
|
203
253
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
254
|
+
// The two stores are pruned apart: an entry whose blob is gone is a
|
|
255
|
+
// miss every run, and kept, its key never hit again.
|
|
256
|
+
const kept = await stored
|
|
257
|
+
if (
|
|
258
|
+
kept !== undefined &&
|
|
259
|
+
(kept.hash === digest.hash || (await this.client.findMissingBlobs([kept])).length === 0)
|
|
260
|
+
) {
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
await this.client
|
|
264
|
+
.updateActionResult(action, {
|
|
265
|
+
exit_code: 0,
|
|
266
|
+
output_files: [{ path: ARTIFACT_PATH, digest, is_executable: false }],
|
|
267
|
+
stdout_raw: encodeDuration(meta.durationMs),
|
|
268
|
+
})
|
|
269
|
+
.catch((err: unknown) => {
|
|
270
|
+
const code = (err as { code?: unknown } | null)?.code
|
|
271
|
+
if (code === grpc.status.PERMISSION_DENIED || code === grpc.status.UNIMPLEMENTED) {
|
|
272
|
+
this.refused = err as Error
|
|
273
|
+
}
|
|
274
|
+
throw err
|
|
275
|
+
})
|
|
210
276
|
}
|
|
211
277
|
|
|
212
278
|
close(): void {
|