carlos / platform Public

Merge pull request #205 from carlosframework/verify-tmp-rationale

verify drills: correct the tmpfs rationale with what the box measured
Paul Campbell c80b67ef4dae8833f999a510a8eb7bbba0ab8efd parent 72a0c10parent ae14338
3 files changed, +65 −30
  • docs/superpowers/specs/2026-08-15-sqlite-corruption-defence-design.md +26 −6
  • internal/host/units/carlos-hibernate-verify.service +9 −8
  • internal/host/units/carlos-restore-verify.service +30 −16
diff --git a/docs/superpowers/specs/2026-08-15-sqlite-corruption-defence-design.md b/docs/superpowers/specs/2026-08-15-sqlite-corruption-defence-design.md
index f1a8332ce..420b7844c 100644
--- a/docs/superpowers/specs/2026-08-15-sqlite-corruption-defence-design.md
+++ b/docs/superpowers/specs/2026-08-15-sqlite-corruption-defence-design.md
@@ -172,16 +172,36 @@ largest single database — but on tmpfs that still means **the ceiling has to
exceed the biggest tenant**, so the drill would begin failing on exactly the
databases it exists to vouch for, about 30M of growth away.
-The fix is to stop restoring into RAM: both drill units now carry
-`CacheDirectory=carlos-verify` and `Environment=TMPDIR=%C/carlos-verify`, so
-restores land on disk and `MemoryMax` bounds litestream's working set (tens of
-MB) rather than tenant size. Pinned by
+Both drill units now carry `CacheDirectory=carlos-verify` and
+`Environment=TMPDIR=%C/carlos-verify`, so restores land on disk. Pinned by
`TestVerifyDrillsRestoreToDiskNotTmpfs`, because it is a one-line property
that regresses silently — the drill keeps passing right up until a tenant
grows.
-The general lesson, worth carrying: **under `PrivateTmp=yes`, temp files are
-memory.** Any unit that stages data through /tmp is charged for it.
+**What that actually buys, stated correctly.** The first framing of this fix
+(in PR #204) claimed the ceiling would then bound litestream's working set
+rather than tenant size. That is WRONG, and the box said so: page cache is
+charged to the cgroup just as tmpfs is, so the reported peak barely moved
+(485.6M on tmpfs → 494.4M on disk, same 412M tenant; tito's box-wide drill did
+fall, 194.9M → 141.3M). What changes is not the number but **what the ceiling
+does when the restore does not fit**:
+
+| | hitting `MemoryMax` |
+| --- | --- |
+| tmpfs (unevictable) | **kills** the drill |
+| page cache (reclaimable) | **throttles** it |
+
+Measured on the flagship, same 412M database, `MemoryMax=128M`: on tmpfs,
+oom-killed after 8.3s; on disk, still running after ten minutes under reclaim,
+stopped by hand. So a tenant outgrowing the ceiling now degrades instead of
+failing — but size is not irrelevant, because a badly undersized ceiling makes
+the drill crawl. `MemoryMax` must still sit comfortably above the largest
+single database. (Watch item: `seapointish.bag.oncarlos.com.db` was 412M
+against a 512M ceiling on 2026-08-16.)
+
+The general lesson, worth carrying: **files a unit stages are charged to its
+cgroup — tmpfs fatally, page cache survivably.** The fix for a drill that OOMs
+is to get the bytes onto disk; the fix for one that crawls is a bigger ceiling.
## 6. Known follow-ups
diff --git a/internal/host/units/carlos-hibernate-verify.service b/internal/host/units/carlos-hibernate-verify.service
index e491c837f..3d0880649 100644
--- a/internal/host/units/carlos-hibernate-verify.service
+++ b/internal/host/units/carlos-hibernate-verify.service
@@ -29,11 +29,12 @@ Type=oneshot
# wrong store.
EnvironmentFile=-/etc/carlos/host.env
ExecStart=/opt/carlos/carlos ops hibernate-verify
-# Disk, not the PrivateTmp tmpfs — see carlos-restore-verify.service's note.
-# This drill hit it hardest: the flagship's largest hibernating tenant is 412M
-# and the first live run peaked at 485.6M against a 512M ceiling, i.e. the
-# drill was ~30M of tenant growth away from failing on the very databases it
-# is there to vouch for.
+# Disk, not the PrivateTmp tmpfs — see carlos-restore-verify.service's note
+# for the measurements. This drill is where it mattered most: the flagship's
+# largest hibernating tenant is 412M and the first live run peaked at 485.6M
+# against a 512M ceiling. On tmpfs that gap was ~30M of tenant growth away
+# from an OOM kill on the very databases the drill is there to vouch for; on
+# disk the same overrun only slows it down.
CacheDirectory=carlos-verify
Environment=TMPDIR=%C/carlos-verify
User=carlos
@@ -50,7 +51,7 @@ PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_INET AF_INET6
MemoryDenyWriteExecute=yes
-# Bounds the working set, not the databases (TMPDIR is on disk above). This
-# drill is one-at-a-time by construction — lease.VerifyReplica deletes each
-# throwaway copy in a defer — so the same generous 512M ceiling applies.
+# One-at-a-time by construction — lease.VerifyReplica deletes each throwaway
+# copy in a defer — so this needs to clear the largest single tenant, not the
+# fleet. Same ceiling and the same WATCH note as the sibling unit.
MemoryMax=512M
diff --git a/internal/host/units/carlos-restore-verify.service b/internal/host/units/carlos-restore-verify.service
index 6fb57944e..c5ebeed2f 100644
--- a/internal/host/units/carlos-restore-verify.service
+++ b/internal/host/units/carlos-restore-verify.service
@@ -13,14 +13,25 @@ Wants=network-online.target
Type=oneshot
ExecStart=/opt/carlos/carlos ops restore-verify -config /etc/litestream.yml
EnvironmentFile=/data/litestream.env
-# Restore into a DISK-backed directory, not the PrivateTmp tmpfs. A restored
-# database on tmpfs is charged to this cgroup as memory, so the drill's peak
-# tracked the size of the fleet's largest tenant and would OOM the moment one
-# outgrew MemoryMax — measured on the flagship 2026-08-15, a 412M tenant
-# produced a 485.6M peak against a 512M ceiling. On disk, the peak is
-# litestream's own working set (tens of MB) and the drill stops competing with
-# the tenants it exists to protect. systemd creates and cleans %C/carlos-verify
-# and binds it writable despite ProtectSystem=strict.
+# Restore into a DISK-backed directory, not the PrivateTmp tmpfs. Both are
+# charged to this cgroup — page cache is accounted just like tmpfs, so the
+# reported peak barely moves (flagship: 485.6M on tmpfs, 494.4M on disk, for a
+# 412M tenant). What changes is what the ceiling DOES when the restore does not
+# fit, and that is the whole point:
+#
+# tmpfs pages are unevictable -> hitting MemoryMax KILLS the drill
+# page cache is reclaimable -> hitting MemoryMax THROTTLES it
+#
+# Measured on the flagship 2026-08-16, same 412M database, MemoryMax=128M:
+# on tmpfs, oom-killed after 8.3s; on disk, still running after 10 minutes
+# under reclaim (stopped by hand). So the restored file no longer has to FIT
+# in the cgroup's memory — a tenant outgrowing the ceiling degrades instead of
+# failing.
+#
+# It does NOT make size irrelevant: a badly undersized ceiling makes the drill
+# crawl. Keep MemoryMax comfortably above the largest tenant. systemd creates
+# and cleans %C/carlos-verify and binds it writable despite
+# ProtectSystem=strict.
CacheDirectory=carlos-verify
Environment=TMPDIR=%C/carlos-verify
User=carlos
@@ -33,12 +44,15 @@ PrivateTmp=yes
PrivateDevices=yes
RestrictAddressFamilies=AF_INET AF_INET6
MemoryDenyWriteExecute=yes
-# With TMPDIR on disk (above) this bounds litestream's WORKING SET, not the
-# databases — measured 194.9M for tito's 27 replicas and 485.6M on the
-# flagship back when a 412M tenant still landed in tmpfs. A ceiling costs
-# nothing unused, so it stays generous. Two earlier lessons are kept here
-# because both are load-bearing: tito was OOM-killed at "256.2M memory peak"
-# on 2026-08-15 when restored copies ACCUMULATED (RestoreVerify now deletes
-# each once checked — see its comment), and tmpfs is why the files counted as
-# memory at all.
+# Sized to sit ABOVE the largest single database, not to bound the process:
+# restored bytes are charged here either way (see the TMPDIR note). Measured
+# after the move to disk: tito's 27 replicas 141.3M (was 194.9M on tmpfs), the
+# flagship's 412M tenant 494.4M. Two earlier lessons are load-bearing and kept:
+# tito was OOM-killed at a "256.2M memory peak" on 2026-08-15 when restored
+# copies ACCUMULATED (RestoreVerify now deletes each once checked — see its
+# comment), and tmpfs is why unfitting files were fatal rather than slow.
+#
+# WATCH: seapointish.bag.oncarlos.com.db was 412M against this 512M on
+# 2026-08-16. It will throttle, not fail, when it crosses — but raise this
+# then.
MemoryMax=512M