Skip to content

feat(sandbox): cross-node golden dependency cache (L2 archive) - #5351

Merged
pedrofrxncx merged 8 commits into
mainfrom
feat/sandbox-golden-l2-remote-archive
Jul 29, 2026
Merged

feat(sandbox): cross-node golden dependency cache (L2 archive)#5351
pedrofrxncx merged 8 commits into
mainfrom
feat/sandbox-golden-l2-remote-archive

Conversation

@pedrofrxncx

@pedrofrxncx pedrofrxncx commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

The P1b transport from the EFS spec. Buildable and testable today — only deploying it needs the EFS CSI driver.

Why

The golden cache is node-local. A hit needs the pod to land on a node already warm for its repo, and the pool churns ~170 nodes/day across three AZs with ~80% fresh pods — so a large share of boots land cold and pay a full install, and cross-zone a node-local cache can't help at all.

What

A second tier, keyed identically to L1 (same credential-stripped repo hash, same lockfile hash — the two tiers can never disagree about what a key means, and per-repo isolation is the same boundary L1 already relies on):

<GOLDEN_CACHE_REMOTE>/golden/<repoHash>/<pm>-<lockHash>.tar.zst

Restore order is now L1 reflink (~1s) → L2 extract → install. An L2 hit also seeds this node's L1 via the existing pendingGolden path, so the next pod on this node gets the reflink instead of another extract.

Tarball, never the tree. EFS/NFS charges per-operation metadata latency — fatal across ~100k small files, a non-issue for one large sequential blob. The per-file cost is paid by the local extract, on local disk. Mounting node_modules over NFS would be slower than no cache at all.

Portability — the part that needed checking

Transport is an explicit zstd -dc | tar -xf - pipe, not tar's own compressor flags. Those are not portable and fail differently per flavor. The image ships GNU tar, but a macOS host runner has bsdtar, where:

  • -I zstd means --include, so it looks for a file named "zstd" — and exits 0, which would have looked like a successful restore of nothing;
  • --use-compress-program=zstd is rejected on read with "Unrecognized archive format".

Both verified on bsdtar 3.5.3 before choosing. The explicit pipe behaves identically everywhere.

Spawned and streamed throughout, no *Sync — a 2 GB tree through the daemon's single-threaded loop would stall the health probe, which Studio reads as a dead sandbox (CONTRIBUTING rule #1).

Failing closed

  • Restore extracts into a staging dir and renames into place. A tar interrupted mid-write (pod killed, archive truncated) would otherwise leave a partial node_modules that later code reads as complete — the boot skips install and fails somewhere unrelated. Covered by a test that corrupts the archive and asserts nothing is left behind.
  • Publish writes a temp file and renames, so concurrent publishers on different nodes can't produce a truncated archive. Idempotent — no-op when the key already exists.
  • Publish is gated on the same healthy-boot transition as L1, which matters more here: a broken install published to a shared store poisons the whole fleet, not one node.

Security

The chart mounts the volume read-only into the sandbox container. That's the load-bearing control, not a hint — sandboxes run untrusted code and bun installs cached content as-is, so a writable shared store widens the existing node-local RCE surface to a fleet-wide one (one tenant poisoning another repo's archive). This is exactly the case the existing depsCache comment warns about ("Do NOT widen to a shared cache without making it read-only to tenant pods").

Publishing from tenant pods therefore does nothing in the deployed config; a trusted writer comes with the infra work. The daemon-side publish path exists and is tested, but only a writable mount would exercise it.

Ships dark

No GOLDEN_CACHE_REMOTE → the daemon is L1-only, exactly today's behavior. Chart defaults depsCache.remote.enabled: false; enabling without a pvcName fails the render rather than silently mounting nothing. Rollback is unsetting the value.

Deploying additionally needs the EFS CSI driver, which is not installed in prod (only ebs-csi) — that's terraform, tracked separately. Also note prod pins chart 0.9.5 while this bumps 0.9.34, so it needs a targetRevision bump in deco-apps-cd to land (see the note on #5340).

Also

zstd added to the sandbox image — the base ships tar/gzip/xz but not zstd, and the L1 path never needed a compressor.

Testing

