@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.
Files changed (3) hide show
  1. package/README.md +28 -14
  2. package/package.json +2 -2
  3. 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`). The
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. 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
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 run asks the server's limits once
101
- (`/v1/capabilities`) and checks in chunks of its `maxCheckHashes`
102
- lookups, all at once; a run reading more refs than one check takes
103
- (Workers' default is 6) spreads each hash's scopes over several. It
104
- keeps to the limits each `/v1/artifacts` answer names, a refusal's too,
105
- so a server restarted with lower ones refuses one check, not the run's. Each
106
- 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
107
120
  bucket untouched by the server's code (a batch, split by that code, ran
108
- out of Workers' CPU). While the server names no limits (said once),
109
- checks go one hash at a time: no limit is guessed. An artifact over the server's upload limit is not sent: it
110
- is said once. An answer is read no further than its use: 64 KiB for the
111
- 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
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.651",
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.651"
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,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
- /** 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
- /** A check's answer per hash asked: a hash, a size and a scope in JSON are under 150 bytes. */
152
- const CHECK_BYTES_PER_HASH = 512
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
- /** A refusal, named by its status and the server's own `message`. */
164
- 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> {
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
- const limitsNamed = (res: Response): Limits | undefined =>
368
- limitsOf((k) => Number(res.headers.get(LIMIT_HEADERS[k]) ?? NaN))
369
-
370
- /** Each limit, or undefined when one is missing or bad. */
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` (`Lane`). A check names the
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
- /** 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
+ */
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
- 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> {
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
- /** Refusals a call threw: a check that waited on the start's ask threw it too. */
659
- private readonly said = new WeakSet<object>()
634
+ /** A `replace` refused was said: it is said once a run. */
635
+ private saidNoReplace = false
660
636
 
661
- /** Throws the refusal met while starting, once. */
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 && !this.said.has(err)) {
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 the server (and GitHub)
674
- * 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.
675
650
  */
676
651
  async open(): Promise<void> {
677
- this.start()
678
- }
679
-
680
- /** Starts asking for the limits, so the first check need not wait for them. */
681
- start(): void {
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
- 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> {
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.refs.map(scopeOf)
678
+ const scopes = this.scopes
704
679
  const ask = [...new Set(hashes)]
705
- let limits: Limits | undefined
706
- try {
707
- limits = await this.limitsNow()
708
- } catch (err) {
709
- // A refusal turned the layer off: each waiter shares it, the first says it.
710
- if (this.disabled && err instanceof Error) {
711
- if (this.said.has(err)) return null
712
- this.said.add(err)
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
- const max = limits?.maxCheckHashes ?? scopes.length
717
- // More refs than one check takes: each hash's scopes span several
718
- // checks, the first in order holding it answering (a check of every
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
- /** `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
+ */
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) throw await refusal(res)
743
- 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
+ }
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
- /** The scope a check found it under, else each ref's in order until one holds it. */
771
- 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> {
772
776
  this.sayUnsaid()
773
777
  const at = this.found.get(hash)
774
- for (const scope of at === undefined ? this.refs.map(scopeOf) : [at]) {
775
- 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))
776
784
  const res = await this.request(`/v1/artifacts/${hash}`, {
777
785
  method: 'GET',
778
- headers: { 'vx-scope': scope },
786
+ headers: { 'vx-scope': some.join(', ') },
779
787
  streamed: true,
780
788
  })
781
- if (res === undefined) return null
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) 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
+ }
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
- 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> {
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
- const max = this.limits?.maxUploadBytes
798
- if (max !== undefined && body.size > max) {
799
- this.warn(
800
- `[vx] vx-cache: ${hash} is ${body.size} bytes, over the server's upload limit of ${max}: not uploaded`,
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
- return this.uploads.push({ hash, body })
837
+ if (this.overLimit(hash, body)) return false
838
+ return this.upload({ hash, body })
805
839
  }
806
840
 
807
- /** 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
+ */
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
- if (/^https:\/\/\S/.test(url)) return true
869
- if (!/^http:\/\/\S/.test(url)) return false
870
- const host = URL.parse(url)?.hostname
871
- return host === '127.0.0.1' || host === '[::1]' || host === 'localhost'
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(