@vzn/vx-cache 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 +26 -13
- package/package.json +2 -2
- package/src/index.ts +230 -157
package/README.md
CHANGED
|
@@ -87,29 +87,42 @@ its remote is off for the run.
|
|
|
87
87
|
An upload answered with a 5xx or a 429, or whose connection reset, is sent again, three tries in
|
|
88
88
|
all: a PUT names its hash, so a second one stores the same bytes.
|
|
89
89
|
|
|
90
|
+
An artifact is immutable on the server: one that did not read back
|
|
91
|
+
(corrupt) is not replaced. Its re-run's upload is not sent and not
|
|
92
|
+
counted as landed (a DTE worker is not told it did), and the run says
|
|
93
|
+
once that the remote misses the key until the bucket's lifecycle rule
|
|
94
|
+
expires it (`@vzn/vx-cache-server`'s Expiry).
|
|
95
|
+
|
|
90
96
|
A refused token is said once and turns the remote off for the run; a
|
|
91
97
|
refused write (a job that reads only) turns off writes alone. Any other
|
|
92
98
|
refusal is a warning naming the server's own words, and the task reads
|
|
93
|
-
as a miss.
|
|
99
|
+
as a miss. A token the request sent (the job's OIDC token, a lent one,
|
|
100
|
+
`VX_CACHE_TOKEN`) that those words or any error quote is written `***`.
|
|
94
101
|
|
|
95
102
|
Every read names scopes, each a ref, percent-encoded. A check names the run's refs in order (its branch, then the
|
|
96
103
|
branch it merges into, then the default branch; on CI `github()` or
|
|
97
104
|
`gitlab()` from `@vzn/vx-ci` names them, so declare it too: without it
|
|
98
105
|
a run reads git's branch and the default branch only), and the first ref
|
|
99
|
-
holding a hash answers it; its download names that ref
|
|
106
|
+
holding a hash answers it; its download names that ref, and one the
|
|
107
|
+
check has not answered yet names them all in one request. A ref longer
|
|
100
108
|
than a scope may be (512 characters, percent-encoded) is said and not
|
|
101
|
-
read; the others are. A run that reads no ref says so and stays local. The
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
109
|
+
read; the others are. A run that reads no ref says so and stays local. The server is asked nothing until a
|
|
110
|
+
check, a download or an upload, so a run the local cache answers sends
|
|
111
|
+
it no request. Its limits are not asked apart: each `/v1/artifacts`
|
|
112
|
+
answer names them, a refusal's too, and no limit is guessed. A check
|
|
113
|
+
before any answer goes whole; one refused for its size is sent again,
|
|
114
|
+
once, in chunks of the `maxCheckHashes` lookups its refusal names, all at
|
|
115
|
+
once, so a server restarted with lower ones costs one check a round-trip,
|
|
116
|
+
not the run its reads. A run reading more refs than one check takes
|
|
117
|
+
(Workers' default is 6) spreads each hash's scopes over several. Each
|
|
118
|
+
upload is one artifact by `PUT`, as many at once as core's
|
|
119
|
+
`cache.concurrency.upload` lets (64 to 96 by default): its bytes reach the
|
|
108
120
|
bucket untouched by the server's code (a batch, split by that code, ran
|
|
109
|
-
out of Workers' CPU).
|
|
110
|
-
|
|
111
|
-
is
|
|
112
|
-
|
|
121
|
+
out of Workers' CPU). An artifact over the server's upload limit is not sent: it
|
|
122
|
+
is said. Before any answer has named the limits, the first such upload
|
|
123
|
+
is sent and refused `413`, which names them: it is said the same way,
|
|
124
|
+
never a failed upload. An answer is read no further than its use: 64 KiB for a
|
|
125
|
+
token, 1 KiB a hash for a check, a refusal's first
|
|
113
126
|
2 KiB; a longer one is refused, so a server's endless answer cannot fill
|
|
114
127
|
memory.
|
|
115
128
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-cache",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.653",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "vxCache(): a vx cache plugin for a server that speaks the vx-cache protocol, such as @vzn/vx-cache-server.",
|
|
6
6
|
"keywords": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"jose: the tests sign the CI tokens the server checks."
|
|
33
33
|
],
|
|
34
34
|
"peerDependencies": {
|
|
35
|
-
"@vzn/vx": "^0.0.
|
|
35
|
+
"@vzn/vx": "^0.0.653"
|
|
36
36
|
},
|
|
37
37
|
"repository": {
|
|
38
38
|
"type": "git",
|
package/src/index.ts
CHANGED
|
@@ -26,7 +26,7 @@ export interface VxCacheOptions {
|
|
|
26
26
|
issuer: string
|
|
27
27
|
/** The repository at that issuer, `org/name`. */
|
|
28
28
|
repository: string
|
|
29
|
-
/** Per-request deadline for reads and queries (default 30 s); a download's bounds its answer, then
|
|
29
|
+
/** Per-request deadline for reads and queries (default 30 s); a download's bounds its answer, then its bytes must arrive at 1 MiB a second over each. */
|
|
30
30
|
timeoutMs?: number
|
|
31
31
|
/** … and for an upload (default 60 s): its bytes must leave at 1 MiB a second over each, then its answer come within one. */
|
|
32
32
|
uploadTimeoutMs?: number
|
|
@@ -70,8 +70,6 @@ const LEND_MARGIN_MS = 5 * 60_000
|
|
|
70
70
|
const ASK_ATTEMPTS = 3
|
|
71
71
|
/** A connection that dropped, not a server that answered: an upload is sent again. */
|
|
72
72
|
const RESET = new Set(['ECONNRESET', 'EPIPE', 'ECONNREFUSED'])
|
|
73
|
-
/** Uploads open at once; more wait. */
|
|
74
|
-
const IN_FLIGHT = 4
|
|
75
73
|
|
|
76
74
|
/**
|
|
77
75
|
* The names a request sets itself: one of them in `headers`, in another
|
|
@@ -146,7 +144,7 @@ async function jsonUpTo(res: Response, max: number, what: string): Promise<unkno
|
|
|
146
144
|
return JSON.parse(text)
|
|
147
145
|
}
|
|
148
146
|
|
|
149
|
-
/**
|
|
147
|
+
/** A CI token's answer is a few hundred bytes: room to spare. */
|
|
150
148
|
const SMALL_ANSWER_BYTES = 64 * 1024
|
|
151
149
|
/**
|
|
152
150
|
* A check's answer per hash asked: a hash (up to 128), a size and a scope
|
|
@@ -164,8 +162,22 @@ const printable = (s: string): string =>
|
|
|
164
162
|
// oxlint-disable-next-line no-control-regex
|
|
165
163
|
s.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
166
164
|
|
|
167
|
-
/**
|
|
168
|
-
|
|
165
|
+
/** Shorter values are not hidden, as core masks none under 6 characters: each would blank common words. */
|
|
166
|
+
const MIN_TOKEN_CHARS = 6
|
|
167
|
+
|
|
168
|
+
/** `text` with each of `tokens` written `***`. */
|
|
169
|
+
function hideTokens(text: string, tokens: Iterable<string>): string {
|
|
170
|
+
let out = text
|
|
171
|
+
for (const token of tokens)
|
|
172
|
+
if (token.length >= MIN_TOKEN_CHARS) out = out.replaceAll(token, '***')
|
|
173
|
+
return out
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* A refusal, named by its status and the server's own `message`, each
|
|
178
|
+
* token `hide` knows written `***` before the cut, so none is left in part.
|
|
179
|
+
*/
|
|
180
|
+
async function refusal(res: Response, hide: (text: string) => string): Promise<Error> {
|
|
169
181
|
let said = ''
|
|
170
182
|
try {
|
|
171
183
|
const { text } = await upTo(res, 2048)
|
|
@@ -179,7 +191,7 @@ async function refusal(res: Response): Promise<Error> {
|
|
|
179
191
|
} catch {
|
|
180
192
|
// quiet: the status alone still names the refusal
|
|
181
193
|
}
|
|
182
|
-
said = printable(said.replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
194
|
+
said = printable(hide(said).replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
183
195
|
return new Error(`HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
|
|
184
196
|
}
|
|
185
197
|
|
|
@@ -315,40 +327,7 @@ function paced(
|
|
|
315
327
|
return { body, stop: window.stop }
|
|
316
328
|
}
|
|
317
329
|
|
|
318
|
-
/**
|
|
319
|
-
* Uploads: up to `IN_FLIGHT` at once, each one artifact by `PUT`, whose
|
|
320
|
-
* bytes reach the bucket untouched (2026-10-25: no batch upload; a
|
|
321
|
-
* batch passes through the server's code, which on Workers costs CPU by the
|
|
322
|
-
* byte, and a 40 MB one ran out of it).
|
|
323
|
-
*/
|
|
324
|
-
class Lane<T, R> {
|
|
325
|
-
private readonly waiting: { item: T; resolve(r: R): void; reject(err: unknown): void }[] = []
|
|
326
|
-
private open = 0
|
|
327
|
-
|
|
328
|
-
constructor(private readonly send: (item: T) => Promise<R>) {}
|
|
329
|
-
|
|
330
|
-
push(item: T): Promise<R> {
|
|
331
|
-
const { promise, resolve, reject } = Promise.withResolvers<R>()
|
|
332
|
-
this.waiting.push({ item, resolve, reject })
|
|
333
|
-
this.pump()
|
|
334
|
-
return promise
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
private pump(): void {
|
|
338
|
-
while (this.open < IN_FLIGHT && this.waiting.length > 0) {
|
|
339
|
-
const w = this.waiting.shift()!
|
|
340
|
-
this.open++
|
|
341
|
-
void this.send(w.item)
|
|
342
|
-
.then(w.resolve, w.reject)
|
|
343
|
-
.finally(() => {
|
|
344
|
-
this.open--
|
|
345
|
-
this.pump()
|
|
346
|
-
})
|
|
347
|
-
}
|
|
348
|
-
}
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/** The limits a server names at `/v1/capabilities` and on each check (`vx-max-*`). */
|
|
330
|
+
/** The limits a server names on each `/v1/artifacts` answer (`vx-max-*`). */
|
|
352
331
|
interface Limits {
|
|
353
332
|
readonly maxCheckHashes: number
|
|
354
333
|
readonly maxUploadBytes: number
|
|
@@ -367,14 +346,11 @@ const chunks = <T>(all: readonly T[], size: number): T[][] =>
|
|
|
367
346
|
all.slice(i * size, (i + 1) * size),
|
|
368
347
|
)
|
|
369
348
|
|
|
370
|
-
/** The limits an answer's `vx-max-*` headers name. */
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
function limitsOf(read: (k: keyof Limits) => unknown): Limits | undefined {
|
|
376
|
-
const maxCheckHashes = count(read('maxCheckHashes'))
|
|
377
|
-
const maxUploadBytes = count(read('maxUploadBytes'))
|
|
349
|
+
/** The limits an answer's `vx-max-*` headers name, or undefined when one is missing or bad. */
|
|
350
|
+
function limitsNamed(res: Response): Limits | undefined {
|
|
351
|
+
const read = (k: keyof Limits) => count(Number(res.headers.get(LIMIT_HEADERS[k]) ?? NaN))
|
|
352
|
+
const maxCheckHashes = read('maxCheckHashes')
|
|
353
|
+
const maxUploadBytes = read('maxUploadBytes')
|
|
378
354
|
return maxCheckHashes === undefined || maxUploadBytes === undefined
|
|
379
355
|
? undefined
|
|
380
356
|
: { maxCheckHashes, maxUploadBytes }
|
|
@@ -382,7 +358,7 @@ function limitsOf(read: (k: keyof Limits) => unknown): Limits | undefined {
|
|
|
382
358
|
|
|
383
359
|
/**
|
|
384
360
|
* The seam over vx-cache v1. `has` and `hasMany` are `check`, `get` a
|
|
385
|
-
* download and `put` an upload by `PUT
|
|
361
|
+
* download and `put` an upload by `PUT`. A check names the
|
|
386
362
|
* scope of each ref the run reads, in order, and the first holding a hash
|
|
387
363
|
* answers it; a download names the scope its check found. A refused token (401/403) throws
|
|
388
364
|
* once, for core to report, and turns the layer off for the run; an
|
|
@@ -394,18 +370,25 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
394
370
|
private writesRefused = false
|
|
395
371
|
/** The refs a read names, in order: the first holding a hash answers it. */
|
|
396
372
|
refs: readonly string[] = []
|
|
373
|
+
private scoped: { refs: readonly string[]; scopes: readonly string[] } | undefined
|
|
374
|
+
/** `refs` as the wire names them (`scopeOf`), encoded once. */
|
|
375
|
+
private get scopes(): readonly string[] {
|
|
376
|
+
if (this.scoped?.refs !== this.refs)
|
|
377
|
+
this.scoped = { refs: this.refs, scopes: this.refs.map(scopeOf) }
|
|
378
|
+
return this.scoped.scopes
|
|
379
|
+
}
|
|
397
380
|
/** The ref a check found each hash under, which its download names. */
|
|
398
381
|
private readonly found = new Map<string, string>()
|
|
399
|
-
/**
|
|
382
|
+
/**
|
|
383
|
+
* The limits the server's last answer named; none before its first.
|
|
384
|
+
* Nothing is guessed, nor asked apart: a check before any answer goes
|
|
385
|
+
* whole, and one refused for its size is sent again in the chunks its
|
|
386
|
+
* refusal names (`/v1/capabilities` cost every run a request, one
|
|
387
|
+
* round-trip before its first check, 2026-11-06).
|
|
388
|
+
*/
|
|
400
389
|
private limits: Limits | undefined
|
|
401
|
-
private asked: Promise<Limits | undefined> | undefined
|
|
402
390
|
/** Where the layer says what is no failure of a request: core's `ctx.warn`. */
|
|
403
391
|
warn: (message: string) => void = () => {}
|
|
404
|
-
private limitsMissing = false
|
|
405
|
-
|
|
406
|
-
private readonly uploads = new Lane<{ hash: string; body: Blob }, void | false>((item) =>
|
|
407
|
-
this.upload(item),
|
|
408
|
-
)
|
|
409
392
|
|
|
410
393
|
/** The issuer and repository, named on every request by their hashes. */
|
|
411
394
|
private readonly named: Readonly<Record<string, string>>
|
|
@@ -424,6 +407,26 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
424
407
|
}
|
|
425
408
|
|
|
426
409
|
private idToken: { jwt: string; exp: number } | undefined
|
|
410
|
+
/**
|
|
411
|
+
* Every token this layer sent: a server that quoted a request's
|
|
412
|
+
* Authorization header in its refusal printed it to the CI log and
|
|
413
|
+
* cache.db, as core masks only secret-named variables.
|
|
414
|
+
*/
|
|
415
|
+
private readonly sent = new Set<string>()
|
|
416
|
+
private readonly hidden = (text: string): string => hideTokens(text, this.sent)
|
|
417
|
+
|
|
418
|
+
/** `work`, each error it throws with every token sent hidden: the one way an error leaves the layer. */
|
|
419
|
+
private async guard<T>(work: Promise<T>): Promise<T> {
|
|
420
|
+
try {
|
|
421
|
+
return await work
|
|
422
|
+
} catch (err) {
|
|
423
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
424
|
+
const hidden = this.hidden(message)
|
|
425
|
+
if (hidden === message) throw err
|
|
426
|
+
const code = (err as { code?: unknown } | null)?.code
|
|
427
|
+
throw Object.assign(new Error(hidden), code === undefined ? {} : { code })
|
|
428
|
+
}
|
|
429
|
+
}
|
|
427
430
|
/** The token a run running its tasks here lent (`CacheContext.lent`). */
|
|
428
431
|
lent: (() => string | undefined) | undefined
|
|
429
432
|
|
|
@@ -463,6 +466,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
463
466
|
* run on every worker. A refusal (a 4xx) is final.
|
|
464
467
|
*/
|
|
465
468
|
private async askOnce(ask: URL): Promise<Response> {
|
|
469
|
+
this.sent.add(this.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN!)
|
|
466
470
|
for (let attempt = 1; ; attempt++) {
|
|
467
471
|
let res: Response | undefined
|
|
468
472
|
let failure: unknown
|
|
@@ -490,7 +494,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
490
494
|
let jwt: unknown
|
|
491
495
|
try {
|
|
492
496
|
const res = await this.askOnce(ask)
|
|
493
|
-
if (!res.ok) throw await refusal(res)
|
|
497
|
+
if (!res.ok) throw await refusal(res, this.hidden)
|
|
494
498
|
jwt = ((await jsonUpTo(res, SMALL_ANSWER_BYTES, 'it')) as { value?: unknown } | null)?.value
|
|
495
499
|
if (typeof jwt !== 'string' || jwt === '') throw new Error('no token in its answer')
|
|
496
500
|
} catch (err) {
|
|
@@ -511,7 +515,11 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
511
515
|
* lends while its policy reads, and a refused token lent was refused on
|
|
512
516
|
* every worker.
|
|
513
517
|
*/
|
|
514
|
-
|
|
518
|
+
lend(): Promise<LentAccess | undefined> {
|
|
519
|
+
return this.guard(this.lending())
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async lending(): Promise<LentAccess | undefined> {
|
|
515
523
|
if (this.disabled || this.lent?.() !== undefined) return undefined
|
|
516
524
|
const jwt = await this.bearer(LEND_MARGIN_MS)
|
|
517
525
|
if (jwt === '') return undefined
|
|
@@ -545,6 +553,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
545
553
|
this.tokenFailed = true
|
|
546
554
|
throw err
|
|
547
555
|
}
|
|
556
|
+
if (bearer !== '') this.sent.add(bearer)
|
|
548
557
|
const ms = init.timeoutMs ?? this.config.timeoutMs
|
|
549
558
|
const controller = new AbortController()
|
|
550
559
|
let answer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -596,7 +605,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
596
605
|
return undefined
|
|
597
606
|
}
|
|
598
607
|
throw new Error(
|
|
599
|
-
`${(await refusal(res)).message}: the write was refused (this job reads only); writes off for this run, reads go on`,
|
|
608
|
+
`${(await refusal(res, this.hidden)).message}: the write was refused (this job reads only); writes off for this run, reads go on`,
|
|
600
609
|
)
|
|
601
610
|
}
|
|
602
611
|
if (res.status === 401 || res.status === 403) {
|
|
@@ -607,7 +616,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
607
616
|
return undefined
|
|
608
617
|
}
|
|
609
618
|
throw new Error(
|
|
610
|
-
`${(await refusal(res)).message}: ${bearer === '' ? 'a read with no token was refused' : `the token was refused (this workspace names ${this.config.repository} at ${this.config.issuer})`}; remote cache off for this run`,
|
|
619
|
+
`${(await refusal(res, this.hidden)).message}: ${bearer === '' ? 'a read with no token was refused' : `the token was refused (this workspace names ${this.config.repository} at ${this.config.issuer})`}; remote cache off for this run`,
|
|
611
620
|
)
|
|
612
621
|
}
|
|
613
622
|
return res
|
|
@@ -620,73 +629,31 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
620
629
|
})
|
|
621
630
|
}
|
|
622
631
|
|
|
623
|
-
/**
|
|
624
|
-
* The server's limits: `/v1/capabilities`, asked once and kept, then
|
|
625
|
-
* whatever each check names. Unnamed, said once: checks go one hash at
|
|
626
|
-
* a time.
|
|
627
|
-
*/
|
|
628
|
-
private async limitsNow(): Promise<Limits | undefined> {
|
|
629
|
-
if (this.limits !== undefined) return this.limits
|
|
630
|
-
const asked = (this.asked ??= (async () => {
|
|
631
|
-
const res = await this.request('/v1/capabilities', { method: 'GET' })
|
|
632
|
-
if (res === undefined) return undefined
|
|
633
|
-
if (res.status !== 200) throw await refusal(res)
|
|
634
|
-
const body = (await jsonOf(res, '/v1/capabilities', SMALL_ANSWER_BYTES)) as Record<
|
|
635
|
-
string,
|
|
636
|
-
unknown
|
|
637
|
-
> | null
|
|
638
|
-
const named = limitsOf((k) => body?.[k])
|
|
639
|
-
if (named === undefined) this.limitsUnnamed(`/v1/capabilities (HTTP ${res.status})`)
|
|
640
|
-
return named
|
|
641
|
-
})())
|
|
642
|
-
// A failed ask is asked again by the next check: kept, two failures
|
|
643
|
-
// while a server restarted failed every read of the run (2026-10-26).
|
|
644
|
-
// quiet: the awaited ask rethrows it; this only forgets it
|
|
645
|
-
asked.catch(() => {
|
|
646
|
-
if (this.asked === asked) this.asked = undefined
|
|
647
|
-
})
|
|
648
|
-
this.limits ??= await asked
|
|
649
|
-
return this.limits
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
private limitsUnnamed(where: string): void {
|
|
653
|
-
if (this.limitsMissing) return
|
|
654
|
-
this.limitsMissing = true
|
|
655
|
-
this.warn(
|
|
656
|
-
`[vx] vx-cache: ${where} named no limits (maxCheckHashes, maxUploadBytes); checks go one hash at a time`,
|
|
657
|
-
)
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
/** A refusal met while starting, which no call could throw: the next check or download does. */
|
|
632
|
+
/** A token refusal met by `open`'s ask, which no call could throw: the next check or download does. */
|
|
661
633
|
private unsaid: Error | undefined
|
|
662
|
-
/**
|
|
663
|
-
private
|
|
634
|
+
/** A `replace` refused was said: it is said once a run. */
|
|
635
|
+
private saidNoReplace = false
|
|
664
636
|
|
|
665
|
-
/** Throws the refusal
|
|
637
|
+
/** Throws the refusal `open`'s ask met, once. */
|
|
666
638
|
private sayUnsaid(): void {
|
|
667
639
|
const err = this.unsaid
|
|
668
640
|
this.unsaid = undefined
|
|
669
|
-
if (err !== undefined
|
|
670
|
-
this.said.add(err)
|
|
671
|
-
throw err
|
|
672
|
-
}
|
|
641
|
+
if (err !== undefined) throw err
|
|
673
642
|
}
|
|
674
643
|
|
|
675
644
|
/**
|
|
676
645
|
* Core's `open`: called only when the run's policy reads or writes this
|
|
677
|
-
* remote, so a `--no-cache` run or a plan asks
|
|
678
|
-
*
|
|
646
|
+
* remote, so a `--no-cache` run or a plan asks GitHub nothing. Starts
|
|
647
|
+
* asking for the job's token, so the first check need not wait on
|
|
648
|
+
* GitHub; the server is asked nothing until a check, a download or an
|
|
649
|
+
* upload, so a run the local cache answers sends it none.
|
|
679
650
|
*/
|
|
680
651
|
async open(): Promise<void> {
|
|
681
|
-
this.
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
this.limitsNow().catch((err: unknown) => {
|
|
687
|
-
// A refusal turned the layer off, and is said only once: the first
|
|
688
|
-
// check says it. Anything else, the first check asks again and says.
|
|
689
|
-
if (this.disabled) this.unsaid = err instanceof Error ? err : new Error(String(err))
|
|
652
|
+
this.bearer().catch((err: unknown) => {
|
|
653
|
+
// Said by the first call; the requests that shared the ask say nothing.
|
|
654
|
+
if (this.tokenFailed) return
|
|
655
|
+
this.tokenFailed = true
|
|
656
|
+
this.unsaid = err instanceof Error ? err : new Error(String(err))
|
|
690
657
|
})
|
|
691
658
|
}
|
|
692
659
|
|
|
@@ -700,33 +667,42 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
700
667
|
* keeps apart from "none held" (an empty map rewrote every key it asked
|
|
701
668
|
* as a miss in `remote_hits`, and the next run fetched none of them).
|
|
702
669
|
*/
|
|
703
|
-
|
|
670
|
+
hasMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
|
|
671
|
+
return this.guard(this.lookup(hashes))
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
private async lookup(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
|
|
704
675
|
const held = new Map<string, ArtifactInfo>()
|
|
705
676
|
this.sayUnsaid()
|
|
706
677
|
if (this.disabled || this.refs.length === 0) return null
|
|
707
|
-
const scopes = this.
|
|
678
|
+
const scopes = this.scopes
|
|
708
679
|
const ask = [...new Set(hashes)]
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
}
|
|
718
|
-
throw err
|
|
680
|
+
const checks = (max: number, again: boolean) => {
|
|
681
|
+
// More refs than one check takes: each hash's scopes span several
|
|
682
|
+
// checks, the first in order holding it answering (a check of every
|
|
683
|
+
// scope at once was refused, every time).
|
|
684
|
+
const span = Math.min(scopes.length, max)
|
|
685
|
+
const per = Math.max(1, Math.floor(max / span))
|
|
686
|
+
const spans = chunks(scopes, span)
|
|
687
|
+
return chunks(ask, per).flatMap((some) => spans.map((s) => this.check(s, some, again)))
|
|
719
688
|
}
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
// scope at once was refused, every time).
|
|
724
|
-
const span = Math.min(scopes.length, max)
|
|
725
|
-
const per = Math.max(1, Math.floor(max / span))
|
|
726
|
-
const spans = chunks(scopes, span)
|
|
727
|
-
const parts = await Promise.all(
|
|
728
|
-
chunks(ask, per).flatMap((some) => spans.map((s) => this.check(s, some))),
|
|
689
|
+
// None named yet: one check of every lookup.
|
|
690
|
+
const first = await Promise.allSettled(
|
|
691
|
+
checks(this.limits?.maxCheckHashes ?? Math.max(1, ask.length * scopes.length), true),
|
|
729
692
|
)
|
|
693
|
+
const refused = first.flatMap((r) => (r.status === 'rejected' ? [r.reason as unknown] : []))
|
|
694
|
+
const failed = refused.find((err) => !(err instanceof TooMany))
|
|
695
|
+
if (failed !== undefined) throw failed
|
|
696
|
+
const parts =
|
|
697
|
+
refused.length === 0
|
|
698
|
+
? first.map(
|
|
699
|
+
(r) =>
|
|
700
|
+
(r as PromiseFulfilledResult<Map<string, { size: number; scope: string }>>).value,
|
|
701
|
+
)
|
|
702
|
+
: await settledAll(checks(Math.min(...refused.map((err) => (err as TooMany).max)), false))
|
|
703
|
+
// A token refused to `open`'s ask, which a check here shared.
|
|
704
|
+
this.sayUnsaid()
|
|
705
|
+
if (this.disabled) return null
|
|
730
706
|
for (const part of parts)
|
|
731
707
|
for (const [hash, { size, scope }] of part) {
|
|
732
708
|
if (held.has(hash)) continue
|
|
@@ -736,15 +712,27 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
736
712
|
return held
|
|
737
713
|
}
|
|
738
714
|
|
|
739
|
-
/**
|
|
715
|
+
/**
|
|
716
|
+
* `POST /v1/artifacts` (Turbo's batch query): the size and scope of each
|
|
717
|
+
* hash held. One refused for its size, its refusal naming a smaller
|
|
718
|
+
* `maxCheckHashes`, throws `TooMany` when `again`: the caller sends it
|
|
719
|
+
* in chunks.
|
|
720
|
+
*/
|
|
740
721
|
private async check(
|
|
741
722
|
scopes: readonly string[],
|
|
742
723
|
hashes: readonly string[],
|
|
724
|
+
again: boolean,
|
|
743
725
|
): Promise<Map<string, { size: number; scope: string }>> {
|
|
744
726
|
const res = await this.json('/v1/artifacts', { hashes, scopes })
|
|
745
727
|
if (res === undefined) return new Map()
|
|
746
|
-
if (res.status !== 200)
|
|
747
|
-
|
|
728
|
+
if (res.status !== 200) {
|
|
729
|
+
const max = limitsNamed(res)?.maxCheckHashes
|
|
730
|
+
if (again && max !== undefined && hashes.length * scopes.length > max) {
|
|
731
|
+
await res.body?.cancel()
|
|
732
|
+
throw new TooMany(max)
|
|
733
|
+
}
|
|
734
|
+
throw await refusal(res, this.hidden)
|
|
735
|
+
}
|
|
748
736
|
const body = (await jsonOf(
|
|
749
737
|
res,
|
|
750
738
|
'/v1/artifacts',
|
|
@@ -771,44 +759,104 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
771
759
|
return out
|
|
772
760
|
}
|
|
773
761
|
|
|
774
|
-
/**
|
|
775
|
-
|
|
762
|
+
/**
|
|
763
|
+
* The scope a check found it under, else every ref's in order in one
|
|
764
|
+
* request, the first holding it answering: one by one, a branch's run
|
|
765
|
+
* paid a round-trip and a 404 for its own scope before each download of
|
|
766
|
+
* main's (2026-11-06). More refs than the server's `maxCheckHashes` go
|
|
767
|
+
* in several, in order.
|
|
768
|
+
*/
|
|
769
|
+
get(hash: string): Promise<{ body: Response; durationMs: undefined } | null | undefined> {
|
|
770
|
+
return this.guard(this.download(hash))
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
private async download(
|
|
774
|
+
hash: string,
|
|
775
|
+
): Promise<{ body: Response; durationMs: undefined } | null | undefined> {
|
|
776
776
|
this.sayUnsaid()
|
|
777
777
|
const at = this.found.get(hash)
|
|
778
|
-
|
|
779
|
-
|
|
778
|
+
const scopes = at === undefined ? this.scopes : [at]
|
|
779
|
+
for (let next = 0; next < scopes.length;) {
|
|
780
|
+
// Off, or refused: no answer, which core keeps apart from a miss (a
|
|
781
|
+
// null forgot each hit the index kept, the remote never asked).
|
|
782
|
+
if (this.disabled) return undefined
|
|
783
|
+
const some = scopes.slice(next, next + (this.limits?.maxCheckHashes ?? scopes.length))
|
|
780
784
|
const res = await this.request(`/v1/artifacts/${hash}`, {
|
|
781
785
|
method: 'GET',
|
|
782
|
-
headers: { 'vx-scope':
|
|
786
|
+
headers: { 'vx-scope': some.join(', ') },
|
|
783
787
|
streamed: true,
|
|
784
788
|
})
|
|
785
|
-
if (res === undefined) return
|
|
789
|
+
if (res === undefined) return undefined
|
|
786
790
|
if (res.status === 404) {
|
|
787
791
|
await res.body?.cancel()
|
|
792
|
+
next += some.length
|
|
788
793
|
continue
|
|
789
794
|
}
|
|
790
|
-
if (res.status !== 200)
|
|
795
|
+
if (res.status !== 200) {
|
|
796
|
+
// Refused for its scopes: sent again in as many as the refusal names.
|
|
797
|
+
const max = limitsNamed(res)?.maxCheckHashes
|
|
798
|
+
if (max !== undefined && some.length > max) {
|
|
799
|
+
await res.body?.cancel()
|
|
800
|
+
continue
|
|
801
|
+
}
|
|
802
|
+
throw await refusal(res, this.hidden)
|
|
803
|
+
}
|
|
791
804
|
return { body: res, durationMs: undefined }
|
|
792
805
|
}
|
|
793
806
|
return null
|
|
794
807
|
}
|
|
795
808
|
|
|
796
809
|
/** An artifact over the server's upload limit is not sent: it is said. */
|
|
797
|
-
|
|
810
|
+
put(
|
|
811
|
+
hash: string,
|
|
812
|
+
body: Blob,
|
|
813
|
+
meta: { durationMs: number; replace?: true },
|
|
814
|
+
): Promise<void | false> {
|
|
815
|
+
return this.guard(this.store(hash, body, meta))
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
private async store(
|
|
819
|
+
hash: string,
|
|
820
|
+
body: Blob,
|
|
821
|
+
meta: { durationMs: number; replace?: true },
|
|
822
|
+
): Promise<void | false> {
|
|
798
823
|
// With no token a job reads only: nothing is uploaded, and none counted.
|
|
799
824
|
if (this.disabled || this.writesRefused || !hasCiToken(this.env, this.lent?.() !== undefined))
|
|
800
825
|
return false
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
)
|
|
826
|
+
// The server keeps the bytes a scope holds and answers 204 unread
|
|
827
|
+
// (PROTOCOL.md): a PUT reported the corrupt copy fixed, so no run (nor
|
|
828
|
+
// DTE peer) learned it was not, and each one downloaded it again.
|
|
829
|
+
if (meta.replace === true) {
|
|
830
|
+
if (!this.saidNoReplace)
|
|
831
|
+
this.warn(
|
|
832
|
+
`[vx] vx-cache: ${hash} did not read back, and a vx-cache server never replaces an artifact it holds: not uploaded; the remote misses it until the bucket's lifecycle rule expires it`,
|
|
833
|
+
)
|
|
834
|
+
this.saidNoReplace = true
|
|
806
835
|
return false
|
|
807
836
|
}
|
|
808
|
-
|
|
837
|
+
if (this.overLimit(hash, body)) return false
|
|
838
|
+
return this.upload({ hash, body })
|
|
809
839
|
}
|
|
810
840
|
|
|
811
|
-
/**
|
|
841
|
+
/** Says an artifact over the upload limit the server last named, which is not sent. */
|
|
842
|
+
private overLimit(hash: string, body: Blob): boolean {
|
|
843
|
+
const max = this.limits?.maxUploadBytes
|
|
844
|
+
if (max === undefined || body.size <= max) return false
|
|
845
|
+
this.warn(
|
|
846
|
+
`[vx] vx-cache: ${hash} is ${body.size} bytes, over the server's upload limit of ${max}: not uploaded`,
|
|
847
|
+
)
|
|
848
|
+
return true
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
/**
|
|
852
|
+
* One artifact by `PUT`, its bytes reaching the bucket untouched
|
|
853
|
+
* (2026-10-25: no batch upload; a batch passes through the server's code,
|
|
854
|
+
* which on Workers costs CPU by the byte, and a 40 MB one ran out of it).
|
|
855
|
+
* The server answers one its scope holds before reading the bytes. As
|
|
856
|
+
* many at once as core sends (`cache.concurrency.upload`): a cap of four
|
|
857
|
+
* here held a 1,500-task cold run 11 s past its last task on a remote
|
|
858
|
+
* 40 ms away, the setting unheard.
|
|
859
|
+
*/
|
|
812
860
|
private async upload(item: { hash: string; body: Blob }): Promise<void | false> {
|
|
813
861
|
// Queued before the remote or its writes were turned off.
|
|
814
862
|
if (this.disabled || this.writesRefused) return false
|
|
@@ -836,14 +884,39 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
836
884
|
}
|
|
837
885
|
if (res === undefined) return false
|
|
838
886
|
if (res.ok) return
|
|
887
|
+
// Sent before any answer named the limits: the 413 names them
|
|
888
|
+
// (PROTOCOL.md), so it is said as a later one is, never a failure.
|
|
889
|
+
if (res.status === 413 && this.overLimit(item.hash, item.body)) {
|
|
890
|
+
await res.body?.cancel()
|
|
891
|
+
return false
|
|
892
|
+
}
|
|
839
893
|
if ((res.status !== 429 && res.status < 500) || attempt === ASK_ATTEMPTS)
|
|
840
|
-
throw await refusal(res)
|
|
894
|
+
throw await refusal(res, this.hidden)
|
|
841
895
|
await res.body?.cancel()
|
|
842
896
|
await this.wait(1000 * attempt)
|
|
843
897
|
}
|
|
844
898
|
}
|
|
845
899
|
}
|
|
846
900
|
|
|
901
|
+
/**
|
|
902
|
+
* `Promise.all` that ends only once every check has: a refused chunk
|
|
903
|
+
* rejected the lookup while its siblings were still in flight, so their
|
|
904
|
+
* requests outlived the call that sent them.
|
|
905
|
+
*/
|
|
906
|
+
async function settledAll<T>(ps: readonly Promise<T>[]): Promise<T[]> {
|
|
907
|
+
const done = await Promise.allSettled(ps)
|
|
908
|
+
const failed = done.find((r) => r.status === 'rejected')
|
|
909
|
+
if (failed !== undefined) throw failed.reason
|
|
910
|
+
return done.map((r) => (r as PromiseFulfilledResult<T>).value)
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
/** A check refused for its size: sent again in checks of `max` lookups. */
|
|
914
|
+
class TooMany extends Error {
|
|
915
|
+
constructor(readonly max: number) {
|
|
916
|
+
super(`a check is at most ${max} lookups`)
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
847
920
|
/** The issuer and repository as a key names them: the issuer's URL with no trailing slash, `org/name` lowercased. */
|
|
848
921
|
function namedOf(options: VxCacheOptions): { issuer: string; repository: string } {
|
|
849
922
|
const { issuer, repository } = options as Partial<VxCacheOptions>
|