src/cache/layered-cache.ts — local + remote cache composition
Purpose
Section titled “Purpose”Wraps the v13 local Cache with a RemoteCache and exposes the same
CacheLayer interface. The orchestrator doesn’t know which layer
it’s talking to.
- Read-through: try local; on miss, fetch from remote, materialize into local, return.
- Write-through with async upload: write to local synchronously, then pack + PUT to remote in the background. Remote errors are logged, not thrown — the task already succeeded; failed uploads shouldn’t fail the user’s run.
Public surface
Section titled “Public surface”export class LayeredCache implements CacheLayer { constructor(local: CacheLayer, remote: RemoteCache, options?: LayeredCacheOptions) // CacheLayer methods — see docs/modules/cache.md.}
export interface LayeredCacheOptions { onRemoteError?: (err: Error) => void onRemoteHit?: (hash: string, bytes: number) => void}LayeredCache accepts a CacheLayer (not a concrete Cache) as the
local layer, so future layerings (local → regional → global) can stack
without churn.
Read path
Section titled “Read path”local.get(hash)— return immediately on local hit (source: 'local').remote.get(hash)—nullon miss; suppress errors and returnnull.- On remote hit:
unpackArchive(body, stage)into a temp dir.- Read the entry’s
stdout/stderrandoutputs/from the stage; calllocal.save()to materialize it into the local layer. - Return the now-local entry with
source: 'remote'so the orchestrator marks itcache-hit-remote.
Write path
Section titled “Write path”local.save(args)— synchronous, succeeds before we touch network.- Stage stdout/stderr +
outputs/<rel paths>into a temp dir (mirroring the local v13 entry shape). packAndDiscard(stage)→ tar.gz bytes.remote.put(hash, bytes, { durationMs }).
Any failure in steps 2-4 is caught and routed through
onRemoteError (defaults to process.stderr.write). The task is
considered fully saved as soon as step 1 returns.
Delegation
Section titled “Delegation”key / recordRun / stats / prune / restoreOutputs / close are pure
delegations to the local Cache. The remote layer doesn’t participate
in cache identity, run history, or eviction — those are workspace-
local concerns.
What this does NOT do
Section titled “What this does NOT do”- No HMAC computation/verification of its own — that lives inside
RemoteCache(opt-insignatureKey). A signature mismatch on GET surfaces here as aRemoteCacheError, which this layer degrades toonRemoteError+ a cache miss like any other remote fault. - No pre-signed URLs (v2 of the remote-cache design).
- No write-batching or retry on transient errors. v1 fire-and-forget.
- No event posting (
POST /v8/artifacts/events).
src/layered-cache.test.ts spins a real in-process Bun.serve()
server and asserts:
save()writes local + uploads to remotesave()doesn’t fail when the remote rejectsget()returns local without touching remote when local hitsget()falls back to remote, materializes into local, then future reads are local hitsget()returnsnullwhen both missget()suppresses remote errorskey()matcheslocal.key()stats / recordRun / prunedelegate to local
Replacing this module
Section titled “Replacing this module”Most likely replacement: a layered cache with a different topology
(e.g., local → regional → global). Keep the public methods stable and
the orchestrator doesn’t change. Or add metadata-only HEAD-style
prefetching before bulk GET — would need extending RemoteCache.has
into the read path here.
To remove the remote layer entirely, the orchestrator’s
wrapWithRemoteCache returns the local Cache unchanged when env
vars aren’t set.