feat(disk): KEEPSAKE_ENABLED trades the archive for ~3x the usable media

The upload gate refuses any photo that would leave too little room to build the
keepsake, and the archive needs both halves at once — so it demands
`2.2 x media + 10 GB reserve + 1.5 GB headroom`. Every gigabyte of photos
therefore costs about 3.2 GB of disk budget, and usable media works out at
`(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB of photos.

Measured, not theorised: a 945-photo wedding (8.74 GB of real files) stopped
after ~780 photos with 24.98 GB free — the gate wanted 25.91 GB on a disk that
was 79% empty. Uploads ended for want of an archive nobody had asked for yet.

`KEEPSAKE_ENABLED=false` drops the archive term, leaving only `DISK_RESERVE_BYTES`
— the one question still live without an archive, which is whether Postgres can
still write. That turns the same 30 GB volume into ~20 GB of usable media.
Verified against the exact disk state that ended the run: the photo that returned
413 with the keepsake armed is accepted with it off.

The headroom term goes with it, deliberately. It exists only to keep this gate
strictly ahead of the EXPORT PREFLIGHT, and with no keepsake there is no
preflight to stay ahead of.

Boot-time immutable, like COMMENTS_ENABLED: flipping it mid-event would move the
gate under uploads already accepted against the old one. Releasing the gallery
and minting download tickets are refused while it is off, and the host dashboard
hides the release control rather than offering a button that answers 403 — a new
`keepsake_enabled` field on `GET /host/event` carries that.

The gate and the host's low-disk banner now share `upload_gate_required_free`.
They were already two expressions of one threshold, and the banner exists to fire
BEFORE the gate closes; applying the switch to only one of them would warn the
host about a limit that can no longer fire, or stay silent past one that can.
Tests pin the lead in both modes.

The cost is real and worth stating plainly: there is no downloadable gallery at
the end, so guests keep only what they save from the feed. Prefer a bigger disk
where you can — ~45 GB holds this library with the keepsake intact.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-08-20 22:57:25 +02:00
parent cacf616c2d
commit 464b270716
6 changed files with 238 additions and 35 deletions

View File

@@ -207,6 +207,30 @@ COMPRESSION_WORKER_CONCURRENCY=2
# production. Remove that line from the compose file first if you want comments back.
COMMENTS_ENABLED=true
# ── Keepsake archive (and the disk ceiling it imposes) ────────────────────────
# Master switch for the downloadable gallery. Boot-time only, like COMMENTS_ENABLED.
#
# This is really a DISK-SIZING knob. The keepsake needs room for both halves at once (a ZIP
# and an HTML viewer, each gallery-sized), and the upload gate refuses any photo that would
# leave too little space to build it. The gate demands:
#
# free >= 2.2 x media + DISK_RESERVE (10 GB) + UPLOAD_GATE_HEADROOM (1.5 GB)
#
# so usable media is roughly `(disk - 11.5 GB) / 3.2`. On a 30 GB volume that is only ~5.8 GB
# of photos — uploads stop with most of the disk still free. Measured: a 945-photo wedding
# (8.74 GB) hit the wall after ~780 photos with 25 GB free.
#
# KEEPSAKE_ENABLED=true (default) archive available; ~5.8 GB of photos on a 30 GB disk
# KEEPSAKE_ENABLED=false no archive; ~20 GB of photos on a 30 GB disk
#
# Turning it OFF is irreversible for the event in the way that matters: there is no download
# at the end, so guests keep only what they save from the feed themselves. Releasing the
# gallery and minting download tickets are refused while it is off. The 10 GB database
# reserve still applies — that one protects Postgres, not the archive.
#
# Prefer a bigger disk if you can: ~45 GB holds a 9.7 GB library WITH the keepsake.
KEEPSAKE_ENABLED=true
# ── Logging ───────────────────────────────────────────────────────────────────
# SET THIS IN PRODUCTION. Without it the app falls back to
# `eventsnap_backend=debug,tower_http=debug` (see main.rs), and with TraceLayer that is a