Files
esh-pfi-infrastructure/persistent-memory.d/2026-08-19-waterland-studio-containerised.md
vh e6907819b0 feat(waterland-studio): deploy b72425b — all three upstream findings fixed
One update.sh run on irv-ml1 carried both open upstream PRs, per the
operator's green-light on the job-store fix:

- #5 (464dfc2) declares cupy-cuda12x[ctk] on the gpu extra and takes uv
  out of the render path (sys.executable -m waterland.cli), retiring the
  runtime prune trap at the source.
- #6 (b72425b) rehydrates the job index from the data volume at startup,
  fixing the unbounded store growth reported from this side.

Verified after the update rather than assumed: healthy on backend cupy;
/api/jobs went 1 -> 16 against 16 directories on disk, so API and volume
agree for the first time; nothing wrongly reclaimed, correct since 16 is
under RETAIN=40 and adoption only makes them visible; a real 256^2 plate
render completes warm, so the kernel-cache volume survived the image swap.
A subsequent render took both counts to 17.

The image keeps its explicit [ctk] install and UV_NO_SYNC/UV_OFFLINE pins
even though both are now redundant. The header requirement is a property
of this slim base, not of the upstream extra, and the cost is measured
rather than assumed: uv sync satisfies it first, so the line reports
"Audited 1 package" and adds 0.3s to the build. The env pins are now
cheap defence-in-depth against any future path that re-enters uv.

Docs corrected in place: the README's upstream-finding section is now a
resolved-finding record, and the two "bounded ~500 MB" claims say which
commit made that bound hold across restarts rather than only within a
process. Comment-side changes pushed to the live compose dir; no restart
was needed for them.
2026-08-19 01:39:13 -07:00

9.4 KiB
Raw Permalink Blame History

[2026-08-19] waterland studio containerised on irv-ml1 — three landmines, all measured

Handover from waterland-dev over althing (thread 01M0CDRGEZWAJCEJXXMQWXV80F): a FastAPI + SPA GPU service fronting the waterland CLI, running as a bare nohup (PID 1283383) that would not survive a reboot. Now stacks/waterland-studio/, restart: unless-stopped, healthy on irv-ml1:8410. Commits a2b5b58, 8189076.

Tracking main per operator: PR #4 merged and main HEAD was exactly the pinned 8025366, so tracking-a-moving-ref and keeping-the-pin agreed anyway.

Now deployed at b72425b (2026-08-19). The container sat on 8025366 for a few hours after PR #5 (464dfc2) landed — deliberately, since the image's own guards already neutralised both landmines and the project was in wind-down. PR #6 (the job-store rehydrate, operator-green-lit) was the rebuild with a real reason behind it, and one update.sh run carried both. Verified end to end after the update: healthy, backend: cupy, and a real 256² plate render completes warm — the kernel-cache volume survived the image swap.

Build context lives OUTSIDE the compose dir — on purpose

/opt/waterland-studio/src is the checkout; the Dockerfile is passed out-of-context from /opt/docker/compose/waterland-studio/. deploy-stack.sh rsyncs stacks/<stack>/ with --delete, so a checkout kept beside compose.yaml would be destroyed by the next deploy of this stack. update.sh refreshes source → rebuild → recreate → health, and is verified end to end.

Landmine 1 — both uv extras are load-bearing at BUILD and RUN

gpu carries cupy-cuda12x; a bare uv sync prunes it and the renderer silently drops to the numpy path at ~21x wall time — it does not error, it just gets slow. waterland-dev warned about the build side.

The runtime side is worse and was not in the handover: studio/jobs.py shells the renderer out as a literal uv run waterland ... with no --extra flags (cwd=WATERLAND_STUDIO_REPO). Left alone, uv re-syncs the project mid-job to its default extras and prunes cupy back out from under a correctly built venv. Pinned with UV_NO_SYNC=1; UV_OFFLINE=1 alongside so that if the pin ever stops holding the job fails loudly instead of quietly rebuilding a slower environment.

Fixed upstream in 464dfc2: the server now spawns sys.executable -m waterland.cli directly — no resolver in the render path at all. The pins stay anyway. They cost nothing and are now defence-in-depth: if any future code path re-enters uv inside the container, the job fails loudly instead of quietly dropping to the numpy backend. uv itself must stay in the image regardless — it performs the build-time uv sync / uv pip install, and this is a single-stage build.

Landmine 2 — cupy needs CUDA HEADERS, which the host never had to declare

Every render died 1.7s in with:

RuntimeError: Failed to find CUDA headers.

printed through argparse's usage banner, which makes it read like a CLI argument bug rather than a missing toolkit. That misdirection is the reason this is written down.

cupy compiles kernels at runtime through NVRTC, which needs the toolkit headers — not just the driver and the runtime libs bundled in the cupy-cuda12x wheel. irv-ml1 has a CUDA toolkit installed system-wide, so the bare nohup process found them by accident; a slim image has none.

Fixed with uv pip install "cupy-cuda12x[ctk]" — headers as wheels, a few hundred MB against ~6 GB for a -devel base image. It runs after uv sync, because sync prunes what it does not know about.

