@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 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. `VX_REAPI_ENDPOINT` / `VX_REAPI_INSTANCE` configure it
45
- from the environment, and `VX_REAPI_EXECUTE=1` turns on remote execution the
46
- way `execute: true` does (off by default: a plugin must not move where a
47
- build runs merely by being configured for caching). `execute` is a
48
- boolean: a string (`process.env.X`) is refused.
49
- `instanceName` is the option form of `VX_REAPI_INSTANCE`; like the endpoint
50
- it is trimmed, and a blank one is unset. `headers`
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` (or `VX_REAPI_TLS_CERTIFICATE`), a PEM file of
62
- that CA; one that asks for mutual TLS takes `tlsClientCertificate` and
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, and the plugin refuses to start on
106
- anything older with a named error: the alternative is a wedged upload with
107
- nothing for a user to act on.
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: '…', chunkBytes: 128 * 1024 })
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 folded for it. A file edited or removed
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 folded: core
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`) — the action cache and the
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 claims. vx fails the task and names the upstream rather
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 claiming a complete build. Under a literal capture the
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), and a
233
- directory that resolves out through a link are refused before anything
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 must be a positive number of ms; anything else is refused when
272
- the plugin starts. `executeTimeoutMs` and `queueTimeoutMs` are held to the
273
- same rule when the executor starts, so with `execute` off they are not
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 READ is never a failed task. The execution-record lookup is a
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
- distributed run, where they start on the agent that runs `test.live`.
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.646",
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.646"
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
- try {
67
- const parsed = JSON.parse(new TextDecoder().decode(raw)) as { durationMs?: number }
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` / `--graph` plan, whose entire job is predicting
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 = decodeDuration(result.stdout_raw)
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 decodeDuration(result.stdout_raw)
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
- if ((result.stdout_digest?.size_bytes ?? 0) > 0) {
161
- const raw = await this.client.readBlob(result.stdout_digest!).catch(() => null)
162
- return decodeDuration(raw ?? undefined)
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?.some((f) => f.path === ARTIFACT_PATH) === true,
181
- () => false,
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
- .batchUpdateBlobs([{ digest, data }])
197
- .catch(() => this.client.writeBlob(digest, data))
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
- if (await stored) return
205
- await this.client.updateActionResult(action, {
206
- exit_code: 0,
207
- output_files: [{ path: ARTIFACT_PATH, digest, is_executable: false }],
208
- stdout_raw: encodeDuration(meta.durationMs),
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 {