remote-golden.test.ts — real files, real tar/zstd, no mocks:

  • publish on one node → restore on another: a cold install root with no node_modules gets a complete tree, contents verified. That's the whole point of the tier.
  • discriminating: with GOLDEN_CACHE_REMOTE unset, the identical case restores nothing — without this the first test would pass even if the code ignored the env.
  • truncated archive: fails closed, no partial tree, no staging dir left.
  • idempotent publish: a second healthy boot doesn't rewrite the archive (mtime unchanged) and leaves no temp file.

bun test packages/sandbox/daemon/setup/ → 90 pass. daemon.e2e.test.ts → 103 pass. helm template verified off / on / missing-PVC. helm lint, bun run fmt, bun run lint (0 errors), knip clean, tsc clean apart from a pre-existing zod error in packages/shared.

Not validated on a real pod — that needs the EFS infra. Per repo norm, "done" means a tailed pod showing an L2 hit; this is not that yet.


Summary by cubic

Adds an opt-in L2 cross-node golden cache that restores node_modules from a shared .tar.zst on an RWX PVC so cold boots across nodes/zones skip installs. Updates the transport to a shell-owned zstd -dc | tar -xf - pipeline with pipefail to fix EPIPE/truncation on large trees, adds l2 metrics, and ships via Helm chart 0.9.44 with a read-only mount.

  • New Features

    • Orchestrator: L1 reflink → L2 extract → install; L2 hits seed L1. Publish after healthy boot, idempotent (temp + rename), read-back before expose, then prune per repo (untouched >7d; newest 5).
    • Transport: shell-owned fd-to-fd pipe (bash -c with set -o pipefail, stderr surfaced, args shell-quoted) avoids relayed pipes that caused EPIPE/silent truncation under backpressure; excludes pod-local caches (.vite, .cache). Sandbox image includes zstd.
    • Metrics: source="l2" added to sandbox.deps.restore; guarded by an orchestrator test.
    • Helm: depsCache.remote.* (off by default) mounts an RWX PVC read-only and sets GOLDEN_CACHE_REMOTE; chart 0.9.44. Removed unused defaultTier/tiers/emptyDirSizeLimits. Values docs call out provisioning the volume root for uid/gid 1000.
  • Migration

    • Off by default. To enable: set depsCache.enabled: true, depsCache.remote.enabled: true, provide depsCache.remote.pvcName, and bump to chart 0.9.44.
    • Requires the EFS CSI driver and a pre-provisioned root the sandbox user can read/traverse; mount is read-only in tenant pods. Publishing/prune need a trusted writer.
    • Cross-repo reads are possible; do not enable for private-registry repos without per-repo enforcement.

Written for commit 51bb9a4. Summary will update on new commits.

Review in cubic

@pedrofrxncx

Copy link
Copy Markdown
Collaborator Author

Validated on a real cluster — it works, and it turned up two things

Rancher Desktop (k3s v1.25.16), image built from this branch's Dockerfile.

The image change is real

$ docker run --rm studio-sandbox:local sh -c 'zstd --version; tar --version | head -1'
*** Zstandard CLI (64-bit) v1.5.7, by Yann Collet ***
tar (GNU tar) 1.35

Round trip in the real image (two containers, shared store)

4000 files / ~32 MB of incompressible content, driving the actual remote-golden.ts (bundled with the same flags the daemon ships with, not a reimplementation):

NODE A publish  → ok=true  archive=32.8MB  483ms
NODE B restore  → ok=true  files=4000/4000  tail_intact=true  246ms   [read-only mount]

Round trip on the cluster (two pods, one RWX PVC)

l2-publisher (trusted-writer stand-in, root)         Succeeded  → published, 150ms
l2-restorer  (uid=1000 sandbox, readOnly PVC mount)  Succeeded  → 4000/4000, tail intact, 110ms

Verified the restorer really was the tenant shape, not an accident:

runAsUser: (unset) → image default uid=1000(sandbox) gid=1000(sandbox)
volumeMount: {"mountPath":"/golden-cache","name":"golden","readOnly":true}
volume:      {"claimName":"l2-golden","readOnly":true}

This settles the open risk in the PR description: a tenant can restore through a read-only mount. The security posture doesn't cost us the feature. Same archive key derived in both environments (bun-81cc5bb3bf7a7043ebf6f4ae5b78a32f.tar.zst), so the keying is deterministic across hosts.

