A streaming remote cache seam (2026-09-23, roadmap 2.2)
Status: IMPLEMENTED (2026-09-23) as written. Proof 1 measured with
packages/vx-bench/stream-remote-bench.ts on a 150 MiB artifact over a
disk-backed stub: peak RSS +495 MiB over the round trip before, +45 MiB
after. The signed Turbo download writes its temp first and signs it from
the file (the tag’s length prefix precedes the body, and a chunked
response declares no length), and the REAPI streamed read keeps the
digest check readBlob already made.
The remote cache seam is the last place a whole artifact must sit in
memory. RemoteCacheLayer.get resolves { body: ArrayBuffer } and
put takes ArrayBuffer | Uint8Array. A 150 MiB artifact therefore
costs 150 MiB of resident memory on every upload and every download,
times the upload pool. This page fixes the new contract before any code
changes, because the change breaks the plugin API and must land before
the 1.0 freeze (roadmap-1.0.md § 2.2).
The contract
Section titled “The contract”interface RemoteCacheLayer { has(hash: string): Promise<boolean> hasMany?(hashes: readonly string[]): Promise<Set<string> | null> /** `body` is read once, by core, straight into the local cache. */ get(hash: string): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> /** `body` is file-backed when the local store holds the artifact. */ put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void>}getreturnsBlob | Response. An HTTP plugin returns thefetchResponseitself, so the body streams from the socket to disk and is never collected. A plugin whose wire is chunked (REAPI’s ByteStream) returnsnew Response(readableStream). A plugin holding bytes returnsnew Blob([bytes]). Core never calls.arrayBuffer()on it.puttakes aBlob. Core passesBun.file(<local artifact>), so a plugin that hands the Blob tofetchuploads it as a stream, and one that needs a digest first readsbody.stream()twice. The one mode with no local artifact (--cache=local:,remote:rw) packs in memory as today and passesnew Blob([bytes]). That mode keeps its memory profile by necessity, as the save path already documents.- No bytes union. The old
ArrayBuffer | Uint8Arrayshapes are not accepted beside the new ones. Pre-alpha, one release train, and every first-party layer changes in the same commit. A layer that resolves bytes is refused at the boundary like any other wrong shape (invalidRemoteResult), and the message names the new shape.
- Download.
LayeredCache.doPullFromRemotechecks the shape (BloborResponse, else the existing invalid-result miss), then hands the body toCache.ingest.ingestwrites it to its temp path withBun.write(tempPath, body), which streams aResponseand a fileBlobwithout collecting them. It then callswriteArtifactAndIndex(hash, { tmpPath }, meta), which already validates from the file (the decompression-bomb and archive checks run on the temp before the final path is touched). A failed write or validation unlinks the temp and degrades to a miss, as today. - Upload.
LayeredCache.saveenqueuesput(hash, Bun.file(outputsPath(hash)), meta). The file is opened by the job, so the “bytes read inside the job” rule (item 642) now costs nothing: a queued job holds a path, not a buffer. A concurrent prune that removed the file makes the plugin’s read throw, and the job’s catch reports it, as today. ingest(hash, Uint8Array)goes. Its only caller is the pull path. Tests that seed through it pass aBlob.
First-party layers
Section titled “First-party layers”turboCache()(vx-migrate/src/turbo-cache). Without a signing key,getreturns theResponseafter the status checks, andputsends the Blob as the fetch body. With a key, the tag (x-artifact-tag, an HMAC over the body) must pass before core sees a byte, or a bad artifact would reach the cache. Sogetstreams the body into a temp file in the OS temp directory while feeding the HMAC, refuses a bad tag (deleting the temp), and otherwise returns aResponseover the temp file’s stream that unlinks the temp when the stream ends or is cancelled.putcomputes the HMAC with one pass overbody.stream()before the request.nxCache()(vx-migrate/src/nx-cache).getreturns theResponse;putsends the Blob.@vzn/vx-reapi(src/cache.ts).put: one pass overbody.stream()through a sha256 hasher for the digest, thenfindMissingBlobs, thenwriteBlobfrom a second pass.writeBlobgains a stream overload that sends ByteStream chunks of the existingCHUNK_BYTESfrom the Blob’s stream; a blob under the batch limit may still be read whole.get:readBlobgains a streaming variant that yields ByteStream chunks; the layer returnsnew Response(stream). CAS digests are verified by the server on write; a read is verified by core’s archive validation, as today.
What does not change
Section titled “What does not change”The cache key, the artifact bytes, CACHE_VERSION and the local store’s
on-disk layout. The upload pool’s concurrency. The degrade-to-miss rule
for every remote failure.
- Memory. A 150 MiB artifact round trip (save with upload, wipe the
local copy, pull) through the stub layer in
vx-bench, before and after, peak RSS read back from the process (vx lastorprocess.resourceUsage().maxRSS). Expected: the before arm’s peak grows by at least the artifact size, and the after arm’s by a small constant. - Rows. A layer resolving
{ body: Uint8Array }is refused as invalid (the old shape). AResponseand aBlobboth ingest. A truncatedResponsebody degrades to a miss and leaves no temp. The upload job passes a file-backed Blob (body.nameis the artifact path). REAPI: a blob larger than one chunk round-trips through the stream path against the in-process fake server, and one under the batch limit still round-trips. Turbo: a signed artifact with a bad tag is refused and leaves no temp. - Docs. The plugin guide,
remote-caching.md, the module page forlayered-cache.ts, and the snapshot of the facade’s types.