fix(export): refuse an export that cannot fit, and stop peaking at two generations

Nothing in export.rs ever asked whether the keepsake would fit. Both archives write
their media `Compression::Stored`, so each is essentially a byte-for-byte second copy
of the originals -- Gallery.zip always, and Memories.zip for every video and every
image at or under 5 MB. On the documented CX33 (80 GB, all three volumes on one
filesystem) the upload quota's fixed point leaves ~40 GB free, and a release spawns
BOTH halves concurrently against it.

The failure is not "the export failed", it is "the deliverable is stuck":

  1. ENOSPC lands partway through a multi-GB write.
  2. The epoch has already moved, so the job row is `failed` at the CURRENT
     generation and readiness (epoch = event.export_epoch AND status = 'done') is
     false -- GET /export/zip 404s.
  3. The last good archive sits on disk, unreferenced and unreachable.
  4. POST /host/export/rebuild, the only escape, re-arms the same doomed write.

Three changes.

Reclaim before building. `prune_stale_export_files` ran only after the new archive
was written, renamed and finalised. That reads as durability but buys nothing: the
moment `invalidate_and_arm` bumps the epoch the old archive is ALREADY unreachable,
so keeping it reserves gigabytes for a download nobody can perform -- and for a
takedown it is content someone explicitly asked to have removed. Peak usage is now
one generation. Narrower than the post-finalize prune on purpose: final archives
only, never a `.tmp` or a `viewer_tmp_` dir, since a superseded worker can still be
streaming into those and at build START is far more likely to be alive.

Preflight the space. SUM(original_size_bytes) over exactly `query_uploads`'
visibility filter, +10% for ZIP overhead, multiplied by the number of armed jobs --
without that multiplier each of the two concurrent halves independently sees "it
fits" and together they don't. Runs AFTER claim_job, not before as reported: bailing
before the claim leaves the row `pending` with no worker and no error, the
spinner-forever state `mark_failed`'s status guard exists to prevent. Fails open when
the mount can't be read, exactly as the upload quota does.

Show the host the reason. /export/status returned {status, progress_pct} and nothing
else, so the host dashboard could only render "fehlgeschlagen" next to the retry
button. The message was written to the row and surfaced solely in the ADMIN job list
-- a different screen, possibly a different person. It now travels with the status,
and only on a failure, so a message left on a since-succeeded row can't appear beside
a green "ist bereit".

Tests: 10 unit (the u128 clamp caught a real bug in the first draft -- saturating_mul
then /100 turns an overflow into a number ~100x too small, the one direction that
authorises the write being guarded against; the carried-forward archive must survive
its own older epoch in the filename), 4 DB-backed (the estimate is asserted against
the row set the archive actually contains, not against a restatement of the WHERE
clause, so the two queries cannot drift), 3 e2e over the four-hop plumbing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
fabi
2026-07-29 19:38:26 +02:00
parent 8c93cbb045
commit 281eb3bec7
9 changed files with 705 additions and 23 deletions

View File

@@ -0,0 +1,95 @@
/**
* Regression guard — when the keepsake fails to build, the HOST must be told why.
*
* `/export/status` reported `{status, progress_pct}` and nothing else, so the host dashboard could
* only ever render "Keepsake-Erstellung fehlgeschlagen." next to an "Erneut versuchen" button. The
* reason WAS being written — `mark_failed` stores it on the job row — but it surfaced solely in the
* admin dashboard's job list. The host is the person who releases the gallery, owns the retry
* button, and is standing at the venue; the admin may be someone else entirely, or the same person
* without the password to hand.
*
* That matters most for the failure this shipped alongside: the export disk preflight. Its message
* names the two numbers that decide what to do ("benötigt ca. X GB, frei sind Y GB"), and without
* it "Erneut versuchen" fails identically, forever, with no hint that the answer is free some space.
*
* These drive the real UI and the real endpoint — the plumbing is four hops (SQL → handler JSON →
* store type → Svelte branch) and any one of them dropping the field restores the silent version.
*/
import { test, expect } from '../../fixtures/test';
import { BASE } from '../../helpers/env';
const SLUG = 'e2e-test-event';
const DISK_REASON =
'Nicht genug Speicherplatz für das Keepsake: benötigt ca. 42.0 GB, frei sind 3.0 GB. ' +
'Bitte Speicher freigeben und das Keepsake anschließend neu erstellen.';
test.describe('Export — a failed keepsake explains itself to the host', () => {
test('the failure reason reaches /export/status', async ({ host, db }) => {
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON);
await db.fakeExportJob(SLUG, 'html', 'failed', DISK_REASON);
const res = await fetch(`${BASE}/api/v1/export/status`, {
headers: { Authorization: `Bearer ${host.jwt}` },
});
expect(res.status).toBe(200);
const body = (await res.json()) as {
zip: { status: string; error_message: string | null };
html: { status: string; error_message: string | null };
};
expect(body.zip.status).toBe('failed');
expect(
body.zip.error_message,
'the reason must travel with the status, not live only in the admin job list'
).toBe(DISK_REASON);
expect(body.html.error_message).toBe(DISK_REASON);
});
test('a succeeding export carries no stale reason', async ({ host, db }) => {
// The mirror that keeps the above honest: a handler that returned `error_message`
// unconditionally would pass the first test while showing an error next to a green
// "Keepsake ist bereit." Rows keep their last message until they are re-armed, so this is a
// real state, not a hypothetical one.
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON);
await db.fakeExportJob(SLUG, 'zip', 'done', DISK_REASON);
const res = await fetch(`${BASE}/api/v1/export/status`, {
headers: { Authorization: `Bearer ${host.jwt}` },
});
const body = (await res.json()) as { zip: { status: string; error_message: string | null } };
expect(body.zip.status).toBe('done');
expect(
body.zip.error_message,
'a message left on a row that has since succeeded must not be shown'
).toBeNull();
});
test('the host dashboard renders the reason under the failure', async ({
page,
host,
signIn,
db,
}) => {
await db.setExportReleased(SLUG, true);
await db.fakeExportJob(SLUG, 'zip', 'failed', DISK_REASON);
await db.fakeExportJob(SLUG, 'html', 'failed', DISK_REASON);
await signIn(page, host);
await page.goto('/host');
await expect(page.getByText(/Keepsake-Erstellung fehlgeschlagen/i)).toBeVisible({
timeout: 15_000,
});
// The actionable half — the numbers, not just the verdict.
await expect(
page.getByText(/Nicht genug Speicherplatz/i),
'the host must see WHY, next to the only button they have'
).toBeVisible();
await expect(page.getByText(/3\.0 GB/)).toBeVisible();
// And the retry button is still mounted — it is deliberately outside the status branches.
await expect(page.getByTestId('export-rebuild')).toBeVisible();
});
});