fix(export): stop the keepsake guards from destroying the keepsake

Two guards added to protect the archive each had a failure mode worse than the one they
prevented, and both were unrecoverable — which is what makes them worth reverting rather
than tuning.

The completeness gate refused to publish once skips passed max(2, 10% of expected). That
refusal is DETERMINISTIC ACROSS RETRIES: the unreadable files are still unreadable when the
host taps "Neu erzeugen", and the gallery is already released so the uploads cannot be
collected again. On a 30-photo event, four bad files meant nobody ever got the other 26.
That is precisely the "one-photo gap becomes total loss" outcome MAX_SKIPPED_FRACTION's own
comment says it exists to avoid. Anything short of an empty archive now publishes and logs
the counts at error level. `written == 0` stays fatal — a wrong MEDIA_PATH is a
misconfiguration the host CAN fix and retry, and it once shipped a few-hundred-byte ZIP
containing zero photos that passed every automated check.

The space reclaim refused to prune unless it freed the entire shortfall, to protect an
archive that no handler can serve: a download resolves through `export_current`, which
requires `job.epoch = event.export_epoch`, and the epoch only increments. Meanwhile
`reclaimable` is scoped to the caller's own prefix — one old archive — while `deficit` is
sized for both halves plus the reserve. So on a tight disk each worker measured its own
share as insufficient and neither pruned, though the two shares were jointly sufficient.
Every "Neu erzeugen" reran the identical arithmetic and refused identically: permanently
stuck, with dead archives nothing would reclaim and nothing could serve. It now prunes what
it can and lets the re-check decide, so the sibling's prune lets the host's retry converge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-08-11 22:44:10 +02:00
parent 7154b3a810
commit c9a4d4a9c0
3 changed files with 646 additions and 30 deletions

View File

@@ -44,7 +44,6 @@ pub struct EventStatus {
pub disk_low: bool,
}
/// Is free space low enough that the host needs to know?
///
/// Two triggers, because a fixed threshold answers the wrong question. `postgres_data`,
@@ -67,7 +66,8 @@ pub struct EventStatus {
/// The 25% margin makes it a warning rather than an obituary: the host sees it while there is
/// still room to act (delete a few large videos, which refunds immediately and reopens the gate).
fn disk_is_low(free: u64, keepsake_required: u64) -> bool {
let gate_closes_at = keepsake_required.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
let gate_closes_at =
keepsake_required.saturating_add(crate::handlers::upload::DISK_RESERVE_BYTES as u64);
let warn_at = gate_closes_at.saturating_add(gate_closes_at / 4);
// No separate absolute-floor clause. There used to be `free < LOW_DISK_FLOOR_BYTES ||` here,
// and it was unreachable: `gate_closes_at` is at least DISK_RESERVE_BYTES, so `warn_at` is at
@@ -256,6 +256,19 @@ pub async fn ban_user(
"host: ban_user"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"ban_user",
Some(user_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -314,12 +327,40 @@ pub async fn unban_user(
start_regen(&state, r);
}
// The exact mirror of `ban_user`'s `user-hidden`, and it was missing entirely: every open
// feed and the unattended projector kept the guest evicted until somebody reloaded the page
// by hand. Meanwhile the host's own confirm copy promises the photos "come back to the
// gallery, die Diashow und den Export" — so the one surface that would have shown the host
// their action had worked showed the opposite.
//
// Also the signal a banned guest's upload queue waits on: their queued photos parked with
// the blob intact rather than being purged (see `AppError::UserBanned`), and this is what
// releases them.
let _ = state.sse_tx.send(SseEvent::new(
"user-shown",
serde_json::json!({ "user_id": user_id }).to_string(),
));
tracing::info!(
actor_user_id = %auth.user_id,
target_user_id = %user_id,
event_id = %auth.event_id,
"host: unban_user"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"unban_user",
Some(user_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -443,6 +484,19 @@ pub async fn set_role(
new_role,
"host: set_role"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"set_role",
Some(user_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -535,6 +589,19 @@ pub async fn reset_user_pin(
"host: reset_user_pin"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"reset_pin",
Some(user_id),
None,
None,
)
.await;
Ok(Json(PinResetResponse { pin }))
}
@@ -611,8 +678,13 @@ pub fn start_regen(state: &AppState, regen: crate::services::export::PendingRege
state.config.comments_enabled,
// Debounced: a takedown pass is a burst, and each request retires the last generation. The
// delay lets superseded workers fail their claim and do zero work instead of each building
// a full archive. See export::REGEN_DEBOUNCE.
crate::services::export::REGEN_DEBOUNCE,
// a full archive.
//
// Measured from the START of the burst, not from this request — a fixed per-request delay
// meant a steady stream of invalidations faster than one per 20s deferred the build
// forever, leaving the keepsake permanently 404 and the UI stuck on "Wird vorbereitet…".
// See export::regen_delay_for.
crate::services::export::regen_delay_for(regen.event_id),
state.pool.clone(),
state.config.media_path.clone(),
state.config.export_path.clone(),
@@ -660,6 +732,19 @@ pub async fn host_delete_upload(
"host: host_delete_upload"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"delete_upload",
Some(upload_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
@@ -697,12 +782,25 @@ pub async fn host_delete_comment(
comment_id = %comment_id,
"host: host_delete_comment"
);
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"delete_comment",
Some(comment_id),
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
pub async fn close_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
let result = sqlx::query(
"UPDATE event SET uploads_locked_at = NOW() WHERE slug = $1 AND uploads_locked_at IS NULL",
@@ -717,12 +815,27 @@ pub async fn close_event(
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
}
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
// the record the morning after had no way to see when uploads were locked or the
// gallery released, which are the two actions that change what every guest can do.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"lock_uploads",
None,
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
pub async fn open_event(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
// Reopening invalidates any prior release: the keepsake was snapshotted at release time, so
// allowing new uploads afterwards would silently diverge the live feed from the frozen export.
@@ -748,12 +861,27 @@ pub async fn open_event(
let _ = state.sse_tx.send(SseEvent::new("event-opened", "{}"));
}
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
// the record the morning after had no way to see when uploads were locked or the
// gallery released, which are the two actions that change what every guest can do.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"unlock_uploads",
None,
None,
None,
)
.await;
Ok(StatusCode::NO_CONTENT)
}
pub async fn release_gallery(
State(state): State<AppState>,
RequireHost(_auth): RequireHost,
RequireHost(auth): RequireHost,
) -> Result<StatusCode, AppError> {
// The release claim, the epoch bump, the upload lock and BOTH job rows are written in ONE
// transaction. Two reasons, both of which were live bugs:
@@ -808,6 +936,22 @@ pub async fn release_gallery(
// discovering it via a rejected upload.
let _ = state.sse_tx.send(SseEvent::new("event-closed", "{}"));
// Was logged NOWHERE at all before this — not even a tracing line. A host reading
// the record the morning after had no way to see when uploads were locked or the
// gallery released, which are the two actions that change what every guest can do.
crate::services::audit::record(
&state.pool,
auth.event_id,
auth.user_id,
None,
auth.role.clone(),
"release_gallery",
None,
None,
None,
)
.await;
// Detached — survives this handler being cancelled.
crate::services::export::spawn_export_jobs(
event_id,