Reported upstream: it is an undeclared runtime dependency of the gpu extra, and anyone running this without a system toolkit hits it. Declared upstream in 464dfc2 (gpu is now cupy-cuda12x[ctk]>=13). The explicit install stays in the Dockerfile: the header requirement is a property of this image — a slim base with no system CUDA toolkit — so it belongs in the file that creates the problem, not inherited from an extra two repos away. It also survives any future restructuring of the gpu extra. Cost of keeping it is now measured, not assumed: since uv sync satisfies it first, the line reports Audited 1 package in 49ms and adds 0.3s to the build. A no-op that documents a non-obvious requirement is worth 0.3s. (waterland-dev independently agreed they would keep it too.)

Landmine 3 — the GPU index inside the container is not the host's

The app pins CUDA_DEVICE_ORDER=PCI_BUS_ID and selects CUDA_VISIBLE_DEVICES_TARGET (default 1, correct on the host, where nvidia-smi shows A6000 at 1). Compose exposes exactly one GPU (device_ids: ["1"], the A6000 in Docker's ordering), so inside the container that card is index 0CUDA_VISIBLE_DEVICES_TARGET=0. Copying the host's value selects a device that does not exist. Host device 0 is the 3090, which carries the TTS zoo and must not be touched.

Cold start is ~17s of NVRTC compile → /root/.cupy is a volume

job wall
256² + anim, cold container 23.3 s
256² + anim, warm 6.1 s
256² plate only (--codec none) 3.9 s
512² plate only 6.4 s

Warm beats the 7.4 s recorded against the bare-metal process, so containerising cost nothing. Verified the cache volume properly: recreate (fresh cache → 23.2 s first render) then restart (populated → 6.0 s). Without it every restart makes the next user wait 4x and the service merely looks slow.

Upstream finding — the on-disk job store grows without bound

JobStore._jobs is a plain dict and nothing scans WATERLAND_STUDIO_DATA at startup. Consequences:

  1. After a restart /api/jobs lists only jobs created since — cosmetic, and how this was spotted: the API reported 1 job while the volume held all 16 directories, 60.6 MB. Not data loss.
  2. The real one: RETAIN = 40 eviction only ever iterates the in-memory dict, so directories orphaned by a restart are never reclaimed. The handover's "bounded around 500 MB" holds within a single process lifetime; across restarts the store grows monotonically at ~12 MB per animated job.

Reported to waterland-dev with evidence; not patched from the infra side — it is their code. Prune the volume by hand if it bites first.

waterland-dev confirmed it (2026-08-19) — their "bounded ~500 MB" handover claim holds within one process lifetime and nowhere else, which on a restart: unless-stopped service is the wrong lifetime to have bounded. They have surfaced a startup-rehydrate fix to the operator rather than opening a third PR during wind-down. Operator green-lit it; PR #6 merged as b72425b and is DEPLOYED (2026-08-19).

Startup rehydrate, as recommended — and waterland-dev deliberately went further than the framing I sent them. I had said a directory the scan cannot parse "just does not enter the index"; they made the opposite call, because a directory that never enters the index is exactly the one that never gets reclaimed. That is the sharper reading and it is the reason the fix works on this volume at all — the 16 pre-existing dirs have no sidecar. Their adoption ladder: sidecar → restored verbatim; no sidecar → adopted with dimensions recovered from the PNG IHDR (24-byte read, not a decode); corrupt sidecar → degrades to inference, no startup crash; neither source nor sidecar → skipped on purpose, since adopting it would turn eviction into a delete-arbitrary-directories primitive pointed at this volume. Sidecar writes go through os.replace, and job.json is excluded from ARTIFACTS so it is unreachable via the artifact route.

They also closed a second leak I never saw, because it needs a restart mid-render to surface: a job left running/queued in its sidecar is non-terminal forever, and eviction skips non-terminal jobs — so it is a phantom that is never reclaimed and queue_depth over-reports for the life of the process. Adoption now marks those failed.

Verified on this host after the update: /api/jobs went 1 → 16 while the volume stayed at 16 dirs / 61 MB — disk and API agree for the first time. Nothing was reclaimed, correctly: 16 is under RETAIN=40, so adoption only made them visible. A subsequent real render took both to 17. From here the store is bounded across restarts, not merely within a process.

Access

Repo is not anonymously readable (a bare clone 403s). Operator granted claude-bot read on vh/waterland — verified admin: False, push: False, pull: True. Token on irv-ml1 at /root/.config/waterland-studio/git-credentials, 0600 root-owned, wired as a repo-scoped credential helper; .git/config carries no token (verified), so the remote stays clean in any diff or backup. The operator's vh site-admin token was used only for the initial clone and the grant itself and was never written to disk on that host — a site-admin credential on a GPU box is a blast radius nobody needs for a read-only fetch.

Constraints honoured as stated (not inferred)

  • Serial by design — one replica, one card. A render is 2045s of near-full GPU with a single worker thread. Two on the same A6000 would OOM or thrash. Throughput is a hardware conversation, not a replica-count one.
  • No authentication, arbitrary file uploads ⇒ stays inside the LAN/WireGuard boundary. Do not paper over it with a proxy password; waterland-dev offered to add a real auth layer if wider reach is ever needed.