@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.
Files changed (3) hide show
  1. package/README.md +26 -13
  2. package/package.json +2 -2
  3. 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. A ref longer
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 run asks the server's limits once
102
- (`/v1/capabilities`) and checks in chunks of its `maxCheckHashes`
103
- lookups, all at once; a run reading more refs than one check takes
104
- (Workers' default is 6) spreads each hash's scopes over several. It
105
- keeps to the limits each `/v1/artifacts` answer names, a refusal's too,
106
- so a server restarted with lower ones refuses one check, not the run's. Each
107
- upload is one artifact by `PUT`, up to four at once: its bytes reach the
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). While the server names no limits (said once),
110
- checks go one hash at a time: no limit is guessed. An artifact over the server's upload limit is not sent: it
111
- is said once. An answer is read no further than its use: 64 KiB for the
112
- capabilities or a token, 512 bytes a hash for a check, a refusal's first
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.652",
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.652"
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 each gap between its bytes. */
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
- /** Capabilities and a CI token are a few hundred bytes: room to spare. */
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
- /** A refusal, named by its status and the server's own `message`. */
168
- async function refusal(res: Response): Promise<Error> {
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
- const limitsNamed = (res: Response): Limits | undefined =>
372
- limitsOf((k) => Number(res.headers.get(LIMIT_HEADERS[k]) ?? NaN))
373
-
374
- /** Each limit, or undefined when one is missing or bad. */
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` (`Lane`). A check names the
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
- /** The limits the server named, at `/v1/capabilities` and on its last check; nothing is guessed. */
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
- async lend(): Promise<LentAccess | undefined> {
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
- /** Refusals a call threw: a check that waited on the start's ask threw it too. */
663
- private readonly said = new WeakSet<object>()
634
+ /** A `replace` refused was said: it is said once a run. */
635
+ private saidNoReplace = false
664
636
 
665
- /** Throws the refusal met while starting, once. */
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 && !this.said.has(err)) {
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 the server (and GitHub)
678
- * nothing.
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.start()
682
- }
683
-
684
- /** Starts asking for the limits, so the first check need not wait for them. */
685
- start(): void {
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
- async hasMany(hashes: readonly string[]): Promise<Map<string, ArtifactInfo> | null> {
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.refs.map(scopeOf)
678
+ const scopes = this.scopes
708
679
  const ask = [...new Set(hashes)]
709
- let limits: Limits | undefined
710
- try {
711
- limits = await this.limitsNow()
712
- } catch (err) {
713
- // A refusal turned the layer off: each waiter shares it, the first says it.
714
- if (this.disabled && err instanceof Error) {
715
- if (this.said.has(err)) return null
716
- this.said.add(err)
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
- const max = limits?.maxCheckHashes ?? scopes.length
721
- // More refs than one check takes: each hash's scopes span several
722
- // checks, the first in order holding it answering (a check of every
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
- /** `POST /v1/artifacts` (Turbo's batch query): the size and scope of each hash held. */
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) throw await refusal(res)
747
- if (limitsNamed(res) === undefined) this.limitsUnnamed(`the check (HTTP ${res.status})`)
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
- /** The scope a check found it under, else each ref's in order until one holds it. */
775
- async get(hash: string): Promise<{ body: Response; durationMs: undefined } | null> {
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
- for (const scope of at === undefined ? this.refs.map(scopeOf) : [at]) {
779
- if (this.disabled) return null
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': scope },
786
+ headers: { 'vx-scope': some.join(', ') },
783
787
  streamed: true,
784
788
  })
785
- if (res === undefined) return null
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) throw await refusal(res)
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
- async put(hash: string, body: Blob, _meta: { durationMs: number }): Promise<void | false> {
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
- const max = this.limits?.maxUploadBytes
802
- if (max !== undefined && body.size > max) {
803
- this.warn(
804
- `[vx] vx-cache: ${hash} is ${body.size} bytes, over the server's upload limit of ${max}: not uploaded`,
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
- return this.uploads.push({ hash, body })
837
+ if (this.overLimit(hash, body)) return false
838
+ return this.upload({ hash, body })
809
839
  }
810
840
 
811
- /** One artifact by `PUT`: the server answers one its scope holds before reading the bytes. */
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>