Skip to content

Node-local env staging: pack envs to single images, extract to local disk at spawn - #223

Open
OwenPriceSkelly wants to merge 3 commits into
mainfrom
node-local-staging
Open

Node-local env staging: pack envs to single images, extract to local disk at spawn#223
OwenPriceSkelly wants to merge 3 commits into
mainfrom
node-local-staging

Conversation

@OwenPriceSkelly

Copy link
Copy Markdown
Member

Closes #180. Subsumes #179 (the rootstock stage CLI is the one-shot job-prologue warmer).

Why

Cold worker starts on network filesystems (worst on Delta's HDD-backed Lustre, #160/#167) are bounded by two costs the page-cache prewarm (#168/#171/#178) structurally can't remove:

  • metadata RPCs — every cold stat of a 35k-file env tree is a round trip, and
  • warmth eviction — cached pages are rented, not owned (cgroup limits, memory pressure), and a resubmitted job lands on a cold node anyway.

The 2026-07-29/30 field data (3–4 concurrent prewarms at 946–1322 s/env; 8 of 45 sync verifies blowing the 600 s connect timeout on freshly rebuilt envs) motivated the prior-art survey, which landed squarely on this shape: single-image env + extract to node-local disk (CSC Tykky, NERSC Shifter, OLCF sbcast+NVMe, Meta XAR).

What

Packinstall now archives each env it builds into {root}/images/<env>-<sha12>.tar.zst (env tree + its .python/ interpreter, root-relative paths, streamed tar | zstd with in-flight hashing), inside the same transaction, best-effort (--no-pack to skip; a failed pack degrades spawns to the prewarm path). rootstock pack backfills existing envs. Manifest schema bumps to v7 with an optional per-env image record; currency is packed_at >= built_at, stamped in the same refresh as built_at, so a rebuild without a repack can never serve a stale image.

Stage — at spawn, worker spawns resolve a node-local base (ROOTSTOCK_STAGE_DIR > layout.json stage_dir > Cluster.stage_dir registry fallback; ROOTSTOCK_NO_STAGE=1 disables; the dir must exist, be writable, and be a different filesystem than the root), then:

  • extract the image content-addressed by archive sha — O_EXCL lock so one extractor runs per node while concurrent same-env spawns wait for the atomic rename (the committee-demo contention case), disk preflight with LRU eviction (age-gated), dead-partial sweeping;
  • fix up the venv (bin/python* symlink targets, pyvenv.cfg home =) so the interpreter and stdlib are local too — targets are written pre-rename in final-path terms, so the rename stays the atomic publication point;
  • overlay the checkpoint's recorded weight files (Track where each checkpoint's weights live: record weight files in the manifest at add/verify time #177) into a local cache mirror: recorded files copied, everything else symlinked back to the shared cache so unrecorded side files (HF config.json — capture only sees mmap'd files) still resolve; HF-hub snapshots//refs/ are recreated so their relative links land on the local blobs;
  • repoint HOME/XDG_CACHE_HOME/HF_HOME at the mirror only when the overlay succeeds, and set ROOTSTOCK_NO_PREWARM=1 only when nothing shared remains to warm.

Safety boundaries: download spawns never stage (they must write the shared cache); weights_capture spawns (add/verify) stage the env but keep caches shared, so captured records can't be poisoned with mirror paths — while first-verify-after-rebuild still gets the fast path that the 600 s timeout data asked for. Graceful degradation is load-bearing: any missing piece (no config, no image, stale image, no zstd, no space, lock timeout, any error) falls back to today's prewarm, and an unconfigured cluster behaves exactly as before.

Config/CLI: rootstock init --stage-dir '$SLURM_TMPDIR' writes the declaration to layout.json (env vars expand on the node at spawn time); rootstock stage <checkpoint-id> ... warms envs+weights up front in job prologues and degrades to a sequential prewarm pass where staging isn't configured. Docs in cluster-setup.md.

Testing

35 new stage/pack tests (real tar|zstd round trips, venv fixups, warm reuse, content-addressed rebuild-beside, every fallback edge, the HF-hub overlay indirection, spawn integration gates) plus manifest v7 migration/round-trip/currency coverage. Full suite: 997 passed; ruff/format/ty clean.

Follow-ups (not in this PR)

  • Orphaned-image GC in prune (an env pruned after packing leaves its image behind; repacks already clean their own superseded images).
  • Per-cluster stage_dir recon + rollout, Delta first (node-local path + wipe policy still unconfirmed there), then A/B against the prewarm path with ROOTSTOCK_NO_STAGE — the metric is the multi-worker congested-afternoon case.

🤖 Generated with Claude Code

OwenPriceSkelly and others added 3 commits September 1, 2026 10:48
…disk at spawn (#180)

Cold worker starts on network filesystems are bounded by per-file metadata
RPCs and mmap fault storms — the two costs the page-cache prewarm can't
remove. This adds the structural fix from the prior-art survey: pack each
built env into one tar.zst image, extract it to node-local disk at spawn,
and run the worker entirely from the local copy.

Pack half:
- rootstock/pack.py: stream tar|zstd into {root}/images/<env>-<sha12>.tar.zst
  (env tree + its .python interpreter, root-relative), hashing in-flight;
  superseded images of the same env are removed.
- Manifest schema v7: optional per-env `image` record (path, sha256, format,
  sizes, packed_at). Currency is `packed_at >= built_at`, stamped in the same
  refresh as built_at, so a rebuild without a repack reads as image-less.
- `install` packs inside its own transaction (best-effort; `--no-pack` skips),
  so sync-triggered rebuilds get images for free. `rootstock pack` backfills.

Stage half:
- rootstock/stage.py: resolve the node-local base (ROOTSTOCK_STAGE_DIR >
  layout.json stage_dir > Cluster.stage_dir; ROOTSTOCK_NO_STAGE disables),
  validate it (exists, writable, different filesystem than the root), then
  extract content-addressed by archive sha with an O_EXCL lock (one extractor
  per node, losers wait for the atomic rename), disk preflight + LRU eviction,
  and venv fixups (bin/python* symlinks, pyvenv.cfg home) so nothing executes
  from the shared tree.
- Checkpoint weights overlay into a local cache mirror from the manifest's
  recorded weight_files: recorded files are copied, everything else symlinks
  back to the shared cache so unrecorded side files (HF config.json etc.)
  still resolve; HF-hub snapshots/refs are recreated so their relative links
  land on the local blobs. Worker caches are repointed only when the overlay
  succeeds, and the prewarm is skipped only when nothing shared remains.
- spawn_in_env: worker spawns stage; download spawns never do (they write the
  shared cache), and weights_capture runs keep caches shared so records can't
  be poisoned by mirror paths. Any missing piece falls back to the prewarm
  path — an unconfigured cluster behaves exactly as before.
- CLI: `rootstock stage <checkpoint-id> ...` for job prologues (subsumes the
  #179 one-shot prewarm idea; degrades to a sequential prewarm pass where
  staging isn't configured), `rootstock init --stage-dir`, docs in
  cluster-setup.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…single manifest parse

From the PR #223 review's verified follow-up findings:

- `rootstock stage` now mirrors weights only alongside a staged env
  (spawns never consult a mirror without one), and its prewarm fallback
  goes through prewarm_from_spec — keeping the cgroup working-set warning
  and ROOTSTOCK_NO_PREWARM semantics — via a new `label` parameter instead
  of a hand-rolled copy.
- Mirror currency is now size+mtime (copies preserve the source mtime), so
  a same-size in-place overwrite of a stable-path weight file invalidates
  warm mirrors instead of silently serving stale bytes.
- Warm-mirror spawns are lock-free: a per-checkpoint completion marker
  records the digest of the last finished overlay (paths + source
  sizes/mtimes); matching it returns without the mirror lock or any
  shared-cache walk, so concurrent same-node spawns no longer serialize.
  The marker is written only after a full overlay, so a crashed pass is
  completed by the next spawn under the lock.
- The weights free-space gate counts only bytes still needing copies — a
  warm mirror on a full disk stays staged.
- One raw manifest read per spawn: stage_for_spawn reads the env record
  once and threads it through stage_env/stage_weights; the spawn-path
  prewarm lookup is skipped when the spawn is fully node-local (its result
  was dead payload under ROOTSTOCK_NO_PREWARM).
- Cleanups: stage_env probes tools via pack_tools_missing(), image format
  checked against IMAGE_FORMAT, _fmt_bytes deduplicated into prewarm,
  dead StagedSpawn.weights_staged_bytes dropped, always-true partial-glob
  guard in pack removed, cluster-setup.md layout.json wording corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce + repair paths, prune images tier

From the PR #223 review's main report, most-severe first:

1. Overlay: a fallthrough symlink is never treated as a current copy —
   stat follows the link to the very shared file, so a later-recorded
   weight file would have stayed on Lustre with the worker's prewarm off.
2. Eviction: staged envs carry .users/<pid> markers written by the client
   process; _evict_lru skips dirs with a live registered user, so a >6h
   MD run on persistent /tmp can't lose its env mid-trajectory.
3. {base}/rootstock is created sticky-1777 (the /tmp recipe) so the first
   user's umask can't lock everyone else out of staging on that node.
4. Pack partial sweep is pid+age aware (a concurrent pack's live partial
   survives), and a swept-partial rename failure surfaces as PackError
   instead of an uncaught traceback.
5. The bare `rootstock pack` sweep re-packs when the recorded archive
   file is missing on disk (purged images/ dir was previously
   unrepairable — "all current" forever).
6. pack_environments records successful packs in the manifest before
   raising on failures, so superseded-archive deletions can't strand the
   manifest pointing at deleted files.
7. Venv fixup remaps through install-time mount-alias spellings
   (/eagle vs /lus/eagle) by resolving the target against the resolved
   root; a deterministic fixup failure is noted per archive + client
   version so spawns stop re-paying extract-and-discard.
8. stage_env/stage_weights never raise (preamble mkdir/disk/lock failures
   degrade to prewarm with a log line, instead of crashing the prologue
   CLI), and _sweep_partials guards its stat against rename races.
9. Superseded-image cleanup matches <env>-<12 hex>.tar.zst exactly, so
   packing 'ani' can never delete 'ani-tuned-*' images.
10. prune gains an images GC tier: archives no manifest env records (and
    crashed packs' partials) are collected, age-guarded like the rest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@OwenPriceSkelly

Copy link
Copy Markdown
Member Author

Code-review follow-up status — all 16 verified findings (top-10 report + 6 overflow) are applied: overflow six in 128610d, top-10 in 8b5c7bc. Per-finding outcomes for the top-10, most-severe first:

  1. Fixed_mirror_current treats a fallthrough symlink as never-current; _copy_file_atomic's rename-over replaces the link. The completion marker can't mask it: post-fix, markers are only written after an overlay whose currency checks were symlink-aware.
  2. Fixed — staged envs carry .users/<pid> markers written by the client process at every stage_env success; _evict_lru skips dirs with a live registered pid (dead pidfiles pruned in the scan), min-age kept as fallback shield.
  3. Fixed{base}/rootstock is created explicitly and chmod'd sticky-1777 (usage-spool recipe) before the 0700 per-user leaf.
  4. Fixed — pack's partial sweep is pid+age aware; the finished-image rename failure surfaces as PackError. _pid_alive moved to pack.py (stage imports it).
  5. Fixed — the bare-sweep filter is _image_usable = current-by-timestamp AND archive on disk; a purged images/ dir reads as needs-repack.
  6. Fixed — both pack_environments modes update the manifest for successful packs before raising an aggregated failure error.
  7. Fixed_remap_into_stage resolves absolute targets against the resolved root (install-time alias spellings); deterministic fixup failures raise _FixupError and are cached per archive+client-version in {sha}.failed; transient extraction failures deliberately not cached.
  8. Fixedstage_env/stage_weights are never-raise wrappers (preamble failures degrade to prewarm with a log line); _sweep_partials stat guarded against rename races.
  9. Fixed — superseded-image cleanup matches ^<env>-[0-9a-f]{12}\.tar\.zst$ exactly; demo-tuned-* survives packing demo.
  10. Fixedplan_prune gains an image GC tier: archives no manifest env records (and crashed packs' partials) are collected, age-guarded like the other tiers.

Verification: 1010 tests passing (11 new), ruff/format/ty clean.

🤖 Generated with Claude Code

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.

Node-local env staging: pack envs to single images, extract to local disk at spawn

1 participant