@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/src/wire.ts
ADDED
|
@@ -0,0 +1,1658 @@
|
|
|
1
|
+
// The full gRPC surface of the Bazel Remote Execution API: Execution,
|
|
2
|
+
// ActionCache, ContentAddressableStorage, Capabilities and ByteStream.
|
|
3
|
+
// `@grpc/proto-loader` parses the vendored protos (28 ms) and `@grpc/grpc-js`
|
|
4
|
+
// carries the calls.
|
|
5
|
+
|
|
6
|
+
import * as grpc from '@grpc/grpc-js'
|
|
7
|
+
import * as protoLoader from '@grpc/proto-loader'
|
|
8
|
+
import path from 'node:path'
|
|
9
|
+
import { canDigest, concat, digestWith, hasherFor, type DigestFunctionName } from './merkle.js'
|
|
10
|
+
|
|
11
|
+
/** Bare varint bytes, for the hand-encoded RequestMetadata header. */
|
|
12
|
+
function varintBytes(n: number): Uint8Array {
|
|
13
|
+
const out: number[] = []
|
|
14
|
+
let v = n
|
|
15
|
+
while (v >= 0x80) {
|
|
16
|
+
out.push((v & 0x7f) | 0x80)
|
|
17
|
+
v >>>= 7
|
|
18
|
+
}
|
|
19
|
+
out.push(v)
|
|
20
|
+
return new Uint8Array(out)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Default bytes per ByteStream message.
|
|
25
|
+
*
|
|
26
|
+
* NOT a throughput knob. Bun's `node:http2` client HANGS — it does not error —
|
|
27
|
+
* when a request carries more than one message and any single message exceeds
|
|
28
|
+
* a threshold that the PEER's flow-control behaviour decides. Go's gRPC server
|
|
29
|
+
* grows its window dynamically (a `WINDOW_UPDATE` then a `SETTINGS` raise) and
|
|
30
|
+
* Bun mishandles the tail of that sequence; a `node:http2` server, which does
|
|
31
|
+
* not do it, accepts 4 MB writes happily. See Bun #30342 / #26915, largely
|
|
32
|
+
* fixed by #31584 — which is why the ceiling ROSE from ~64 KB on 1.3.x to
|
|
33
|
+
* ~216 KB on 1.4.0 rather than the hang disappearing.
|
|
34
|
+
*
|
|
35
|
+
* The default is therefore `SAFE_CHUNK_BYTES` (65535, the RFC 7540 default
|
|
36
|
+
* initial window every peer must honour with no WINDOW_UPDATE at all), the
|
|
37
|
+
* one size with no peer-dependence. 128 KB was the default and stalled a
|
|
38
|
+
* 1 MiB write against bazel-remote in 2 of 12 fresh runs on Bun 1.4.2, each
|
|
39
|
+
* costing the call's 30 s deadline before the downgrade below retried it;
|
|
40
|
+
* 65535 stalled in none (F-20). It costs ~51% on a 32 MiB upload
|
|
41
|
+
* (390 → 590 ms on loopback), far below one expected stall. A larger
|
|
42
|
+
* `chunkBytes` stays available, with the downgrade as its net.
|
|
43
|
+
*
|
|
44
|
+
* Full probe matrix: `docs/design/plugin-executor-reapi-2026-08.md` §14.
|
|
45
|
+
*/
|
|
46
|
+
export const CHUNK_BYTES = 65535
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The largest message needing no `WINDOW_UPDATE` from any conformant peer, so
|
|
50
|
+
* the one size with no peer-dependence: the default, and what a larger
|
|
51
|
+
* `chunkBytes` downgrades to when a write stalls.
|
|
52
|
+
*/
|
|
53
|
+
export const SAFE_CHUNK_BYTES = 65535
|
|
54
|
+
|
|
55
|
+
/** The oldest Bun whose http2 client survives a CHUNK_BYTES-sized message. */
|
|
56
|
+
export const MIN_BUN = [1, 4, 0] as const
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Refuse to run on a Bun that would hang instead of uploading. A version this
|
|
60
|
+
* plugin cannot use is a startup error naming the fix, never a wedged run —
|
|
61
|
+
* the failure mode being guarded is a HANG, which gives a user nothing to go
|
|
62
|
+
* on.
|
|
63
|
+
*/
|
|
64
|
+
export function assertBunSupportsChunking(version: string = Bun.version): void {
|
|
65
|
+
const parts = version.split('.').map((p) => Number.parseInt(p, 10))
|
|
66
|
+
const [maj = 0, min = 0, patch = 0] = parts
|
|
67
|
+
const older =
|
|
68
|
+
maj < MIN_BUN[0] ||
|
|
69
|
+
(maj === MIN_BUN[0] && min < MIN_BUN[1]) ||
|
|
70
|
+
(maj === MIN_BUN[0] && min === MIN_BUN[1] && patch < MIN_BUN[2])
|
|
71
|
+
if (older) {
|
|
72
|
+
throw new Error(
|
|
73
|
+
`@vzn/vx-reapi needs Bun >= ${MIN_BUN.join('.')} (running ${version}). ` +
|
|
74
|
+
`Older Bun hangs on the chunked uploads this plugin makes — see ` +
|
|
75
|
+
`docs/design/plugin-executor-reapi-2026-08.md §14. Upgrade with \`bun upgrade\`.`,
|
|
76
|
+
)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface Digest {
|
|
81
|
+
hash: string
|
|
82
|
+
size_bytes: number
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ServerCapabilities {
|
|
86
|
+
digestFunctions: string[]
|
|
87
|
+
maxBatchBytes: number
|
|
88
|
+
acUpdateEnabled: boolean
|
|
89
|
+
/** False for a cache-only deployment (bazel-remote). Gates the executor. */
|
|
90
|
+
execEnabled: boolean
|
|
91
|
+
/** `Compressor.Value` names the server accepts on ByteStream. */
|
|
92
|
+
supportedCompressors: string[]
|
|
93
|
+
/** Compressors accepted specifically on `BatchUpdateBlobs`. */
|
|
94
|
+
supportedBatchCompressors: string[]
|
|
95
|
+
/** How the server treats absolute symlink targets. */
|
|
96
|
+
symlinkAbsolutePathStrategy: string
|
|
97
|
+
/** Experimental `SplitBlob`/`SpliceBlob` support. */
|
|
98
|
+
splitBlobSupport: boolean
|
|
99
|
+
spliceBlobSupport: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** REAPI `Directory` node types — the Merkle input root. */
|
|
103
|
+
export interface FileNode {
|
|
104
|
+
name: string
|
|
105
|
+
digest: Digest
|
|
106
|
+
is_executable: boolean
|
|
107
|
+
node_properties?: { unixMode?: number; mtimeMs?: number }
|
|
108
|
+
}
|
|
109
|
+
export interface DirectoryNode {
|
|
110
|
+
name: string
|
|
111
|
+
digest: Digest
|
|
112
|
+
}
|
|
113
|
+
export interface SymlinkNode {
|
|
114
|
+
name: string
|
|
115
|
+
target: string
|
|
116
|
+
}
|
|
117
|
+
export interface Directory {
|
|
118
|
+
files: FileNode[]
|
|
119
|
+
directories: DirectoryNode[]
|
|
120
|
+
symlinks: SymlinkNode[]
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const PROTO_ROOT = path.join(import.meta.dir, '..', 'protos')
|
|
124
|
+
/**
|
|
125
|
+
* Download-integrity check: bytes that came back for `digest` must hash to
|
|
126
|
+
* it. A corrupt or poisoned remote otherwise lands wrong bytes in the local
|
|
127
|
+
* content-addressed store under a trusted name — served forever under a
|
|
128
|
+
* green hit. Hashed with the NEGOTIATED digest function via the same
|
|
129
|
+
* helper every upload uses; a function this build cannot compute falls
|
|
130
|
+
* back to the length check alone (it also could never have negotiated).
|
|
131
|
+
*/
|
|
132
|
+
function assertBlobIntegrity(
|
|
133
|
+
bytes: Uint8Array,
|
|
134
|
+
digest: Digest,
|
|
135
|
+
digestFunction: DigestFunctionName,
|
|
136
|
+
): void {
|
|
137
|
+
assertServed(
|
|
138
|
+
bytes.length,
|
|
139
|
+
canDigest(digestFunction) ? () => digestWith(digestFunction, bytes).hash : undefined,
|
|
140
|
+
digest,
|
|
141
|
+
)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* A zstd reply decoded no further than the size its digest declares, plus
|
|
146
|
+
* one byte to tell "more" from "exactly": `Bun.zstdDecompressSync` expands
|
|
147
|
+
* a frame to its end, and a 1 MiB batch entry or a ByteStream body of a
|
|
148
|
+
* few KB expanded to GiBs before the size check ran (L-3). A body that
|
|
149
|
+
* decodes past the digest is refused as it passes the bound.
|
|
150
|
+
*/
|
|
151
|
+
async function unzstdBounded(data: Uint8Array, digest: Digest): Promise<Uint8Array> {
|
|
152
|
+
const max = Number(digest.size_bytes)
|
|
153
|
+
const parts: Uint8Array[] = []
|
|
154
|
+
let size = 0
|
|
155
|
+
const reader = new Blob([data]).stream().pipeThrough(new DecompressionStream('zstd')).getReader()
|
|
156
|
+
for (;;) {
|
|
157
|
+
const { done, value } = await reader.read()
|
|
158
|
+
if (done) break
|
|
159
|
+
size += value.byteLength
|
|
160
|
+
if (size > max) {
|
|
161
|
+
await reader.cancel()
|
|
162
|
+
throw overServed(digest)
|
|
163
|
+
}
|
|
164
|
+
parts.push(value)
|
|
165
|
+
}
|
|
166
|
+
return parts.length === 1 ? parts[0]! : new Uint8Array(Buffer.concat(parts))
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** More bytes than the digest declares, refused before the rest arrive. */
|
|
170
|
+
const overServed = (digest: Digest): Error =>
|
|
171
|
+
new Error(
|
|
172
|
+
`@vzn/vx-reapi: blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
|
|
173
|
+
`served past its declared ${digest.size_bytes} bytes`,
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The most a zstd body of `size` decoded bytes can take on the wire: the
|
|
178
|
+
* format's own bound for incompressible input (ZSTD_COMPRESSBOUND) and a
|
|
179
|
+
* frame header's slack.
|
|
180
|
+
*/
|
|
181
|
+
const wireBound = (size: number): number => size + (size >> 8) + 1024
|
|
182
|
+
|
|
183
|
+
/** The check itself, over a size and a hash however they were computed (whole or streamed). */
|
|
184
|
+
function assertServed(size: number, hash: (() => string) | undefined, digest: Digest): void {
|
|
185
|
+
if (size !== Number(digest.size_bytes)) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`@vzn/vx-reapi: blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
|
|
188
|
+
`size ${size} != declared ${digest.size_bytes}`,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
if (hash === undefined) return
|
|
192
|
+
const got = hash()
|
|
193
|
+
if (got !== digest.hash) {
|
|
194
|
+
throw new Error(
|
|
195
|
+
`@vzn/vx-reapi: blob integrity failure: bytes hash to ${got.slice(0, 16)}… ` +
|
|
196
|
+
`but were served for digest ${digest.hash.slice(0, 16)}…`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const LOAD_OPTIONS: protoLoader.Options = {
|
|
202
|
+
includeDirs: [PROTO_ROOT],
|
|
203
|
+
keepCase: true,
|
|
204
|
+
longs: String,
|
|
205
|
+
enums: String,
|
|
206
|
+
defaults: true,
|
|
207
|
+
oneofs: true,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface ServiceClients {
|
|
211
|
+
cas: grpc.Client
|
|
212
|
+
ac: grpc.Client
|
|
213
|
+
bs: grpc.Client
|
|
214
|
+
caps: grpc.Client
|
|
215
|
+
exec: grpc.Client
|
|
216
|
+
ops: grpc.Client
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
type Ctors = Record<string, new (...a: unknown[]) => grpc.Client>
|
|
220
|
+
let loaded: { v2: Ctors; bs: Ctors; ops: Ctors } | undefined
|
|
221
|
+
|
|
222
|
+
/** Parsed ONCE per process: proto parsing is ~28 ms and identical every time. */
|
|
223
|
+
function ctors(): { v2: Ctors; bs: Ctors; ops: Ctors } {
|
|
224
|
+
if (loaded !== undefined) return loaded
|
|
225
|
+
// remote_execution.proto imports google.longrunning: its Operations
|
|
226
|
+
// service comes with the same parse.
|
|
227
|
+
const reapi = grpc.loadPackageDefinition(
|
|
228
|
+
protoLoader.loadSync('build/bazel/remote/execution/v2/remote_execution.proto', LOAD_OPTIONS),
|
|
229
|
+
) as unknown as {
|
|
230
|
+
build: { bazel: { remote: { execution: { v2: Ctors } } } }
|
|
231
|
+
google: { longrunning: Ctors }
|
|
232
|
+
}
|
|
233
|
+
const bytestream = grpc.loadPackageDefinition(
|
|
234
|
+
protoLoader.loadSync('google/bytestream/bytestream.proto', LOAD_OPTIONS),
|
|
235
|
+
) as unknown as { google: { bytestream: Ctors } }
|
|
236
|
+
loaded = {
|
|
237
|
+
v2: reapi.build.bazel.remote.execution.v2,
|
|
238
|
+
bs: bytestream.google.bytestream,
|
|
239
|
+
ops: reapi.google.longrunning,
|
|
240
|
+
}
|
|
241
|
+
return loaded
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** Per-entry encoding cost in a Batch request: a 64-char hex hash, a size,
|
|
245
|
+
* and the nested field tags and length prefixes around them. Rounded up. */
|
|
246
|
+
const BATCH_ENTRY_OVERHEAD = 128
|
|
247
|
+
|
|
248
|
+
/** One batch must fit in a single gRPC message; 4 MiB is the ecosystem
|
|
249
|
+
* default and 64 KiB leaves room for the envelope. */
|
|
250
|
+
const SAFE_BATCH_BYTES = 4 * 1024 * 1024 - 64 * 1024
|
|
251
|
+
|
|
252
|
+
/** Batches and streamed writes one `uploadBlobs` keeps in flight. */
|
|
253
|
+
const UPLOAD_CONCURRENCY = 8
|
|
254
|
+
|
|
255
|
+
/** A `Digest` in a request past its hash: size varint and field framing. */
|
|
256
|
+
const FIND_MISSING_ENTRY_OVERHEAD = 16
|
|
257
|
+
|
|
258
|
+
/** grpc's 4 MiB receive default is far below REAPI's real message sizes. */
|
|
259
|
+
const MAX_MESSAGE_BYTES = 256 * 1024 * 1024
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* The receive window the client offers, per stream and per connection.
|
|
263
|
+
* HTTP/2's default 64 KiB moves one window per round trip: a read at 30 ms
|
|
264
|
+
* round trip ran at 2 MB/s (8 MB in 3988 ms, 142 ms at 16 MiB; F-32).
|
|
265
|
+
*/
|
|
266
|
+
const FLOW_CONTROL_WINDOW = 16 * 1024 * 1024
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* All six service stubs share ONE channel. Constructing them independently
|
|
270
|
+
* opens one HTTP/2 connection per service to the same endpoint — six times
|
|
271
|
+
* the sockets, six times the flow-control state, and a server that sees six
|
|
272
|
+
* clients where there is one. `channelOverride` is grpc-js's supported way to
|
|
273
|
+
* bind extra stubs onto an existing channel.
|
|
274
|
+
*/
|
|
275
|
+
function loadServices(target: string, creds: grpc.ChannelCredentials): ServiceClients {
|
|
276
|
+
const { v2, bs, ops } = ctors()
|
|
277
|
+
// An ActionResult listing a real dependency tree is megabytes, and the
|
|
278
|
+
// default limit rejects it with RESOURCE_EXHAUSTED — measured at 4 359 595
|
|
279
|
+
// bytes against the 4 194 304 default. Applied to EVERY stub: the limit is
|
|
280
|
+
// per-client, so sharing one channel does not share it.
|
|
281
|
+
const opts = {
|
|
282
|
+
'grpc.max_receive_message_length': MAX_MESSAGE_BYTES,
|
|
283
|
+
'grpc.max_send_message_length': MAX_MESSAGE_BYTES,
|
|
284
|
+
'grpc-node.flow_control_window': FLOW_CONTROL_WINDOW,
|
|
285
|
+
}
|
|
286
|
+
const cas = new v2['ContentAddressableStorage']!(target, creds, opts)
|
|
287
|
+
const shared = { ...opts, channelOverride: cas.getChannel() }
|
|
288
|
+
return {
|
|
289
|
+
cas,
|
|
290
|
+
ac: new v2['ActionCache']!(target, creds, shared),
|
|
291
|
+
caps: new v2['Capabilities']!(target, creds, shared),
|
|
292
|
+
exec: new v2['Execution']!(target, creds, shared),
|
|
293
|
+
bs: new bs['ByteStream']!(target, creds, shared),
|
|
294
|
+
ops: new ops['Operations']!(target, creds, shared),
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Transient statuses a retry can heal: UNAVAILABLE, RESOURCE_EXHAUSTED when
|
|
299
|
+
* the server is shedding load, and INTERNAL, which is how grpc-js spells a
|
|
300
|
+
* call cut in transit — an RST_STREAM(INTERNAL_ERROR), the frame a proxy
|
|
301
|
+
* sends when the backend behind it goes away, and a stream that ended with
|
|
302
|
+
* no gRPC status at all. Bazel's remote executor retries all three.
|
|
303
|
+
* NOT_FOUND/INVALID_ARGUMENT never heal. */
|
|
304
|
+
function isRetryable(code: number | undefined): boolean {
|
|
305
|
+
return (
|
|
306
|
+
code === grpc.status.UNAVAILABLE ||
|
|
307
|
+
code === grpc.status.RESOURCE_EXHAUSTED ||
|
|
308
|
+
code === grpc.status.INTERNAL
|
|
309
|
+
)
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
const RETRY_DELAYS_MS = [100, 400, 1600]
|
|
313
|
+
|
|
314
|
+
const deadlineOf = (bounds: grpc.CallOptions): number => (bounds.deadline as Date).getTime()
|
|
315
|
+
|
|
316
|
+
const executionAborted = (): Error => new Error('reapi: execution aborted')
|
|
317
|
+
|
|
318
|
+
/** A backoff an abort ends at once: the caller's deadline may fire mid-wait. */
|
|
319
|
+
function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
320
|
+
return new Promise((resolve, reject) => {
|
|
321
|
+
const onAbort = (): void => {
|
|
322
|
+
clearTimeout(timer)
|
|
323
|
+
reject(executionAborted())
|
|
324
|
+
}
|
|
325
|
+
const timer = setTimeout(() => {
|
|
326
|
+
signal?.removeEventListener('abort', onAbort)
|
|
327
|
+
resolve()
|
|
328
|
+
}, ms)
|
|
329
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
330
|
+
})
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
const NOT_FOUND = grpc.status.NOT_FOUND
|
|
334
|
+
|
|
335
|
+
export interface ReapiOptions {
|
|
336
|
+
/** `host:port` of the REAPI server. */
|
|
337
|
+
endpoint: string
|
|
338
|
+
/** Multi-tenant servers scope by instance; most single-tenant ones use ''. */
|
|
339
|
+
instanceName?: string
|
|
340
|
+
/** Sent on every call (auth, routing). */
|
|
341
|
+
headers?: Record<string, string>
|
|
342
|
+
/** TLS. Default: on with any PEM below or an `https://`/`grpcs://` endpoint, else insecure. */
|
|
343
|
+
tls?: boolean
|
|
344
|
+
/** PEM of the CA that signed the server's certificate, in place of the system roots. */
|
|
345
|
+
tlsCaPem?: string
|
|
346
|
+
/** PEM of a client certificate and its key, for a server that asks for mutual TLS. */
|
|
347
|
+
tlsClientCertPem?: string
|
|
348
|
+
tlsClientKeyPem?: string
|
|
349
|
+
/** Reported in REAPI `RequestMetadata.tool_details`. */
|
|
350
|
+
toolName?: string
|
|
351
|
+
toolVersion?: string
|
|
352
|
+
/** Groups several vx runs as one logical build in a server's UI. */
|
|
353
|
+
correlatedInvocationsId?: string
|
|
354
|
+
/** Surfaced for degraded-but-recovered operations (e.g. a chunk-size downgrade). */
|
|
355
|
+
onWarn?: (message: string) => void
|
|
356
|
+
/**
|
|
357
|
+
* Deadline for every CACHE-PATH call (unary RPCs, ByteStream transfers).
|
|
358
|
+
* Default 30 000 ms. This is what turns a WEDGED server — accepts TCP,
|
|
359
|
+
* never answers — into an error the layer above can degrade to a MISS;
|
|
360
|
+
* without it the first probe hangs the whole run. Execution streams are
|
|
361
|
+
* NOT bounded by this (queueing behind a busy worker pool is legitimate
|
|
362
|
+
* and unbounded); a wedged server still cannot reach Execute, because the
|
|
363
|
+
* deadline-bounded Capabilities call runs first and fails.
|
|
364
|
+
*/
|
|
365
|
+
callTimeoutMs?: number
|
|
366
|
+
/**
|
|
367
|
+
* Deadline for CONTROL-PLANE calls — Capabilities, GetActionResult,
|
|
368
|
+
* UpdateActionResult, FindMissingBlobs, QueryWriteStatus. Defaults to
|
|
369
|
+
* `min(callTimeoutMs, 15 000)`.
|
|
370
|
+
*
|
|
371
|
+
* Separate from `callTimeoutMs` because the two classes have nothing in
|
|
372
|
+
* common but a transport. A control-plane message is small and bounded — a
|
|
373
|
+
* healthy server answers in single-digit milliseconds — while a bulk
|
|
374
|
+
* transfer is size-proportional and legitimately slow: capturing a
|
|
375
|
+
* `node_modules` tree is the case that pushes real deployments to raise
|
|
376
|
+
* `callTimeoutMs` into the minutes. With one knob for both, buying enough
|
|
377
|
+
* headroom for the upload also means every metadata probe against a WEDGED
|
|
378
|
+
* server costs those same minutes before it can degrade to a miss, which is
|
|
379
|
+
* the opposite of what the deadline exists for. Measured against a
|
|
380
|
+
* NativeLink that had degraded into serving every AC hit never: misses
|
|
381
|
+
* answered in 3 ms while every hit burned the full deadline.
|
|
382
|
+
*/
|
|
383
|
+
metaTimeoutMs?: number
|
|
384
|
+
/**
|
|
385
|
+
* Bytes per ByteStream message. Defaults to `CHUNK_BYTES` (65535, the size
|
|
386
|
+
* with no peer-dependence); a larger value risks the stall described on
|
|
387
|
+
* `CHUNK_BYTES`, retried once at `SAFE_CHUNK_BYTES`.
|
|
388
|
+
*/
|
|
389
|
+
chunkBytes?: number
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/** The ceiling a derived control-plane deadline never exceeds, however far
|
|
393
|
+
* `callTimeoutMs` is raised for a bulk transfer. */
|
|
394
|
+
export const META_TIMEOUT_CAP_MS = 15_000
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* `body` from `from` as ByteStream messages of `chunkBytes`. A `Blob` is read
|
|
398
|
+
* from its stream as the caller asks, re-cut to the message size: a file
|
|
399
|
+
* stream's pieces are its own (256 KiB and up), not the wire's.
|
|
400
|
+
*/
|
|
401
|
+
async function* messagesOf(
|
|
402
|
+
body: Uint8Array | Blob,
|
|
403
|
+
from: number,
|
|
404
|
+
chunkBytes: number,
|
|
405
|
+
): AsyncGenerator<Uint8Array> {
|
|
406
|
+
if (body instanceof Uint8Array) {
|
|
407
|
+
for (let at = from; at < body.length; at += chunkBytes) {
|
|
408
|
+
yield body.subarray(at, Math.min(at + chunkBytes, body.length))
|
|
409
|
+
}
|
|
410
|
+
return
|
|
411
|
+
}
|
|
412
|
+
let carry: Uint8Array = new Uint8Array(0)
|
|
413
|
+
let skip = from
|
|
414
|
+
for await (const whole of body.stream()) {
|
|
415
|
+
// A resumed write starts past what the server committed.
|
|
416
|
+
const piece = skip === 0 ? whole : whole.subarray(Math.min(skip, whole.length))
|
|
417
|
+
skip -= whole.length - piece.length
|
|
418
|
+
if (piece.length === 0) continue
|
|
419
|
+
const data = carry.length === 0 ? piece : concat([carry, piece])
|
|
420
|
+
let at = 0
|
|
421
|
+
for (; data.length - at >= chunkBytes; at += chunkBytes)
|
|
422
|
+
yield data.subarray(at, at + chunkBytes)
|
|
423
|
+
carry = data.slice(at)
|
|
424
|
+
}
|
|
425
|
+
if (carry.length > 0) yield carry
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
export class ReapiClient {
|
|
429
|
+
private readonly svc: ServiceClients
|
|
430
|
+
private readonly instance: string
|
|
431
|
+
private readonly headers: Record<string, string>
|
|
432
|
+
private readonly chunkBytes: number
|
|
433
|
+
private readonly callTimeoutMs: number
|
|
434
|
+
/** What `negotiate()` learned the server will accept in one Batch call.
|
|
435
|
+
* Used whenever a caller does not pass a budget, so a call site cannot
|
|
436
|
+
* silently fall back to a larger default than the server allows. */
|
|
437
|
+
private negotiatedBatchBytes = 0
|
|
438
|
+
/** The control-plane deadline in force: `metaTimeoutMs` as given, else
|
|
439
|
+
* `min(callTimeoutMs, META_TIMEOUT_CAP_MS)`. Readable so the derivation is
|
|
440
|
+
* pinned on the instance, not waited out on the wire. */
|
|
441
|
+
readonly metaTimeoutMs: number
|
|
442
|
+
private readonly onWarn: (message: string) => void
|
|
443
|
+
private digestFunction: DigestFunctionName = 'SHA256'
|
|
444
|
+
private compression = false
|
|
445
|
+
private batchCompression = false
|
|
446
|
+
private negotiated: ServerCapabilities | undefined
|
|
447
|
+
private readonly toolName: string
|
|
448
|
+
private readonly toolVersion: string
|
|
449
|
+
private readonly correlatedInvocationsId: string
|
|
450
|
+
/** Set when a call spent its retries on UNAVAILABLE; cleared by any answer. */
|
|
451
|
+
private unreachable = false
|
|
452
|
+
/** Set per action so the server can group its RPCs under one action. */
|
|
453
|
+
actionId = ''
|
|
454
|
+
toolInvocationId = ''
|
|
455
|
+
|
|
456
|
+
constructor(opts: ReapiOptions) {
|
|
457
|
+
assertBunSupportsChunking()
|
|
458
|
+
const pem = (text: string | undefined): Buffer | null =>
|
|
459
|
+
text === undefined ? null : Buffer.from(text)
|
|
460
|
+
const tls =
|
|
461
|
+
opts.tls ??
|
|
462
|
+
(opts.tlsCaPem !== undefined ||
|
|
463
|
+
opts.tlsClientCertPem !== undefined ||
|
|
464
|
+
/^(https|grpcs):\/\//.test(opts.endpoint))
|
|
465
|
+
const target = opts.endpoint.replace(/^(https?|grpcs?):\/\//, '')
|
|
466
|
+
this.svc = loadServices(
|
|
467
|
+
target,
|
|
468
|
+
tls
|
|
469
|
+
? grpc.credentials.createSsl(
|
|
470
|
+
pem(opts.tlsCaPem),
|
|
471
|
+
pem(opts.tlsClientKeyPem),
|
|
472
|
+
pem(opts.tlsClientCertPem),
|
|
473
|
+
)
|
|
474
|
+
: grpc.credentials.createInsecure(),
|
|
475
|
+
)
|
|
476
|
+
this.instance = opts.instanceName ?? ''
|
|
477
|
+
this.headers = opts.headers ?? {}
|
|
478
|
+
// grpc-js refuses a metadata value outside printable ASCII on EVERY call
|
|
479
|
+
// and quotes it in the error, so an auth header's secret reached each
|
|
480
|
+
// degrade warning (item 928). Refused once, here, by key alone.
|
|
481
|
+
for (const [k, v] of Object.entries(this.headers)) {
|
|
482
|
+
if (!/^[ -~]*$/.test(v)) {
|
|
483
|
+
throw new Error(
|
|
484
|
+
`reapi: header ${JSON.stringify(k)} holds a character gRPC metadata cannot carry (printable ASCII only) — check it (its value is not printed)`,
|
|
485
|
+
)
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
this.toolName = opts.toolName ?? 'vx'
|
|
489
|
+
this.toolVersion = opts.toolVersion ?? '0.0.0'
|
|
490
|
+
this.correlatedInvocationsId = opts.correlatedInvocationsId ?? ''
|
|
491
|
+
this.chunkBytes = opts.chunkBytes ?? CHUNK_BYTES
|
|
492
|
+
this.callTimeoutMs = opts.callTimeoutMs ?? 30_000
|
|
493
|
+
// Derived, not required: raising `callTimeoutMs` for a big upload must
|
|
494
|
+
// not silently lengthen every metadata probe too.
|
|
495
|
+
this.metaTimeoutMs = opts.metaTimeoutMs ?? Math.min(this.callTimeoutMs, META_TIMEOUT_CAP_MS)
|
|
496
|
+
this.onWarn = opts.onWarn ?? (() => undefined)
|
|
497
|
+
// A zero, negative or non-number deadline ended every call as it began.
|
|
498
|
+
for (const [name, ms] of [
|
|
499
|
+
['callTimeoutMs', this.callTimeoutMs],
|
|
500
|
+
['metaTimeoutMs', this.metaTimeoutMs],
|
|
501
|
+
] as const) {
|
|
502
|
+
if (!(typeof ms === 'number' && Number.isFinite(ms) && ms > 0)) {
|
|
503
|
+
throw new Error(
|
|
504
|
+
`@vzn/vx-reapi: ${name} must be a positive number of ms (got ${JSON.stringify(ms)})`,
|
|
505
|
+
)
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
if (!Number.isInteger(this.chunkBytes) || this.chunkBytes < 1) {
|
|
509
|
+
throw new Error(
|
|
510
|
+
`@vzn/vx-reapi: chunkBytes must be a positive integer (got ${this.chunkBytes})`,
|
|
511
|
+
)
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The wait before retry `attempt` of a cache-path call that failed with
|
|
517
|
+
* `code`, or undefined to give up. A call that spends its retries on
|
|
518
|
+
* UNAVAILABLE marks the server unreachable, and until a call succeeds the
|
|
519
|
+
* next ones give up at their first UNAVAILABLE, until a call succeeds or
|
|
520
|
+
* the server answers with a status of its own: a refused port cost every
|
|
521
|
+
* request 2.1 s of backoff, and a five-task run 24 s (J-74).
|
|
522
|
+
*/
|
|
523
|
+
private retryDelay(attempt: number, code: number | undefined): number | undefined {
|
|
524
|
+
const unavailable = code === grpc.status.UNAVAILABLE
|
|
525
|
+
// A status the server sent (NOT_FOUND is a miss) proves it answers.
|
|
526
|
+
if (!unavailable && code !== grpc.status.DEADLINE_EXCEEDED && code !== grpc.status.CANCELLED) {
|
|
527
|
+
this.unreachable = false
|
|
528
|
+
}
|
|
529
|
+
if (!isRetryable(code)) return undefined
|
|
530
|
+
if (unavailable && this.unreachable) return undefined
|
|
531
|
+
const delay = RETRY_DELAYS_MS[attempt]
|
|
532
|
+
if (delay === undefined && unavailable) this.unreachable = true
|
|
533
|
+
return delay
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Promisified unary call with bounded retry on transient failure. Every
|
|
538
|
+
* unary REAPI call is idempotent by construction (CAS writes are
|
|
539
|
+
* content-addressed, AC updates are last-writer-wins on an immutable key),
|
|
540
|
+
* so retrying cannot double-apply anything.
|
|
541
|
+
*/
|
|
542
|
+
private async unary<T>(
|
|
543
|
+
client: grpc.Client,
|
|
544
|
+
method: string,
|
|
545
|
+
req: unknown,
|
|
546
|
+
meta: grpc.Metadata,
|
|
547
|
+
options: grpc.CallOptions = {},
|
|
548
|
+
): Promise<T> {
|
|
549
|
+
for (let attempt = 0; ; attempt++) {
|
|
550
|
+
try {
|
|
551
|
+
const res = await new Promise<T>((resolve, reject) => {
|
|
552
|
+
;(client as unknown as Record<string, Function>)[method]!(
|
|
553
|
+
req,
|
|
554
|
+
meta,
|
|
555
|
+
options,
|
|
556
|
+
(err: grpc.ServiceError | null, res: T) => (err ? reject(err) : resolve(res)),
|
|
557
|
+
)
|
|
558
|
+
})
|
|
559
|
+
this.unreachable = false
|
|
560
|
+
return res
|
|
561
|
+
} catch (err) {
|
|
562
|
+
const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
|
|
563
|
+
if (delay === undefined) throw err
|
|
564
|
+
await Bun.sleep(delay)
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** Call options for a BULK transfer — deadline scales with payload size. */
|
|
570
|
+
private bounded(): grpc.CallOptions {
|
|
571
|
+
return { deadline: new Date(Date.now() + this.callTimeoutMs) }
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/** Call options for a CONTROL-PLANE call — small message, short deadline. */
|
|
575
|
+
private boundedMeta(): grpc.CallOptions {
|
|
576
|
+
return { deadline: new Date(Date.now() + this.metaTimeoutMs) }
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* Per-call metadata. Beyond the user's headers this carries REAPI's
|
|
581
|
+
* `RequestMetadata` in the well-known binary header, which is how a server
|
|
582
|
+
* groups the dozens of CAS/AC calls an action makes into one build in its
|
|
583
|
+
* UI. Omitting it is legal and makes vx invisible in every REAPI server's
|
|
584
|
+
* dashboard, which is the whole reason the field exists.
|
|
585
|
+
*/
|
|
586
|
+
private meta(): grpc.Metadata {
|
|
587
|
+
const m = new grpc.Metadata()
|
|
588
|
+
for (const [k, v] of Object.entries(this.headers)) m.set(k, v)
|
|
589
|
+
m.set(
|
|
590
|
+
'build.bazel.remote.execution.v2.requestmetadata-bin',
|
|
591
|
+
Buffer.from(this.requestMetadata()),
|
|
592
|
+
)
|
|
593
|
+
return m
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/**
|
|
597
|
+
* `RequestMetadata { tool_details = 1, action_id = 2, tool_invocation_id = 3,
|
|
598
|
+
* correlated_invocations_id = 4, action_mnemonic = 6 }`
|
|
599
|
+
* with `ToolDetails { tool_name = 1, tool_version = 2 }`.
|
|
600
|
+
*/
|
|
601
|
+
private requestMetadata(): Uint8Array {
|
|
602
|
+
const str = (field: number, v: string): Uint8Array => {
|
|
603
|
+
if (v === '') return new Uint8Array()
|
|
604
|
+
const bytes = new TextEncoder().encode(v)
|
|
605
|
+
return concat([varintBytes((field << 3) | 2), varintBytes(bytes.length), bytes])
|
|
606
|
+
}
|
|
607
|
+
const tool = concat([str(1, this.toolName), str(2, this.toolVersion)])
|
|
608
|
+
const toolField =
|
|
609
|
+
tool.length === 0
|
|
610
|
+
? new Uint8Array()
|
|
611
|
+
: concat([varintBytes((1 << 3) | 2), varintBytes(tool.length), tool])
|
|
612
|
+
return concat([
|
|
613
|
+
toolField,
|
|
614
|
+
str(2, this.actionId),
|
|
615
|
+
str(3, this.toolInvocationId),
|
|
616
|
+
str(4, this.correlatedInvocationsId),
|
|
617
|
+
])
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* `GetCapabilities`. `execEnabled` is what decides whether this server can
|
|
622
|
+
* take an `executor` at all — a cache-only deployment (bazel-remote)
|
|
623
|
+
* advertises no execution capability, and offering it work would hang a run
|
|
624
|
+
* on a server that will never answer.
|
|
625
|
+
*/
|
|
626
|
+
async capabilities(): Promise<ServerCapabilities> {
|
|
627
|
+
const res = await this.unary<{
|
|
628
|
+
cache_capabilities?: {
|
|
629
|
+
digest_functions?: string[]
|
|
630
|
+
max_batch_total_size_bytes?: string
|
|
631
|
+
action_cache_update_capabilities?: { update_enabled?: boolean }
|
|
632
|
+
supported_compressors?: string[]
|
|
633
|
+
supported_batch_update_compressors?: string[]
|
|
634
|
+
symlink_absolute_path_strategy?: string
|
|
635
|
+
split_blob_support?: boolean
|
|
636
|
+
splice_blob_support?: boolean
|
|
637
|
+
}
|
|
638
|
+
execution_capabilities?: { exec_enabled?: boolean; digest_function?: string }
|
|
639
|
+
}>(
|
|
640
|
+
this.svc.caps,
|
|
641
|
+
'getCapabilities',
|
|
642
|
+
{ instance_name: this.instance },
|
|
643
|
+
this.meta(),
|
|
644
|
+
this.boundedMeta(),
|
|
645
|
+
)
|
|
646
|
+
const cc = res.cache_capabilities
|
|
647
|
+
return {
|
|
648
|
+
digestFunctions: cc?.digest_functions ?? [],
|
|
649
|
+
// CLAMPED, not trusted. `max_batch_total_size_bytes` is what the
|
|
650
|
+
// server's CAS logic will accept in one Batch call; it says nothing
|
|
651
|
+
// about what its gRPC layer will carry, and grpc's default caps a
|
|
652
|
+
// single message at 4 MiB. Buildbarn advertises its configured message
|
|
653
|
+
// size here, so a generous config produced batches it then refused —
|
|
654
|
+
// `batchUpdateBlobs` rejected at 4 356 859 vs 4 194 304. Anything
|
|
655
|
+
// larger than the safe bound goes by ByteStream anyway, which chunks.
|
|
656
|
+
maxBatchBytes: this.rememberBatchBytes(Number(cc?.max_batch_total_size_bytes ?? 0)),
|
|
657
|
+
acUpdateEnabled: cc?.action_cache_update_capabilities?.update_enabled === true,
|
|
658
|
+
execEnabled: res.execution_capabilities?.exec_enabled === true,
|
|
659
|
+
supportedCompressors: cc?.supported_compressors ?? [],
|
|
660
|
+
supportedBatchCompressors: cc?.supported_batch_update_compressors ?? [],
|
|
661
|
+
symlinkAbsolutePathStrategy: cc?.symlink_absolute_path_strategy ?? 'UNKNOWN',
|
|
662
|
+
splitBlobSupport: cc?.split_blob_support === true,
|
|
663
|
+
spliceBlobSupport: cc?.splice_blob_support === true,
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/**
|
|
668
|
+
* Negotiate once against the server's advertised capabilities: the digest
|
|
669
|
+
* function to hash with, and whether blobs may ride compressed. Mixing
|
|
670
|
+
* digest functions within an action is invalid, so this is a per-client
|
|
671
|
+
* decision, not a per-call one.
|
|
672
|
+
*/
|
|
673
|
+
async negotiate(prefer?: {
|
|
674
|
+
digestFunction?: DigestFunctionName
|
|
675
|
+
compression?: boolean
|
|
676
|
+
}): Promise<void> {
|
|
677
|
+
const caps = await this.capabilities()
|
|
678
|
+
const wanted = prefer?.digestFunction
|
|
679
|
+
if (wanted !== undefined) {
|
|
680
|
+
if (!caps.digestFunctions.includes(wanted)) {
|
|
681
|
+
throw new Error(
|
|
682
|
+
`@vzn/vx-reapi: server does not support digest function ${wanted} (has ${caps.digestFunctions.join(', ')})`,
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
if (!canDigest(wanted))
|
|
686
|
+
throw new Error(`@vzn/vx-reapi: this runtime cannot compute ${wanted}`)
|
|
687
|
+
this.digestFunction = wanted
|
|
688
|
+
} else {
|
|
689
|
+
// Default stays SHA256 even when the server advertises stronger
|
|
690
|
+
// functions: the Merkle/action encoders digest with the SAME function
|
|
691
|
+
// as every blob upload, and auto-upgrading here while a caller still
|
|
692
|
+
// hashes trees with sha256 would mix functions inside one action —
|
|
693
|
+
// which servers reject at best and mis-address at worst. Opting into
|
|
694
|
+
// another function is `negotiate({ digestFunction: 'SHA512' })`, a
|
|
695
|
+
// caller-level decision made where the tree hashing can follow it.
|
|
696
|
+
this.digestFunction = 'SHA256'
|
|
697
|
+
}
|
|
698
|
+
this.compression =
|
|
699
|
+
prefer?.compression !== false &&
|
|
700
|
+
caps.supportedCompressors.includes('ZSTD') &&
|
|
701
|
+
typeof Bun.zstdCompressSync === 'function'
|
|
702
|
+
this.batchCompression = this.compression && caps.supportedBatchCompressors.includes('ZSTD')
|
|
703
|
+
this.negotiated = caps
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
/** The negotiated digest function; SHA256 until `negotiate()` says otherwise. */
|
|
707
|
+
get digest(): DigestFunctionName {
|
|
708
|
+
return this.digestFunction
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Whether ByteStream transfers will be zstd-compressed. */
|
|
712
|
+
get compressionEnabled(): boolean {
|
|
713
|
+
return this.compression
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Digest a payload under the negotiated function. */
|
|
717
|
+
digestOf(data: Uint8Array): Digest {
|
|
718
|
+
return digestWith(this.digestFunction, data)
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* `QueryWriteStatus` — how much of a resource the server already holds, so
|
|
723
|
+
* an interrupted upload resumes instead of restarting. Returns null when the
|
|
724
|
+
* server has no record of it (a fresh upload).
|
|
725
|
+
*/
|
|
726
|
+
async queryWriteStatus(
|
|
727
|
+
resourceName: string,
|
|
728
|
+
): Promise<{ committedSize: number; complete: boolean } | null> {
|
|
729
|
+
try {
|
|
730
|
+
const res = await this.unary<{ committed_size?: string; complete?: boolean }>(
|
|
731
|
+
this.svc.bs,
|
|
732
|
+
'queryWriteStatus',
|
|
733
|
+
{ resource_name: resourceName },
|
|
734
|
+
this.meta(),
|
|
735
|
+
this.boundedMeta(),
|
|
736
|
+
)
|
|
737
|
+
return { committedSize: Number(res.committed_size ?? 0), complete: res.complete === true }
|
|
738
|
+
} catch (err) {
|
|
739
|
+
if ((err as grpc.ServiceError).code === NOT_FOUND) return null
|
|
740
|
+
throw err
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Upload many small blobs in ONE round trip. The server caps the total
|
|
746
|
+
* (`max_batch_total_size_bytes`, 0 = unspecified → assume the 4 MB gRPC
|
|
747
|
+
* default), so callers must partition; `uploadBlobs` does that and falls
|
|
748
|
+
* back to ByteStream for anything too large on its own.
|
|
749
|
+
*/
|
|
750
|
+
async batchUpdateBlobs(
|
|
751
|
+
blobs: ReadonlyArray<{ digest: Digest; data: Uint8Array }>,
|
|
752
|
+
): Promise<void> {
|
|
753
|
+
if (blobs.length === 0) return
|
|
754
|
+
const res = await this.unary<{
|
|
755
|
+
responses?: Array<{ digest: Digest; status?: { code?: number; message?: string } }>
|
|
756
|
+
}>(
|
|
757
|
+
this.svc.cas,
|
|
758
|
+
'batchUpdateBlobs',
|
|
759
|
+
{
|
|
760
|
+
instance_name: this.instance,
|
|
761
|
+
requests: blobs.map((b) => ({
|
|
762
|
+
digest: b.digest,
|
|
763
|
+
data: this.batchCompression ? Bun.zstdCompressSync(b.data) : b.data,
|
|
764
|
+
...(this.batchCompression ? { compressor: 'ZSTD' } : {}),
|
|
765
|
+
})),
|
|
766
|
+
...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
|
|
767
|
+
},
|
|
768
|
+
this.meta(),
|
|
769
|
+
this.bounded(),
|
|
770
|
+
)
|
|
771
|
+
// A batch call succeeds at the RPC layer while individual blobs fail; a
|
|
772
|
+
// silently dropped input blob would surface later as an unexplained
|
|
773
|
+
// remote execution failure, so surface it here.
|
|
774
|
+
for (const r of res.responses ?? []) {
|
|
775
|
+
const code = r.status?.code ?? 0
|
|
776
|
+
if (code !== 0) {
|
|
777
|
+
throw new Error(
|
|
778
|
+
`reapi: BatchUpdateBlobs rejected ${r.digest?.hash}: code ${code} ${r.status?.message ?? ''}`,
|
|
779
|
+
)
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
/**
|
|
785
|
+
* Fetch many small blobs, partitioned to the server's batch budget so one
|
|
786
|
+
* call can never exceed the message cap. Missing entries are omitted. When
|
|
787
|
+
* compression was negotiated the request declares ZSTD acceptable and each
|
|
788
|
+
* response is decompressed per its OWN `compressor` field — a server is
|
|
789
|
+
* free to answer some entries compressed and others not.
|
|
790
|
+
*/
|
|
791
|
+
async batchReadBlobs(
|
|
792
|
+
digests: readonly Digest[],
|
|
793
|
+
maxBatchBytes = 0,
|
|
794
|
+
): Promise<Map<string, Uint8Array>> {
|
|
795
|
+
const out = new Map<string, Uint8Array>()
|
|
796
|
+
if (digests.length === 0) return out
|
|
797
|
+
const budget =
|
|
798
|
+
maxBatchBytes > 0
|
|
799
|
+
? Math.min(maxBatchBytes, SAFE_BATCH_BYTES)
|
|
800
|
+
: this.negotiatedBatchBytes || SAFE_BATCH_BYTES
|
|
801
|
+
let group: Digest[] = []
|
|
802
|
+
let grouped = 0
|
|
803
|
+
const flush = async (): Promise<void> => {
|
|
804
|
+
if (group.length === 0) return
|
|
805
|
+
const res = await this.unary<{
|
|
806
|
+
responses?: Array<{
|
|
807
|
+
digest: Digest
|
|
808
|
+
data?: Uint8Array
|
|
809
|
+
compressor?: string | number
|
|
810
|
+
status?: { code?: number }
|
|
811
|
+
}>
|
|
812
|
+
}>(
|
|
813
|
+
this.svc.cas,
|
|
814
|
+
'batchReadBlobs',
|
|
815
|
+
{
|
|
816
|
+
instance_name: this.instance,
|
|
817
|
+
digests: group,
|
|
818
|
+
...(this.compression ? { acceptable_compressors: ['ZSTD'] } : {}),
|
|
819
|
+
...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
|
|
820
|
+
},
|
|
821
|
+
this.meta(),
|
|
822
|
+
this.bounded(),
|
|
823
|
+
)
|
|
824
|
+
// Each entry is held to the digest ASKED for: the response's own
|
|
825
|
+
// digest is the server's word, and a bound read from it bounds nothing.
|
|
826
|
+
const asked = new Map(group.map((d) => [d.hash, d]))
|
|
827
|
+
for (const r of res.responses ?? []) {
|
|
828
|
+
if ((r.status?.code ?? 0) !== 0 || r.data === undefined) continue
|
|
829
|
+
const digest = asked.get(r.digest?.hash)
|
|
830
|
+
if (digest === undefined) continue
|
|
831
|
+
const zstd = r.compressor === 'ZSTD' || r.compressor === 1
|
|
832
|
+
const body = zstd ? await unzstdBounded(r.data, digest) : r.data
|
|
833
|
+
assertBlobIntegrity(body, digest, this.digestFunction)
|
|
834
|
+
out.set(digest.hash, body)
|
|
835
|
+
}
|
|
836
|
+
group = []
|
|
837
|
+
grouped = 0
|
|
838
|
+
}
|
|
839
|
+
for (const d of digests) {
|
|
840
|
+
// Same encoding charge as the upload side: the RESPONSE carries a
|
|
841
|
+
// digest and framing per blob on top of the bytes, so counting payload
|
|
842
|
+
// alone overshoots the server's ceiling — observed as
|
|
843
|
+
// `Attempted to read a total of at least 2 291 516 bytes, while a
|
|
844
|
+
// maximum of 2 097 152 bytes is permitted`.
|
|
845
|
+
const cost = d.size_bytes + BATCH_ENTRY_OVERHEAD
|
|
846
|
+
// A blob too large for ANY batch has to go by ByteStream, which chunks.
|
|
847
|
+
// The upload side always did this; the read side did not, so one large
|
|
848
|
+
// blob was put in a group of its own and the request still exceeded the
|
|
849
|
+
// ceiling no matter how the rest were grouped.
|
|
850
|
+
if (cost > budget) {
|
|
851
|
+
const bytes = await this.readBlob(d)
|
|
852
|
+
if (bytes !== null) out.set(d.hash, bytes)
|
|
853
|
+
continue
|
|
854
|
+
}
|
|
855
|
+
if (grouped + cost > budget && group.length > 0) await flush()
|
|
856
|
+
group.push(d)
|
|
857
|
+
grouped += cost
|
|
858
|
+
}
|
|
859
|
+
await flush()
|
|
860
|
+
return out
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
/**
|
|
864
|
+
* Upload blobs, choosing the transport per blob: batched while they fit the
|
|
865
|
+
* server's batch budget, ByteStream for the rest. Only what
|
|
866
|
+
* `FindMissingBlobs` reports absent is sent at all.
|
|
867
|
+
*/
|
|
868
|
+
async uploadBlobs(
|
|
869
|
+
blobs: ReadonlyArray<{ digest: Digest; data: Uint8Array }>,
|
|
870
|
+
maxBatchBytes = 0,
|
|
871
|
+
): Promise<void> {
|
|
872
|
+
if (blobs.length === 0) return
|
|
873
|
+
const missing = new Set(
|
|
874
|
+
(await this.findMissingBlobs(blobs.map((b) => b.digest))).map((d) => d.hash),
|
|
875
|
+
)
|
|
876
|
+
const todo = blobs.filter((b) => missing.has(b.digest.hash))
|
|
877
|
+
// 0 means the server did not say; the gRPC default max message is 4 MB and
|
|
878
|
+
// the request carries framing on top, so leave headroom rather than
|
|
879
|
+
// discovering the limit as a RESOURCE_EXHAUSTED mid-run.
|
|
880
|
+
const budget =
|
|
881
|
+
maxBatchBytes > 0
|
|
882
|
+
? Math.min(maxBatchBytes, SAFE_BATCH_BYTES)
|
|
883
|
+
: this.negotiatedBatchBytes || SAFE_BATCH_BYTES
|
|
884
|
+
const jobs: Array<() => Promise<void>> = []
|
|
885
|
+
let batch: Array<{ digest: Digest; data: Uint8Array }> = []
|
|
886
|
+
let batched = 0
|
|
887
|
+
for (const b of todo) {
|
|
888
|
+
// Charge the ENCODING, not just the payload. Each entry also carries a
|
|
889
|
+
// 64-character hash, a size, and several layers of field tags and
|
|
890
|
+
// length prefixes; summing raw sizes alone under-counts by ~100 bytes
|
|
891
|
+
// per blob, which for a few thousand small files is hundreds of
|
|
892
|
+
// kilobytes — enough to push a batch that "fits" over the wire limit
|
|
893
|
+
// and have the server refuse it (observed: 4 356 859 sent against a
|
|
894
|
+
// 4 194 304 ceiling).
|
|
895
|
+
const cost = b.digest.size_bytes + BATCH_ENTRY_OVERHEAD
|
|
896
|
+
if (cost > budget) {
|
|
897
|
+
jobs.push(() => this.writeBlob(b.digest, b.data))
|
|
898
|
+
continue
|
|
899
|
+
}
|
|
900
|
+
if (batched + cost > budget) {
|
|
901
|
+
const full = batch
|
|
902
|
+
jobs.push(() => this.batchUpdateBlobs(full))
|
|
903
|
+
batch = []
|
|
904
|
+
batched = 0
|
|
905
|
+
}
|
|
906
|
+
batch.push(b)
|
|
907
|
+
batched += cost
|
|
908
|
+
}
|
|
909
|
+
if (batch.length > 0) jobs.push(() => this.batchUpdateBlobs(batch))
|
|
910
|
+
// UPLOAD_CONCURRENCY at once, not one after another: a grpc-go server
|
|
911
|
+
// grows its receive window from what it measures arriving, and one
|
|
912
|
+
// stream at a time held four 8 MB writes to 10.2 s through 15 ms each
|
|
913
|
+
// way, 0.49 s at once (F-34). The first failure stops the queue.
|
|
914
|
+
let next = 0
|
|
915
|
+
let failed = false
|
|
916
|
+
const worker = async (): Promise<void> => {
|
|
917
|
+
while (!failed && next < jobs.length) {
|
|
918
|
+
const job = jobs[next++]!
|
|
919
|
+
await job().catch((err: unknown) => {
|
|
920
|
+
failed = true
|
|
921
|
+
throw err
|
|
922
|
+
})
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
await Promise.all(Array.from({ length: Math.min(UPLOAD_CONCURRENCY, jobs.length) }, worker))
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
private rememberBatchBytes(advertised: number): number {
|
|
929
|
+
this.negotiatedBatchBytes = Math.min(advertised || SAFE_BATCH_BYTES, SAFE_BATCH_BYTES)
|
|
930
|
+
return this.negotiatedBatchBytes
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** The subset of `digests` the server does NOT have — the upload-minimality primitive. */
|
|
934
|
+
async findMissingBlobs(digests: readonly Digest[]): Promise<Digest[]> {
|
|
935
|
+
if (digests.length === 0) return []
|
|
936
|
+
// One request per SAFE_BATCH_BYTES of digests: a server's receive limit
|
|
937
|
+
// (4 MiB by default) refused an input tree of 70 000 files in one
|
|
938
|
+
// message, RESOURCE_EXHAUSTED on every attempt.
|
|
939
|
+
const groups: Digest[][] = [[]]
|
|
940
|
+
let size = 0
|
|
941
|
+
for (const d of digests) {
|
|
942
|
+
const cost = d.hash.length + FIND_MISSING_ENTRY_OVERHEAD
|
|
943
|
+
if (size + cost > SAFE_BATCH_BYTES && groups.at(-1)!.length > 0) {
|
|
944
|
+
groups.push([])
|
|
945
|
+
size = 0
|
|
946
|
+
}
|
|
947
|
+
groups.at(-1)!.push(d)
|
|
948
|
+
size += cost
|
|
949
|
+
}
|
|
950
|
+
const answers = await Promise.all(
|
|
951
|
+
groups.map((group) =>
|
|
952
|
+
this.unary<{ missing_blob_digests?: Digest[] }>(
|
|
953
|
+
this.svc.cas,
|
|
954
|
+
'findMissingBlobs',
|
|
955
|
+
{ instance_name: this.instance, blob_digests: group },
|
|
956
|
+
this.meta(),
|
|
957
|
+
this.boundedMeta(),
|
|
958
|
+
),
|
|
959
|
+
),
|
|
960
|
+
)
|
|
961
|
+
return answers.flatMap((res) => res.missing_blob_digests ?? [])
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/**
|
|
965
|
+
* `GetActionResult`; `null` on NOT_FOUND — a miss is not an error.
|
|
966
|
+
* `inline` asks the server to return stdout and the named output files in
|
|
967
|
+
* the reply. The spec lets it decline (and requires it past the message
|
|
968
|
+
* limit), so a caller reads `stdout_raw` / `contents` when present and
|
|
969
|
+
* fetches otherwise.
|
|
970
|
+
*/
|
|
971
|
+
async getActionResult(
|
|
972
|
+
action: Digest,
|
|
973
|
+
inline?: { stdout: boolean; files: readonly string[] },
|
|
974
|
+
): Promise<ActionResult | null> {
|
|
975
|
+
try {
|
|
976
|
+
return await this.unary<ActionResult>(
|
|
977
|
+
this.svc.ac,
|
|
978
|
+
'getActionResult',
|
|
979
|
+
{
|
|
980
|
+
instance_name: this.instance,
|
|
981
|
+
action_digest: action,
|
|
982
|
+
...(inline === undefined
|
|
983
|
+
? {}
|
|
984
|
+
: { inline_stdout: inline.stdout, inline_output_files: inline.files }),
|
|
985
|
+
},
|
|
986
|
+
this.meta(),
|
|
987
|
+
this.boundedMeta(),
|
|
988
|
+
)
|
|
989
|
+
} catch (err) {
|
|
990
|
+
if ((err as grpc.ServiceError).code === NOT_FOUND) return null
|
|
991
|
+
throw err
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
async updateActionResult(action: Digest, result: ActionResult): Promise<void> {
|
|
996
|
+
await this.unary(
|
|
997
|
+
this.svc.ac,
|
|
998
|
+
'updateActionResult',
|
|
999
|
+
{ instance_name: this.instance, action_digest: action, action_result: result },
|
|
1000
|
+
this.meta(),
|
|
1001
|
+
this.boundedMeta(),
|
|
1002
|
+
)
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* Upload a blob via ByteStream, chunked at `chunkBytes`, RESUMING an
|
|
1007
|
+
* interrupted identity upload from the server's committed offset
|
|
1008
|
+
* (`QueryWriteStatus`) instead of restarting. A compressed upload restarts
|
|
1009
|
+
* under a fresh resource name — compressed write offsets count compressed
|
|
1010
|
+
* bytes, and mid-stream resumption of a zstd frame is not a thing a server
|
|
1011
|
+
* can honour.
|
|
1012
|
+
*
|
|
1013
|
+
* A `Blob` past the batch limit is sent from its own stream and never held
|
|
1014
|
+
* whole: a file-backed one (the vx artifact) is read from disk message by
|
|
1015
|
+
* message as the channel drains. It goes identity-encoded — re-compressing
|
|
1016
|
+
* it would need the bytes in hand, and the one Blob this carries is a
|
|
1017
|
+
* zstd artifact already. A Blob under the limit is small enough to read.
|
|
1018
|
+
*/
|
|
1019
|
+
async writeBlob(digest: Digest, source: Uint8Array | Blob): Promise<void> {
|
|
1020
|
+
const streamed =
|
|
1021
|
+
source instanceof Blob && source.size > (this.negotiatedBatchBytes || SAFE_BATCH_BYTES)
|
|
1022
|
+
const body = source instanceof Blob && !streamed ? await source.bytes() : source
|
|
1023
|
+
const compressed = this.compression && body instanceof Uint8Array
|
|
1024
|
+
// REAPI carries compression in the RESOURCE NAME:
|
|
1025
|
+
// uploads/{uuid}/compressed-blobs/{compressor}/{hash}/{uncompressed_size}
|
|
1026
|
+
// The digest and size stay those of the UNCOMPRESSED bytes — the server
|
|
1027
|
+
// decompresses and verifies against them — so only the wire payload
|
|
1028
|
+
// changes. vx artifacts are already-compressed tarballs, but source input
|
|
1029
|
+
// trees are not, and those are the bulk of a remote-execution upload.
|
|
1030
|
+
let chunk = this.chunkBytes
|
|
1031
|
+
for (let attempt = 0; ; attempt++) {
|
|
1032
|
+
const wire = compressed ? Bun.zstdCompressSync(body) : body
|
|
1033
|
+
const wireBytes = wire instanceof Blob ? wire.size : wire.length
|
|
1034
|
+
const segment = compressed
|
|
1035
|
+
? `compressed-blobs/zstd/${digest.hash}/${digest.size_bytes}`
|
|
1036
|
+
: `blobs/${digest.hash}/${digest.size_bytes}`
|
|
1037
|
+
const resource = `${this.instance ? `${this.instance}/` : ''}uploads/${crypto.randomUUID()}/${segment}`
|
|
1038
|
+
const bounds = this.bounded()
|
|
1039
|
+
try {
|
|
1040
|
+
await this.writeResource(resource, wire, digest, 0, chunk, compressed, bounds)
|
|
1041
|
+
return
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
const code = (err as grpc.ServiceError).code
|
|
1044
|
+
// ADAPTIVE DOWNGRADE. The Bun node:http2 flow-control defect is a
|
|
1045
|
+
// RACE, not a boundary: chunk sizes above the RFC default initial
|
|
1046
|
+
// window (65535) usually work and occasionally wedge — observed as a
|
|
1047
|
+
// one-off DEADLINE_EXCEEDED at 128 KB on the same Bun that passed it
|
|
1048
|
+
// hundreds of times. A deadline on a multi-message write therefore
|
|
1049
|
+
// retries ONCE at SAFE_CHUNK_BYTES, the size never observed hanging,
|
|
1050
|
+
// instead of failing the task over a lost coin-flip.
|
|
1051
|
+
// Only a MULTI-message write can be the chunk race — a body that fit
|
|
1052
|
+
// one message never exercised flow control between messages, so its
|
|
1053
|
+
// deadline is the server's problem and re-chunking cannot help.
|
|
1054
|
+
// The deadline has TWO spellings: this client's own timer
|
|
1055
|
+
// (DEADLINE_EXCEEDED), or the server's. A grpc-go server (bazel-remote)
|
|
1056
|
+
// arms a timer at the call's `grpc-timeout` and ends the stream with
|
|
1057
|
+
// RST_STREAM(CANCEL), which grpc-js reports as `CANCELLED: Call
|
|
1058
|
+
// cancelled`; whichever lands first names the error, and on a stalled
|
|
1059
|
+
// write the server's usually does (item 1017: 5 of 6 against a grpc-go
|
|
1060
|
+
// server that stopped reading). This client never cancels a write it
|
|
1061
|
+
// reports — its own `cancel()` rethrows the error that caused it — so
|
|
1062
|
+
// a CANCELLED at or past this attempt's deadline IS the deadline. The
|
|
1063
|
+
// header carries `ceil(deadline - now)`, so the server's timer cannot
|
|
1064
|
+
// fire before ours: no slack. An earlier CANCELLED is the server's own.
|
|
1065
|
+
if (
|
|
1066
|
+
(code === grpc.status.DEADLINE_EXCEEDED ||
|
|
1067
|
+
(code === grpc.status.CANCELLED && Date.now() >= deadlineOf(bounds))) &&
|
|
1068
|
+
chunk > SAFE_CHUNK_BYTES &&
|
|
1069
|
+
wireBytes > chunk
|
|
1070
|
+
) {
|
|
1071
|
+
this.onWarn(
|
|
1072
|
+
`vx/reapi: chunked write of ${digest.hash.slice(0, 12)} hit the ${chunk}-byte chunk stall (Bun http2 flow control); retrying at ${SAFE_CHUNK_BYTES}`,
|
|
1073
|
+
)
|
|
1074
|
+
chunk = SAFE_CHUNK_BYTES
|
|
1075
|
+
continue
|
|
1076
|
+
}
|
|
1077
|
+
const delay = this.retryDelay(attempt, code)
|
|
1078
|
+
if (delay === undefined) throw err
|
|
1079
|
+
if (!compressed) {
|
|
1080
|
+
// Identity path: ask how far the server got and resume there.
|
|
1081
|
+
const status = await this.queryWriteStatus(resource).catch(() => null)
|
|
1082
|
+
if (status?.complete === true) return
|
|
1083
|
+
if (status !== null && status.committedSize > 0 && status.committedSize < wireBytes) {
|
|
1084
|
+
try {
|
|
1085
|
+
await this.writeResource(
|
|
1086
|
+
resource,
|
|
1087
|
+
wire,
|
|
1088
|
+
digest,
|
|
1089
|
+
status.committedSize,
|
|
1090
|
+
chunk,
|
|
1091
|
+
false,
|
|
1092
|
+
this.bounded(),
|
|
1093
|
+
)
|
|
1094
|
+
return
|
|
1095
|
+
} catch {
|
|
1096
|
+
// fall through to a fresh attempt
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
}
|
|
1100
|
+
await Bun.sleep(delay)
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* One ByteStream `Write` from `startOffset`. Each message waits for the
|
|
1107
|
+
* channel to drain before the next is read, so a streamed `Blob` holds at
|
|
1108
|
+
* most a few messages in memory however large it is.
|
|
1109
|
+
*/
|
|
1110
|
+
private async writeResource(
|
|
1111
|
+
resource: string,
|
|
1112
|
+
body: Uint8Array | Blob,
|
|
1113
|
+
digest: Digest,
|
|
1114
|
+
startOffset: number,
|
|
1115
|
+
chunkBytes: number,
|
|
1116
|
+
compressed: boolean,
|
|
1117
|
+
bounds: grpc.CallOptions,
|
|
1118
|
+
): Promise<void> {
|
|
1119
|
+
const total = body instanceof Blob ? body.size : body.length
|
|
1120
|
+
let stream!: {
|
|
1121
|
+
write(m: unknown): boolean
|
|
1122
|
+
end(): void
|
|
1123
|
+
cancel(): void
|
|
1124
|
+
on(e: string, f: (x: unknown) => void): void
|
|
1125
|
+
once(e: string, f: () => void): void
|
|
1126
|
+
}
|
|
1127
|
+
const done = new Promise<void>((resolve, reject) => {
|
|
1128
|
+
stream = (this.svc.bs as unknown as Record<string, Function>)['write']!(
|
|
1129
|
+
this.meta(),
|
|
1130
|
+
bounds,
|
|
1131
|
+
(err: grpc.ServiceError | null, res: { committed_size?: string }) => {
|
|
1132
|
+
if (err) return reject(err)
|
|
1133
|
+
const committed = Number(res.committed_size ?? 0)
|
|
1134
|
+
// For a compressed upload the server reports the COMPRESSED byte
|
|
1135
|
+
// count it accepted, so the equality only holds on the identity
|
|
1136
|
+
// path; on the compressed path a non-zero commit is the signal.
|
|
1137
|
+
// A server that already holds the blob ends a compressed write
|
|
1138
|
+
// with `-1` (the spec's word for it); read as a short write, it
|
|
1139
|
+
// failed the upload of an input two actions share (F-44).
|
|
1140
|
+
const expected = compressed ? total : digest.size_bytes
|
|
1141
|
+
if (committed !== expected && !(compressed && (committed > 0 || committed === -1))) {
|
|
1142
|
+
return reject(
|
|
1143
|
+
new Error(`reapi: short write for ${digest.hash}: ${committed}/${expected}`),
|
|
1144
|
+
)
|
|
1145
|
+
}
|
|
1146
|
+
resolve()
|
|
1147
|
+
},
|
|
1148
|
+
)
|
|
1149
|
+
stream.on('error', reject)
|
|
1150
|
+
})
|
|
1151
|
+
// Raced against every drain wait and awaited at the end; a failure that
|
|
1152
|
+
// lands between the two is not an unhandled rejection.
|
|
1153
|
+
done.catch(() => undefined)
|
|
1154
|
+
let offset = startOffset
|
|
1155
|
+
let first = true
|
|
1156
|
+
try {
|
|
1157
|
+
for await (const data of messagesOf(body, startOffset, chunkBytes)) {
|
|
1158
|
+
const end = offset + data.length
|
|
1159
|
+
const flowing = stream.write({
|
|
1160
|
+
resource_name: first ? resource : '',
|
|
1161
|
+
write_offset: offset,
|
|
1162
|
+
finish_write: end === total,
|
|
1163
|
+
data,
|
|
1164
|
+
})
|
|
1165
|
+
first = false
|
|
1166
|
+
offset = end
|
|
1167
|
+
if (!flowing) await Promise.race([new Promise<void>((r) => stream.once('drain', r)), done])
|
|
1168
|
+
}
|
|
1169
|
+
// Empty blobs still need one message so the server sees finish_write.
|
|
1170
|
+
if (first) {
|
|
1171
|
+
stream.write({
|
|
1172
|
+
resource_name: resource,
|
|
1173
|
+
write_offset: offset,
|
|
1174
|
+
finish_write: true,
|
|
1175
|
+
data: new Uint8Array(0),
|
|
1176
|
+
})
|
|
1177
|
+
}
|
|
1178
|
+
} catch (err) {
|
|
1179
|
+
// The source failed mid-write (a pruned artifact) or the call did:
|
|
1180
|
+
// either way the half-sent write must not be left open on the channel.
|
|
1181
|
+
stream.cancel()
|
|
1182
|
+
throw err
|
|
1183
|
+
}
|
|
1184
|
+
stream.end()
|
|
1185
|
+
await done
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/**
|
|
1189
|
+
* Read a blob via ByteStream; `null` on NOT_FOUND. A transient status is
|
|
1190
|
+
* retried as a unary call's is (the read is whole and idempotent): one
|
|
1191
|
+
* UNAVAILABLE reading a finished action's stdout or outputs failed the
|
|
1192
|
+
* task after the action succeeded (item 919).
|
|
1193
|
+
*/
|
|
1194
|
+
async readBlob(digest: Digest): Promise<Uint8Array | null> {
|
|
1195
|
+
for (let attempt = 0; ; attempt++) {
|
|
1196
|
+
try {
|
|
1197
|
+
const blob = await this.readBlobOnce(digest)
|
|
1198
|
+
this.unreachable = false
|
|
1199
|
+
return blob
|
|
1200
|
+
} catch (err) {
|
|
1201
|
+
const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
|
|
1202
|
+
if (delay === undefined) throw err
|
|
1203
|
+
await Bun.sleep(delay)
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
private readBlobOnce(digest: Digest): Promise<Uint8Array | null> {
|
|
1209
|
+
const segment = this.compression
|
|
1210
|
+
? `compressed-blobs/zstd/${digest.hash}/${digest.size_bytes}`
|
|
1211
|
+
: `blobs/${digest.hash}/${digest.size_bytes}`
|
|
1212
|
+
const resource = `${this.instance ? `${this.instance}/` : ''}${segment}`
|
|
1213
|
+
const compressed = this.compression
|
|
1214
|
+
// Refused as the bytes pass the bound, not once the server stops: a
|
|
1215
|
+
// body that never ends held every chunk it sent (L-3).
|
|
1216
|
+
const bound = compressed ? wireBound(Number(digest.size_bytes)) : Number(digest.size_bytes)
|
|
1217
|
+
return new Promise((resolve, reject) => {
|
|
1218
|
+
const chunks: Uint8Array[] = []
|
|
1219
|
+
let received = 0
|
|
1220
|
+
let over = false
|
|
1221
|
+
const stream = (this.svc.bs as unknown as Record<string, Function>)['read']!(
|
|
1222
|
+
{ resource_name: resource, read_offset: 0, read_limit: 0 },
|
|
1223
|
+
this.meta(),
|
|
1224
|
+
this.bounded(),
|
|
1225
|
+
) as { on(e: string, f: (x: never) => void): void; cancel(): void }
|
|
1226
|
+
stream.on('data', (m: { data: Uint8Array }) => {
|
|
1227
|
+
if (over) return
|
|
1228
|
+
received += m.data.length
|
|
1229
|
+
if (received > bound) {
|
|
1230
|
+
over = true
|
|
1231
|
+
chunks.length = 0
|
|
1232
|
+
reject(overServed(digest))
|
|
1233
|
+
stream.cancel()
|
|
1234
|
+
return
|
|
1235
|
+
}
|
|
1236
|
+
chunks.push(m.data)
|
|
1237
|
+
})
|
|
1238
|
+
stream.on('error', (err: grpc.ServiceError) =>
|
|
1239
|
+
err.code === NOT_FOUND ? resolve(null) : reject(err),
|
|
1240
|
+
)
|
|
1241
|
+
stream.on('end', async () => {
|
|
1242
|
+
if (over) return
|
|
1243
|
+
const total = chunks.reduce((n, c) => n + c.length, 0)
|
|
1244
|
+
const out = new Uint8Array(total)
|
|
1245
|
+
let at = 0
|
|
1246
|
+
for (const c of chunks) {
|
|
1247
|
+
out.set(c, at)
|
|
1248
|
+
at += c.length
|
|
1249
|
+
}
|
|
1250
|
+
try {
|
|
1251
|
+
const body = compressed ? await unzstdBounded(out, digest) : out
|
|
1252
|
+
assertBlobIntegrity(body, digest, this.digestFunction)
|
|
1253
|
+
resolve(body)
|
|
1254
|
+
} catch (err) {
|
|
1255
|
+
reject(err)
|
|
1256
|
+
}
|
|
1257
|
+
})
|
|
1258
|
+
})
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
/**
|
|
1262
|
+
* `readBlob` as a stream: each ByteStream message is taken from the call
|
|
1263
|
+
* only when the reader asks for the next, so a blob of any size costs the
|
|
1264
|
+
* call's small read-ahead in memory. `null` on NOT_FOUND, known from the
|
|
1265
|
+
* first message before the stream is handed over. The digest is checked as
|
|
1266
|
+
* the bytes pass and a mismatch errors the stream at its end, so no reader
|
|
1267
|
+
* reaches the end of a blob that is not the one asked for.
|
|
1268
|
+
* Identity-encoded, as `writeBlob` streams: the blob this reads is a zstd
|
|
1269
|
+
* artifact already.
|
|
1270
|
+
*/
|
|
1271
|
+
async readBlobStream(digest: Digest): Promise<ReadableStream<Uint8Array> | null> {
|
|
1272
|
+
const resource = `${this.instance ? `${this.instance}/` : ''}blobs/${digest.hash}/${digest.size_bytes}`
|
|
1273
|
+
type Message = { data: Uint8Array }
|
|
1274
|
+
let call: AsyncIterable<Message> & { cancel(): void }
|
|
1275
|
+
let messages: AsyncIterator<Message>
|
|
1276
|
+
const open = (offset: number): void => {
|
|
1277
|
+
call = (this.svc.bs as unknown as Record<string, Function>)['read']!(
|
|
1278
|
+
{ resource_name: resource, read_offset: offset, read_limit: 0 },
|
|
1279
|
+
this.meta(),
|
|
1280
|
+
this.bounded(),
|
|
1281
|
+
) as AsyncIterable<Message> & { cancel(): void }
|
|
1282
|
+
messages = call[Symbol.asyncIterator]()
|
|
1283
|
+
}
|
|
1284
|
+
const hasher = hasherFor(this.digestFunction)
|
|
1285
|
+
let size = 0
|
|
1286
|
+
let attempt = 0
|
|
1287
|
+
// A transient status is retried as `readBlob`'s is, and not only before
|
|
1288
|
+
// the first message: a cut past it (a proxy's RST, a server's GOAWAY)
|
|
1289
|
+
// re-opens the Read at `read_offset` = the bytes the reader already has,
|
|
1290
|
+
// so the hash carries on over the same byte sequence. The budget is one
|
|
1291
|
+
// for the whole blob. Before the first message a NOT_FOUND is a miss.
|
|
1292
|
+
const take = async (): Promise<IteratorResult<Message>> => {
|
|
1293
|
+
for (;;) {
|
|
1294
|
+
try {
|
|
1295
|
+
return await messages.next()
|
|
1296
|
+
} catch (err) {
|
|
1297
|
+
const code = (err as grpc.ServiceError).code
|
|
1298
|
+
const delay = this.retryDelay(attempt++, code)
|
|
1299
|
+
if (delay === undefined) throw err
|
|
1300
|
+
await Bun.sleep(delay)
|
|
1301
|
+
open(size)
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
open(0)
|
|
1306
|
+
let next: IteratorResult<Message> | undefined
|
|
1307
|
+
try {
|
|
1308
|
+
next = await take()
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
if ((err as grpc.ServiceError).code === NOT_FOUND) return null
|
|
1311
|
+
throw err
|
|
1312
|
+
}
|
|
1313
|
+
return new ReadableStream<Uint8Array>({
|
|
1314
|
+
pull: async (controller) => {
|
|
1315
|
+
const got = next ?? (await take())
|
|
1316
|
+
next = undefined
|
|
1317
|
+
if (got.done) {
|
|
1318
|
+
assertServed(size, hasher && (() => hasher.digest('hex')), digest)
|
|
1319
|
+
controller.close()
|
|
1320
|
+
return
|
|
1321
|
+
}
|
|
1322
|
+
size += got.value.data.length
|
|
1323
|
+
if (size > Number(digest.size_bytes)) {
|
|
1324
|
+
call.cancel()
|
|
1325
|
+
throw overServed(digest)
|
|
1326
|
+
}
|
|
1327
|
+
hasher?.update(got.value.data)
|
|
1328
|
+
controller.enqueue(got.value.data)
|
|
1329
|
+
},
|
|
1330
|
+
cancel: () => {
|
|
1331
|
+
call.cancel()
|
|
1332
|
+
},
|
|
1333
|
+
})
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
/**
|
|
1337
|
+
* `Execute` — a SERVER-STREAMING call yielding `Operation`s until one is
|
|
1338
|
+
* `done`. Resolves with the terminal operation. If the stream drops
|
|
1339
|
+
* mid-flight with a transient status, or ends before the operation is
|
|
1340
|
+
* done, the call RE-ATTACHES to the same operation through
|
|
1341
|
+
* `WaitExecution` instead of re-running the action — that is exactly what
|
|
1342
|
+
* the RPC exists for.
|
|
1343
|
+
*
|
|
1344
|
+
* `skip_cache_lookup` is TRUE by design: vx has already decided this is a
|
|
1345
|
+
* miss (it owns the cache key and consulted its own layers), so letting the
|
|
1346
|
+
* server re-check its ActionCache would be a second, differently-keyed
|
|
1347
|
+
* cache deciding whether the user's task runs.
|
|
1348
|
+
*/
|
|
1349
|
+
async execute(
|
|
1350
|
+
actionDigest: Digest,
|
|
1351
|
+
opts: ExecuteOptions = {},
|
|
1352
|
+
signal?: AbortSignal,
|
|
1353
|
+
): Promise<Operation> {
|
|
1354
|
+
const req = {
|
|
1355
|
+
instance_name: this.instance,
|
|
1356
|
+
action_digest: actionDigest,
|
|
1357
|
+
skip_cache_lookup: opts.skipCacheLookup ?? true,
|
|
1358
|
+
// Ask the server to INLINE stdout/stderr in the ActionResult. Without
|
|
1359
|
+
// this every finished action costs two extra CAS round trips just to
|
|
1360
|
+
// read what it printed.
|
|
1361
|
+
inline_stdout: opts.inlineStdout ?? true,
|
|
1362
|
+
inline_stderr: opts.inlineStderr ?? true,
|
|
1363
|
+
...(opts.inlineOutputFiles === undefined
|
|
1364
|
+
? {}
|
|
1365
|
+
: { inline_output_files: opts.inlineOutputFiles }),
|
|
1366
|
+
...(opts.priority === undefined ? {} : { execution_policy: { priority: opts.priority } }),
|
|
1367
|
+
...(opts.resultsCachePriority === undefined
|
|
1368
|
+
? {}
|
|
1369
|
+
: { results_cache_policy: { priority: opts.resultsCachePriority } }),
|
|
1370
|
+
...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
|
|
1371
|
+
}
|
|
1372
|
+
let operationName = ''
|
|
1373
|
+
for (let attempt = 0; ; attempt++) {
|
|
1374
|
+
let failure: unknown
|
|
1375
|
+
try {
|
|
1376
|
+
const op =
|
|
1377
|
+
operationName === ''
|
|
1378
|
+
? await this.operationStream('execute', req, signal, opts.onStage, (n) => {
|
|
1379
|
+
operationName = n
|
|
1380
|
+
opts.onOperation?.(n)
|
|
1381
|
+
})
|
|
1382
|
+
: await this.operationStream(
|
|
1383
|
+
'waitExecution',
|
|
1384
|
+
{ name: operationName },
|
|
1385
|
+
signal,
|
|
1386
|
+
opts.onStage,
|
|
1387
|
+
)
|
|
1388
|
+
if (op.done) return op
|
|
1389
|
+
// A stream can END cleanly on an operation still QUEUED or EXECUTING
|
|
1390
|
+
// (a server or proxy cutting long streams); the operation lives on,
|
|
1391
|
+
// so it is re-attached as a dropped stream is, on the same budget.
|
|
1392
|
+
// An unnamed one cannot be re-attached, and a plain Error is not
|
|
1393
|
+
// retried.
|
|
1394
|
+
if (operationName === '') {
|
|
1395
|
+
throw new Error('reapi: execution stream closed before the operation finished')
|
|
1396
|
+
}
|
|
1397
|
+
failure = new Error(
|
|
1398
|
+
`reapi: execution stream for ${operationName} closed before the operation finished`,
|
|
1399
|
+
)
|
|
1400
|
+
} catch (err) {
|
|
1401
|
+
const code = (err as grpc.ServiceError).code
|
|
1402
|
+
// WaitExecution's NOT_FOUND: the server no longer knows the operation
|
|
1403
|
+
// (a restart lost it). Nothing is running to re-attach to, and the
|
|
1404
|
+
// action is the same bytes, so it is executed again, on the same
|
|
1405
|
+
// budget — as Bazel's executor does.
|
|
1406
|
+
if (code === NOT_FOUND && operationName !== '') operationName = ''
|
|
1407
|
+
else if (!isRetryable(code)) throw err
|
|
1408
|
+
failure = err
|
|
1409
|
+
}
|
|
1410
|
+
const delay = RETRY_DELAYS_MS[attempt]
|
|
1411
|
+
if (delay === undefined) throw failure
|
|
1412
|
+
await abortableSleep(delay, signal)
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
/**
|
|
1417
|
+
* `Operations.CancelOperation`: closing the Execute stream leaves a queued
|
|
1418
|
+
* action to the server, which may still run it. One attempt on the
|
|
1419
|
+
* control-plane deadline: a cancel is a courtesy, and a server without
|
|
1420
|
+
* the service answers UNIMPLEMENTED.
|
|
1421
|
+
*/
|
|
1422
|
+
cancelOperation(name: string): Promise<void> {
|
|
1423
|
+
return new Promise((resolve, reject) => {
|
|
1424
|
+
;(this.svc.ops as unknown as Record<string, Function>)['cancelOperation']!(
|
|
1425
|
+
{ name },
|
|
1426
|
+
this.meta(),
|
|
1427
|
+
this.boundedMeta(),
|
|
1428
|
+
(err: grpc.ServiceError | null) => (err ? reject(err) : resolve()),
|
|
1429
|
+
)
|
|
1430
|
+
})
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/** Re-attach to an in-flight operation after a disconnect. */
|
|
1434
|
+
waitExecution(
|
|
1435
|
+
operationName: string,
|
|
1436
|
+
signal?: AbortSignal,
|
|
1437
|
+
onStage?: (stage: string) => void,
|
|
1438
|
+
): Promise<Operation> {
|
|
1439
|
+
return this.operationStream('waitExecution', { name: operationName }, signal, onStage)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
private operationStream(
|
|
1443
|
+
method: string,
|
|
1444
|
+
req: unknown,
|
|
1445
|
+
signal?: AbortSignal,
|
|
1446
|
+
onStage?: (stage: string) => void,
|
|
1447
|
+
onName?: (name: string) => void,
|
|
1448
|
+
): Promise<Operation> {
|
|
1449
|
+
return new Promise((resolve, reject) => {
|
|
1450
|
+
// An aborted signal never fires `abort` again, and the stream has no
|
|
1451
|
+
// deadline of its own (time QUEUED is unbounded), so a listener added
|
|
1452
|
+
// after the abort would wait on a wedged server forever.
|
|
1453
|
+
if (signal?.aborted === true) return reject(executionAborted())
|
|
1454
|
+
const stream = (this.svc.exec as unknown as Record<string, Function>)[method]!(
|
|
1455
|
+
req,
|
|
1456
|
+
this.meta(),
|
|
1457
|
+
) as { on(e: string, f: (x: never) => void): void; cancel(): void }
|
|
1458
|
+
let last: Operation | undefined
|
|
1459
|
+
const onAbort = (): void => {
|
|
1460
|
+
stream.cancel()
|
|
1461
|
+
reject(executionAborted())
|
|
1462
|
+
}
|
|
1463
|
+
signal?.addEventListener('abort', onAbort, { once: true })
|
|
1464
|
+
stream.on('data', (op: Operation) => {
|
|
1465
|
+
last = op
|
|
1466
|
+
if (op.name !== '' && op.name !== undefined && onName !== undefined) onName(op.name)
|
|
1467
|
+
// ExecuteOperationMetadata carries the action's STAGE
|
|
1468
|
+
// (QUEUED / EXECUTING / COMPLETED). Surfacing it is the difference
|
|
1469
|
+
// between "vx is hung" and "the action is queued behind 40 others".
|
|
1470
|
+
if (onStage !== undefined && op.metadata?.value !== undefined) {
|
|
1471
|
+
const stage = decodeStage(op.metadata.value)
|
|
1472
|
+
if (stage !== undefined) onStage(stage)
|
|
1473
|
+
}
|
|
1474
|
+
})
|
|
1475
|
+
stream.on('error', (err: grpc.ServiceError) => {
|
|
1476
|
+
signal?.removeEventListener('abort', onAbort)
|
|
1477
|
+
reject(err)
|
|
1478
|
+
})
|
|
1479
|
+
stream.on('end', () => {
|
|
1480
|
+
signal?.removeEventListener('abort', onAbort)
|
|
1481
|
+
if (last === undefined)
|
|
1482
|
+
return reject(new Error('reapi: execution stream closed with no operation'))
|
|
1483
|
+
resolve(last)
|
|
1484
|
+
})
|
|
1485
|
+
})
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
/**
|
|
1489
|
+
* `SplitBlob` — ask the server to content-defined-chunk a blob and return
|
|
1490
|
+
* the chunk digests. With `FindMissingBlobs` over those chunks, a client
|
|
1491
|
+
* transfers only the parts of a large blob it does not already hold. Gated
|
|
1492
|
+
* by `split_blob_support`; experimental, so callers must check first.
|
|
1493
|
+
*/
|
|
1494
|
+
async splitBlob(blobDigest: Digest): Promise<{ chunks: Digest[]; chunkingFunction: string }> {
|
|
1495
|
+
const res = await this.unary<{ chunk_digests?: Digest[]; chunking_function?: string }>(
|
|
1496
|
+
this.svc.cas,
|
|
1497
|
+
'splitBlob',
|
|
1498
|
+
{
|
|
1499
|
+
instance_name: this.instance,
|
|
1500
|
+
blob_digest: blobDigest,
|
|
1501
|
+
...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
|
|
1502
|
+
},
|
|
1503
|
+
this.meta(),
|
|
1504
|
+
this.bounded(),
|
|
1505
|
+
)
|
|
1506
|
+
return { chunks: res.chunk_digests ?? [], chunkingFunction: res.chunking_function ?? 'UNKNOWN' }
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* `SpliceBlob` — the inverse: hand the server an ordered chunk list and it
|
|
1511
|
+
* reassembles the blob in CAS, so the client never uploads the parts it
|
|
1512
|
+
* already knows are there. Gated by `splice_blob_support`.
|
|
1513
|
+
*/
|
|
1514
|
+
async spliceBlob(chunkDigests: readonly Digest[], expected?: Digest): Promise<Digest> {
|
|
1515
|
+
const res = await this.unary<{ blob_digest?: Digest }>(
|
|
1516
|
+
this.svc.cas,
|
|
1517
|
+
'spliceBlob',
|
|
1518
|
+
{
|
|
1519
|
+
instance_name: this.instance,
|
|
1520
|
+
chunk_digests: chunkDigests,
|
|
1521
|
+
...(expected === undefined ? {} : { blob_digest: expected }),
|
|
1522
|
+
...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
|
|
1523
|
+
},
|
|
1524
|
+
this.meta(),
|
|
1525
|
+
this.bounded(),
|
|
1526
|
+
)
|
|
1527
|
+
return res.blob_digest ?? { hash: '', size_bytes: 0 }
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/**
|
|
1531
|
+
* `GetTree` — every Directory under `rootDigest`. The RPC is
|
|
1532
|
+
* SERVER-STREAMING: one call carries every page, and `next_page_token`
|
|
1533
|
+
* only names where a later call would resume. It went through the unary
|
|
1534
|
+
* helper before (item 824), whose callback a streaming stub never calls,
|
|
1535
|
+
* so every call hung until its deadline passed unheard.
|
|
1536
|
+
*/
|
|
1537
|
+
async getTree(rootDigest: Digest): Promise<Directory[]> {
|
|
1538
|
+
const call = (this.svc.cas as unknown as Record<string, Function>)['getTree']!(
|
|
1539
|
+
{ instance_name: this.instance, root_digest: rootDigest, page_token: '' },
|
|
1540
|
+
this.meta(),
|
|
1541
|
+
this.bounded(),
|
|
1542
|
+
) as AsyncIterable<{ directories?: Directory[] }>
|
|
1543
|
+
const out: Directory[] = []
|
|
1544
|
+
for await (const page of call) out.push(...(page.directories ?? []))
|
|
1545
|
+
return out
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
/**
|
|
1549
|
+
* Closing the channel-owning stub tears the connection down; the others
|
|
1550
|
+
* borrow it, so closing them too would double-close one channel.
|
|
1551
|
+
*/
|
|
1552
|
+
close(): void {
|
|
1553
|
+
this.svc.cas.close()
|
|
1554
|
+
}
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/** google.longrunning.Operation, narrowed to what Execute returns. */
|
|
1558
|
+
export interface Operation {
|
|
1559
|
+
name: string
|
|
1560
|
+
done: boolean
|
|
1561
|
+
/** `google.rpc.Status` when the EXECUTION ITSELF failed (not a non-zero exit). */
|
|
1562
|
+
error?: { code?: number; message?: string }
|
|
1563
|
+
/** `ExecuteResponse`, packed in an Any. */
|
|
1564
|
+
response?: { type_url?: string; value?: Uint8Array }
|
|
1565
|
+
/** `ExecuteOperationMetadata`, packed in an Any. */
|
|
1566
|
+
metadata?: { type_url?: string; value?: Uint8Array }
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
export interface ExecuteOptions {
|
|
1570
|
+
/** The operation's name, once the server has given one. */
|
|
1571
|
+
onOperation?: (name: string) => void
|
|
1572
|
+
/** Default TRUE: vx owns the cache decision, so the server must not re-check its own AC. */
|
|
1573
|
+
skipCacheLookup?: boolean
|
|
1574
|
+
inlineStdout?: boolean
|
|
1575
|
+
inlineStderr?: boolean
|
|
1576
|
+
/** Output files the server should inline in the result, sparing a fetch. */
|
|
1577
|
+
inlineOutputFiles?: readonly string[]
|
|
1578
|
+
/** `ExecutionPolicy.priority` — lower runs sooner. */
|
|
1579
|
+
priority?: number
|
|
1580
|
+
/** `ResultsCachePolicy.priority` — retention hint for the result. */
|
|
1581
|
+
resultsCachePriority?: number
|
|
1582
|
+
/** QUEUED / EXECUTING / COMPLETED as the operation progresses. */
|
|
1583
|
+
onStage?: (stage: string) => void
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
const EXEC_STAGE = ['UNKNOWN', 'CACHE_CHECK', 'QUEUED', 'EXECUTING', 'COMPLETED'] as const
|
|
1587
|
+
|
|
1588
|
+
/** `ExecuteOperationMetadata { stage = 1 (enum), action_digest = 2, ... }` */
|
|
1589
|
+
function decodeStage(buf: Uint8Array): string | undefined {
|
|
1590
|
+
let i = 0
|
|
1591
|
+
while (i < buf.length) {
|
|
1592
|
+
let key = 0
|
|
1593
|
+
let shift = 0
|
|
1594
|
+
for (;;) {
|
|
1595
|
+
const b = buf[i++]
|
|
1596
|
+
if (b === undefined) return undefined
|
|
1597
|
+
key |= (b & 0x7f) << shift
|
|
1598
|
+
if ((b & 0x80) === 0) break
|
|
1599
|
+
shift += 7
|
|
1600
|
+
}
|
|
1601
|
+
const field = key >>> 3
|
|
1602
|
+
const wire = key & 7
|
|
1603
|
+
if (wire === 0) {
|
|
1604
|
+
let v = 0
|
|
1605
|
+
let sh = 0
|
|
1606
|
+
for (;;) {
|
|
1607
|
+
const b = buf[i++]
|
|
1608
|
+
if (b === undefined) return undefined
|
|
1609
|
+
v |= (b & 0x7f) << sh
|
|
1610
|
+
if ((b & 0x80) === 0) break
|
|
1611
|
+
sh += 7
|
|
1612
|
+
}
|
|
1613
|
+
if (field === 1) return EXEC_STAGE[v] ?? `STAGE_${v}`
|
|
1614
|
+
} else if (wire === 2) {
|
|
1615
|
+
let len = 0
|
|
1616
|
+
let sh = 0
|
|
1617
|
+
for (;;) {
|
|
1618
|
+
const b = buf[i++]
|
|
1619
|
+
if (b === undefined) return undefined
|
|
1620
|
+
len |= (b & 0x7f) << sh
|
|
1621
|
+
if ((b & 0x80) === 0) break
|
|
1622
|
+
sh += 7
|
|
1623
|
+
}
|
|
1624
|
+
i += len
|
|
1625
|
+
} else break
|
|
1626
|
+
}
|
|
1627
|
+
return undefined
|
|
1628
|
+
}
|
|
1629
|
+
|
|
1630
|
+
export interface ExecuteResponse {
|
|
1631
|
+
result?: ActionResult
|
|
1632
|
+
cached_result?: boolean
|
|
1633
|
+
status?: { code?: number; message?: string }
|
|
1634
|
+
message?: string
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
/** The subset of REAPI's ActionResult a cache entry uses. */
|
|
1638
|
+
export interface ActionResult {
|
|
1639
|
+
exit_code?: number
|
|
1640
|
+
output_files?: Array<{
|
|
1641
|
+
path: string
|
|
1642
|
+
digest: Digest
|
|
1643
|
+
is_executable?: boolean
|
|
1644
|
+
contents?: Uint8Array
|
|
1645
|
+
}>
|
|
1646
|
+
output_directories?: Array<{ path: string; tree_digest: Digest }>
|
|
1647
|
+
output_symlinks?: Array<{ path: string; target: string }>
|
|
1648
|
+
/** Servers MAY normalise inline stdout/stderr into CAS and return digests instead. */
|
|
1649
|
+
stdout_digest?: Digest
|
|
1650
|
+
stdout_raw?: Uint8Array
|
|
1651
|
+
stderr_digest?: Digest
|
|
1652
|
+
stderr_raw?: Uint8Array
|
|
1653
|
+
execution_metadata?: {
|
|
1654
|
+
worker?: string
|
|
1655
|
+
execution_start_timestamp?: unknown
|
|
1656
|
+
execution_completed_timestamp?: unknown
|
|
1657
|
+
}
|
|
1658
|
+
}
|