fix(media): stop a poster-frame failure from deleting the guest's video
Reproduced live, by accident, while smoke-testing on a machine with no ffmpeg: the clip uploaded fine, returned 201, and roughly six seconds later had `deleted_at` set and was gone from the feed. The `Ok(None)` "this clip yields no frame" case was already handled — that fix landed when sub-second clips were being destroyed. But the `?` on the call itself still routed every OTHER failure into the same give-up path, which soft-deletes: ffmpeg missing from the image, ffmpeg hanging on a truncated `.mov` and tripping the timeout, an ENOSPC on `thumbnails/`, or a DB blip in `set_thumbnail_path`. None of those says anything about the video, and `get_original` serves the file byte-for-byte, so a post that merely lacks a poster is fully watchable. No failure in the video branch may fail the upload. iPhone `.mov` is exactly the input most likely to trip it, and a wedding clip is not retakeable. ENOSPC gets its own classifier. It was the one failure the retry loop actively made worse: a disk does not drain during six seconds of backoff, so all three attempts failed identically while holding a compression permit that photos were queued behind — and the give-up path then refunded the quota and soft-deleted the row while deliberately KEEPING the original. That freed nothing, removed the photo seconds after a 201, and handed the guest the allowance to upload it again into the same full disk. Now: no retry, no refund, no delete. The row stays live and the photo is served from its original, and `backfill_stale_derivatives` regenerates the derivatives on the next start once there is room. `is_storage_full_error` has to look inside `ImageError::IoError` as well as at bare io errors, because `image` wraps rather than sources it and a plain chain walk would miss every derivative-write failure. FFMPEG_TIMEOUT drops 120s -> 45s. It was never a budget for honest work — a poster from a phone clip takes well under a second, and `-ss` before `-i` means even a 500 MB file seeks rather than scans. It is the ceiling on how long a pathological input holds a permit that guests' photos are waiting behind, so it should be as tight as it can be without cutting off real work. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -88,7 +88,8 @@ impl CompressionWorker {
|
||||
Ok(v) => break Ok(v),
|
||||
Err(e)
|
||||
if attempt < Self::MAX_PROCESS_ATTEMPTS
|
||||
&& !crate::services::imaging::is_permanent_image_error(&e) =>
|
||||
&& !crate::services::imaging::is_permanent_image_error(&e)
|
||||
&& !crate::services::imaging::is_storage_full_error(&e) =>
|
||||
{
|
||||
tracing::warn!(
|
||||
error = ?e, %upload_id, attempt,
|
||||
@@ -113,6 +114,34 @@ impl CompressionWorker {
|
||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) if crate::services::imaging::is_storage_full_error(&e) => {
|
||||
// Out of disk. Keep the row AND the original — the opposite of the branch
|
||||
// below, and for the same reason it retains the file: nothing here is the
|
||||
// guest's fault and nothing about the photo is wrong.
|
||||
//
|
||||
// Soft-deleting on ENOSPC was strictly harmful. It refunded the quota while
|
||||
// keeping the bytes, so it freed nothing, removed the photo from the feed
|
||||
// seconds after a `201 Created`, and handed the guest the allowance to
|
||||
// upload it again into the same full disk. Leaving the row live costs
|
||||
// nothing instead: every client already falls back to the original when
|
||||
// `preview_url` and `thumbnail_url` are NULL, so the photo stays visible —
|
||||
// just uncompressed — and `backfill_stale_derivatives` regenerates the
|
||||
// derivatives on the next start, once there is room for them.
|
||||
tracing::error!(
|
||||
%upload_id,
|
||||
"compression failed: the media filesystem is out of space. The upload is \
|
||||
kept and served from its original; free disk space and restart to \
|
||||
regenerate derivatives: {e:#}"
|
||||
);
|
||||
let _ = Upload::set_compression_status(&worker.pool, upload_id, "failed").await;
|
||||
// Not an "error" event: nothing was lost and there is nothing for the guest
|
||||
// to act on. Clients treat this purely as "refetch me", which is what makes
|
||||
// the card appear with its original as the image source.
|
||||
let _ = worker.sse_tx.send(SseEvent {
|
||||
event_type: "upload-processed".to_string(),
|
||||
data: serde_json::json!({ "upload_id": upload_id }).to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(
|
||||
"compression failed for upload {upload_id} after {attempt} attempt(s): {e:#}"
|
||||
@@ -180,17 +209,39 @@ impl CompressionWorker {
|
||||
// this non-fatal would have been far worse than the bug. Every clip of a second or less
|
||||
// would fail compression, exhaust its retries and be soft-deleted — a cosmetic defect
|
||||
// turned into data loss, on exactly the mis-tap/Live-Photo clips guests produce most.
|
||||
match self.generate_video_thumbnail(upload_id, &original).await? {
|
||||
Some(thumb_rel) => {
|
||||
Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await?;
|
||||
tracing::info!("thumbnail generated for upload {upload_id}");
|
||||
// Handling only the `Ok(None)` arm was not enough: the `?` on the call itself still
|
||||
// routed every OTHER poster failure into the give-up path. `extract_poster_frame`
|
||||
// returns `Err` when ffmpeg is missing from the image, when it hangs on a truncated
|
||||
// `.mov` and trips FFMPEG_TIMEOUT, or when `thumbnails/` can't be created — and
|
||||
// `set_thumbnail_path` returns `Err` on any DB blip. None of those say anything about
|
||||
// the video itself, yet each one destroyed it. Confirmed live: on a box with no ffmpeg
|
||||
// the spawn error propagated, exhausted all three attempts and soft-deleted the clip.
|
||||
//
|
||||
// Nothing about a video post depends on the poster — `get_original` serves the file
|
||||
// byte-for-byte and the tile falls back to the video element — so no failure in this
|
||||
// branch may fail the upload.
|
||||
match self.generate_video_thumbnail(upload_id, &original).await {
|
||||
Ok(Some(thumb_rel)) => {
|
||||
match Upload::set_thumbnail_path(&self.pool, upload_id, &thumb_rel).await {
|
||||
Ok(()) => tracing::info!("thumbnail generated for upload {upload_id}"),
|
||||
Err(e) => tracing::warn!(
|
||||
error = ?e, %upload_id,
|
||||
"poster extracted but could not be recorded; the video keeps its own tile"
|
||||
),
|
||||
}
|
||||
}
|
||||
None => {
|
||||
Ok(None) => {
|
||||
tracing::warn!(
|
||||
%upload_id,
|
||||
"no poster frame could be extracted; the video keeps its own tile"
|
||||
);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
error = ?e, %upload_id,
|
||||
"poster extraction failed; the video keeps its own tile"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user