fix(upload): editing a caption after release now regenerates the keepsake

edit_upload updated the caption/hashtags and nothing else. A caption lives in the HTML
viewer keepsake (the ZIP holds media only — export.rs), so after release the downloadable
viewer kept showing the OLD caption forever while the live feed showed the new one.

Editing stays allowed while locked/released — like comments and likes, the lock freezes
new uploads only (USER_JOURNEYS §9.3) — so the fix is to regenerate, not forbid: the
edit and an invalidate_and_arm(ViewerOnly) share one transaction (same atomicity as
delete_upload), then start_regen after commit. ViewerOnly carries the finished ZIP forward
untouched since the media didn't change; when the gallery isn't released, invalidate_and_arm
returns None and this is a no-op. Mutation-verified: without it, an edit doesn't bump the
epoch and the caption test fails.

Also (test isolation): the compression worker now carries a generation counter that
TRUNCATE bumps. A worker queued on the concurrency semaphore when a truncate wiped media
would otherwise wake in the NEXT test, fail to find its file, and broadcast
upload-error/upload-deleted into that test's SSE stream — corrupting any test asserting on
toasts or feed. It now abandons itself when the generation moved. No-op in production
(TRUNCATE is the only caller). Export workers were already inert across a truncate (epoch
guard on a fresh-UUID event).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-15 19:48:15 +02:00
parent dd7b05415e
commit db7c4459d7
3 changed files with 48 additions and 0 deletions

View File

@@ -98,6 +98,13 @@ pub async fn truncate_all(
// surviving ticket is a dangling reference to a user that no longer exists. // surviving ticket is a dangling reference to a user that no longer exists.
state.sse_tickets.clear(); state.sse_tickets.clear();
// Invalidate any in-flight/queued compression task spawned by the previous test. Without this a
// task still waiting on the concurrency semaphore wakes AFTER this wipe, fails to find its
// (now-deleted) file, and broadcasts upload-error/upload-deleted into the NEXT test's SSE
// stream. (Export workers are already inert across a truncate: they are epoch-guarded on the
// event row, and truncate gives the event a fresh random UUID, so their writes match nothing.)
state.compression.bump_generation();
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }

View File

@@ -416,6 +416,15 @@ pub async fn edit_upload(
// Caption update + hashtag wipe-then-relink in one transaction, so a crash // Caption update + hashtag wipe-then-relink in one transaction, so a crash
// mid-relink can't leave the upload with its hashtags stripped. // mid-relink can't leave the upload with its hashtags stripped.
//
// Editing is intentionally allowed while uploads are locked or the gallery is released — like
// comments and likes, the lock freezes *new uploads* only (USER_JOURNEYS §9.3). But a caption
// is embedded in the HTML viewer keepsake (the ZIP holds media only — see export.rs), so an
// edit AFTER release must regenerate the viewer, or the downloadable keepsake keeps showing the
// old caption forever while the live feed shows the new one. Same atomicity as delete_upload:
// the edit and its invalidation share one tx so a dropped handler can't leave them disagreeing.
// `Affects::ViewerOnly` carries the finished ZIP forward (the media didn't change); when the
// gallery isn't released, `invalidate_and_arm` returns None and this is a no-op.
let mut tx = state.pool.begin().await?; let mut tx = state.pool.begin().await?;
if let Some(ref caption) = body.caption { if let Some(ref caption) = body.caption {
Upload::update_caption(&mut *tx, upload_id, Some(caption)).await?; Upload::update_caption(&mut *tx, upload_id, Some(caption)).await?;
@@ -427,7 +436,16 @@ pub async fn edit_upload(
Hashtag::link_to_upload(&mut *tx, upload_id, h.id).await?; Hashtag::link_to_upload(&mut *tx, upload_id, h.id).await?;
} }
} }
let regen = crate::services::export::invalidate_and_arm(
&mut tx,
&state.config.event_slug,
crate::services::export::Affects::ViewerOnly,
)
.await?;
tx.commit().await?; tx.commit().await?;
if let Some(r) = regen {
crate::handlers::host::start_regen(&state, r);
}
Ok(StatusCode::OK) Ok(StatusCode::OK)
} }

View File

@@ -1,4 +1,5 @@
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc; use std::sync::Arc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
@@ -15,6 +16,10 @@ pub struct CompressionWorker {
pool: PgPool, pool: PgPool,
media_path: PathBuf, media_path: PathBuf,
sse_tx: broadcast::Sender<SseEvent>, sse_tx: broadcast::Sender<SseEvent>,
/// Bumped whenever the underlying data is reset out from under in-flight work (only the e2e
/// TRUNCATE does this today). A task captures the value at spawn and abandons itself if it has
/// changed by the time it runs — see `process`.
generation: Arc<AtomicU64>,
} }
impl CompressionWorker { impl CompressionWorker {
@@ -24,14 +29,32 @@ impl CompressionWorker {
pool, pool,
media_path, media_path,
sse_tx, sse_tx,
generation: Arc::new(AtomicU64::new(0)),
} }
} }
/// Invalidate all in-flight and queued compression work. Called by the e2e TRUNCATE endpoint:
/// truncating deletes the upload rows and wipes `media/`, so a worker that was queued on the
/// semaphore when the wipe happened would otherwise wake in the NEXT test, fail to find its
/// file, and broadcast `upload-error` / `upload-deleted` into that test's live SSE stream —
/// corrupting any test that asserts on toasts or feed contents. Bumping the generation makes
/// those stale tasks return silently instead. A no-op in production (never called there).
pub fn bump_generation(&self) {
self.generation.fetch_add(1, Ordering::SeqCst);
}
/// Spawn a background task to process an uploaded file. /// Spawn a background task to process an uploaded file.
pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) { pub fn process(&self, upload_id: Uuid, original_path: String, mime_type: String) {
let worker = self.clone(); let worker = self.clone();
let born_at = worker.generation.load(Ordering::SeqCst);
tokio::spawn(async move { tokio::spawn(async move {
let _permit = worker.semaphore.acquire().await; let _permit = worker.semaphore.acquire().await;
// The data this task was queued against may have been reset while it waited for a permit
// (e2e TRUNCATE). If so, its file and row are gone; doing anything — including
// broadcasting a failure — would leak into an unrelated test. Abandon quietly.
if worker.generation.load(Ordering::SeqCst) != born_at {
return;
}
match worker.do_process(upload_id, &original_path, &mime_type).await { match worker.do_process(upload_id, &original_path, &mime_type).await {
Ok(_) => { Ok(_) => {
tracing::info!("compression completed for upload {upload_id}"); tracing::info!("compression completed for upload {upload_id}");