Finding 1 — a fresh volume breaks publish, silently (fixed in b2e8f80)

First cluster run died on EACCES: permission denied, mkdir '/golden-cache/golden'. A newly-provisioned volume is root-owned, and this chart cannot fix that: the tenant mount is read-only by design and fsGroup doesn't apply to it. The failure mode is nasty — publish is best-effort, so it swallows the error and every restore misses forever, which looks exactly like "the cache just doesn't hit much".

values.yaml now says so where someone setting pvcName will read it, with the EFS spelling (access point with RootDirectory CreationInfo for uid/gid 1000, not the filesystem root).

Finding 2 — the fd-to-fd fix had no test (fixed in cbfc84e)

063578671 fixed the silent tail truncation but shipped no test, and the four existing fixtures cannot catch it: its own commit message notes the loss never happened below ~1 MB, and every fixture was ~1 KB. A revert to p.stdout.pipe(c.stdin) stayed green on all four.

Added a 1500-file / ~9 MB round trip asserting the exact member count and the contents of the last file — the tail is what a truncated stream loses, and the observed failure delivered 532 of 1069 members with both children exiting 0.

Verified as a guard rather than just an addition:

result
reverted to the relayed pipe 4 pass, 1 fail — 3/3 runs, only this test
with the fd-to-fd fix 5 pass

Still not proven

Single node, so this is cross-cache, not cross-node: pod B had an empty local cache and restored from the shared volume alone, which is the mechanism — but NFS metadata latency and a cold-node herd hitting one EFS filesystem at once are untested and need the real infra.

bun test packages/sandbox/daemon/setup/ → 91 pass. helm lint clean.

@pedrofrxncx
pedrofrxncx force-pushed the feat/sandbox-golden-l2-remote-archive branch from b2e8f80 to 921c525 Compare July 28, 2026 20:32
@pedrofrxncx

Copy link
Copy Markdown
Collaborator Author

Conflicts cleared, and the PR now ships the metrics that judge it.

Rebased

