@vzn/vx-reapi 0.0.652 → 0.0.653
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 +77 -34
- package/package.json +2 -2
- package/src/cache.ts +30 -14
- package/src/executor.ts +569 -263
- package/src/index.ts +1 -1
- package/src/merkle.ts +113 -67
- package/src/wire.ts +79 -29
package/src/index.ts
CHANGED
|
@@ -97,7 +97,7 @@ function connection(options: ReapiPluginOptions): ReapiOptions | undefined {
|
|
|
97
97
|
// socket path is the file system's to read.
|
|
98
98
|
const given = options.endpoint?.trim()
|
|
99
99
|
const endpoint =
|
|
100
|
-
given?.startsWith('unix') === true ? given : given?.replace(/^(.*[^/:])\/+$/, '$1')
|
|
100
|
+
given?.startsWith('unix:') === true ? given : given?.replace(/^(.*[^/:])\/+$/, '$1')
|
|
101
101
|
if (endpoint === undefined || endpoint === '') return undefined
|
|
102
102
|
assertEndpoint(endpoint, from)
|
|
103
103
|
// A secrets file's trailing newline sent a name no blob is under, and
|
package/src/merkle.ts
CHANGED
|
@@ -30,11 +30,11 @@ interface InputTree {
|
|
|
30
30
|
/** File count, for logging/metrics. */
|
|
31
31
|
fileCount: number
|
|
32
32
|
/**
|
|
33
|
-
* Paths where a grafted subtree SHADOWED
|
|
34
|
-
* same name. The graft wins (it is the upstream's
|
|
35
|
-
* the disk copy at best a stale materialisation) —
|
|
36
|
-
*
|
|
37
|
-
*
|
|
33
|
+
* Paths where a grafted subtree SHADOWED declared input files read from
|
|
34
|
+
* disk under the same name. The graft wins (it is the upstream's
|
|
35
|
+
* authoritative output; the disk copy at best a stale materialisation) —
|
|
36
|
+
* but those files are absent from the tree, so the caller should surface
|
|
37
|
+
* these. Another upstream's output under a graft is merged in instead.
|
|
38
38
|
*/
|
|
39
39
|
shadowed: string[]
|
|
40
40
|
/**
|
|
@@ -102,7 +102,10 @@ export interface TreeGraft {
|
|
|
102
102
|
* the graph self-consistent regardless of who produced it — file digests are
|
|
103
103
|
* untouched (content-addressed, already in the CAS).
|
|
104
104
|
*/
|
|
105
|
-
function canonicaliseTree(
|
|
105
|
+
function canonicaliseTree(
|
|
106
|
+
graft: TreeGraft,
|
|
107
|
+
known: Map<string, Directory>,
|
|
108
|
+
): { root: Digest; blobs: Blob[] } {
|
|
106
109
|
// Keyed by the worker's own bytes first: a child re-encoded by US matches
|
|
107
110
|
// its parent's reference only when both encoders agree byte for byte, and
|
|
108
111
|
// one that does not (a field order, a `node_properties` our decoder drops)
|
|
@@ -136,6 +139,7 @@ function canonicaliseTree(graft: TreeGraft): { root: Digest; blobs: Blob[] } {
|
|
|
136
139
|
const data = encodeDirectory(canonical)
|
|
137
140
|
const digest = sha256(data)
|
|
138
141
|
blobs.push({ digest, data })
|
|
142
|
+
known.set(digest.hash, canonical)
|
|
139
143
|
return digest
|
|
140
144
|
}
|
|
141
145
|
return { root: rebuild(graft.root), blobs }
|
|
@@ -202,6 +206,61 @@ export async function buildInputTree(args: {
|
|
|
202
206
|
const seen = new Set<string>()
|
|
203
207
|
const moved: string[] = []
|
|
204
208
|
let fileCount = 0
|
|
209
|
+
// A graft's directories by canonical digest, and the nodes opened from
|
|
210
|
+
// them: another upstream's output under a Tree graft is inserted into it,
|
|
211
|
+
// never dropped by it (prisma's client under install's node_modules).
|
|
212
|
+
const known = new Map<string, Directory>()
|
|
213
|
+
const fromGraft = new WeakSet<DirNode>()
|
|
214
|
+
const opened = (raw: Digest, at: string): Directory => {
|
|
215
|
+
const dir = known.get(raw.hash)
|
|
216
|
+
if (dir === undefined) {
|
|
217
|
+
throw new Error(
|
|
218
|
+
`vx-reapi: cannot add an upstream output under ${at}: the upstream Tree that holds it does not list it`,
|
|
219
|
+
)
|
|
220
|
+
}
|
|
221
|
+
return dir
|
|
222
|
+
}
|
|
223
|
+
const descend = (node: DirNode, seg: string, at: string): DirNode => {
|
|
224
|
+
let next = node.dirs.get(seg)
|
|
225
|
+
if (next !== undefined) return next
|
|
226
|
+
const raw = node.rawDirs.get(seg)
|
|
227
|
+
if (raw === undefined) {
|
|
228
|
+
next = emptyDir()
|
|
229
|
+
} else {
|
|
230
|
+
const dir = opened(raw, at)
|
|
231
|
+
next = {
|
|
232
|
+
files: new Map(dir.files.map((f) => [f.name, f])),
|
|
233
|
+
dirs: new Map(),
|
|
234
|
+
symlinks: new Map(dir.symlinks.map((s) => [s.name, s])),
|
|
235
|
+
rawDirs: new Map(dir.directories.map((d) => [d.name, d.digest])),
|
|
236
|
+
}
|
|
237
|
+
fromGraft.add(next)
|
|
238
|
+
node.rawDirs.delete(seg)
|
|
239
|
+
}
|
|
240
|
+
node.dirs.set(seg, next)
|
|
241
|
+
return next
|
|
242
|
+
}
|
|
243
|
+
const insertAt = (rel: string): { node: DirNode; leaf: string } => {
|
|
244
|
+
const parts = rel.split('/')
|
|
245
|
+
let node = root
|
|
246
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
247
|
+
node = descend(node, parts[i]!, parts.slice(0, i + 1).join('/'))
|
|
248
|
+
}
|
|
249
|
+
return { node, leaf: parts[parts.length - 1]! }
|
|
250
|
+
}
|
|
251
|
+
/** A graft's directory laid over `target`, merging where both hold a directory. */
|
|
252
|
+
const overlay = (target: DirNode, raw: Digest, at: string): void => {
|
|
253
|
+
const dir = opened(raw, at)
|
|
254
|
+
for (const f of dir.files) target.files.set(f.name, f)
|
|
255
|
+
for (const s of dir.symlinks) target.symlinks.set(s.name, s)
|
|
256
|
+
for (const d of dir.directories) {
|
|
257
|
+
if (target.dirs.has(d.name) || target.rawDirs.has(d.name)) {
|
|
258
|
+
overlay(descend(target, d.name, `${at}/${d.name}`), d.digest, `${at}/${d.name}`)
|
|
259
|
+
} else {
|
|
260
|
+
target.rawDirs.set(d.name, d.digest)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
205
264
|
|
|
206
265
|
// Insertion order is NOT what makes the tree deterministic — `encodeDirectory`
|
|
207
266
|
// sorts every node's names at encode time, and it has to, because grafts are
|
|
@@ -232,27 +291,27 @@ export async function buildInputTree(args: {
|
|
|
232
291
|
if (!st.isFile()) return { kind: 'other' as const }
|
|
233
292
|
return { kind: 'file' as const, data: await read(abs), mode: st.mode }
|
|
234
293
|
})
|
|
294
|
+
// A declared input is placed before the Tree grafts, which replace one
|
|
295
|
+
// under them (`shadowed`); an upstream's output read from disk after
|
|
296
|
+
// them, as it is that upstream's own and lands beside theirs.
|
|
297
|
+
const later: (() => void)[] = []
|
|
235
298
|
for (const [i, rel] of sorted.entries()) {
|
|
236
299
|
const got = reads[i]!
|
|
237
300
|
if (got.kind === 'gone') {
|
|
238
301
|
moved.push(rel)
|
|
239
302
|
continue
|
|
240
303
|
}
|
|
304
|
+
const deferred = args.expected !== undefined && !args.expected.has(rel)
|
|
241
305
|
if (got.kind === 'link') {
|
|
242
306
|
const target = got.target
|
|
243
307
|
const want = args.expected?.get(rel)
|
|
244
308
|
if (want !== undefined && !carriesOid(new TextEncoder().encode(target), want)) moved.push(rel)
|
|
245
|
-
const
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
let next = node.dirs.get(seg)
|
|
249
|
-
if (next === undefined) {
|
|
250
|
-
next = emptyDir()
|
|
251
|
-
node.dirs.set(seg, next)
|
|
252
|
-
}
|
|
253
|
-
node = next
|
|
309
|
+
const place = (): void => {
|
|
310
|
+
const { node, leaf } = insertAt(rel)
|
|
311
|
+
node.symlinks.set(leaf, { name: leaf, target })
|
|
254
312
|
}
|
|
255
|
-
|
|
313
|
+
if (deferred) later.push(place)
|
|
314
|
+
else place()
|
|
256
315
|
continue
|
|
257
316
|
}
|
|
258
317
|
if (got.kind === 'other') {
|
|
@@ -272,40 +331,47 @@ export async function buildInputTree(args: {
|
|
|
272
331
|
seen.add(digest.hash)
|
|
273
332
|
blobs.push({ digest, data })
|
|
274
333
|
}
|
|
275
|
-
const parts = rel.split('/')
|
|
276
|
-
let node = root
|
|
277
|
-
for (const seg of parts.slice(0, -1)) {
|
|
278
|
-
let next = node.dirs.get(seg)
|
|
279
|
-
if (next === undefined) {
|
|
280
|
-
next = emptyDir()
|
|
281
|
-
node.dirs.set(seg, next)
|
|
282
|
-
}
|
|
283
|
-
node = next
|
|
284
|
-
}
|
|
285
334
|
// The owner-execute bit is what REAPI models; group/other add nothing a
|
|
286
335
|
// worker can act on.
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
}
|
|
336
|
+
const isExecutable = (got.mode & 0o100) !== 0
|
|
337
|
+
const place = (): void => {
|
|
338
|
+
const { node, leaf } = insertAt(rel)
|
|
339
|
+
node.files.set(leaf, { name: leaf, digest, is_executable: isExecutable })
|
|
340
|
+
}
|
|
341
|
+
if (deferred) later.push(place)
|
|
342
|
+
else place()
|
|
292
343
|
fileCount++
|
|
293
344
|
}
|
|
294
345
|
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
346
|
+
// Ancestors first, so a nested graft finds the one above it to merge into.
|
|
347
|
+
const shadowed: string[] = []
|
|
348
|
+
const treeGrafts = [...(args.treeGrafts ?? [])].sort((a, b) =>
|
|
349
|
+
a.path < b.path ? -1 : a.path > b.path ? 1 : 0,
|
|
350
|
+
)
|
|
351
|
+
for (const graft of treeGrafts) {
|
|
352
|
+
const { node, leaf } = insertAt(graft.path)
|
|
353
|
+
const canonical = canonicaliseTree(graft, known)
|
|
354
|
+
for (const b of canonical.blobs) {
|
|
355
|
+
if (!seen.has(b.digest.hash)) {
|
|
356
|
+
seen.add(b.digest.hash)
|
|
357
|
+
blobs.push(b)
|
|
303
358
|
}
|
|
304
|
-
node = next
|
|
305
359
|
}
|
|
306
|
-
|
|
360
|
+
const there = node.dirs.get(leaf)
|
|
361
|
+
if (node.rawDirs.has(leaf) || (there !== undefined && fromGraft.has(there))) {
|
|
362
|
+
overlay(descend(node, leaf, graft.path), canonical.root, graft.path)
|
|
363
|
+
continue
|
|
364
|
+
}
|
|
365
|
+
// The graft wins over declared inputs read from disk: it is the
|
|
366
|
+
// upstream's authoritative output, the disk copy at best a stale
|
|
367
|
+
// materialisation of it.
|
|
368
|
+
if (there !== undefined) {
|
|
369
|
+
shadowed.push(graft.path)
|
|
370
|
+
node.dirs.delete(leaf)
|
|
371
|
+
}
|
|
372
|
+
node.rawDirs.set(leaf, canonical.root)
|
|
307
373
|
}
|
|
308
|
-
|
|
374
|
+
for (const place of later) place()
|
|
309
375
|
for (const graft of args.fileGrafts ?? []) {
|
|
310
376
|
const { node, leaf } = insertAt(graft.path)
|
|
311
377
|
node.files.set(leaf, { name: leaf, digest: graft.digest, is_executable: graft.isExecutable })
|
|
@@ -317,31 +383,13 @@ export async function buildInputTree(args: {
|
|
|
317
383
|
}
|
|
318
384
|
for (const dir of args.ensureDirs ?? []) {
|
|
319
385
|
if (dir === '') continue
|
|
386
|
+
const parts = dir.split('/')
|
|
320
387
|
let node = root
|
|
321
|
-
for (
|
|
322
|
-
|
|
323
|
-
if (next === undefined) {
|
|
324
|
-
next = emptyDir()
|
|
325
|
-
node.dirs.set(seg, next)
|
|
326
|
-
}
|
|
327
|
-
node = next
|
|
388
|
+
for (let i = 0; i < parts.length; i++) {
|
|
389
|
+
node = descend(node, parts[i]!, parts.slice(0, i + 1).join('/'))
|
|
328
390
|
}
|
|
329
391
|
}
|
|
330
392
|
|
|
331
|
-
const shadowed: string[] = []
|
|
332
|
-
for (const graft of args.treeGrafts ?? []) {
|
|
333
|
-
const { node, leaf } = insertAt(graft.path)
|
|
334
|
-
if (node.dirs.has(leaf)) shadowed.push(graft.path)
|
|
335
|
-
const canonical = canonicaliseTree(graft)
|
|
336
|
-
for (const b of canonical.blobs) {
|
|
337
|
-
if (!seen.has(b.digest.hash)) {
|
|
338
|
-
seen.add(b.digest.hash)
|
|
339
|
-
blobs.push(b)
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
node.rawDirs.set(leaf, canonical.root)
|
|
343
|
-
}
|
|
344
|
-
|
|
345
393
|
const rootDigest = serialise(root, blobs, seen)
|
|
346
394
|
return { root: rootDigest, blobs, fileCount, shadowed, moved }
|
|
347
395
|
}
|
|
@@ -384,13 +432,11 @@ function serialise(node: DirNode, blobs: Blob[], seen: Set<string>): Digest {
|
|
|
384
432
|
const directories: DirectoryNode[] = []
|
|
385
433
|
const dirNames = utf8Sorted([...new Set([...node.dirs.keys(), ...node.rawDirs.keys()])])
|
|
386
434
|
for (const name of dirNames) {
|
|
435
|
+
// A name is in one map or the other, never both (`buildInputTree`).
|
|
387
436
|
const sub = node.dirs.get(name)
|
|
388
437
|
directories.push({
|
|
389
438
|
name,
|
|
390
|
-
|
|
391
|
-
// graft is the upstream's AUTHORITATIVE output, the disk copy at best
|
|
392
|
-
// a stale materialisation of it.
|
|
393
|
-
digest: node.rawDirs.get(name) ?? serialise(sub!, blobs, seen),
|
|
439
|
+
digest: sub === undefined ? node.rawDirs.get(name)! : serialise(sub, blobs, seen),
|
|
394
440
|
})
|
|
395
441
|
}
|
|
396
442
|
const files = utf8Sorted([...node.files.keys()]).map((n) => node.files.get(n)!)
|
package/src/wire.ts
CHANGED
|
@@ -318,6 +318,16 @@ function spent(err: unknown): unknown {
|
|
|
318
318
|
|
|
319
319
|
const RETRY_DELAYS_MS = [100, 400, 1600]
|
|
320
320
|
|
|
321
|
+
/**
|
|
322
|
+
* Re-attaching to an operation. A proxy that cuts long streams cuts a
|
|
323
|
+
* progressing action again and again, so only attaches that delivered no
|
|
324
|
+
* operation spend `RETRY_DELAYS_MS`'s budget. The wait still grows per
|
|
325
|
+
* consecutive cut, so a server that answers EXECUTING and hangs up is not
|
|
326
|
+
* re-dialled every 100 ms; it starts over once a stream advanced the stage
|
|
327
|
+
* or stayed open `steadyMs`. A mutable object: tests shorten it.
|
|
328
|
+
*/
|
|
329
|
+
export const reattach = { backoffMs: [100, 400, 1600, 5000], steadyMs: 10_000 }
|
|
330
|
+
|
|
321
331
|
const deadlineOf = (bounds: grpc.CallOptions): number => (bounds.deadline as Date).getTime()
|
|
322
332
|
|
|
323
333
|
const executionAborted = (): Error => new Error('execution aborted')
|
|
@@ -909,17 +919,19 @@ export class ReapiClient {
|
|
|
909
919
|
/**
|
|
910
920
|
* Upload blobs, choosing the transport per blob: batched while they fit the
|
|
911
921
|
* server's batch budget, ByteStream for the rest. Only what
|
|
912
|
-
* `FindMissingBlobs` reports absent is sent
|
|
922
|
+
* `FindMissingBlobs` reports absent is sent, unless `everyBlob`: a server
|
|
923
|
+
* whose Execute just named them missing has already contradicted it.
|
|
913
924
|
*/
|
|
914
925
|
async uploadBlobs(
|
|
915
926
|
blobs: ReadonlyArray<{ digest: Digest; data: Uint8Array }>,
|
|
916
927
|
maxBatchBytes = 0,
|
|
928
|
+
everyBlob = false,
|
|
917
929
|
): Promise<void> {
|
|
918
930
|
if (blobs.length === 0) return
|
|
919
|
-
const missing =
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const todo = blobs.filter((b) => missing.has(b.digest.hash))
|
|
931
|
+
const missing = everyBlob
|
|
932
|
+
? undefined
|
|
933
|
+
: new Set((await this.findMissingBlobs(blobs.map((b) => b.digest))).map((d) => d.hash))
|
|
934
|
+
const todo = missing === undefined ? blobs : blobs.filter((b) => missing.has(b.digest.hash))
|
|
923
935
|
// 0 means the server did not say; the gRPC default max message is 4 MB and
|
|
924
936
|
// the request carries framing on top, so leave headroom rather than
|
|
925
937
|
// discovering the limit as a RESOURCE_EXHAUSTED mid-run.
|
|
@@ -1009,14 +1021,14 @@ export class ReapiClient {
|
|
|
1009
1021
|
|
|
1010
1022
|
/**
|
|
1011
1023
|
* `GetActionResult`; `null` on NOT_FOUND — a miss is not an error.
|
|
1012
|
-
* `inline` asks the server to return
|
|
1013
|
-
* the reply. The spec lets it decline (and requires it
|
|
1014
|
-
* limit), so a caller reads `
|
|
1015
|
-
* fetches otherwise.
|
|
1024
|
+
* `inline` asks the server to return the streams it names and the named
|
|
1025
|
+
* output files in the reply. The spec lets it decline (and requires it
|
|
1026
|
+
* past the message limit), so a caller reads `*_raw` / `contents` when
|
|
1027
|
+
* present and fetches otherwise.
|
|
1016
1028
|
*/
|
|
1017
1029
|
async getActionResult(
|
|
1018
1030
|
action: Digest,
|
|
1019
|
-
inline?: { stdout: boolean; files: readonly string[] },
|
|
1031
|
+
inline?: { stdout: boolean; stderr?: boolean; files: readonly string[] },
|
|
1020
1032
|
): Promise<ActionResult | null> {
|
|
1021
1033
|
try {
|
|
1022
1034
|
const result = await this.unary<ActionResult>(
|
|
@@ -1027,7 +1039,11 @@ export class ReapiClient {
|
|
|
1027
1039
|
action_digest: action,
|
|
1028
1040
|
...(inline === undefined
|
|
1029
1041
|
? {}
|
|
1030
|
-
: {
|
|
1042
|
+
: {
|
|
1043
|
+
inline_stdout: inline.stdout,
|
|
1044
|
+
...(inline.stderr === true ? { inline_stderr: true } : {}),
|
|
1045
|
+
inline_output_files: inline.files,
|
|
1046
|
+
}),
|
|
1031
1047
|
},
|
|
1032
1048
|
this.meta(),
|
|
1033
1049
|
this.boundedMeta(),
|
|
@@ -1317,9 +1333,13 @@ export class ReapiClient {
|
|
|
1317
1333
|
* the bytes pass and a mismatch errors the stream at its end, so no reader
|
|
1318
1334
|
* reaches the end of a blob that is not the one asked for.
|
|
1319
1335
|
* Identity-encoded, as `writeBlob` streams: the blob this reads is a zstd
|
|
1320
|
-
* artifact already.
|
|
1336
|
+
* artifact already. `refused` is what that error becomes, for a reader
|
|
1337
|
+
* that tells wrong bytes from a cut.
|
|
1321
1338
|
*/
|
|
1322
|
-
async readBlobStream(
|
|
1339
|
+
async readBlobStream(
|
|
1340
|
+
digest: Digest,
|
|
1341
|
+
refused: (err: Error) => Error = (err) => err,
|
|
1342
|
+
): Promise<ReadableStream<Uint8Array> | null> {
|
|
1323
1343
|
const resource = `${this.instance ? `${this.instance}/` : ''}blobs/${digest.hash}/${digest.size_bytes}`
|
|
1324
1344
|
type Message = { data: Uint8Array }
|
|
1325
1345
|
let call: AsyncIterable<Message> & { cancel(): void }
|
|
@@ -1366,14 +1386,18 @@ export class ReapiClient {
|
|
|
1366
1386
|
const got = next ?? (await take())
|
|
1367
1387
|
next = undefined
|
|
1368
1388
|
if (got.done) {
|
|
1369
|
-
|
|
1389
|
+
try {
|
|
1390
|
+
assertServed(size, hasher && (() => hasher.digest('hex')), digest)
|
|
1391
|
+
} catch (err) {
|
|
1392
|
+
throw refused(err as Error)
|
|
1393
|
+
}
|
|
1370
1394
|
controller.close()
|
|
1371
1395
|
return
|
|
1372
1396
|
}
|
|
1373
1397
|
size += got.value.data.length
|
|
1374
1398
|
if (size > Number(digest.size_bytes)) {
|
|
1375
1399
|
call.cancel()
|
|
1376
|
-
throw overServed(digest)
|
|
1400
|
+
throw refused(overServed(digest))
|
|
1377
1401
|
}
|
|
1378
1402
|
hasher?.update(got.value.data)
|
|
1379
1403
|
controller.enqueue(got.value.data)
|
|
@@ -1421,27 +1445,47 @@ export class ReapiClient {
|
|
|
1421
1445
|
...(this.digestFunction === 'SHA256' ? {} : { digest_function: this.digestFunction }),
|
|
1422
1446
|
}
|
|
1423
1447
|
let operationName = ''
|
|
1424
|
-
|
|
1448
|
+
// Attaches in a row that delivered no operation, cuts since a stream last
|
|
1449
|
+
// advanced or held, and the furthest stage reached.
|
|
1450
|
+
let empty = 0
|
|
1451
|
+
let cuts = 0
|
|
1452
|
+
let reached = -1
|
|
1453
|
+
for (;;) {
|
|
1425
1454
|
let failure: unknown
|
|
1455
|
+
let delivered = false
|
|
1456
|
+
let advanced = false
|
|
1457
|
+
const opened = performance.now()
|
|
1458
|
+
const onStage = (stage: string): void => {
|
|
1459
|
+
const rank = EXEC_STAGE.indexOf(stage as (typeof EXEC_STAGE)[number])
|
|
1460
|
+
if (rank > reached) {
|
|
1461
|
+
reached = rank
|
|
1462
|
+
advanced = true
|
|
1463
|
+
}
|
|
1464
|
+
opts.onStage?.(stage)
|
|
1465
|
+
}
|
|
1426
1466
|
try {
|
|
1427
1467
|
const op =
|
|
1428
1468
|
operationName === ''
|
|
1429
|
-
? await this.operationStream('execute', req, signal,
|
|
1430
|
-
|
|
1431
|
-
|
|
1469
|
+
? await this.operationStream('execute', req, signal, onStage, (o) => {
|
|
1470
|
+
delivered = true
|
|
1471
|
+
if (o.name === '' || o.name === undefined) return
|
|
1472
|
+
operationName = o.name
|
|
1473
|
+
opts.onOperation?.(o.name)
|
|
1432
1474
|
})
|
|
1433
1475
|
: await this.operationStream(
|
|
1434
1476
|
'waitExecution',
|
|
1435
1477
|
{ name: operationName },
|
|
1436
1478
|
signal,
|
|
1437
|
-
|
|
1479
|
+
onStage,
|
|
1480
|
+
() => {
|
|
1481
|
+
delivered = true
|
|
1482
|
+
},
|
|
1438
1483
|
)
|
|
1439
1484
|
if (op.done) return op
|
|
1440
1485
|
// A stream can END cleanly on an operation still QUEUED or EXECUTING
|
|
1441
1486
|
// (a server or proxy cutting long streams); the operation lives on,
|
|
1442
|
-
// so it is re-attached as a dropped stream is
|
|
1443
|
-
//
|
|
1444
|
-
// retried.
|
|
1487
|
+
// so it is re-attached as a dropped stream is. An unnamed one cannot
|
|
1488
|
+
// be re-attached, and a plain Error is not retried.
|
|
1445
1489
|
if (operationName === '') {
|
|
1446
1490
|
throw new Error('execution stream closed before the operation finished')
|
|
1447
1491
|
}
|
|
@@ -1454,12 +1498,18 @@ export class ReapiClient {
|
|
|
1454
1498
|
// (a restart lost it). Nothing is running to re-attach to, and the
|
|
1455
1499
|
// action is the same bytes, so it is executed again, on the same
|
|
1456
1500
|
// budget — as Bazel's executor does.
|
|
1457
|
-
if (code === NOT_FOUND && operationName !== '')
|
|
1458
|
-
|
|
1501
|
+
if (code === NOT_FOUND && operationName !== '') {
|
|
1502
|
+
operationName = ''
|
|
1503
|
+
reached = -1
|
|
1504
|
+
} else if (!isRetryable(code)) throw err
|
|
1459
1505
|
failure = err
|
|
1460
1506
|
}
|
|
1461
|
-
|
|
1462
|
-
|
|
1507
|
+
// An EXECUTING-then-cut loop never spends this budget; the executor's
|
|
1508
|
+
// stall bound (`exec.timeout` / `executeTimeoutMs`) ends it.
|
|
1509
|
+
empty = delivered ? 0 : empty + 1
|
|
1510
|
+
if (empty > RETRY_DELAYS_MS.length) throw failure
|
|
1511
|
+
if (advanced || performance.now() - opened >= reattach.steadyMs) cuts = 0
|
|
1512
|
+
const delay = reattach.backoffMs[Math.min(cuts++, reattach.backoffMs.length - 1)]!
|
|
1463
1513
|
await abortableSleep(delay, signal)
|
|
1464
1514
|
}
|
|
1465
1515
|
}
|
|
@@ -1495,7 +1545,7 @@ export class ReapiClient {
|
|
|
1495
1545
|
req: unknown,
|
|
1496
1546
|
signal?: AbortSignal,
|
|
1497
1547
|
onStage?: (stage: string) => void,
|
|
1498
|
-
|
|
1548
|
+
onOperation?: (op: Operation) => void,
|
|
1499
1549
|
): Promise<Operation> {
|
|
1500
1550
|
return new Promise((resolve, reject) => {
|
|
1501
1551
|
// An aborted signal never fires `abort` again, and the stream has no
|
|
@@ -1514,7 +1564,7 @@ export class ReapiClient {
|
|
|
1514
1564
|
signal?.addEventListener('abort', onAbort, { once: true })
|
|
1515
1565
|
stream.on('data', (op: Operation) => {
|
|
1516
1566
|
last = op
|
|
1517
|
-
|
|
1567
|
+
onOperation?.(op)
|
|
1518
1568
|
// ExecuteOperationMetadata carries the action's STAGE
|
|
1519
1569
|
// (QUEUED / EXECUTING / COMPLETED). Surfacing it is the difference
|
|
1520
1570
|
// between "vx is hung" and "the action is queued behind 40 others".
|