@vzn/vx-reapi 0.0.646 → 0.0.648
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +52 -40
- package/package.json +3 -3
- package/src/cache.ts +89 -23
- package/src/executor.ts +289 -109
- package/src/index.ts +67 -51
- package/src/merkle.ts +57 -18
- package/src/wire.ts +106 -32
package/src/wire.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import * as grpc from '@grpc/grpc-js'
|
|
7
7
|
import * as protoLoader from '@grpc/proto-loader'
|
|
8
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
8
9
|
import path from 'node:path'
|
|
9
10
|
import {
|
|
10
11
|
canDigest,
|
|
@@ -15,6 +16,7 @@ import {
|
|
|
15
16
|
strField,
|
|
16
17
|
type DigestFunctionName,
|
|
17
18
|
} from './merkle.js'
|
|
19
|
+
import { ui } from '@vzn/vx'
|
|
18
20
|
|
|
19
21
|
/**
|
|
20
22
|
* Default bytes per ByteStream message.
|
|
@@ -48,8 +50,11 @@ export const CHUNK_BYTES = 65535
|
|
|
48
50
|
*/
|
|
49
51
|
export const SAFE_CHUNK_BYTES = 65535
|
|
50
52
|
|
|
51
|
-
/**
|
|
52
|
-
|
|
53
|
+
/**
|
|
54
|
+
* The package's Bun floor (`engines.bun`). Its http2 client survives a
|
|
55
|
+
* CHUNK_BYTES-sized message from 1.4.0 on; the floor is core's, 1.4.4.
|
|
56
|
+
*/
|
|
57
|
+
export const MIN_BUN = [1, 4, 4] as const
|
|
53
58
|
|
|
54
59
|
/**
|
|
55
60
|
* Refuse to run on a Bun that would hang instead of uploading. A version this
|
|
@@ -165,7 +170,7 @@ async function unzstdBounded(data: Uint8Array, digest: Digest): Promise<Uint8Arr
|
|
|
165
170
|
/** More bytes than the digest declares, refused before the rest arrive. */
|
|
166
171
|
const overServed = (digest: Digest): Error =>
|
|
167
172
|
new Error(
|
|
168
|
-
|
|
173
|
+
`blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
|
|
169
174
|
`served past its declared ${digest.size_bytes} bytes`,
|
|
170
175
|
)
|
|
171
176
|
|
|
@@ -180,7 +185,7 @@ const wireBound = (size: number): number => size + (size >> 8) + 1024
|
|
|
180
185
|
function assertServed(size: number, hash: (() => string) | undefined, digest: Digest): void {
|
|
181
186
|
if (size !== Number(digest.size_bytes)) {
|
|
182
187
|
throw new Error(
|
|
183
|
-
|
|
188
|
+
`blob integrity failure for ${digest.hash.slice(0, 16)}…: ` +
|
|
184
189
|
`size ${size} != declared ${digest.size_bytes}`,
|
|
185
190
|
)
|
|
186
191
|
}
|
|
@@ -188,7 +193,7 @@ function assertServed(size: number, hash: (() => string) | undefined, digest: Di
|
|
|
188
193
|
const got = hash()
|
|
189
194
|
if (got !== digest.hash) {
|
|
190
195
|
throw new Error(
|
|
191
|
-
|
|
196
|
+
`blob integrity failure: bytes hash to ${got.slice(0, 16)}… ` +
|
|
192
197
|
`but were served for digest ${digest.hash.slice(0, 16)}…`,
|
|
193
198
|
)
|
|
194
199
|
}
|
|
@@ -305,11 +310,17 @@ function isRetryable(code: number | undefined): boolean {
|
|
|
305
310
|
)
|
|
306
311
|
}
|
|
307
312
|
|
|
313
|
+
/** grpc-js ends an UNAVAILABLE with `Resolution note: ` and nothing after it. */
|
|
314
|
+
function spent(err: unknown): unknown {
|
|
315
|
+
if (err instanceof Error) err.message = err.message.replace(/\s*Resolution note:\s*$/, '')
|
|
316
|
+
return err
|
|
317
|
+
}
|
|
318
|
+
|
|
308
319
|
const RETRY_DELAYS_MS = [100, 400, 1600]
|
|
309
320
|
|
|
310
321
|
const deadlineOf = (bounds: grpc.CallOptions): number => (bounds.deadline as Date).getTime()
|
|
311
322
|
|
|
312
|
-
const executionAborted = (): Error => new Error('
|
|
323
|
+
const executionAborted = (): Error => new Error('execution aborted')
|
|
313
324
|
|
|
314
325
|
/** A backoff an abort ends at once: the caller's deadline may fire mid-wait. */
|
|
315
326
|
function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<void> {
|
|
@@ -328,6 +339,13 @@ function abortableSleep(ms: number, signal: AbortSignal | undefined): Promise<vo
|
|
|
328
339
|
|
|
329
340
|
const NOT_FOUND = grpc.status.NOT_FOUND
|
|
330
341
|
|
|
342
|
+
/**
|
|
343
|
+
* The action a call is made for, per async context: one client serves every
|
|
344
|
+
* task of a run at once, and a field on it named, on each action's uploads,
|
|
345
|
+
* whichever action had set it last.
|
|
346
|
+
*/
|
|
347
|
+
const currentAction = new AsyncLocalStorage<{ id: string }>()
|
|
348
|
+
|
|
331
349
|
export interface ReapiOptions {
|
|
332
350
|
/** `host:port` of the REAPI server; TLS unless `grpc://`, `http://` or a socket. */
|
|
333
351
|
endpoint: string
|
|
@@ -385,6 +403,13 @@ export interface ReapiOptions {
|
|
|
385
403
|
chunkBytes?: number
|
|
386
404
|
}
|
|
387
405
|
|
|
406
|
+
/**
|
|
407
|
+
* The longest a timer waits: one past 2^31-1 ms fires after 1 ms, so a
|
|
408
|
+
* month's bound gave up at once. Every deadline option is refused past it,
|
|
409
|
+
* as core refuses such an exec.timeout.
|
|
410
|
+
*/
|
|
411
|
+
export const MAX_TIMER_MS = 2 ** 31 - 1
|
|
412
|
+
|
|
388
413
|
/** The ceiling a derived control-plane deadline never exceeds, however far
|
|
389
414
|
* `callTimeoutMs` is raised for a bulk transfer. */
|
|
390
415
|
export const META_TIMEOUT_CAP_MS = 15_000
|
|
@@ -463,7 +488,7 @@ export class ReapiClient {
|
|
|
463
488
|
private readonly correlatedInvocationsId: string
|
|
464
489
|
/** Set when a call spent its retries on UNAVAILABLE; cleared by any answer. */
|
|
465
490
|
private unreachable = false
|
|
466
|
-
/**
|
|
491
|
+
/** The action last named (`nameAction`); a call outside any `inAction` carries it. */
|
|
467
492
|
actionId = ''
|
|
468
493
|
toolInvocationId = ''
|
|
469
494
|
|
|
@@ -489,9 +514,18 @@ export class ReapiClient {
|
|
|
489
514
|
// and quotes it in the error, so an auth header's secret reached each
|
|
490
515
|
// degrade warning (item 928). Refused once, here, by key alone.
|
|
491
516
|
for (const [k, v] of Object.entries(this.headers)) {
|
|
517
|
+
// A name too: refused on every call, it degraded the cache to misses
|
|
518
|
+
// and read as an unreachable server to the executor. A `-bin` name
|
|
519
|
+
// takes bytes, which a string header is not.
|
|
520
|
+
const name = k.toLowerCase()
|
|
521
|
+
if (!/^[0-9a-z_.-]+$/.test(name) || name.endsWith('-bin')) {
|
|
522
|
+
throw new Error(
|
|
523
|
+
`reapi: header name ${JSON.stringify(k)} is not one gRPC metadata can carry (lowercase letters, digits, "-", "_" and ".", not ending "-bin")`,
|
|
524
|
+
)
|
|
525
|
+
}
|
|
492
526
|
if (!/^[ -~]*$/.test(v)) {
|
|
493
527
|
throw new Error(
|
|
494
|
-
`reapi: header ${JSON.stringify(k)} holds a character gRPC metadata cannot carry (printable ASCII only) — check it (its value is not printed)`,
|
|
528
|
+
`reapi(): header ${JSON.stringify(k)} holds a character gRPC metadata cannot carry (printable ASCII only) — check it (its value is not printed)`,
|
|
495
529
|
)
|
|
496
530
|
}
|
|
497
531
|
}
|
|
@@ -503,22 +537,22 @@ export class ReapiClient {
|
|
|
503
537
|
// Derived, not required: raising `callTimeoutMs` for a big upload must
|
|
504
538
|
// not silently lengthen every metadata probe too.
|
|
505
539
|
this.metaTimeoutMs = opts.metaTimeoutMs ?? Math.min(this.callTimeoutMs, META_TIMEOUT_CAP_MS)
|
|
506
|
-
|
|
507
|
-
|
|
540
|
+
// No listener is no reason to say nothing.
|
|
541
|
+
this.onWarn = opts.onWarn ?? ((m) => ui.say('warn', m))
|
|
542
|
+
// A zero, negative or non-number deadline ended every call as it began,
|
|
543
|
+
// and so did one past the longest timer.
|
|
508
544
|
for (const [name, ms] of [
|
|
509
545
|
['callTimeoutMs', this.callTimeoutMs],
|
|
510
546
|
['metaTimeoutMs', this.metaTimeoutMs],
|
|
511
547
|
] as const) {
|
|
512
|
-
if (!(typeof ms === 'number' &&
|
|
548
|
+
if (!(typeof ms === 'number' && ms > 0 && ms <= MAX_TIMER_MS)) {
|
|
513
549
|
throw new Error(
|
|
514
|
-
|
|
550
|
+
`reapi(): ${name} must be a positive number of ms up to ${MAX_TIMER_MS} (got ${typeof ms === 'number' ? String(ms) : JSON.stringify(ms)})`,
|
|
515
551
|
)
|
|
516
552
|
}
|
|
517
553
|
}
|
|
518
554
|
if (!Number.isInteger(this.chunkBytes) || this.chunkBytes < 1) {
|
|
519
|
-
throw new Error(
|
|
520
|
-
`@vzn/vx-reapi: chunkBytes must be a positive integer (got ${this.chunkBytes})`,
|
|
521
|
-
)
|
|
555
|
+
throw new Error(`reapi(): chunkBytes must be a positive integer (got ${this.chunkBytes})`)
|
|
522
556
|
}
|
|
523
557
|
}
|
|
524
558
|
|
|
@@ -570,7 +604,7 @@ export class ReapiClient {
|
|
|
570
604
|
return res
|
|
571
605
|
} catch (err) {
|
|
572
606
|
const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
|
|
573
|
-
if (delay === undefined) throw err
|
|
607
|
+
if (delay === undefined) throw spent(err)
|
|
574
608
|
await Bun.sleep(delay)
|
|
575
609
|
}
|
|
576
610
|
}
|
|
@@ -612,12 +646,24 @@ export class ReapiClient {
|
|
|
612
646
|
const tool = concat([strField(1, this.toolName), strField(2, this.toolVersion)])
|
|
613
647
|
return concat([
|
|
614
648
|
tool.length === 0 ? tool : lenField(1, tool),
|
|
615
|
-
strField(2, this.actionId),
|
|
649
|
+
strField(2, currentAction.getStore()?.id ?? this.actionId),
|
|
616
650
|
strField(3, this.toolInvocationId),
|
|
617
651
|
strField(4, this.correlatedInvocationsId),
|
|
618
652
|
])
|
|
619
653
|
}
|
|
620
654
|
|
|
655
|
+
/** Runs `fn` as one action's calls: each carries the id `nameAction` gives it there. */
|
|
656
|
+
inAction<T>(fn: () => Promise<T>): Promise<T> {
|
|
657
|
+
return currentAction.run({ id: '' }, fn)
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** Names the action of the `inAction` this runs in, so the server groups its RPCs under it. */
|
|
661
|
+
nameAction(id: string): void {
|
|
662
|
+
const scope = currentAction.getStore()
|
|
663
|
+
if (scope !== undefined) scope.id = id
|
|
664
|
+
this.actionId = id
|
|
665
|
+
}
|
|
666
|
+
|
|
621
667
|
/**
|
|
622
668
|
* `GetCapabilities`. `execEnabled` is what decides whether this server can
|
|
623
669
|
* take an `executor` at all — a cache-only deployment (bazel-remote)
|
|
@@ -680,11 +726,10 @@ export class ReapiClient {
|
|
|
680
726
|
if (wanted !== undefined) {
|
|
681
727
|
if (!caps.digestFunctions.includes(wanted)) {
|
|
682
728
|
throw new Error(
|
|
683
|
-
|
|
729
|
+
`server does not support digest function ${wanted} (has ${caps.digestFunctions.join(', ')})`,
|
|
684
730
|
)
|
|
685
731
|
}
|
|
686
|
-
if (!canDigest(wanted))
|
|
687
|
-
throw new Error(`@vzn/vx-reapi: this runtime cannot compute ${wanted}`)
|
|
732
|
+
if (!canDigest(wanted)) throw new Error(`this runtime cannot compute ${wanted}`)
|
|
688
733
|
this.digestFunction = wanted
|
|
689
734
|
} else {
|
|
690
735
|
// Default stays SHA256 even when the server advertises stronger
|
|
@@ -776,7 +821,7 @@ export class ReapiClient {
|
|
|
776
821
|
const code = r.status?.code ?? 0
|
|
777
822
|
if (code !== 0) {
|
|
778
823
|
throw new Error(
|
|
779
|
-
`
|
|
824
|
+
`BatchUpdateBlobs rejected ${r.digest?.hash}: code ${code} ${r.status?.message ?? ''}`,
|
|
780
825
|
)
|
|
781
826
|
}
|
|
782
827
|
}
|
|
@@ -974,7 +1019,7 @@ export class ReapiClient {
|
|
|
974
1019
|
inline?: { stdout: boolean; files: readonly string[] },
|
|
975
1020
|
): Promise<ActionResult | null> {
|
|
976
1021
|
try {
|
|
977
|
-
|
|
1022
|
+
const result = await this.unary<ActionResult>(
|
|
978
1023
|
this.svc.ac,
|
|
979
1024
|
'getActionResult',
|
|
980
1025
|
{
|
|
@@ -987,6 +1032,7 @@ export class ReapiClient {
|
|
|
987
1032
|
this.meta(),
|
|
988
1033
|
this.boundedMeta(),
|
|
989
1034
|
)
|
|
1035
|
+
return withNumericSizes(result)
|
|
990
1036
|
} catch (err) {
|
|
991
1037
|
if ((err as grpc.ServiceError).code === NOT_FOUND) return null
|
|
992
1038
|
throw err
|
|
@@ -1074,13 +1120,13 @@ export class ReapiClient {
|
|
|
1074
1120
|
wireBytes > chunk
|
|
1075
1121
|
) {
|
|
1076
1122
|
this.onWarn(
|
|
1077
|
-
`vx
|
|
1123
|
+
`[vx] 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}`,
|
|
1078
1124
|
)
|
|
1079
1125
|
chunk = SAFE_CHUNK_BYTES
|
|
1080
1126
|
continue
|
|
1081
1127
|
}
|
|
1082
1128
|
const delay = this.retryDelay(attempt, code)
|
|
1083
|
-
if (delay === undefined) throw err
|
|
1129
|
+
if (delay === undefined) throw spent(err)
|
|
1084
1130
|
if (!compressed) {
|
|
1085
1131
|
// Identity path: ask how far the server got and resume there.
|
|
1086
1132
|
const status = await this.queryWriteStatus(resource).catch(() => null)
|
|
@@ -1097,6 +1143,7 @@ export class ReapiClient {
|
|
|
1097
1143
|
this.bounded(),
|
|
1098
1144
|
)
|
|
1099
1145
|
return
|
|
1146
|
+
// quiet: a resume that fails starts afresh; its last failure throws
|
|
1100
1147
|
} catch {
|
|
1101
1148
|
// fall through to a fresh attempt
|
|
1102
1149
|
}
|
|
@@ -1144,9 +1191,7 @@ export class ReapiClient {
|
|
|
1144
1191
|
// failed the upload of an input two actions share (F-44).
|
|
1145
1192
|
const expected = compressed ? total : digest.size_bytes
|
|
1146
1193
|
if (committed !== expected && !(compressed && (committed > 0 || committed === -1))) {
|
|
1147
|
-
return reject(
|
|
1148
|
-
new Error(`reapi: short write for ${digest.hash}: ${committed}/${expected}`),
|
|
1149
|
-
)
|
|
1194
|
+
return reject(new Error(`short write for ${digest.hash}: ${committed}/${expected}`))
|
|
1150
1195
|
}
|
|
1151
1196
|
resolve()
|
|
1152
1197
|
},
|
|
@@ -1155,6 +1200,7 @@ export class ReapiClient {
|
|
|
1155
1200
|
})
|
|
1156
1201
|
// Raced against every drain wait and awaited at the end; a failure that
|
|
1157
1202
|
// lands between the two is not an unhandled rejection.
|
|
1203
|
+
// quiet: raced and awaited below, where its error throws
|
|
1158
1204
|
done.catch(() => undefined)
|
|
1159
1205
|
let offset = startOffset
|
|
1160
1206
|
let first = true
|
|
@@ -1204,7 +1250,7 @@ export class ReapiClient {
|
|
|
1204
1250
|
return blob
|
|
1205
1251
|
} catch (err) {
|
|
1206
1252
|
const delay = this.retryDelay(attempt, (err as grpc.ServiceError).code)
|
|
1207
|
-
if (delay === undefined) throw err
|
|
1253
|
+
if (delay === undefined) throw spent(err)
|
|
1208
1254
|
await Bun.sleep(delay)
|
|
1209
1255
|
}
|
|
1210
1256
|
}
|
|
@@ -1301,7 +1347,7 @@ export class ReapiClient {
|
|
|
1301
1347
|
} catch (err) {
|
|
1302
1348
|
const code = (err as grpc.ServiceError).code
|
|
1303
1349
|
const delay = this.retryDelay(attempt++, code)
|
|
1304
|
-
if (delay === undefined) throw err
|
|
1350
|
+
if (delay === undefined) throw spent(err)
|
|
1305
1351
|
await Bun.sleep(delay)
|
|
1306
1352
|
open(size)
|
|
1307
1353
|
}
|
|
@@ -1397,10 +1443,10 @@ export class ReapiClient {
|
|
|
1397
1443
|
// An unnamed one cannot be re-attached, and a plain Error is not
|
|
1398
1444
|
// retried.
|
|
1399
1445
|
if (operationName === '') {
|
|
1400
|
-
throw new Error('
|
|
1446
|
+
throw new Error('execution stream closed before the operation finished')
|
|
1401
1447
|
}
|
|
1402
1448
|
failure = new Error(
|
|
1403
|
-
`
|
|
1449
|
+
`execution stream for ${operationName} closed before the operation finished`,
|
|
1404
1450
|
)
|
|
1405
1451
|
} catch (err) {
|
|
1406
1452
|
const code = (err as grpc.ServiceError).code
|
|
@@ -1484,7 +1530,7 @@ export class ReapiClient {
|
|
|
1484
1530
|
stream.on('end', () => {
|
|
1485
1531
|
signal?.removeEventListener('abort', onAbort)
|
|
1486
1532
|
if (last === undefined)
|
|
1487
|
-
return reject(new Error('
|
|
1533
|
+
return reject(new Error('execution stream closed with no operation'))
|
|
1488
1534
|
resolve(last)
|
|
1489
1535
|
})
|
|
1490
1536
|
})
|
|
@@ -1559,6 +1605,34 @@ export class ReapiClient {
|
|
|
1559
1605
|
}
|
|
1560
1606
|
}
|
|
1561
1607
|
|
|
1608
|
+
/**
|
|
1609
|
+
* proto-loader reads an int64 as a string (`longs: String`), and every reader
|
|
1610
|
+
* of a result sums and compares `size_bytes` as a number: a record replay's
|
|
1611
|
+
* batch budget concatenated `"12" + 128`, so each output file was a
|
|
1612
|
+
* BatchReadBlobs round trip of its own, and an empty file grafted from a
|
|
1613
|
+
* record encoded its `"0"` size where the canonical form omits it.
|
|
1614
|
+
*/
|
|
1615
|
+
function withNumericSizes(result: ActionResult): ActionResult {
|
|
1616
|
+
const num = <D extends Digest | null | undefined>(d: D): D =>
|
|
1617
|
+
d === null || d === undefined ? d : ({ ...d, size_bytes: Number(d.size_bytes) } as D)
|
|
1618
|
+
return {
|
|
1619
|
+
...result,
|
|
1620
|
+
...(result.output_files === undefined
|
|
1621
|
+
? {}
|
|
1622
|
+
: { output_files: result.output_files.map((f) => ({ ...f, digest: num(f.digest) })) }),
|
|
1623
|
+
...(result.output_directories === undefined
|
|
1624
|
+
? {}
|
|
1625
|
+
: {
|
|
1626
|
+
output_directories: result.output_directories.map((d) => ({
|
|
1627
|
+
...d,
|
|
1628
|
+
tree_digest: num(d.tree_digest),
|
|
1629
|
+
})),
|
|
1630
|
+
}),
|
|
1631
|
+
...(result.stdout_digest === undefined ? {} : { stdout_digest: num(result.stdout_digest) }),
|
|
1632
|
+
...(result.stderr_digest === undefined ? {} : { stderr_digest: num(result.stderr_digest) }),
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1562
1636
|
/** google.longrunning.Operation, narrowed to what Execute returns. */
|
|
1563
1637
|
export interface Operation {
|
|
1564
1638
|
name: string
|