Onto current main (which had moved to chart 0.9.37 and merged #5346). Chart bumped to 0.9.38 and its changelog entry moved to the top where it belongs — the conflict resolution had buried it between 0.9.5 and 0.9.4. mergeable=MERGEABLE.

L2 was shipping blind — fixed

Good catch. sandbox.deps.restore (merged in #5346) only knew l1 and miss, so a boot the shared archive rescued was indistinguishable from one the node-local golden served. The single question this tier exists to answer — was it worth the shared store? — was unanswerable from its own data.

source is now l1 | l2 | miss:

  • counter split → hit rate per tier. l2 is the count of boots rescued from a full install.
  • duration_ms on l2 vs miss → the per-boot saving, which is the number that decides whether EFS pays for itself.
  • repo_hash → whether the head of the distribution is concentrated enough for the P2 warmer.

Guarded, because a mislabel is worse than no metric — it credits the node-local cache with work it never did. The new orchestrator test publishes a real archive from a separate root with L1 switched off, so a hit can only be L2; flipping the emitted label to l1 fails it (verified: 12 pass / 1 fail).

Cross-node on Rancher: not possible, and it wouldn't prove anything

Two separate reasons, and the second is the one that matters.

Mechanically: Rancher Desktop is one VM. I stood up an in-cluster NFS server and an NFS-backed RWX PV to get the real thing, and kubelet failed with MountVolume.SetUp failed ... mount failed: exit status 255. The lima VM has no NFS client:

$ rdctl shell sh -c 'which mount.nfs mount.nfs4; ls /sbin/mount.nfs*'
(nothing)
$ grep -c nfs /proc/filesystems
3          # kernel knows nfs; the userspace mount helper is absent

Torn back down; the cluster is clean.

Substantively: the L2 code has zero node awareness — it reads a path. What a second node would change is (a) the node-local cache is cold and (b) the store is network-backed. (a) is already covered — the restorer pod had an empty local cache and restored from the shared volume alone, which is the entire mechanism. (b) is the real untested risk, and it isn't about node count: it's NFS metadata latency and a cold-node herd hitting one filesystem at once. A second kubelet on the same VM and the same disk reproduces neither.

So the honest gap after this PR is unchanged and specific: NFS behaviour under load, provable only on real EFS. Everything the code can distinguish locally is tested.

bun test packages/sandbox/daemon/setup/ → 95 pass. helm lint, knip, lint (0 errors), tsc clean.

@nicacioliveira nicacioliveira left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM

just some points that I noticed:

the L2 store has no pruning like L1 does, so as written it grows forever, one archive per repo+lockfile, and it'll need a prune or an EFS lifecycle policy before it becomes writable

...also, since the whole volume is mounted and repoHash is derived from the clone url, any sandbox can read any repos files, that's fine if we treat node_modules as non-sensitive, but private registry deps would be readable across tenants, so its worth deciding on purpose.

Last one that does matter for this PR: version 0.9.38 is behind mains 0.9.39 (the publish skips lowr versions) and app version regresses from 1.25.3 to 1.25.1, so rebase and bump both above main before procede

@pedrofrxncx
pedrofrxncx force-pushed the feat/sandbox-golden-l2-remote-archive branch from 36f7a5f to fca1b6c Compare July 29, 2026 18:36
@pedrofrxncx

Copy link
Copy Markdown
Collaborator Author

Thanks — all three handled, and the version one was worse than you saw: main has moved to 0.9.43 / appVersion 1.26.1 since you looked.

Versions (the one that blocked)

Rebased onto current main. Chart 0.9.44 (above 0.9.43, so the publish workflow won't skip it) and its changelog entry stays at the top. appVersion is back to main's 1.26.1 — the 1.25.1 regression was my earlier conflict resolution taking the branch side of a field this PR never intended to touch; nothing here bumps the image tag default. mergeable=MERGEABLE.

No pruning — fixed

You're right, and it was worse than asymmetric: L1 has had TTL + per-repo cap since it shipped, so L2 was the one tier that grew forever. pruneRemoteGoldens now applies the same rule (untouched >7d, then newest 5 per repo), importing L1's constants rather than re-declaring them so the two tiers can't drift.

Two things it does differently from pruneGoldens:

  • Async throughout. pruneGoldens may use *Sync — it walks local disk. Here a readdir is an NFS round trip per entry, and parking the daemon's event loop stalls the health probe, which Studio reads as a dead sandbox (CONTRIBUTING feat: Genesis #1).
  • Skips .tmp.<pid> entries — those are another node's publish in flight; reaping one corrupts a publish already running.

On your "before it becomes writable" framing: prune hangs off a successful publish, so it runs exactly where writes do and nowhere else. Which means the honest statement is that nothing prunes in the deployed config — the tenant mount is read-only, so until the trusted writer exists a filesystem lifecycle policy is the only reaper. That's now written at the remote: knob instead of left implied.

Tests: the rule (TTL reap, cap, temp files spared) and separately the wiring, because the rule being correct doesn't stop the store growing if publish never calls it. Commenting out the one call: 6 pass / 1 fail, only the wiring test.

Cross-repo reads — decided on purpose, in writing

This is the one I'd been sloppy about. The spec claimed a cross-repo read "leaks only a dependency set, not source or secrets" — and that's wrong for a private registry, where node_modules holds proprietary package source. Your read is the accurate one.

Also worth being precise about the mechanism: it isn't just that the whole volume is mounted, it's that the key is sha256(cloneUrl), which any tenant can compute for a URL it can guess (github.com/<org>/<repo> is not high entropy). So per-repo read isolation here is obscurity, not enforcement.

Decision, now at the knob where someone enabling it reads it: accepted for public-registry dependencies — no first-party source, no secrets, and the read-only mount means nothing is tamperable. And the condition that flips it is named rather than left to be rediscovered: a repo installing from a private registry must not be enabled on this store without per-repo enforcement, which means an EFS access point per repo — and that needs the repo known at mount time, i.e. it costs the warm pool. That trade belongs to whoever has a private-registry repo, not to this PR.

No enforcement added, deliberately: the only mechanism that would work is the access-point-per-repo one, and paying for it before a private-registry repo exists buys nothing.

bun test packages/sandbox/daemon/setup/97 pass. helm lint clean, render verified off / on / missing-PVC. bun run check, lint (0 errors), knip clean.

Unchanged gap, same as before: NFS behaviour under a cold-node herd, provable only on real EFS.

Comment thread deploy/helm/sandbox-env/values.yaml Outdated
cpu: "4"
memory: 16Gi
ephemeral-storage: 40Gi
emptyDirSizeLimits:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

those tiers/defaultTier/emptyDirSizeLimits are ghost, no template here reads it, so it does nothing. drop it or use it

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropped — 91 lines gone.

You're right, and it's worse than dead config: defaultTier, tiers and emptyDirSizeLimits describe a multi-SandboxTemplate feature that doesn't exist in this chart. Every key is declared, documented at length, and then ignored at render time — so the block reads as configurable and isn't. Orphan config that renders nothing is worse than no config.

It also had nothing to do with L2. It rode in on a values.yaml conflict resolution during an earlier rebase; main never carried it (git log -S'defaultTier: small' origin/main → empty), which is why it looked like context rather than something this PR added. Thanks for catching it — I'd stopped reading that region as mine.

Deleting rather than implementing, deliberately: a tiers feature is a real change to how claims pick a template, and it isn't this PR.

values.yaml vs main is now depsCache.remote only — no new top-level keys. Verified there's no other reference to any of the three anywhere in the chart, helm lint clean, and the render is byte-identical off / on / still-fails-on-missing-pvcName.

Also rebased onto current main (52c2457), so the branch is no longer behind. Chart stays 0.9.44, above main's 0.9.43.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correction to what I wrote above — I mischaracterized this, and the difference matters if you're reviewing #5361 next.

Those values are not a feature that doesn't exist. They're the values half of #5361 (feat(sandbox): per-(org, repo) size tiers), which is open. The 91-line block I deleted here is byte-identical to that branch's, and over there it is read — _helpers.tpl references the three keys 14 times, plus a rewritten sandbox-template.yaml and the warm-pool template.

So the accurate statement is narrower than mine: the values leaked into this PR without the templates that consume them, which is what made them inert here. Your read stands unchanged — ghost in #5351, does nothing, drop it — and deleting was still right, because #5351 shouldn't carry half of #5361. But I shouldn't have implied the work was junk. Nothing was lost: #5361 still has all of it.

Two things this turned up that do need attention on #5361, not here:

  • It's CONFLICTING against main right now.
  • It has the same version problem you caught on this PR: appVersion 1.24.2, behind main's 1.26.1. Chart 0.10.0 is fine (above 0.9.43), but the appVersion regresses. Same fix.

And for whoever merges second: #5351 and #5361 conflict on Chart.yaml only (both bump version + add a changelog entry at the top). values.yaml and sandbox-template.yaml merge clean between them, so it's a one-file resolution, not a rebase fight.

Pedro França added 7 commits July 29, 2026 16:21
The golden node_modules cache is NODE-LOCAL: it only hits when the pod
lands on a node already warm for its repo. The sandbox pool churns ~170
nodes/day across three AZs and ~80% of ensures are fresh pods, so a large
share of boots land somewhere cold and pay a full install — and cross-zone
a node-local cache cannot help at all.

Add a second tier keyed identically to the first: a per-(repo, pm,
lockfile) tar.zst on a shared RWX volume, restorable on any node in any
zone. Restore order becomes L1 reflink (~1s) → L2 extract → install. An L2
hit also seeds this node's L1, so the next pod here gets the reflink.

Store a TARBALL, never the tree. EFS/NFS charges per-operation metadata
latency: fatal across ~100k small files, a non-issue for one large
sequential blob. The per-file cost is paid by the local extract.

Transport is an explicit `zstd -dc | tar -xf -` pipe rather than tar's own
compressor flags, which are not portable and fail differently per flavor:
the image ships GNU tar, but a macOS host runner has bsdtar, where `-I`
means --include (so `-I zstd` looks for a file named "zstd") and
--use-compress-program is rejected on read. Both verified before choosing.
Spawned and streamed throughout, no *Sync — a 2 GB tree through the
daemon's single-threaded loop would stall the health probe and get the
sandbox torn down.

Extraction stages into a temp dir and renames into place, so a tar
interrupted mid-write can never leave a partial node_modules that later
code reads as complete (the boot would skip install and fail somewhere
unrelated). Publish writes a temp file and renames, so concurrent
publishers on different nodes cannot produce a truncated archive.

Publish is gated on the same healthy-boot transition as L1, which matters
more here: a broken install published to a shared store would poison every
node in the fleet, not just this one.

Chart mounts the volume READ-ONLY into the sandbox container. That is the
load-bearing control, not a hint — sandboxes run untrusted code and bun
installs cached content as-is, so a writable shared store would widen the
existing node-local RCE surface to a fleet-wide one. Publishing from
tenant pods stays off; a trusted writer comes with the infra work.

Ships dark: no GOLDEN_CACHE_REMOTE means the daemon is L1-only, exactly
today's behavior. Deploying it additionally needs the EFS CSI driver,
which is not installed — that is infra, tracked separately.
…cating

Restore never worked on a real-sized tree. `runPiped` relayed the archive
through this process (`p.stdout.pipe(c.stdin)`), and once the consumer applies
backpressure — `tar -x` writing many small files is slower than `zstd -dc`
produces — the tail of the stream was dropped while BOTH children still exited
0. Verified in the sandbox image on a real daemon boot: restore of a
byte-verified-good 2.3 MB archive failed 2/2 with `tar: Unexpected EOF in
archive`; isolated, 5 of 12 runs failed at 2 MB and 12 of 12 pass wired
fd-to-fd. It never fails below ~1 MB, which is why the ~1 KB test fixture
passes.

The same loss hit publish once, and there it is worse: a valid zstd frame
containing a truncated tar (532 of 1069 members) published as a success. Since
publish no-ops when the key exists, that archive is permanent — every node in
the fleet keeps failing restore and paying a full install with nothing to
repair it. Publish now reads the archive back before the rename.

Also:
- Surface the failing side's stderr. "tar exit 2" alone is unactionable; it
  took a hand-run of the pipe to learn it meant a truncated archive rather
  than a permissions or disk problem.
- Render GOLDEN_CACHE_REMOTE whenever `remote.enabled` instead of nesting it
  under `golden`. The volume was already mounted at that scope, so with
  golden off the chart mounted an RWX PVC into every sandbox pod that the
  daemon never read.

Bun.spawn's direct fd wiring also delivers what the old comment claimed:
a multi-GB tree no longer passes through the daemon's single event loop
during a boot.
The fd-to-fd fix shipped without a test, and the existing fixtures cannot
catch what it fixed: the relayed pipe never dropped anything below ~1 MB
and every case here used ~1 KB. A revert to `p.stdout.pipe(c.stdin)` stayed
green on all four.

Round-trip a 1500-file / ~9 MB tree of incompressible content, so tar's
many small writes actually fall behind zstd and the transfer has to survive
backpressure. Assert the exact member count and the contents of the LAST
file — the observed failure delivered 532 of 1069 members with both
children exiting 0, so a partial extract looks fine unless you count.

Verified as a guard, not just an addition: reverted to the relayed pipe and
this test fails 3/3 (and only this test), passes with the fix.
…nally

Found by running the two-pod shape on a bare RWX volume: publish dies with
`EACCES: mkdir '/golden-cache/golden'`. A fresh volume is root-owned, and
this chart has no way to fix that — the tenant mount is read-only by
design and fsGroup does not apply to it. Every restore then misses
silently, forever, which reads as "the cache just never hits".

Say so where someone setting pvcName will read it, and name the EFS
spelling (an access point with RootDirectory CreationInfo for uid/gid
1000, not the filesystem root).
L2 shipped blind. `sandbox.deps.restore` only knew `l1` and `miss`, so a
boot the shared archive rescued was indistinguishable from one the
node-local golden served — which makes the one question the tier exists to
answer ("was it worth the shared store?") unanswerable from the data.

Widen the source union with `l2` and emit it at the archive-restore site.
The counter split is then the hit rate per tier, and `duration_ms` on `l2`
against `miss` is the per-boot saving.

Guarded, because a mislabel is worse than no metric — it credits the
node-local cache with work it never did. The new orchestrator test drives a
real archive published from a separate root with L1 switched OFF, so a hit
can only have come from L2; changing the emitted label to `l1` fails it.
The shared store grew forever: one archive per (repo, lockfile), no reaper,
while L1 has had a TTL + per-repo cap since it shipped. `pruneRemoteGoldens`
applies the same rule to the remote store (untouched >7d, then newest 5 per
repo) after a successful publish, reusing L1's constants so the two tiers
can't drift apart.

Async throughout, unlike L1's `pruneGoldens` — the store is NFS, where a
readdir is a round trip per entry, and parking the daemon's event loop stalls
its health probe (CONTRIBUTING rule #1). `.tmp.<pid>` entries are skipped:
they are another node's publish in flight.

Prune runs only where publish does, so a read-only tenant mount never prunes
anything — that's now stated at the values.yaml knob, since it means a
filesystem lifecycle policy is the only reaper until the trusted writer lands.

Also written down where someone enabling this will read it: any sandbox can
read any repo's archive. The volume is mounted whole (warm-pool pods are bound
before they know their repo) and the key is sha256 of the clone URL, so read
isolation is guessability, not enforcement. Accepted for public-registry
dependencies — no first-party source, no secrets, nothing writable. Named the
condition that flips it: a private registry puts proprietary package source in
node_modules, which this store would expose fleet-wide.

Chart 0.9.44, above main's 0.9.43 (the publish workflow skips lower versions);
appVersion stays at main's 1.26.1 rather than regressing to the pre-rebase tag.
…ver used

91 lines of values that no template reads. `defaultTier`, `tiers` and
`emptyDirSizeLimits` describe a multi-SandboxTemplate feature that does not
exist in this chart — every key is declared, documented, and then ignored at
render time, so the whole block is a lie about what the chart does.

It also has nothing to do with the L2 cache. It rode in on a values.yaml
conflict resolution during an earlier rebase; main never had it. Deleting
rather than implementing: a tiers feature is not this PR, and orphan config
that renders nothing is worse than absent config — it reads as configurable.

values.yaml vs main is now depsCache.remote only. Chart renders identically
off, on, and still fails on a missing pvcName.
@pedrofrxncx
pedrofrxncx force-pushed the feat/sandbox-golden-l2-remote-archive branch from f9b6da6 to af6469e Compare July 29, 2026 19:21
… EPIPE

CI (Linux) failed the 9 MB round-trip: `EPIPE: broken pipe, write` thrown from
inside Bun's stream pump, asynchronously, where the try/catch around the spawn
can't see it. It took out the whole test file and left publish with no archive.

Passing the producer's `stdout` as the consumer's `stdin` reads as an fd
handoff, but Bun relays it through this process once the stream outgrows the
pipe buffer, and then the relay's write races the consumer. That is the same
handoff that previously truncated the stream's tail with both children exiting
0 — two failure modes, one cause. The 1 KB fixtures never reach the buffer's
edge, which is why only the big case caught either.

So hand the pipe to `bash -c` and let the kernel move the bytes: nothing
traverses this process, both failure modes go away, and a multi-GB tree still
stays off the daemon's event loop. `set -o pipefail` keeps the producer's exit
code visible — without it a failed `tar -c` feeding a happy `zstd` would look
like a successful publish. Verified both directions (producer-fails → exit 1,
both-ok → exit 0). Arguments are shell-quoted, since the mount root and install
root come from operator config and a path with a space would otherwise split.

Also fixes why the CI log was so hard to read: `soleArchive` destructured an
empty readdir and threw ERR_INVALID_ARG_TYPE, which reads as a broken helper
rather than "publish produced nothing", and the test passed no `log` so
publish's own diagnostic was discarded. Both now assert with the real reason.
@pedrofrxncx
pedrofrxncx merged commit e9afc14 into main Jul 29, 2026
21 checks passed
@pedrofrxncx
pedrofrxncx deleted the feat/sandbox-golden-l2-remote-archive branch July 29, 2026 20:18
decocms Bot pushed a commit that referenced this pull request Jul 29, 2026
PR: #5351 feat(sandbox): cross-node golden dependency cache (L2 archive)
Bump type: minor

- @decocms/sandbox (packages/sandbox/package.json): 1.26.1 -> 1.27.0
- deploy/helm/sandbox-env (chart 0.9.45) (deploy/helm/sandbox-env/values.yaml deploy/helm/sandbox-env/Chart.yaml): image.tag/appVersion -> 1.27.0

Deploy-Scope: both
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants