Compare commits

...

4 Commits

Author SHA1 Message Date
fabi
43c2a0d09c feat(host): warn about low disk before it becomes unrecoverable
Storage visibility existed in exactly one place: a passive Speicherauslastung widget
on the ADMIN dashboard. A host who isn't the admin had no view of it, and nothing
warned anyone. README carried "Low-disk alert (< 10 GB free)" under Planned since v1.

Two things make this a safety net rather than a nice-to-have. postgres_data,
media_data and exports_data are all Docker named volumes on ONE filesystem, so
running out doesn't degrade a subsystem -- Postgres stops being able to write and the
whole event goes down. And the keepsake needs room for two gallery-sized archives,
which the export preflight can only ever refuse AFTER the release, when the event is
over and every remedy is harder.

So the threshold is not a fixed number alone. It fires on the 10 GB floor the README
always named, OR on "you could not build the keepsake right now" -- the trigger a
host can still act on, computed with the same arithmetic the preflight uses. Unknown
free space is NOT low: it fails open like the upload quota and the preflight do,
because a banner that cries wolf on an unreadable mount is a banner nobody reads.

Carried on GET /host/event, which the dashboard already fetches on load and on every
reload -- no new endpoint, no new poll. Rendered above everything else including the
PIN-reset queue, and it names the consequence (the event, not just the download)
rather than only the number.

Also fixes the host page's formatBytes, which topped out at MB: 30 GB free would have
rendered as "30720.0 MB", and a guest with 2 GB of uploads was already being shown
that way in the user list.

Tests: 5 unit on the threshold (including that plenty of free space is still low when
the keepsake wouldn't fit -- the case a fixed threshold misses entirely), 3 e2e.
The e2e drives it through `original_size_bytes` rather than a genuinely full disk:
the estimate is pure SQL over that column, so overstating one row moves the
accounting without touching a byte on disk.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:48:06 +02:00
fabi
6818cabf91 Merge branch 'fix/reclaim-deleted-originals' 2026-07-29 19:42:03 +02:00
fabi
f777764839 fix(maintenance): reclaim the media of deliberately deleted uploads
The quota stopped bounding the disk. `soft_delete_in_event` stamps `deleted_at` and
refunds `total_upload_bytes`, but nothing ever removed the bytes, and the hourly
sweep reached only `compression_status = 'failed'`. Upload 500 MB, delete, quota back
to zero, upload another 500 MB. Not an attack -- a guest curating their camera roll,
which is what people do. The host then sees guests hitting "Du hast dein Upload-Limit
erreicht" while the admin widget shows a disk full of files no upload row points at,
and the quota message is actively misleading because the space really is gone, just
not to anyone the accounting can name.

Two retention windows, because the two deletes mean different things. A compression
failure keeps its 14 days: the guest didn't ask for it and may not be able to retake
the photo. A deliberate removal gets 24 hours -- 14 days outlives the whole event, so
a deliberate delete would never reclaim anything while it mattered, and a day still
covers a mis-tap.

Wider than reported: ALL FOUR paths are reclaimed, not just the original. Preview,
display and thumbnail are each a separate file, none counted in
`original_size_bytes`, and nothing ever removed them either. That was invisible while
the sweep only saw failed compressions (which produce no derivatives) and becomes
three leaked files per upload the moment it reaches a successful one. A row is
re-selected until every path is cleared, and the columns are cleared only once every
file for that upload is gone -- clearing after a partial success would strand the
survivors in exactly the unowned state this drains.

`backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT
NULL`, which is close enough to the post-sweep state to be worth pinning: it is
guarded on `deleted_at IS NULL`, so it cannot re-decode an original that is no longer
on disk. Covered.

Residual, deliberately: within the 24h window the bytes are still spent and still
unaccounted, so delete-and-re-upload through an eight-hour event can outrun the
sweep. Bounding that means holding the quota until the file is reclaimed rather than
refunding at `deleted_at`. The low-disk warning is the net under it.

Tests: 6 DB-backed, replacing 3. The one asserting an owner-deleted upload IS
reclaimed is the exact inverse of what this file used to assert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 19:41:51 +02:00
fabi
aeb958f6ba Merge branch 'fix/export-disk-preflight' 2026-07-29 19:38:39 +02:00
7 changed files with 625 additions and 175 deletions

View File

@@ -35,6 +35,32 @@ pub struct EventStatus {
pub is_active: bool,
pub uploads_locked: bool,
pub export_released: bool,
/// Free space on the volume the keepsake is written to. `None` when the mount can't be
/// resolved — the UI hides the widget rather than rendering a confident zero.
pub disk_free_bytes: Option<u64>,
/// What a full keepsake build would need right now (both halves).
pub keepsake_required_bytes: u64,
/// Whether the host should be warned. See [`disk_is_low`].
pub disk_low: bool,
}
/// Absolute floor below which free space is worth surfacing regardless of gallery size — the
/// threshold the README has carried on the roadmap since v1.
const LOW_DISK_FLOOR_BYTES: u64 = 10_000_000_000;
/// Is free space low enough that the host needs to know?
///
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
/// `media_data` and `exports_data` are all Docker named volumes on one filesystem, so a full disk
/// does not degrade one subsystem — it stops Postgres writing and takes the event down. That is
/// what the absolute floor is for.
///
/// The second trigger is the one that actually earns its place: the keepsake needs room for two
/// gallery-sized archives, and the only moment a host can do anything about that is BEFORE they
/// release. Warning at "you could not build the keepsake right now" turns a post-event dead end
/// into a decision someone can still make.
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
free < LOW_DISK_FLOOR_BYTES || free < keepsake_required
}
/// Count non-banned hosts/admins in the event OTHER than `excluding` — the operators
@@ -72,11 +98,29 @@ pub async fn get_event_status(
.await?
.ok_or_else(|| AppError::NotFound("Event nicht gefunden.".into()))?;
// Measured on the EXPORT volume, not the media one: that is where the cliff is, and it is a
// distinct mount point even when both are backed by the same filesystem. The cached reading is
// right here — this is advisory, polled on every dashboard load, and a 15s-stale number costs
// nothing (unlike the export preflight, which reads uncached because it is about to write).
let free = state
.disk_cache
.snapshot(&state.config.export_path)
.map(|d| d.free);
let keepsake_required_bytes =
crate::services::export::keepsake_space_required(&state.pool, event.id)
.await
.unwrap_or(0);
Ok(Json(EventStatus {
name: event.name,
is_active: event.is_active,
uploads_locked: event.uploads_locked_at.is_some(),
export_released: event.export_released_at.is_some(),
disk_free_bytes: free,
keepsake_required_bytes,
// Unknown free space is NOT low. Fails open, exactly as the upload quota and the export
// preflight do: a scary banner on an unreadable mount would train the host to ignore it.
disk_low: free.is_some_and(|f| disk_is_low(f, keepsake_required_bytes)),
}))
}
@@ -765,3 +809,45 @@ pub async fn release_gallery(
Ok(StatusCode::NO_CONTENT)
}
#[cfg(test)]
mod tests {
use super::{LOW_DISK_FLOOR_BYTES, disk_is_low};
const GB: u64 = 1_000_000_000;
#[test]
fn a_healthy_disk_with_room_for_the_keepsake_is_not_low() {
assert!(!disk_is_low(40 * GB, 25 * GB));
}
#[test]
fn the_absolute_floor_fires_even_when_the_gallery_is_tiny() {
// All three volumes share one filesystem, so running out doesn't degrade one subsystem —
// Postgres stops being able to write and the event goes down. A 1 GB gallery would clear
// the keepsake test comfortably; the floor is what catches this.
assert!(disk_is_low(5 * GB, GB));
assert!(disk_is_low(LOW_DISK_FLOOR_BYTES - 1, 0));
assert!(!disk_is_low(LOW_DISK_FLOOR_BYTES, 0));
}
#[test]
fn plenty_of_space_is_still_low_when_the_keepsake_would_not_fit() {
// THE case the fixed threshold misses, and the one that matters: 30 GB free is nowhere near
// any floor, but a 30 GB gallery needs room for TWO archives. The host can act on this
// before releasing; after releasing, they cannot.
assert!(disk_is_low(30 * GB, 66 * GB));
}
#[test]
fn the_keepsake_trigger_is_exact_at_the_boundary() {
assert!(!disk_is_low(66 * GB, 66 * GB), "exactly enough is enough");
assert!(disk_is_low(66 * GB - 1, 66 * GB));
}
#[test]
fn an_empty_gallery_needs_nothing_and_only_the_floor_applies() {
assert!(!disk_is_low(11 * GB, 0));
assert!(disk_is_low(9 * GB, 0));
}
}

View File

@@ -1268,7 +1268,7 @@ fn is_superseded_archive(
/// want, since being wrong low means ENOSPC halfway through.
///
/// Matches [`query_uploads`]' visibility filter exactly, so hidden/banned uploads aren't counted.
async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> Result<u64> {
pub async fn estimate_export_bytes(pool: &PgPool, event_id: Uuid) -> Result<u64> {
let (bytes,): (i64,) = sqlx::query_as(
"SELECT COALESCE(SUM(u.original_size_bytes), 0)::bigint
FROM upload u
@@ -1302,6 +1302,20 @@ fn required_free_bytes(media_bytes: u64, armed: i64) -> u64 {
needed.min(u64::MAX as u128) as u64
}
/// Free bytes a full keepsake build would need RIGHT NOW, both halves included.
///
/// The same arithmetic the preflight uses, exposed so the host dashboard can warn BEFORE the
/// release rather than reporting a failure after it. The preflight can only ever say "this didn't
/// fit"; at that point the gallery is full, the event is over, and the remedies (ask guests to stop
/// uploading, grow the volume) are all much harder. Hard-codes both halves because that is what a
/// release arms.
pub async fn keepsake_space_required(pool: &PgPool, event_id: Uuid) -> Result<u64> {
Ok(required_free_bytes(
estimate_export_bytes(pool, event_id).await?,
2,
))
}
/// Refuse to start an export that cannot fit, with a reason the host can act on.
///
/// Without this the failure mode is ENOSPC halfway through a multi-GB write, and the wreckage

View File

@@ -11,8 +11,9 @@
//! 2. **Periodic tasks** — pruning that should happen "every hour" rather than per
//! request: expired sessions (otherwise the table grows unboundedly), the
//! rate-limiter's in-memory windows (so keys for IPs that left long ago don't
//! accumulate), and the originals of uploads whose compression permanently failed
//! (which are deliberately retained for a recovery window, then reclaimed).
//! accumulate), and the media of soft-deleted uploads — both the ones whose compression
//! permanently failed and the ones a guest or host deliberately removed — which are
//! retained for a recovery window and then reclaimed.
use std::path::PathBuf;
use std::time::Duration;
@@ -37,6 +38,27 @@ use crate::services::sse_tickets::SseTicketStore;
/// failed upload still has the file, while the leak stays bounded.
const FAILED_ORIGINAL_RETENTION_DAYS: i64 = 14;
/// How long a DELIBERATELY deleted upload's files are kept before they are reclaimed.
///
/// The same leak, reached by the ordinary path rather than the exceptional one.
/// `soft_delete_in_event` stamps `deleted_at` and refunds `total_upload_bytes`, but nothing ever
/// removed the bytes — so the quota stopped bounding the disk. Upload 500 MB, delete, quota is back
/// to zero, upload another 500 MB: not an attack, just a guest curating their camera roll, which is
/// what people do. The host then sees guests hitting "Du hast dein Upload-Limit erreicht" while the
/// admin widget shows a disk full of files no upload row points at, and the quota message is
/// actively misleading because the space really is gone — just not to anyone the accounting can
/// name.
///
/// Much shorter than the failure window on purpose. Fourteen days outlives the whole event, so a
/// deliberate delete would never reclaim anything while it mattered. A day still gives an operator
/// a recovery window for a mis-tap.
///
/// NOTE what this does NOT do: within the window the bytes are still spent and still unaccounted,
/// so a guest deleting and re-uploading through an eight-hour event can outrun the sweep. Bounding
/// that would mean holding the quota until the file is actually reclaimed rather than refunding at
/// `deleted_at` — a deliberate trade, and the reason the low-disk warning exists.
const DELETED_UPLOAD_RETENTION_HOURS: i64 = 24;
/// Reset rows left in flight by a previous crashed instance. Run once on startup,
/// before the HTTP server starts taking requests, so users never observe the
/// half-state.
@@ -117,38 +139,59 @@ pub fn spawn_periodic_tasks(
loop {
tick.tick().await;
cleanup_sessions(&pool).await;
cleanup_failed_originals(&pool, &media_path).await;
cleanup_deleted_media(&pool, &media_path).await;
rate_limiter.prune();
sse_tickets.prune();
}
});
}
/// Reclaim the originals of uploads whose compression permanently failed, once they are
/// past [`FAILED_ORIGINAL_RETENTION_DAYS`].
/// Reclaim the media of soft-deleted uploads once they are past their retention window.
///
/// Deliberately narrow. It only touches rows that are BOTH `compression_status = 'failed'`
/// AND soft-deleted — i.e. the exact state the compression worker's give-up path leaves
/// behind — so it can never reach a live upload or one whose preview works. `original_path`
/// is cleared in the same pass, which makes the sweep idempotent and stops a later run
/// re-reporting a file that is already gone. The row itself is kept: it is the audit trail
/// for the failure, and it costs a few hundred bytes.
async fn cleanup_failed_originals(pool: &PgPool, media_path: &std::path::Path) {
let rows = sqlx::query_as::<_, (uuid::Uuid, String)>(
"SELECT id, original_path FROM upload
WHERE compression_status = 'failed'
AND deleted_at IS NOT NULL
AND deleted_at < NOW() - ($1 || ' days')::interval
AND original_path <> ''",
/// ONLY ever touches rows with `deleted_at IS NOT NULL`, so it can never reach a live upload. Two
/// classes, two windows, because the two deletes mean different things:
///
/// - a compression failure the guest didn't ask for and may want investigated —
/// [`FAILED_ORIGINAL_RETENTION_DAYS`];
/// - a deliberate removal by the guest or the host — [`DELETED_UPLOAD_RETENTION_HOURS`].
///
/// ALL FOUR paths are reclaimed, not just the original. The previous version cleared
/// `original_path` alone, which was right for its only case (a failed compression produces no
/// derivatives) but wrong the moment the sweep reaches a successfully processed upload: preview,
/// display and thumbnail are each a separate file on disk, none of them counted in
/// `original_size_bytes`, and nothing else ever removed them.
///
/// Every column is cleared in the same pass, which makes the sweep idempotent and stops a later run
/// re-reporting files that are already gone. The ROW is kept: it is the audit trail, it costs a few
/// hundred bytes, and `backfill_stale_derivatives` is guarded on `deleted_at IS NULL` so a nulled
/// `preview_path` can never make it regenerate what was just reclaimed.
async fn cleanup_deleted_media(pool: &PgPool, media_path: &std::path::Path) {
type Row = (
uuid::Uuid,
String,
Option<String>,
Option<String>,
Option<String>,
);
let rows = sqlx::query_as::<_, Row>(
"SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload
WHERE deleted_at IS NOT NULL
AND CASE WHEN compression_status = 'failed'
THEN deleted_at < NOW() - ($1 || ' days')::interval
ELSE deleted_at < NOW() - ($2 || ' hours')::interval
END
AND (original_path <> '' OR preview_path IS NOT NULL
OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)",
)
.bind(FAILED_ORIGINAL_RETENTION_DAYS.to_string())
.bind(DELETED_UPLOAD_RETENTION_HOURS.to_string())
.fetch_all(pool)
.await;
let rows = match rows {
Ok(r) => r,
Err(e) => {
tracing::warn!(error = ?e, "failed-original sweep query failed");
tracing::warn!(error = ?e, "deleted-media sweep query failed");
return;
}
};
@@ -157,32 +200,52 @@ async fn cleanup_failed_originals(pool: &PgPool, media_path: &std::path::Path) {
}
let mut reclaimed = 0usize;
for (id, original_path) in rows {
let absolute = media_path.join(&original_path);
match tokio::fs::remove_file(&absolute).await {
Ok(()) => reclaimed += 1,
// Already gone (manual cleanup, restored backup) — still clear the column so
// the row stops being re-selected every hour.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(error = ?e, %id, path = %absolute.display(),
"could not reclaim failed original; leaving the row for the next sweep");
continue;
for (id, original, preview, display, thumbnail) in rows {
let paths: Vec<String> = std::iter::once(original)
.filter(|p| !p.is_empty())
.chain([preview, display, thumbnail].into_iter().flatten())
.collect();
// All-or-nothing per row: the columns are only cleared once every file for that upload is
// gone. Clearing after a partial success would strand the survivors with nothing pointing
// at them — the same unowned-bytes state this sweep exists to drain.
let mut all_gone = true;
for rel in &paths {
let absolute = media_path.join(rel);
match tokio::fs::remove_file(&absolute).await {
Ok(()) => reclaimed += 1,
// Already gone (manual cleanup, restored backup) — still counts as reclaimed for
// the purpose of clearing the columns, or the row is re-selected every hour forever.
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
Err(e) => {
tracing::warn!(error = ?e, %id, path = %absolute.display(),
"could not reclaim deleted media; leaving the row for the next sweep");
all_gone = false;
}
}
}
if let Err(e) = sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1")
.bind(id)
.execute(pool)
.await
if !all_gone {
continue;
}
if let Err(e) = sqlx::query(
"UPDATE upload SET original_path = '', preview_path = NULL,
display_path = NULL, thumbnail_path = NULL
WHERE id = $1",
)
.bind(id)
.execute(pool)
.await
{
tracing::warn!(error = ?e, %id, "reclaimed the file but could not clear original_path");
tracing::warn!(error = ?e, %id, "reclaimed the files but could not clear the paths");
}
}
if reclaimed > 0 {
tracing::info!(
"reclaimed {reclaimed} original(s) from uploads that failed compression more than \
{FAILED_ORIGINAL_RETENTION_DAYS} days ago"
"reclaimed {reclaimed} file(s) from soft-deleted uploads (deliberate deletes after \
{DELETED_UPLOAD_RETENTION_HOURS}h, compression failures after \
{FAILED_ORIGINAL_RETENTION_DAYS}d)"
);
}
}

View File

@@ -1,19 +1,27 @@
//! DB-backed tests for the failed-original sweep (`services/maintenance.rs`).
//! DB-backed tests for the deleted-media sweep (`services/maintenance.rs`).
//!
//! Context: the compression worker deliberately no longer deletes an upload's original when
//! its transcode fails — a transient ENOSPC or a codec panic must never destroy the only
//! copy of a photo a guest cannot retake. But the row is soft-deleted and the uploader's
//! quota IS refunded, so those bytes become invisible, unowned and free. A guest hitting a
//! reproducible codec failure could accumulate orphans at no personal cost, and since
//! `active_uploaders` counts only users with non-deleted uploads, dropping out of that count
//! actually RAISES everyone's per-user ceiling while the disk fills.
//! Context, in two halves.
//!
//! The sweep reclaims them after a retention window. Its selection predicate is the whole
//! safety argument — it must reach the give-up path's leftovers and nothing else — so that
//! is what these tests pin, following the same "reproduce the SQL verbatim" pattern as
//! `upload_concurrency.rs`.
//! The compression worker deliberately no longer deletes an upload's original when its transcode
//! fails — a transient ENOSPC or a codec panic must never destroy the only copy of a photo a guest
//! cannot retake. But the row is soft-deleted and the uploader's quota IS refunded, so those bytes
//! become invisible, unowned and free.
//!
//! `#[sqlx::test]` gives each test a fresh database with the real migrations applied.
//! The SAME hole was reachable by the ordinary path, and that one is not an edge case at all:
//! `soft_delete_in_event` refunds `total_upload_bytes` on every guest or host delete and nothing
//! removed the files, so the quota stopped bounding the disk. Upload 500 MB, delete, quota back to
//! zero, upload another 500 MB — a guest curating their camera roll, which is what people do. The
//! sweep used to reach only `compression_status = 'failed'`, so it never touched this case; the
//! test below that now asserts an owner-deleted upload IS reclaimed is the one that used to assert
//! the opposite.
//!
//! Two windows, because the two deletes mean different things: 14 days for a failure an operator
//! may want to investigate, 24 hours for a removal someone asked for (14 days outlives the whole
//! event, so a deliberate delete would never reclaim anything while it mattered).
//!
//! The selection predicate is the whole safety argument — it must reach both leftovers and never a
//! live upload — so that is what these pin, following the same "reproduce the SQL verbatim" pattern
//! as `upload_concurrency.rs`. `#[sqlx::test]` gives each test a fresh, migrated database.
mod common;
@@ -21,195 +29,324 @@ use common::*;
use sqlx::PgPool;
use uuid::Uuid;
/// SRC: `services/maintenance.rs::cleanup_failed_originals` — the selection, verbatim.
async fn sweep_selects(pool: &PgPool, retention_days: i64) -> Vec<Uuid> {
sqlx::query_as::<_, (Uuid, String)>(
"SELECT id, original_path FROM upload
WHERE compression_status = 'failed'
AND deleted_at IS NOT NULL
AND deleted_at < NOW() - ($1 || ' days')::interval
AND original_path <> ''",
const FAILED_DAYS: i64 = 14;
const DELETED_HOURS: i64 = 24;
/// SRC: `services/maintenance.rs::cleanup_deleted_media` — the selection, verbatim.
async fn sweep_selects(pool: &PgPool, failed_days: i64, deleted_hours: i64) -> Vec<Uuid> {
type Row = (Uuid, String, Option<String>, Option<String>, Option<String>);
sqlx::query_as::<_, Row>(
"SELECT id, original_path, preview_path, display_path, thumbnail_path FROM upload
WHERE deleted_at IS NOT NULL
AND CASE WHEN compression_status = 'failed'
THEN deleted_at < NOW() - ($1 || ' days')::interval
ELSE deleted_at < NOW() - ($2 || ' hours')::interval
END
AND (original_path <> '' OR preview_path IS NOT NULL
OR display_path IS NOT NULL OR thumbnail_path IS NOT NULL)",
)
.bind(retention_days.to_string())
.bind(failed_days.to_string())
.bind(deleted_hours.to_string())
.fetch_all(pool)
.await
.expect("sweep query")
.into_iter()
.map(|(id, _)| id)
.map(|(id, ..)| id)
.collect()
}
#[allow(clippy::too_many_arguments)]
async fn seed_upload(
/// Seed an upload aged `deleted_hours_ago` (None = live), with optional derivative paths.
async fn seed_aged_upload(
pool: &PgPool,
event_id: Uuid,
user_id: Uuid,
status: &str,
deleted_days_ago: Option<i64>,
deleted_hours_ago: Option<i64>,
original_path: &str,
derivatives: bool,
) -> Uuid {
let id: Uuid = sqlx::query_scalar(
sqlx::query_scalar(
"INSERT INTO upload (event_id, user_id, original_path, mime_type, original_size_bytes,
compression_status, deleted_at)
compression_status, deleted_at,
preview_path, display_path, thumbnail_path)
VALUES ($1, $2, $3, 'image/jpeg', 1000, $4,
CASE WHEN $5::bigint IS NULL THEN NULL
ELSE NOW() - ($5::text || ' days')::interval END)
ELSE NOW() - ($5::text || ' hours')::interval END,
CASE WHEN $6 THEN 'previews/p.jpg' END,
CASE WHEN $6 THEN 'displays/d.jpg' END,
CASE WHEN $6 THEN 'thumbs/t.jpg' END)
RETURNING id",
)
.bind(event_id)
.bind(user_id)
.bind(original_path)
.bind(status)
.bind(deleted_days_ago)
.bind(deleted_hours_ago)
.bind(derivatives)
.fetch_one(pool)
.await
.expect("seed upload");
id
.expect("seed upload")
}
/// A live upload is untouchable no matter how the windows are configured.
///
/// PREVENTS: the catastrophic loosening. Everything else here is about reclaiming more; this is the
/// one assertion that must never bend.
#[sqlx::test]
async fn sweeps_only_long_failed_soft_deleted_uploads(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-event").await;
async fn a_live_upload_is_never_selected(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-live").await;
let user_id = seed_user(&pool, event_id, "Sweeper").await;
// The one and only thing the sweep may touch: the exact state the compression worker's
// give-up path leaves behind, aged past the window.
let target = seed_upload(
&pool,
event_id,
user_id,
"failed",
Some(30),
"originals/e/target.jpg",
)
.await;
// Everything below is a near-miss that must survive.
// A healthy live upload — the catastrophic case if the predicate were ever loosened.
let live = seed_upload(
&pool,
event_id,
user_id,
"done",
None,
"originals/e/live.jpg",
)
.await;
// Failed but still inside the retention window: the recovery window is the entire point
// of keeping the file, so reclaiming it early would defeat the fix it protects.
let recent = seed_upload(
&pool,
event_id,
user_id,
"failed",
Some(1),
"originals/e/recent.jpg",
)
.await;
// Failed but NOT soft-deleted — not the give-up path; something else set this status.
let failed_live = seed_upload(
&pool,
event_id,
user_id,
"failed",
None,
"originals/e/failed-live.jpg",
)
.await;
// Soft-deleted by the OWNER, compression fine. Its file is already gone; this row must
// never be re-processed.
let owner_deleted = seed_upload(
&pool,
event_id,
user_id,
"done",
Some(30),
"originals/e/owner.jpg",
)
.await;
// Already swept: `original_path` cleared. Re-selecting it every hour would log a
// phantom reclaim forever.
let already_swept = seed_upload(&pool, event_id, user_id, "failed", Some(30), "").await;
let selected = sweep_selects(&pool, 14).await;
assert_eq!(
selected,
vec![target],
"the sweep must select exactly the aged give-up-path leftovers"
);
for (id, what) in [
(live, "a live upload"),
(recent, "a failure still inside the retention window"),
(failed_live, "a failed but not soft-deleted upload"),
(owner_deleted, "an owner-deleted upload"),
(already_swept, "an already-swept row"),
] {
assert!(!selected.contains(&id), "the sweep must not touch {what}");
for status in ["done", "failed", "processing", "pending"] {
let live = seed_aged_upload(
&pool,
event_id,
user_id,
status,
None,
"originals/e/live.jpg",
true,
)
.await;
assert!(
!sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
.await
.contains(&live),
"a non-deleted upload with status {status} must never be swept"
);
}
}
/// THE FIX. An upload a guest or host deliberately deleted is reclaimed once past 24 hours.
///
/// PREVENTS: the regression back to a sweep scoped to `compression_status = 'failed'`, which is
/// what let the quota stop bounding the disk. This assertion is the inverse of the one this file
/// used to make.
#[sqlx::test]
async fn retention_window_is_honoured_at_the_boundary(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-boundary").await;
let user_id = seed_user(&pool, event_id, "Boundary").await;
async fn a_deliberately_deleted_upload_is_reclaimed_after_a_day(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-deleted").await;
let user_id = seed_user(&pool, event_id, "Curator").await;
let inside = seed_upload(
let deleted = seed_aged_upload(
&pool,
event_id,
user_id,
"failed",
Some(13),
"originals/e/inside.jpg",
"done",
Some(48),
"originals/e/owner.jpg",
true,
)
.await;
let outside = seed_upload(
// Still inside the window — a mis-tap is recoverable for a day.
let recent = seed_aged_upload(
&pool,
event_id,
user_id,
"failed",
Some(15),
"originals/e/outside.jpg",
"done",
Some(2),
"originals/e/recent.jpg",
true,
)
.await;
let selected = sweep_selects(&pool, 14).await;
let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
assert!(
selected.contains(&outside),
"15 days old must be past a 14-day window"
selected.contains(&deleted),
"a deliberate delete past the window must be reclaimed — this is the leak"
);
assert!(
!selected.contains(&inside),
"13 days old must still be retained"
!selected.contains(&recent),
"a delete inside the window keeps its recovery grace"
);
}
/// The two windows are independent: a failure is retained far longer than a deliberate delete.
///
/// PREVENTS: collapsing them into one. Applying 24h to failures would destroy the recovery window
/// the retained-original fix exists to provide; applying 14 days to deliberate deletes would mean
/// nothing is ever reclaimed during an event.
#[sqlx::test]
async fn clearing_original_path_makes_the_sweep_idempotent(pool: PgPool) {
// The sweep clears `original_path` after reclaiming the file. Without that, a row whose
// file is already gone is re-selected on every hourly tick forever.
let event_id = seed_event(&pool, "sweep-idempotent").await;
let user_id = seed_user(&pool, event_id, "Idem").await;
let id = seed_upload(
async fn the_two_retention_windows_do_not_bleed_into_each_other(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-windows").await;
let user_id = seed_user(&pool, event_id, "Windows").await;
// 48h old: past the deliberate window, nowhere near the failure window.
let failed_recent = seed_aged_upload(
&pool,
event_id,
user_id,
"failed",
Some(30),
"originals/e/once.jpg",
Some(48),
"originals/e/f-recent.jpg",
false,
)
.await;
let deleted_same_age = seed_aged_upload(
&pool,
event_id,
user_id,
"done",
Some(48),
"originals/e/d-same.jpg",
false,
)
.await;
// 30 days old: past both.
let failed_old = seed_aged_upload(
&pool,
event_id,
user_id,
"failed",
Some(30 * 24),
"originals/e/f-old.jpg",
false,
)
.await;
assert_eq!(sweep_selects(&pool, 14).await, vec![id]);
let selected = sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await;
assert!(
!selected.contains(&failed_recent),
"a 2-day-old compression failure is still inside its 14-day recovery window"
);
assert!(
selected.contains(&deleted_same_age),
"a deliberate delete of the same age is past its 24-hour window"
);
assert!(
selected.contains(&failed_old),
"a 30-day-old failure is past both windows"
);
}
/// Boundary behaviour on both windows.
#[sqlx::test]
async fn retention_windows_are_honoured_at_the_boundary(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-boundary").await;
let user_id = seed_user(&pool, event_id, "Boundary").await;
let cases = [
("failed", 13 * 24, false, "13 days"),
("failed", 15 * 24, true, "15 days"),
("done", 23, false, "23 hours"),
("done", 25, true, "25 hours"),
];
for (status, hours, expected, label) in cases {
let id = seed_aged_upload(
&pool,
event_id,
user_id,
status,
Some(hours),
"originals/e/b.jpg",
false,
)
.await;
assert_eq!(
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
.await
.contains(&id),
expected,
"a {status} upload deleted {label} ago: expected swept={expected}"
);
sqlx::query("DELETE FROM upload WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("clean up");
}
}
/// A row is re-selected until EVERY one of its paths is cleared.
///
/// PREVENTS: two failures at once. The sweep used to clear `original_path` alone, which was right
/// for its only case (a failed compression produces no derivatives) but leaves preview, display and
/// thumbnail on disk the moment it reaches a successfully processed upload — three files per
/// upload, none of them counted in `original_size_bytes`, that nothing else ever removes. And a row
/// whose paths are all cleared must stop coming back, or every hourly tick logs a phantom reclaim
/// forever.
#[sqlx::test]
async fn a_row_is_reselected_until_every_path_is_cleared(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-idempotent").await;
let user_id = seed_user(&pool, event_id, "Idem").await;
let id = seed_aged_upload(
&pool,
event_id,
user_id,
"done",
Some(48),
"originals/e/once.jpg",
true,
)
.await;
assert_eq!(sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await, [id]);
// Clearing only the original is NOT enough — the derivatives are still on disk.
sqlx::query("UPDATE upload SET original_path = '' WHERE id = $1")
.bind(id)
.execute(&pool)
.await
.expect("clear path");
.expect("clear original");
assert_eq!(
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS).await,
[id],
"derivatives left behind must keep the row selected"
);
sqlx::query(
"UPDATE upload SET preview_path = NULL, display_path = NULL, thumbnail_path = NULL
WHERE id = $1",
)
.bind(id)
.execute(&pool)
.await
.expect("clear derivatives");
assert!(
sweep_selects(&pool, 14).await.is_empty(),
"a swept row must not come back"
sweep_selects(&pool, FAILED_DAYS, DELETED_HOURS)
.await
.is_empty(),
"a fully swept row must not come back"
);
}
/// The derivative backfill must never resurrect what the sweep just reclaimed.
///
/// PREVENTS: an interaction, not a bug in either piece. The sweep nulls `preview_path`, and
/// `backfill_stale_derivatives` selects on `display_path IS NULL AND preview_path IS NOT NULL` —
/// close enough that a future edit to either could have the backfill re-decode an original that is
/// no longer on disk, on every boot. `deleted_at IS NULL` is what keeps them apart.
#[sqlx::test]
async fn the_backfill_ignores_swept_rows(pool: PgPool) {
let event_id = seed_event(&pool, "sweep-backfill").await;
let user_id = seed_user(&pool, event_id, "Backfill").await;
seed_aged_upload(
&pool,
event_id,
user_id,
"done",
Some(48),
"originals/e/gone.jpg",
true,
)
.await;
// SRC: `services/compression.rs::backfill_stale_derivatives` — the selection, verbatim.
let backfilled: Vec<(Uuid, String, String)> = sqlx::query_as(
"SELECT id, original_path, mime_type FROM upload
WHERE deleted_at IS NULL AND mime_type LIKE 'image/%'
AND original_path IS NOT NULL
AND (
(display_path IS NULL AND preview_path IS NOT NULL)
OR derivatives_rev < $1
)",
)
.bind(1i16)
.fetch_all(&pool)
.await
.expect("backfill query");
assert!(
backfilled.is_empty(),
"a soft-deleted row must be invisible to the backfill, before or after sweeping"
);
}

View File

@@ -105,6 +105,20 @@ export const db = {
});
},
/**
* Overstate an upload's recorded size.
*
* The keepsake size estimate and the low-disk threshold are pure SQL over
* `original_size_bytes` — no file is read — so this is the lever for driving "the keepsake
* would not fit" without a genuinely full disk. The bytes on disk are unchanged; only the
* accounting the warning reads from moves.
*/
async setUploadSizeBytes(uploadId: string, bytes: number) {
await withClient((c) =>
c.query(`UPDATE upload SET original_size_bytes = $2 WHERE id = $1`, [uploadId, bytes])
);
},
async setExportReleased(slug: string, released: boolean) {
await withClient((c) =>
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [

View File

@@ -0,0 +1,97 @@
/**
* Regression guard — the host is warned about storage BEFORE it becomes unrecoverable.
*
* Storage visibility used to exist in exactly one place: a passive "Speicherauslastung" widget on
* the ADMIN dashboard. A host who isn't the admin had no view of it at all, and nothing anywhere
* warned anyone. README listed a low-disk alert under "Planned (v1.x)".
*
* Two things make that a safety net rather than a nice-to-have:
*
* - `postgres_data`, `media_data` and `exports_data` are all Docker named volumes on ONE
* filesystem. A full disk doesn't degrade a subsystem; Postgres stops being able to write and
* the whole event goes down.
* - The keepsake needs room for TWO gallery-sized archives (both write their media
* `Compression::Stored`; `Memories.zip` streams the original for every video and every image
* at or under 5 MB). The export preflight can refuse cleanly, but only AFTER the release —
* when the event is over, the gallery is full, and every remedy is harder.
*
* So the threshold is deliberately NOT a fixed number alone. It fires on an absolute floor (10 GB,
* the figure the README always carried) OR on "you could not build the keepsake right now", which
* is the trigger a host can still act on.
*
* These drive it through `original_size_bytes` rather than a genuinely full disk: the estimate is
* pure SQL over that column, so overstating one row moves the accounting the warning reads without
* touching a byte on disk.
*/
import { test, expect } from '../../fixtures/test';
import { seedUpload } from '../../helpers/seed';
import { BASE } from '../../helpers/env';
/** Comfortably larger than any disk this suite could run on. */
const ABSURD_BYTES = 500_000_000_000_000;
test.describe('Host — low-disk warning', () => {
test('a gallery too big to export warns the host, with the numbers', async ({
page,
host,
guest,
signIn,
db,
}) => {
const g = await guest('BigShooter');
const uploadId = await seedUpload(g.jwt);
await db.setUploadSizeBytes(uploadId, ABSURD_BYTES);
await signIn(page, host);
await page.goto('/host');
const warning = page.getByTestId('low-disk-warning');
await expect(warning, 'the host must be warned before releasing').toBeVisible({
timeout: 15_000,
});
// The actionable half: not just "low", but "the keepsake cannot be built".
await expect(warning).toContainText(/nicht.*erstellt werden/i);
// And the consequence that makes it urgent — the event, not just the download.
await expect(warning).toContainText(/gesamte Event/i);
});
test('the API reports the requirement and the verdict together', async ({ host, guest, db }) => {
const g = await guest('BigShooter2');
const uploadId = await seedUpload(g.jwt);
await db.setUploadSizeBytes(uploadId, ABSURD_BYTES);
const res = await fetch(`${BASE}/api/v1/host/event`, {
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
disk_low: boolean;
disk_free_bytes: number | null;
keepsake_required_bytes: number;
};
expect(body.disk_low).toBe(true);
expect(
body.keepsake_required_bytes,
'both halves are armed by a release, so the requirement covers two archives'
).toBeGreaterThan(ABSURD_BYTES);
expect(body.disk_free_bytes).not.toBeNull();
expect(body.keepsake_required_bytes).toBeGreaterThan(body.disk_free_bytes!);
});
test('an ordinary gallery shows no warning at all', async ({ page, host, guest, signIn }) => {
// The mirror that keeps the above honest. A warning that is always on is a warning nobody
// reads — and it would sit at the very top of the dashboard, above the PIN-reset queue.
const g = await guest('NormalShooter');
await seedUpload(g.jwt);
await signIn(page, host);
await page.goto('/host');
// Wait for the dashboard to actually be loaded before asserting on an absence.
await expect(page.getByRole('heading', { name: 'Host-Dashboard' })).toBeVisible({
timeout: 15_000,
});
await expect(page.getByTestId('low-disk-warning')).toHaveCount(0);
});
});

View File

@@ -28,6 +28,9 @@
is_active: boolean;
uploads_locked: boolean;
export_released: boolean;
disk_free_bytes: number | null;
keepsake_required_bytes: number;
disk_low: boolean;
}
interface PinResetRequest {
@@ -395,7 +398,11 @@
function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
// GB matters here now that this also renders free disk and keepsake size — the previous
// version topped out at MB, so 30 GB free read as "30720.0 MB" (and a guest with 2 GB of
// uploads was already being rendered the same way in the user list).
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
</script>
@@ -507,6 +514,38 @@
{error}
</div>
{:else if event}
<!-- ── Speicherwarnung ─────────────────────────────────────────────
Above everything else on purpose. All three volumes (postgres_data, media_data,
exports_data) sit on one filesystem, so running out doesn't degrade a subsystem —
it stops Postgres writing and takes the event down. And the keepsake needs room
for TWO gallery-sized archives, which is only actionable BEFORE the release: the
export preflight can say "this didn't fit", but by then the event is over and the
remedies are all much harder.
Only the admin dashboard had any storage visibility at all, and a host is often
not the admin. `disk_low` fails closed to "not low" on an unreadable mount, so
this cannot cry wolf. -->
{#if event.disk_low && event.disk_free_bytes !== null}
<div
class="rounded-xl border border-red-300 bg-red-50 p-4 dark:border-red-800 dark:bg-red-950/30"
data-testid="low-disk-warning"
>
<h2 class="font-semibold text-red-900 dark:text-red-200">Speicherplatz wird knapp</h2>
<p class="mt-1 text-sm text-red-800 dark:text-red-300">
Noch <strong>{formatBytes(event.disk_free_bytes)}</strong> frei.
{#if event.keepsake_required_bytes > event.disk_free_bytes}
Für das Keepsake werden derzeit ca.
<strong>{formatBytes(event.keepsake_required_bytes)}</strong> benötigt — es kann
momentan <strong>nicht</strong> erstellt werden.
{/if}
</p>
<p class="mt-1.5 text-xs text-red-700 dark:text-red-400">
Schaffe Speicher frei oder vergrößere den Datenträger. Wenn der Datenträger vollläuft,
fällt das gesamte Event aus — nicht nur der Download.
</p>
</div>
{/if}
<!-- ── PIN-Reset-Anfragen ──────────────────────────────────────── -->
{#if pinResetRequests.length > 0}
<div