fix(user-flow): persona-audit fixes — ban replay, locked-upload data loss, host UX, admin session
Follows the perf + security + user-flow work with a role/persona audit (guest, host, admin, projector) and fixes across three review rounds. Highlights: HIGH - Ban now replays on reconnect. A ban isn't a soft-delete, and the `user-hidden` SSE has no replay, so a client that missed it (esp. the unattended diashow) kept cycling a banned user's slides. New `uploads_hidden_at` (migration 013) + `hidden_user_ids` in /feed/delta; feed + diashow evict those users. Applied even on a truncated delta. MEDIUM - Locked-upload data loss: a photo staged offline during a lock/release was purged as a terminal 4xx and lost when the host reopened. New reversible `uploads_locked` error code; the queue keeps the blob and auto-resumes on the `event-opened` SSE. - Reopen after release now warns (ConfirmSheet) that it revokes the published keepsake. - Host "forgotten-PIN" badge updates live (`pin-reset-requested` was broadcast but never in KNOWN_EVENTS / subscribed); host page also refetches on `pin-reset` so a two-host race can't hand out a conflicting PIN. - Ban modal copy fixed (read-only ban, not "session ended"); Degradieren/Sperren/Entsperren hidden on peer-host rows for non-admins (they always 403'd). - Host dashboard shows live keepsake generation progress / ready state + link to /export. - Admin JWT moved to sessionStorage (§11.1) to bound exposure on shared devices. Export generation guard (H1 from the prior round, hardened): per-(event,type) `release_seq` (migration 012) with seq-guarded claim/finalize/mark_failed/update_progress, per-generation temp/final paths, download follows `file_path`, prune only strictly-older generations. LOW: diashow coalesces upload-processed (avoids self-rate-limit); event-closed reconciles galleryReleased; /recover gains a forgot-PIN request + drops a stale cached PIN on 401; delta `>=` tie-break + 429 retry; misc copy/labels. Adds e2e: ban-replay, upload-lock-code, and rewrites export-reopen-rerelease with a data-completeness test. Reconciles USER_JOURNEYS §9/§11. Verified: cargo build clean, 40 unit tests, svelte-check 0 errors, 33 frontend unit tests, 155 e2e passing on chromium-desktop. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -96,30 +96,45 @@ pub async fn enqueue_and_spawn_exports(
|
||||
export_path: PathBuf,
|
||||
sse_tx: broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
for export_type in ["zip", "html"] {
|
||||
// Reset a prior 'done'/'failed' row to 'pending' so this (re)release regenerates —
|
||||
// but DO NOT touch a row that's still 'running'. If a worker from an earlier
|
||||
// release is mid-export, leaving it 'running' makes the worker we spawn below bail
|
||||
// its claim (WHERE status='pending' → 0 rows), so the two never race the temp file.
|
||||
// Only (re)generate a type that isn't ALREADY complete. A (re)release always arrives
|
||||
// with both ready flags false (a fresh release starts false; `open_event` clears them
|
||||
// before a re-release), so both regenerate as intended. But startup recovery of a
|
||||
// half-finished export (e.g. ZIP done+ready, HTML crashed) must leave the good half
|
||||
// alone — resetting it would 404 an already-downloadable keepsake until it needlessly
|
||||
// regenerates. Skip the ready ones; their still-`done` rows keep their `file_path`, and
|
||||
// the worker we spawn for them below simply bails its claim (no `pending` row to grab).
|
||||
let (zip_ready, html_ready): (bool, bool) = sqlx::query_as(
|
||||
"SELECT export_zip_ready, export_html_ready FROM event WHERE id = $1",
|
||||
)
|
||||
.bind(event_id)
|
||||
.fetch_one(&pool)
|
||||
.await?;
|
||||
|
||||
for (export_type, ready) in [("zip", zip_ready), ("html", html_ready)] {
|
||||
if ready {
|
||||
continue;
|
||||
}
|
||||
// Reset the row to a clean 'pending' and BUMP `release_seq` — unconditionally, even
|
||||
// if a worker from a prior release is still 'running'. That worker captured the old
|
||||
// seq at claim time; bumping it here supersedes its generation, so when it finishes
|
||||
// its guarded finalize (`WHERE release_seq = <captured>`) matches nothing and it
|
||||
// discards its now-stale output instead of overwriting a fresh keepsake. The worker
|
||||
// we spawn below then claims this new generation and regenerates from a current
|
||||
// snapshot. Per-seq temp/final paths (see `run_zip_export`/`run_html_export`) keep
|
||||
// the old and new workers from racing the same file on disk.
|
||||
sqlx::query(
|
||||
"INSERT INTO export_job (event_id, type, status, progress_pct)
|
||||
VALUES ($1, $2::export_type, 'pending', 0)
|
||||
"INSERT INTO export_job (event_id, type, status, progress_pct, release_seq)
|
||||
VALUES ($1, $2::export_type, 'pending', 0, 1)
|
||||
ON CONFLICT (event_id, type) DO UPDATE
|
||||
SET status = 'pending', progress_pct = 0, file_path = NULL,
|
||||
error_message = NULL, completed_at = NULL
|
||||
WHERE export_job.status <> 'running'",
|
||||
error_message = NULL, completed_at = NULL,
|
||||
release_seq = export_job.release_seq + 1",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
}
|
||||
// Clear readiness so /export downloads 404 until the fresh run completes, instead of
|
||||
// serving a stale keepsake that predates a reopen→re-release.
|
||||
sqlx::query("UPDATE event SET export_zip_ready = FALSE, export_html_ready = FALSE WHERE id = $1")
|
||||
.bind(event_id)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
spawn_export_jobs(event_id, event_name, pool, media_path, export_path, sse_tx);
|
||||
Ok(())
|
||||
@@ -182,9 +197,10 @@ pub fn spawn_export_jobs(
|
||||
let event_name2 = event_name.clone();
|
||||
|
||||
tokio::spawn(async move {
|
||||
// Failure marking happens INSIDE the worker (guarded by the claimed `release_seq`),
|
||||
// so a superseded worker's error can't clobber the fresh generation's row.
|
||||
if let Err(e) = run_zip_export(event_id, &pool, &media_path, &export_path, &sse_tx).await {
|
||||
tracing::error!("ZIP export failed for event {event_id}: {e:#}");
|
||||
mark_failed(&pool, event_id, "zip", &e.to_string()).await;
|
||||
}
|
||||
maybe_broadcast_complete(&pool, event_id, &sse_tx).await;
|
||||
});
|
||||
@@ -194,7 +210,6 @@ pub fn spawn_export_jobs(
|
||||
run_html_export(event_id, &event_name2, &pool2, &media_path2, &export_path2, &sse_tx2).await
|
||||
{
|
||||
tracing::error!("HTML export failed for event {event_id}: {e:#}");
|
||||
mark_failed(&pool2, event_id, "html", &e.to_string()).await;
|
||||
}
|
||||
maybe_broadcast_complete(&pool2, event_id, &sse_tx2).await;
|
||||
});
|
||||
@@ -209,11 +224,29 @@ async fn run_zip_export(
|
||||
export_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
if !claim_job(pool, event_id, "zip").await {
|
||||
let seq = match claim_job(pool, event_id, "zip").await {
|
||||
Some(seq) => seq,
|
||||
// Another worker already owns this ZIP export — bail rather than race the temp file.
|
||||
return Ok(());
|
||||
}
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
// Run the body under the captured `seq`; on error mark THIS generation failed (a no-op
|
||||
// if a re-release already superseded us).
|
||||
let res = run_zip_export_inner(seq, event_id, pool, media_path, export_path, sse_tx).await;
|
||||
if let Err(e) = &res {
|
||||
mark_failed(pool, event_id, "zip", seq, &e.to_string()).await;
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
async fn run_zip_export_inner(
|
||||
seq: i64,
|
||||
event_id: Uuid,
|
||||
pool: &PgPool,
|
||||
media_path: &Path,
|
||||
export_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
let uploads = query_uploads(pool, event_id).await?;
|
||||
let total = uploads.len().max(1) as f32;
|
||||
|
||||
@@ -221,8 +254,13 @@ async fn run_zip_export(
|
||||
let exports_dir = export_path.to_path_buf();
|
||||
tokio::fs::create_dir_all(&exports_dir).await?;
|
||||
|
||||
let tmp_path = exports_dir.join("Gallery.zip.tmp");
|
||||
let out_path = exports_dir.join("Gallery.zip");
|
||||
// Per-generation paths: a superseded worker (older `seq`) and the fresh worker never
|
||||
// share a file on disk, so neither can truncate or interleave the other's bytes. The
|
||||
// final name embeds the seq too; the download handler serves whatever `file_path` the
|
||||
// current 'done' row points at, so an orphaned older generation is never served.
|
||||
let tmp_path = exports_dir.join(format!("Gallery.zip.{seq}.tmp"));
|
||||
let out_name = format!("Gallery.{seq}.zip");
|
||||
let out_path = exports_dir.join(&out_name);
|
||||
|
||||
{
|
||||
let file = tokio::fs::File::create(&tmp_path).await?;
|
||||
@@ -247,7 +285,7 @@ async fn run_zip_export(
|
||||
entry.close().await?;
|
||||
|
||||
let pct = ((i + 1) as f32 / total * 100.0) as i16;
|
||||
update_progress(pool, event_id, "zip", pct.min(99)).await;
|
||||
update_progress(pool, event_id, "zip", seq, pct.min(99)).await;
|
||||
}
|
||||
|
||||
zip.close().await?;
|
||||
@@ -255,19 +293,31 @@ async fn run_zip_export(
|
||||
|
||||
tokio::fs::rename(&tmp_path, &out_path).await?;
|
||||
|
||||
// Guarded finalize: commit ONLY if our generation is still current. If a reopen→
|
||||
// re-release bumped `release_seq` while we were streaming, we lost — discard our (now
|
||||
// stale) archive and let the fresh worker own the keepsake. This is the fix for the
|
||||
// stale-keepsake data loss: a superseded worker can no longer flip `export_zip_ready`.
|
||||
if !finalize_job(pool, event_id, "zip", seq, &format!("exports/{out_name}")).await {
|
||||
let _ = tokio::fs::remove_file(&out_path).await;
|
||||
tracing::info!("ZIP export for event {event_id} superseded by a newer release; discarded");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Flip the ready flag only while still current (a re-release between finalize and here
|
||||
// would have cleared it; don't resurrect it for a superseded generation).
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
|
||||
WHERE event_id = $1 AND type = 'zip'::export_type",
|
||||
"UPDATE event SET export_zip_ready = TRUE
|
||||
WHERE id = $1
|
||||
AND EXISTS (SELECT 1 FROM export_job
|
||||
WHERE event_id = $1 AND type = 'zip'::export_type
|
||||
AND release_seq = $2 AND status = 'done')",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind("exports/Gallery.zip")
|
||||
.bind(seq)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE event SET export_zip_ready = TRUE WHERE id = $1")
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
prune_stale_export_files(&exports_dir, "Gallery", event_id, seq).await;
|
||||
|
||||
let _ = sse_tx.send(SseEvent {
|
||||
event_type: "export-progress".to_string(),
|
||||
@@ -305,25 +355,43 @@ async fn run_html_export(
|
||||
export_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
if !claim_job(pool, event_id, "html").await {
|
||||
let seq = match claim_job(pool, event_id, "html").await {
|
||||
Some(seq) => seq,
|
||||
// Another worker already owns this HTML export — bail rather than race the temp dir.
|
||||
return Ok(());
|
||||
}
|
||||
None => return Ok(()),
|
||||
};
|
||||
|
||||
let res =
|
||||
run_html_export_inner(seq, event_id, event_name, pool, media_path, export_path, sse_tx).await;
|
||||
if let Err(e) = &res {
|
||||
mark_failed(pool, event_id, "html", seq, &e.to_string()).await;
|
||||
}
|
||||
res
|
||||
}
|
||||
|
||||
async fn run_html_export_inner(
|
||||
seq: i64,
|
||||
event_id: Uuid,
|
||||
event_name: &str,
|
||||
pool: &PgPool,
|
||||
media_path: &Path,
|
||||
export_path: &Path,
|
||||
sse_tx: &broadcast::Sender<SseEvent>,
|
||||
) -> Result<()> {
|
||||
// 1. Query data
|
||||
let uploads = query_uploads(pool, event_id).await?;
|
||||
let comments = query_comments(pool, event_id).await?;
|
||||
let hashtags_per_upload = query_hashtags(pool, event_id).await?;
|
||||
let total = uploads.len().max(1) as f32;
|
||||
|
||||
update_progress(pool, event_id, "html", 5).await;
|
||||
update_progress(pool, event_id, "html", seq, 5).await;
|
||||
|
||||
// Written OUTSIDE media_path: the public /media ServeDir must never reach these.
|
||||
let exports_dir = export_path.to_path_buf();
|
||||
tokio::fs::create_dir_all(&exports_dir).await?;
|
||||
|
||||
// 2. Create temp directory for media processing
|
||||
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}"));
|
||||
// 2. Create temp directory for media processing (per-generation — see run_zip_export).
|
||||
let tmp_dir = exports_dir.join(format!("viewer_tmp_{event_id}_{seq}"));
|
||||
let media_tmp = tmp_dir.join("media");
|
||||
tokio::fs::create_dir_all(&media_tmp).await?;
|
||||
|
||||
@@ -485,7 +553,7 @@ async fn run_html_export(
|
||||
});
|
||||
|
||||
let pct = 10 + ((i + 1) as f32 / total * 60.0) as i16;
|
||||
update_progress(pool, event_id, "html", pct.min(69)).await;
|
||||
update_progress(pool, event_id, "html", seq, pct.min(69)).await;
|
||||
}
|
||||
|
||||
// 4. Build data.json
|
||||
@@ -499,11 +567,12 @@ async fn run_html_export(
|
||||
let data_json =
|
||||
serde_json::to_string_pretty(&viewer_data).context("failed to serialize data.json")?;
|
||||
|
||||
update_progress(pool, event_id, "html", 72).await;
|
||||
update_progress(pool, event_id, "html", seq, 72).await;
|
||||
|
||||
// 5. Create ZIP
|
||||
let tmp_path = exports_dir.join("Memories.zip.tmp");
|
||||
let out_path = exports_dir.join("Memories.zip");
|
||||
// 5. Create ZIP (per-generation paths — see run_zip_export)
|
||||
let tmp_path = exports_dir.join(format!("Memories.zip.{seq}.tmp"));
|
||||
let out_name = format!("Memories.{seq}.zip");
|
||||
let out_path = exports_dir.join(&out_name);
|
||||
|
||||
{
|
||||
let file = tokio::fs::File::create(&tmp_path).await?;
|
||||
@@ -512,7 +581,7 @@ async fn run_html_export(
|
||||
// Write embedded viewer assets (index.html, _app/*, etc.)
|
||||
write_dir_to_zip(&VIEWER_DIR, &mut zip).await?;
|
||||
|
||||
update_progress(pool, event_id, "html", 75).await;
|
||||
update_progress(pool, event_id, "html", seq, 75).await;
|
||||
|
||||
// Write data.json
|
||||
{
|
||||
@@ -532,7 +601,7 @@ async fn run_html_export(
|
||||
entry.close().await?;
|
||||
}
|
||||
|
||||
update_progress(pool, event_id, "html", 78).await;
|
||||
update_progress(pool, event_id, "html", seq, 78).await;
|
||||
|
||||
// Write media files from the manifest built in step 3. Thumbnails and derived
|
||||
// image variants stream from temp; video and small-image full variants stream
|
||||
@@ -556,7 +625,7 @@ async fn run_html_export(
|
||||
|
||||
files_written += 1;
|
||||
let pct = 78 + (files_written as f32 / file_total * 20.0) as i16;
|
||||
update_progress(pool, event_id, "html", pct.min(98)).await;
|
||||
update_progress(pool, event_id, "html", seq, pct.min(98)).await;
|
||||
}
|
||||
|
||||
zip.close().await?;
|
||||
@@ -568,19 +637,26 @@ async fn run_html_export(
|
||||
// Clean up temp directory
|
||||
let _ = tokio::fs::remove_dir_all(&tmp_dir).await;
|
||||
|
||||
// Guarded finalize — discard if a re-release superseded us mid-export (see run_zip_export).
|
||||
if !finalize_job(pool, event_id, "html", seq, &format!("exports/{out_name}")).await {
|
||||
let _ = tokio::fs::remove_file(&out_path).await;
|
||||
tracing::info!("HTML export for event {event_id} superseded by a newer release; discarded");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
sqlx::query(
|
||||
"UPDATE export_job SET status = 'done', progress_pct = 100, file_path = $2, completed_at = NOW()
|
||||
WHERE event_id = $1 AND type = 'html'::export_type",
|
||||
"UPDATE event SET export_html_ready = TRUE
|
||||
WHERE id = $1
|
||||
AND EXISTS (SELECT 1 FROM export_job
|
||||
WHERE event_id = $1 AND type = 'html'::export_type
|
||||
AND release_seq = $2 AND status = 'done')",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind("exports/Memories.zip")
|
||||
.bind(seq)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("UPDATE event SET export_html_ready = TRUE WHERE id = $1")
|
||||
.bind(event_id)
|
||||
.execute(pool)
|
||||
.await?;
|
||||
prune_stale_export_files(&exports_dir, "Memories", event_id, seq).await;
|
||||
|
||||
let _ = sse_tx.send(SseEvent {
|
||||
event_type: "export-progress".to_string(),
|
||||
@@ -640,45 +716,138 @@ async fn query_hashtags(pool: &PgPool, event_id: Uuid) -> Result<Vec<(Uuid, Stri
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
/// Atomically claim a pending export job for this worker. Returns `true` only if we won
|
||||
/// (status flipped `pending`→`running` in one statement). Returns `false` when another
|
||||
/// worker already owns it — e.g. a reopen→re-release spawned a second worker while a run
|
||||
/// from the first release is still in flight. Both write the SAME fixed temp path
|
||||
/// (`Gallery.zip.tmp` / `viewer_tmp_*`), so a loser MUST NOT run or it would interleave
|
||||
/// bytes and corrupt the archive. The row-level lock serializes the two UPDATEs, so
|
||||
/// exactly one sees `status = 'pending'`.
|
||||
async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str) -> bool {
|
||||
sqlx::query(
|
||||
/// Atomically claim a pending export job for this worker and return the generation
|
||||
/// (`release_seq`) it claimed. Returns `Some(seq)` only if we won (status flipped
|
||||
/// `pending`→`running` in one statement); `None` when another worker already owns it — e.g.
|
||||
/// a reopen→re-release spawned a second worker while a run from a prior release is still in
|
||||
/// flight. The row-level lock serializes the two UPDATEs, so exactly one sees
|
||||
/// `status = 'pending'`. The caller threads the returned seq into per-generation temp/final
|
||||
/// paths and into the guarded finalize, so a superseded worker never corrupts or resurrects
|
||||
/// a stale keepsake.
|
||||
async fn claim_job(pool: &PgPool, event_id: Uuid, export_type: &str) -> Option<i64> {
|
||||
sqlx::query_as::<_, (i64,)>(
|
||||
"UPDATE export_job SET status = 'running'
|
||||
WHERE event_id = $1 AND type = $2::export_type AND status = 'pending'",
|
||||
WHERE event_id = $1 AND type = $2::export_type AND status = 'pending'
|
||||
RETURNING release_seq",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.fetch_optional(pool)
|
||||
.await
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|(seq,)| seq)
|
||||
}
|
||||
|
||||
/// Guarded finalize: mark this export `done` and record its file path ONLY if our
|
||||
/// generation is still current (`release_seq = seq` and still `running`). Returns `true` if
|
||||
/// we won the finalize, `false` if a re-release superseded us mid-export (in which case the
|
||||
/// caller must discard its output — the fresh worker owns the keepsake now). Atomic, so it
|
||||
/// can't race a concurrent re-release.
|
||||
async fn finalize_job(
|
||||
pool: &PgPool,
|
||||
event_id: Uuid,
|
||||
export_type: &str,
|
||||
seq: i64,
|
||||
file_path: &str,
|
||||
) -> bool {
|
||||
sqlx::query(
|
||||
"UPDATE export_job
|
||||
SET status = 'done', progress_pct = 100, file_path = $3, completed_at = NOW()
|
||||
WHERE event_id = $1 AND type = $2::export_type
|
||||
AND release_seq = $4 AND status = 'running'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(file_path)
|
||||
.bind(seq)
|
||||
.execute(pool)
|
||||
.await
|
||||
.map(|r| r.rows_affected() > 0)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, msg: &str) {
|
||||
/// Parse the trailing generation number out of `<prefix><n><suffix>` (e.g.
|
||||
/// `Gallery.<n>.zip`, `Gallery.zip.<n>.tmp`, `viewer_tmp_<event>_<n>`). Returns None if the
|
||||
/// name doesn't fit the shape, so unrelated files are left untouched.
|
||||
fn parse_gen_seq(name: &str, prefix: &str, suffix: &str) -> Option<i64> {
|
||||
name.strip_prefix(prefix)?.strip_suffix(suffix)?.parse::<i64>().ok()
|
||||
}
|
||||
|
||||
/// Best-effort removal of stale per-generation export artifacts for one export type. Deletes
|
||||
/// ONLY strictly-older generations (`n < keep_seq`) — never `keep_seq`'s own current file,
|
||||
/// and never a NEWER generation that a concurrent re-release may already be producing (that
|
||||
/// "delete all but mine" would let a lagging older winner nuke a newer live file). Covers the
|
||||
/// finished archive (`<prefix>.<n>.zip`), its temp (`<prefix>.zip.<n>.tmp`), and — for html —
|
||||
/// the `viewer_tmp_<event>_<n>` staging dir, so crash-orphaned per-generation temps don't
|
||||
/// accumulate. Runs after a worker wins its finalize; older generations can never be served
|
||||
/// again (their `file_path` was nulled by the re-release that superseded them). Tolerates
|
||||
/// races and IO errors — purely disk hygiene.
|
||||
async fn prune_stale_export_files(exports_dir: &Path, prefix: &str, event_id: Uuid, keep_seq: i64) {
|
||||
let final_prefix = format!("{prefix}."); // Gallery. / Memories.
|
||||
let temp_prefix = format!("{prefix}.zip."); // Gallery.zip. / Memories.zip.
|
||||
let viewer_prefix = format!("viewer_tmp_{event_id}_");
|
||||
let mut rd = match tokio::fs::read_dir(exports_dir).await {
|
||||
Ok(rd) => rd,
|
||||
Err(_) => return,
|
||||
};
|
||||
while let Ok(Some(entry)) = rd.next_entry().await {
|
||||
let name = entry.file_name();
|
||||
let name = name.to_string_lossy();
|
||||
// Try each artifact shape; a strictly-older seq in any of them marks it for deletion.
|
||||
// Check the temp shape before the final shape: `Gallery.zip.<n>.tmp` also starts with
|
||||
// `Gallery.` but isn't a `<n>.zip`, so its final-shape parse returns None anyway.
|
||||
let seq = parse_gen_seq(&name, &temp_prefix, ".tmp")
|
||||
.or_else(|| parse_gen_seq(&name, &final_prefix, ".zip"))
|
||||
.or_else(|| {
|
||||
if prefix == "Memories" {
|
||||
parse_gen_seq(&name, &viewer_prefix, "")
|
||||
} else {
|
||||
None
|
||||
}
|
||||
});
|
||||
if seq.is_some_and(|n| n < keep_seq) {
|
||||
let path = entry.path();
|
||||
let is_dir = entry.file_type().await.map(|t| t.is_dir()).unwrap_or(false);
|
||||
let _ = if is_dir {
|
||||
tokio::fs::remove_dir_all(&path).await
|
||||
} else {
|
||||
tokio::fs::remove_file(&path).await
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this worker's export `failed` — but ONLY for the generation it was running
|
||||
/// (`release_seq = seq` and still `running`). Without the seq guard, a superseded worker
|
||||
/// erroring out after a re-release would clobber the FRESH worker's `running`/`pending` row
|
||||
/// to `failed`, stranding the new keepsake. A superseded worker's failure is a no-op here.
|
||||
async fn mark_failed(pool: &PgPool, event_id: Uuid, export_type: &str, seq: i64, msg: &str) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE export_job SET status = 'failed', error_message = $3
|
||||
WHERE event_id = $1 AND type = $2::export_type",
|
||||
WHERE event_id = $1 AND type = $2::export_type
|
||||
AND release_seq = $4 AND status = 'running'",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(msg)
|
||||
.bind(seq)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, pct: i16) {
|
||||
/// Update the progress bar for THIS generation only (`release_seq = seq`). Seq-guarded so a
|
||||
/// superseded worker still streaming can't overwrite the fresh generation's progress and make
|
||||
/// the bar jump backwards. Cosmetic, but keeps the displayed percent monotonic per release.
|
||||
async fn update_progress(pool: &PgPool, event_id: Uuid, export_type: &str, seq: i64, pct: i16) {
|
||||
let _ = sqlx::query(
|
||||
"UPDATE export_job SET progress_pct = $3 WHERE event_id = $1 AND type = $2::export_type",
|
||||
"UPDATE export_job SET progress_pct = $3
|
||||
WHERE event_id = $1 AND type = $2::export_type AND release_seq = $4",
|
||||
)
|
||||
.bind(event_id)
|
||||
.bind(export_type)
|
||||
.bind(pct)
|
||||
.bind(seq)
|
||||
.execute(pool)
|
||||
.await;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user