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>
174 lines
5.7 KiB
TypeScript
174 lines
5.7 KiB
TypeScript
/**
|
|
* Direct PostgreSQL escape hatch for setting up states the public API doesn't
|
|
* expose — e.g. forcing a user into the locked-PIN state to assert the 429
|
|
* recovery path, or expiring sessions for chaos tests.
|
|
*
|
|
* Most tests should NOT use this: prefer `ApiClient` so the tests exercise
|
|
* the same code paths real users do. Reach for direct SQL only when the API
|
|
* can't get you where you need to go.
|
|
*/
|
|
import { Client } from 'pg';
|
|
|
|
const CONN = {
|
|
host: process.env.E2E_DB_HOST ?? 'localhost',
|
|
port: Number(process.env.E2E_DB_PORT ?? '55432'),
|
|
user: process.env.E2E_DB_USER ?? 'eventsnap_test',
|
|
password: process.env.E2E_DB_PASSWORD ?? 'eventsnap_test',
|
|
database: process.env.E2E_DB_NAME ?? 'eventsnap_test',
|
|
};
|
|
|
|
async function withClient<T>(fn: (c: Client) => Promise<T>): Promise<T> {
|
|
const client = new Client(CONN);
|
|
await client.connect();
|
|
try {
|
|
return await fn(client);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
}
|
|
|
|
export const db = {
|
|
async lockUserPin(userId: string, minutesFromNow = 15) {
|
|
await withClient((c) =>
|
|
c.query(
|
|
`UPDATE "user" SET pin_locked_until = NOW() + ($2 || ' minutes')::interval, failed_pin_attempts = 3 WHERE id = $1`,
|
|
[userId, String(minutesFromNow)]
|
|
)
|
|
);
|
|
},
|
|
|
|
async expireSession(userId: string) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE session SET expires_at = NOW() - interval '1 hour' WHERE user_id = $1`, [
|
|
userId,
|
|
])
|
|
);
|
|
},
|
|
|
|
async setUploadCompressionStatus(
|
|
uploadId: string,
|
|
status: 'pending' | 'processing' | 'done' | 'failed'
|
|
) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE upload SET compression_status = $2 WHERE id = $1`, [uploadId, status])
|
|
);
|
|
},
|
|
|
|
async compressionStatus(uploadId: string): Promise<string | null> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ compression_status: string }>(
|
|
`SELECT compression_status FROM upload WHERE id = $1`,
|
|
[uploadId]
|
|
);
|
|
return r.rows[0]?.compression_status ?? null;
|
|
});
|
|
},
|
|
|
|
/** Which revision of the derivative pipeline produced this row's preview/display. */
|
|
async derivativesRev(uploadId: string): Promise<number | null> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ derivatives_rev: number }>(
|
|
`SELECT derivatives_rev FROM upload WHERE id = $1`,
|
|
[uploadId]
|
|
);
|
|
return r.rows[0]?.derivatives_rev ?? null;
|
|
});
|
|
},
|
|
|
|
async countUploadsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM upload WHERE user_id = $1 AND deleted_at IS NULL`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async countSessionsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM session WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async countPinResetRequestsForUser(userId: string): Promise<number> {
|
|
return withClient(async (c) => {
|
|
const r = await c.query<{ count: string }>(
|
|
`SELECT COUNT(*)::text AS count FROM pin_reset_request WHERE user_id = $1`,
|
|
[userId]
|
|
);
|
|
return Number(r.rows[0].count);
|
|
});
|
|
},
|
|
|
|
async setExportReleased(slug: string, released: boolean) {
|
|
await withClient((c) =>
|
|
c.query(`UPDATE event SET export_released_at = $2 WHERE slug = $1`, [
|
|
slug,
|
|
released ? new Date() : null,
|
|
])
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Make an export "ready" (or not) in the epoch model. There is no `export_zip_ready` column any
|
|
* more — readiness is DERIVED (`released AND job.epoch = event.export_epoch AND status='done'`),
|
|
* so a job is ready exactly when its row carries the event's live epoch. To make a `done` job NOT
|
|
* ready we retire it to a dead epoch (-1), which is what a reopen effectively does.
|
|
*
|
|
* `file_path` is deliberately left NULL, so a "ready" job with no file on disk still exercises
|
|
* the download's missing-file 404 branch.
|
|
*/
|
|
async setExportZipReady(slug: string, ready: boolean) {
|
|
await withClient((c) =>
|
|
c.query(
|
|
`UPDATE export_job ej
|
|
SET epoch = CASE WHEN $2 THEN e.export_epoch ELSE -1 END
|
|
FROM event e
|
|
WHERE e.id = ej.event_id AND e.slug = $1 AND ej.type = 'zip'`,
|
|
[slug, ready]
|
|
)
|
|
);
|
|
},
|
|
|
|
/**
|
|
* Insert a pre-baked export job row to skip the (slow) real compression path. Stamped with the
|
|
* event's CURRENT epoch so it counts as the live generation.
|
|
*/
|
|
async fakeExportJob(
|
|
eventSlug: string,
|
|
type: 'zip' | 'html',
|
|
status: 'pending' | 'running' | 'done' | 'failed',
|
|
errorMessage: string | null = null
|
|
) {
|
|
await withClient(async (c) => {
|
|
const ev = await c.query<{ id: string; export_epoch: string }>(
|
|
`SELECT id, export_epoch FROM event WHERE slug = $1`,
|
|
[eventSlug]
|
|
);
|
|
if (ev.rows.length === 0) throw new Error(`No event with slug ${eventSlug}`);
|
|
await c.query(
|
|
`INSERT INTO export_job (event_id, type, status, progress_pct, completed_at, epoch,
|
|
error_message)
|
|
VALUES ($1, $2::export_type, $3::export_status, $4, $5, $6, $7)
|
|
ON CONFLICT (event_id, type) DO UPDATE
|
|
SET status = EXCLUDED.status, progress_pct = EXCLUDED.progress_pct,
|
|
epoch = EXCLUDED.epoch, error_message = EXCLUDED.error_message`,
|
|
[
|
|
ev.rows[0].id,
|
|
type,
|
|
status,
|
|
status === 'done' ? 100 : 0,
|
|
status === 'done' ? new Date() : null,
|
|
ev.rows[0].export_epoch,
|
|
errorMessage,
|
|
]
|
|
);
|
|
});
|
|
},
|
|
};
|