@vzn/vx-cache 0.0.651 → 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 +28 -14
- package/package.json +2 -2
- package/src/index.ts +248 -163
package/README.md
CHANGED
|
@@ -31,7 +31,8 @@ export default {
|
|
|
31
31
|
|
|
32
32
|
- `url` is the server's base URL (`https://cache.example.com`, without `/v1`), or `VX_CACHE_URL`.
|
|
33
33
|
It must be `https://`: every request carries the CI token, so `http://`
|
|
34
|
-
is taken only to this machine (`127.0.0.1`, `::1`, `localhost`).
|
|
34
|
+
is taken only to this machine (`127.0.0.1`, `::1`, `localhost`). It
|
|
35
|
+
takes no `?query` or `#fragment`: each route is appended to it. The
|
|
35
36
|
option is checked when the config loads, `VX_CACHE_URL` when a run
|
|
36
37
|
opens the cache.
|
|
37
38
|
- `issuer` (the CI issuer's URL, as its tokens name it) and `repository`
|
|
@@ -86,29 +87,42 @@ its remote is off for the run.
|
|
|
86
87
|
An upload answered with a 5xx or a 429, or whose connection reset, is sent again, three tries in
|
|
87
88
|
all: a PUT names its hash, so a second one stores the same bytes.
|
|
88
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
|
+
|
|
89
96
|
A refused token is said once and turns the remote off for the run; a
|
|
90
97
|
refused write (a job that reads only) turns off writes alone. Any other
|
|
91
98
|
refusal is a warning naming the server's own words, and the task reads
|
|
92
|
-
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 `***`.
|
|
93
101
|
|
|
94
102
|
Every read names scopes, each a ref, percent-encoded. A check names the run's refs in order (its branch, then the
|
|
95
103
|
branch it merges into, then the default branch; on CI `github()` or
|
|
96
104
|
`gitlab()` from `@vzn/vx-ci` names them, so declare it too: without it
|
|
97
105
|
a run reads git's branch and the default branch only), and the first ref
|
|
98
|
-
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
|
|
99
108
|
than a scope may be (512 characters, percent-encoded) is said and not
|
|
100
|
-
read; the others are. A run that reads no ref says so and stays local. The
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
107
120
|
bucket untouched by the server's code (a batch, split by that code, ran
|
|
108
|
-
out of Workers' CPU).
|
|
109
|
-
|
|
110
|
-
is
|
|
111
|
-
|
|
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
|
|
112
126
|
2 KiB; a longer one is refused, so a server's endless answer cannot fill
|
|
113
127
|
memory.
|
|
114
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,10 +144,14 @@ 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
|
-
/**
|
|
152
|
-
|
|
149
|
+
/**
|
|
150
|
+
* A check's answer per hash asked: a hash (up to 128), a size and a scope
|
|
151
|
+
* (up to 512, a long branch's) in JSON are under 700 bytes. At 512 an
|
|
152
|
+
* answer under the longest scopes was refused as too long.
|
|
153
|
+
*/
|
|
154
|
+
const CHECK_BYTES_PER_HASH = 1024
|
|
153
155
|
|
|
154
156
|
/**
|
|
155
157
|
* `s` with each C0/C1 control and DEL written as `\xNN`: a server's words
|
|
@@ -160,8 +162,22 @@ const printable = (s: string): string =>
|
|
|
160
162
|
// oxlint-disable-next-line no-control-regex
|
|
161
163
|
s.replace(/[\x00-\x1f\x7f-\x9f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
162
164
|
|
|
163
|
-
/**
|
|
164
|
-
|
|
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> {
|
|
165
181
|
let said = ''
|
|
166
182
|
try {
|
|
167
183
|
const { text } = await upTo(res, 2048)
|
|
@@ -175,7 +191,7 @@ async function refusal(res: Response): Promise<Error> {
|
|
|
175
191
|
} catch {
|
|
176
192
|
// quiet: the status alone still names the refusal
|
|
177
193
|
}
|
|
178
|
-
said = printable(said.replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
194
|
+
said = printable(hide(said).replace(/\s+/g, ' ').trim().slice(0, 200))
|
|
179
195
|
return new Error(`HTTP ${res.status}${said === '' ? '' : `: ${said}`}`)
|
|
180
196
|
}
|
|
181
197
|
|
|
@@ -311,40 +327,7 @@ function paced(
|
|
|
311
327
|
return { body, stop: window.stop }
|
|
312
328
|
}
|
|
313
329
|
|
|
314
|
-
/**
|
|
315
|
-
* Uploads: up to `IN_FLIGHT` at once, each one artifact by `PUT`, whose
|
|
316
|
-
* bytes reach the bucket untouched (2026-10-25: no batch upload; a
|
|
317
|
-
* batch passes through the server's code, which on Workers costs CPU by the
|
|
318
|
-
* byte, and a 40 MB one ran out of it).
|
|
319
|
-
*/
|
|
320
|
-
class Lane<T, R> {
|
|
321
|
-
private readonly waiting: { item: T; resolve(r: R): void; reject(err: unknown): void }[] = []
|
|
322
|
-
private open = 0
|
|
323
|
-
|
|
324
|
-
constructor(private readonly send: (item: T) => Promise<R>) {}
|
|
325
|
-
|
|
326
|
-
push(item: T): Promise<R> {
|
|
327
|
-
const { promise, resolve, reject } = Promise.withResolvers<R>()
|
|
328
|
-
this.waiting.push({ item, resolve, reject })
|
|
329
|
-
this.pump()
|
|
330
|
-
return promise
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
private pump(): void {
|
|
334
|
-
while (this.open < IN_FLIGHT && this.waiting.length > 0) {
|
|
335
|
-
const w = this.waiting.shift()!
|
|
336
|
-
this.open++
|
|
337
|
-
void this.send(w.item)
|
|
338
|
-
.then(w.resolve, w.reject)
|
|
339
|
-
.finally(() => {
|
|
340
|
-
this.open--
|
|
341
|
-
this.pump()
|
|
342
|
-
})
|
|
343
|
-
}
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
/** 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-*`). */
|
|
348
331
|
interface Limits {
|
|
349
332
|
readonly maxCheckHashes: number
|
|
350
333
|
readonly maxUploadBytes: number
|
|
@@ -363,14 +346,11 @@ const chunks = <T>(all: readonly T[], size: number): T[][] =>
|
|
|
363
346
|
all.slice(i * size, (i + 1) * size),
|
|
364
347
|
)
|
|
365
348
|
|
|
366
|
-
/** The limits an answer's `vx-max-*` headers name. */
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
function limitsOf(read: (k: keyof Limits) => unknown): Limits | undefined {
|
|
372
|
-
const maxCheckHashes = count(read('maxCheckHashes'))
|
|
373
|
-
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')
|
|
374
354
|
return maxCheckHashes === undefined || maxUploadBytes === undefined
|
|
375
355
|
? undefined
|
|
376
356
|
: { maxCheckHashes, maxUploadBytes }
|
|
@@ -378,7 +358,7 @@ function limitsOf(read: (k: keyof Limits) => unknown): Limits | undefined {
|
|
|
378
358
|
|
|
379
359
|
/**
|
|
380
360
|
* The seam over vx-cache v1. `has` and `hasMany` are `check`, `get` a
|
|
381
|
-
* download and `put` an upload by `PUT
|
|
361
|
+
* download and `put` an upload by `PUT`. A check names the
|
|
382
362
|
* scope of each ref the run reads, in order, and the first holding a hash
|
|
383
363
|
* answers it; a download names the scope its check found. A refused token (401/403) throws
|
|
384
364
|
* once, for core to report, and turns the layer off for the run; an
|
|
@@ -390,18 +370,25 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
390
370
|
private writesRefused = false
|
|
391
371
|
/** The refs a read names, in order: the first holding a hash answers it. */
|
|
392
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
|
+
}
|
|
393
380
|
/** The ref a check found each hash under, which its download names. */
|
|
394
381
|
private readonly found = new Map<string, string>()
|
|
395
|
-
/**
|
|
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
|
+
*/
|
|
396
389
|
private limits: Limits | undefined
|
|
397
|
-
private asked: Promise<Limits | undefined> | undefined
|
|
398
390
|
/** Where the layer says what is no failure of a request: core's `ctx.warn`. */
|
|
399
391
|
warn: (message: string) => void = () => {}
|
|
400
|
-
private limitsMissing = false
|
|
401
|
-
|
|
402
|
-
private readonly uploads = new Lane<{ hash: string; body: Blob }, void | false>((item) =>
|
|
403
|
-
this.upload(item),
|
|
404
|
-
)
|
|
405
392
|
|
|
406
393
|
/** The issuer and repository, named on every request by their hashes. */
|
|
407
394
|
private readonly named: Readonly<Record<string, string>>
|
|
@@ -420,6 +407,26 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
420
407
|
}
|
|
421
408
|
|
|
422
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
|
+
}
|
|
423
430
|
/** The token a run running its tasks here lent (`CacheContext.lent`). */
|
|
424
431
|
lent: (() => string | undefined) | undefined
|
|
425
432
|
|
|
@@ -459,6 +466,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
459
466
|
* run on every worker. A refusal (a 4xx) is final.
|
|
460
467
|
*/
|
|
461
468
|
private async askOnce(ask: URL): Promise<Response> {
|
|
469
|
+
this.sent.add(this.env.ACTIONS_ID_TOKEN_REQUEST_TOKEN!)
|
|
462
470
|
for (let attempt = 1; ; attempt++) {
|
|
463
471
|
let res: Response | undefined
|
|
464
472
|
let failure: unknown
|
|
@@ -486,7 +494,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
486
494
|
let jwt: unknown
|
|
487
495
|
try {
|
|
488
496
|
const res = await this.askOnce(ask)
|
|
489
|
-
if (!res.ok) throw await refusal(res)
|
|
497
|
+
if (!res.ok) throw await refusal(res, this.hidden)
|
|
490
498
|
jwt = ((await jsonUpTo(res, SMALL_ANSWER_BYTES, 'it')) as { value?: unknown } | null)?.value
|
|
491
499
|
if (typeof jwt !== 'string' || jwt === '') throw new Error('no token in its answer')
|
|
492
500
|
} catch (err) {
|
|
@@ -507,7 +515,11 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
507
515
|
* lends while its policy reads, and a refused token lent was refused on
|
|
508
516
|
* every worker.
|
|
509
517
|
*/
|
|
510
|
-
|
|
518
|
+
lend(): Promise<LentAccess | undefined> {
|
|
519
|
+
return this.guard(this.lending())
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private async lending(): Promise<LentAccess | undefined> {
|
|
511
523
|
if (this.disabled || this.lent?.() !== undefined) return undefined
|
|
512
524
|
const jwt = await this.bearer(LEND_MARGIN_MS)
|
|
513
525
|
if (jwt === '') return undefined
|
|
@@ -541,6 +553,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
541
553
|
this.tokenFailed = true
|
|
542
554
|
throw err
|
|
543
555
|
}
|
|
556
|
+
if (bearer !== '') this.sent.add(bearer)
|
|
544
557
|
const ms = init.timeoutMs ?? this.config.timeoutMs
|
|
545
558
|
const controller = new AbortController()
|
|
546
559
|
let answer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -592,7 +605,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
592
605
|
return undefined
|
|
593
606
|
}
|
|
594
607
|
throw new Error(
|
|
595
|
-
`${(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`,
|
|
596
609
|
)
|
|
597
610
|
}
|
|
598
611
|
if (res.status === 401 || res.status === 403) {
|
|
@@ -603,7 +616,7 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
603
616
|
return undefined
|
|
604
617
|
}
|
|
605
618
|
throw new Error(
|
|
606
|
-
`${(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`,
|
|
607
620
|
)
|
|
608
621
|
}
|
|
609
622
|
return res
|
|
@@ -616,73 +629,31 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
616
629
|
})
|
|
617
630
|
}
|
|
618
631
|
|
|
619
|
-
/**
|
|
620
|
-
* The server's limits: `/v1/capabilities`, asked once and kept, then
|
|
621
|
-
* whatever each check names. Unnamed, said once: checks go one hash at
|
|
622
|
-
* a time.
|
|
623
|
-
*/
|
|
624
|
-
private async limitsNow(): Promise<Limits | undefined> {
|
|
625
|
-
if (this.limits !== undefined) return this.limits
|
|
626
|
-
const asked = (this.asked ??= (async () => {
|
|
627
|
-
const res = await this.request('/v1/capabilities', { method: 'GET' })
|
|
628
|
-
if (res === undefined) return undefined
|
|
629
|
-
if (res.status !== 200) throw await refusal(res)
|
|
630
|
-
const body = (await jsonOf(res, '/v1/capabilities', SMALL_ANSWER_BYTES)) as Record<
|
|
631
|
-
string,
|
|
632
|
-
unknown
|
|
633
|
-
> | null
|
|
634
|
-
const named = limitsOf((k) => body?.[k])
|
|
635
|
-
if (named === undefined) this.limitsUnnamed(`/v1/capabilities (HTTP ${res.status})`)
|
|
636
|
-
return named
|
|
637
|
-
})())
|
|
638
|
-
// A failed ask is asked again by the next check: kept, two failures
|
|
639
|
-
// while a server restarted failed every read of the run (2026-10-26).
|
|
640
|
-
// quiet: the awaited ask rethrows it; this only forgets it
|
|
641
|
-
asked.catch(() => {
|
|
642
|
-
if (this.asked === asked) this.asked = undefined
|
|
643
|
-
})
|
|
644
|
-
this.limits ??= await asked
|
|
645
|
-
return this.limits
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
private limitsUnnamed(where: string): void {
|
|
649
|
-
if (this.limitsMissing) return
|
|
650
|
-
this.limitsMissing = true
|
|
651
|
-
this.warn(
|
|
652
|
-
`[vx] vx-cache: ${where} named no limits (maxCheckHashes, maxUploadBytes); checks go one hash at a time`,
|
|
653
|
-
)
|
|
654
|
-
}
|
|
655
|
-
|
|
656
|
-
/** 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. */
|
|
657
633
|
private unsaid: Error | undefined
|
|
658
|
-
/**
|
|
659
|
-
private
|
|
634
|
+
/** A `replace` refused was said: it is said once a run. */
|
|
635
|
+
private saidNoReplace = false
|
|
660
636
|
|
|
661
|
-
/** Throws the refusal
|
|
637
|
+
/** Throws the refusal `open`'s ask met, once. */
|
|
662
638
|
private sayUnsaid(): void {
|
|
663
639
|
const err = this.unsaid
|
|
664
640
|
this.unsaid = undefined
|
|
665
|
-
if (err !== undefined
|
|
666
|
-
this.said.add(err)
|
|
667
|
-
throw err
|
|
668
|
-
}
|
|
641
|
+
if (err !== undefined) throw err
|
|
669
642
|
}
|
|
670
643
|
|
|
671
644
|
/**
|
|
672
645
|
* Core's `open`: called only when the run's policy reads or writes this
|
|
673
|
-
* remote, so a `--no-cache` run or a plan asks
|
|
674
|
-
*
|
|
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.
|
|
675
650
|
*/
|
|
676
651
|
async open(): Promise<void> {
|
|
677
|
-
this.
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
this.limitsNow().catch((err: unknown) => {
|
|
683
|
-
// A refusal turned the layer off, and is said only once: the first
|
|
684
|
-
// check says it. Anything else, the first check asks again and says.
|
|
685
|
-
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))
|
|
686
657
|
})
|
|
687
658
|
}
|
|
688
659
|
|
|
@@ -696,33 +667,42 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
696
667
|
* keeps apart from "none held" (an empty map rewrote every key it asked
|
|
697
668
|
* as a miss in `remote_hits`, and the next run fetched none of them).
|
|
698
669
|
*/
|
|
699
|
-
|
|
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> {
|
|
700
675
|
const held = new Map<string, ArtifactInfo>()
|
|
701
676
|
this.sayUnsaid()
|
|
702
677
|
if (this.disabled || this.refs.length === 0) return null
|
|
703
|
-
const scopes = this.
|
|
678
|
+
const scopes = this.scopes
|
|
704
679
|
const ask = [...new Set(hashes)]
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
}
|
|
714
|
-
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)))
|
|
715
688
|
}
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
// scope at once was refused, every time).
|
|
720
|
-
const span = Math.min(scopes.length, max)
|
|
721
|
-
const per = Math.max(1, Math.floor(max / span))
|
|
722
|
-
const spans = chunks(scopes, span)
|
|
723
|
-
const parts = await Promise.all(
|
|
724
|
-
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),
|
|
725
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
|
|
726
706
|
for (const part of parts)
|
|
727
707
|
for (const [hash, { size, scope }] of part) {
|
|
728
708
|
if (held.has(hash)) continue
|
|
@@ -732,15 +712,27 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
732
712
|
return held
|
|
733
713
|
}
|
|
734
714
|
|
|
735
|
-
/**
|
|
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
|
+
*/
|
|
736
721
|
private async check(
|
|
737
722
|
scopes: readonly string[],
|
|
738
723
|
hashes: readonly string[],
|
|
724
|
+
again: boolean,
|
|
739
725
|
): Promise<Map<string, { size: number; scope: string }>> {
|
|
740
726
|
const res = await this.json('/v1/artifacts', { hashes, scopes })
|
|
741
727
|
if (res === undefined) return new Map()
|
|
742
|
-
if (res.status !== 200)
|
|
743
|
-
|
|
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
|
+
}
|
|
744
736
|
const body = (await jsonOf(
|
|
745
737
|
res,
|
|
746
738
|
'/v1/artifacts',
|
|
@@ -767,44 +759,104 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
767
759
|
return out
|
|
768
760
|
}
|
|
769
761
|
|
|
770
|
-
/**
|
|
771
|
-
|
|
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> {
|
|
772
776
|
this.sayUnsaid()
|
|
773
777
|
const at = this.found.get(hash)
|
|
774
|
-
|
|
775
|
-
|
|
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))
|
|
776
784
|
const res = await this.request(`/v1/artifacts/${hash}`, {
|
|
777
785
|
method: 'GET',
|
|
778
|
-
headers: { 'vx-scope':
|
|
786
|
+
headers: { 'vx-scope': some.join(', ') },
|
|
779
787
|
streamed: true,
|
|
780
788
|
})
|
|
781
|
-
if (res === undefined) return
|
|
789
|
+
if (res === undefined) return undefined
|
|
782
790
|
if (res.status === 404) {
|
|
783
791
|
await res.body?.cancel()
|
|
792
|
+
next += some.length
|
|
784
793
|
continue
|
|
785
794
|
}
|
|
786
|
-
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
|
+
}
|
|
787
804
|
return { body: res, durationMs: undefined }
|
|
788
805
|
}
|
|
789
806
|
return null
|
|
790
807
|
}
|
|
791
808
|
|
|
792
809
|
/** An artifact over the server's upload limit is not sent: it is said. */
|
|
793
|
-
|
|
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> {
|
|
794
823
|
// With no token a job reads only: nothing is uploaded, and none counted.
|
|
795
824
|
if (this.disabled || this.writesRefused || !hasCiToken(this.env, this.lent?.() !== undefined))
|
|
796
825
|
return false
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
)
|
|
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
|
|
802
835
|
return false
|
|
803
836
|
}
|
|
804
|
-
|
|
837
|
+
if (this.overLimit(hash, body)) return false
|
|
838
|
+
return this.upload({ hash, body })
|
|
805
839
|
}
|
|
806
840
|
|
|
807
|
-
/**
|
|
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
|
+
*/
|
|
808
860
|
private async upload(item: { hash: string; body: Blob }): Promise<void | false> {
|
|
809
861
|
// Queued before the remote or its writes were turned off.
|
|
810
862
|
if (this.disabled || this.writesRefused) return false
|
|
@@ -832,14 +884,39 @@ export class VxCacheLayer implements RemoteCacheLayer {
|
|
|
832
884
|
}
|
|
833
885
|
if (res === undefined) return false
|
|
834
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
|
+
}
|
|
835
893
|
if ((res.status !== 429 && res.status < 500) || attempt === ASK_ATTEMPTS)
|
|
836
|
-
throw await refusal(res)
|
|
894
|
+
throw await refusal(res, this.hidden)
|
|
837
895
|
await res.body?.cancel()
|
|
838
896
|
await this.wait(1000 * attempt)
|
|
839
897
|
}
|
|
840
898
|
}
|
|
841
899
|
}
|
|
842
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
|
+
|
|
843
920
|
/** The issuer and repository as a key names them: the issuer's URL with no trailing slash, `org/name` lowercased. */
|
|
844
921
|
function namedOf(options: VxCacheOptions): { issuer: string; repository: string } {
|
|
845
922
|
const { issuer, repository } = options as Partial<VxCacheOptions>
|
|
@@ -865,10 +942,12 @@ function shown(url: string): string {
|
|
|
865
942
|
* http:// to another host sent it in clear.
|
|
866
943
|
*/
|
|
867
944
|
function secure(url: string): boolean {
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
945
|
+
// Parsed as the request will be: a scheme in any case, no url it cannot carry.
|
|
946
|
+
const u = URL.parse(url)
|
|
947
|
+
if (u === null || u.host === '') return false
|
|
948
|
+
if (u.protocol === 'https:') return true
|
|
949
|
+
if (u.protocol !== 'http:') return false
|
|
950
|
+
return u.hostname === '127.0.0.1' || u.hostname === '[::1]' || u.hostname === 'localhost'
|
|
872
951
|
}
|
|
873
952
|
|
|
874
953
|
/** Options over `VX_CACHE_URL`; `undefined`: no server named. */
|
|
@@ -885,6 +964,12 @@ export function resolveVxCacheConfig(
|
|
|
885
964
|
throw new UserError(
|
|
886
965
|
`vxCache(): ${from} must be https (http only to 127.0.0.1, ::1 or localhost), got ${shown(url)}`,
|
|
887
966
|
)
|
|
967
|
+
// Each route is appended to the url: past a `?` or `#` it was query or
|
|
968
|
+
// fragment, and every request asked the server's root.
|
|
969
|
+
if (/[?#]/.test(url))
|
|
970
|
+
throw new UserError(
|
|
971
|
+
`vxCache(): ${from} takes no ?query or #fragment: each route is appended to it, got ${shown(url)}`,
|
|
972
|
+
)
|
|
888
973
|
// Each route is the url's /v1/…: one that names /v1 asked /v1/v1/…, and every answer was a 404.
|
|
889
974
|
if (url.endsWith('/v1'))
|
|
890
975
|
throw new UserError(
|