chore: satisfy prettier in frontend and e2e
`checks.yml` runs `npm run format:check` for both projects and both were failing. - frontend/src/lib/ui-store.ts is mine, unformatted since the round-1 upload-queue badge fix — the same miss as the rustfmt one: I gated on svelte-check and eslint but never on format:check. - e2e/loadtest/* and e2e/shots.mjs have been unformatted since7758270and are unrelated to the audit work. Fixed here because they block the same gate and the fix is mechanical; no behaviour change in either project. Still red and deliberately NOT fixed here: `npm run lint` in the frontend reports `svelte/prefer-svelte-reactivity` on routes/diashow/+page.svelte:208 (a mutable `Set` where the rule wants `SvelteSet`), pre-existing since5009590. That one is a real reactivity change in code I have no test coverage for, so it belongs in its own change rather than smuggled into a formatting commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -9,12 +9,12 @@ acting as the showcase display.
|
||||
|
||||
**Validate the shipping config.** We run at the real production defaults —
|
||||
compression concurrency (`COMPRESSION_WORKER_CONCURRENCY`, default **2**), DB
|
||||
pool (default **10**), quotas **on** — and answer: *does the app survive the
|
||||
event, and how far behind real-time does the diashow fall?*
|
||||
pool (default **10**), quotas **on** — and answer: _does the app survive the
|
||||
event, and how far behind real-time does the diashow fall?_
|
||||
|
||||
The headline metric is **pipeline latency**: time from an upload succeeding to
|
||||
its preview being ready (`upload-processed` SSE event) — i.e. *how long until the
|
||||
photo appears on the diashow*. A backlog that builds is fine; a backlog that
|
||||
its preview being ready (`upload-processed` SSE event) — i.e. _how long until the
|
||||
photo appears on the diashow_. A backlog that builds is fine; a backlog that
|
||||
**never drains** is a fail for a live event.
|
||||
|
||||
## Methodology: what we change vs. shipping
|
||||
@@ -86,13 +86,13 @@ compression backlog to drain** before reporting.
|
||||
|
||||
## What the flags mean
|
||||
|
||||
| Flag | Meaning |
|
||||
|------|---------|
|
||||
| `✗ 5xx` | server errored under load — hard fail |
|
||||
| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size |
|
||||
| Flag | Meaning |
|
||||
| ------------------------- | ---------------------------------------------------------------------------- |
|
||||
| `✗ 5xx` | server errored under load — hard fail |
|
||||
| `✗ 507` | quota rejected uploads — disk/quota misconfig for the event size |
|
||||
| `✗ backlog did not drain` | compression can't keep up even after uploads stop — diashow never catches up |
|
||||
| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak |
|
||||
| `⚠ SSE resyncs` | live consumers lagged the broadcast channel |
|
||||
| `⚠ pipeline p95 > 60s` | photos take >1 min to appear on the diashow at peak |
|
||||
| `⚠ SSE resyncs` | live consumers lagged the broadcast channel |
|
||||
|
||||
## Knobs
|
||||
|
||||
@@ -101,7 +101,7 @@ All via env (see header of `driver.mjs`): `LT_GUESTS`, `LT_IMAGES`,
|
||||
`LT_TRUNCATE`, `LT_DRAIN_TIMEOUT_SEC`, `LT_KEEP_RATELIMITS`, `LT_BASE`,
|
||||
`LT_APP_CONTAINER`, `LT_DB_CONTAINER`.
|
||||
|
||||
To later answer *"what config should I deploy?"*, re-run with a rebuilt stack
|
||||
To later answer _"what config should I deploy?"_, re-run with a rebuilt stack
|
||||
that sets `COMPRESSION_WORKER_CONCURRENCY` higher (boot-time env var in
|
||||
`docker-compose.test.yml`) and compare the pipeline-latency / drain numbers.
|
||||
|
||||
|
||||
@@ -10,17 +10,40 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const j = (r) => r.json();
|
||||
|
||||
const adminLogin = () =>
|
||||
fetch(`${BASE}/api/v1/admin/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password: 'admin-test-pw' }) }).then(j).then((b) => b.jwt);
|
||||
fetch(`${BASE}/api/v1/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password: 'admin-test-pw' }),
|
||||
})
|
||||
.then(j)
|
||||
.then((b) => b.jwt);
|
||||
const join = (name) =>
|
||||
fetch(`${BASE}/api/v1/join`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) }).then(j);
|
||||
fetch(`${BASE}/api/v1/join`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ display_name: name }),
|
||||
}).then(j);
|
||||
async function truncate(admin) {
|
||||
await fetch(`${BASE}/api/v1/admin/__truncate`, { method: 'POST', headers: { Authorization: `Bearer ${admin}` } });
|
||||
await fetch(`${BASE}/api/v1/admin/__truncate`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${admin}` },
|
||||
});
|
||||
}
|
||||
async function upload(jwt) {
|
||||
const form = new FormData();
|
||||
form.append('file', new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], { type: 'image/jpeg' }), 'live.jpg');
|
||||
form.append(
|
||||
'file',
|
||||
new Blob([readFileSync('/tmp/eventsnap-loadtest/photos/photo_000.jpg')], {
|
||||
type: 'image/jpeg',
|
||||
}),
|
||||
'live.jpg'
|
||||
);
|
||||
form.append('caption', 'LIVE-PROBE');
|
||||
const r = await fetch(`${BASE}/api/v1/upload`, { method: 'POST', headers: { Authorization: `Bearer ${jwt}` }, body: form });
|
||||
const r = await fetch(`${BASE}/api/v1/upload`, {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${jwt}` },
|
||||
body: form,
|
||||
});
|
||||
return (await r.json()).id;
|
||||
}
|
||||
|
||||
@@ -35,7 +58,9 @@ const browser = await chromium.launch();
|
||||
const ctx = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await ctx.newPage();
|
||||
const streamReqs = [];
|
||||
page.on('request', (req) => { if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, '')); });
|
||||
page.on('request', (req) => {
|
||||
if (req.url().includes('/stream')) streamReqs.push(req.url().replace(BASE, ''));
|
||||
});
|
||||
|
||||
await page.goto(`${BASE}/join`, { waitUntil: 'domcontentloaded' });
|
||||
await page.evaluate((g) => {
|
||||
@@ -53,7 +78,9 @@ const emptyBefore = (await page.locator('text=Noch keine Beiträge').count()) >
|
||||
const streamOpened = streamReqs.length > 0;
|
||||
console.log(`\n1) direct /diashow on empty event:`);
|
||||
console.log(` "Noch keine Beiträge" shown: ${emptyBefore} (expected: true)`);
|
||||
console.log(` SSE stream opened: ${streamOpened} ${streamOpened ? '('+streamReqs.join(', ')+')' : ''} (expected: true — this is the fix)`);
|
||||
console.log(
|
||||
` SSE stream opened: ${streamOpened} ${streamOpened ? '(' + streamReqs.join(', ') + ')' : ''} (expected: true — this is the fix)`
|
||||
);
|
||||
|
||||
// Now a guest uploads a photo while the display is open.
|
||||
console.log(`\n2) guest uploads a photo (display already open)…`);
|
||||
@@ -65,7 +92,11 @@ for (let i = 0; i < 15; i++) {
|
||||
await sleep(1000);
|
||||
const stillEmpty = (await page.locator('text=Noch keine Beiträge').count()) > 0;
|
||||
const imgs = await page.locator('img').count();
|
||||
if (!stillEmpty && imgs > 0) { appeared = true; console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`); break; }
|
||||
if (!stillEmpty && imgs > 0) {
|
||||
appeared = true;
|
||||
console.log(` photo appeared live after ~${i + 1}s (img rendered, placeholder gone)`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!appeared) console.log(` ✗ photo did NOT appear within 15s`);
|
||||
|
||||
|
||||
@@ -132,9 +132,19 @@ const truncate = (adminJwt) =>
|
||||
api('/admin/__truncate', { method: 'POST', token: adminJwt, expect: [204] });
|
||||
|
||||
const CAPTIONS = [
|
||||
'Was für ein magischer Tag 💍', 'Der erste Tanz 🕺', 'Prost! 🥂', 'Die Torte 🍰',
|
||||
'Feuerwerk 🎆', 'Beste Freunde 💕', 'Was für eine Stimmung! 🎉', 'Details 🌸',
|
||||
'Sonnenuntergang 🌅', 'Tanzfläche brennt 🔥', null, null, null,
|
||||
'Was für ein magischer Tag 💍',
|
||||
'Der erste Tanz 🕺',
|
||||
'Prost! 🥂',
|
||||
'Die Torte 🍰',
|
||||
'Feuerwerk 🎆',
|
||||
'Beste Freunde 💕',
|
||||
'Was für eine Stimmung! 🎉',
|
||||
'Details 🌸',
|
||||
'Sonnenuntergang 🌅',
|
||||
'Tanzfläche brennt 🔥',
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
];
|
||||
const TAGS = ['hochzeit', 'liebe', 'party', 'tanzen', 'natur', 'feier', 'freunde', 'dessert'];
|
||||
|
||||
@@ -238,9 +248,12 @@ async function sampleResources() {
|
||||
const out = { ts: now() };
|
||||
try {
|
||||
const { stdout } = await execFileAsync('docker', [
|
||||
'stats', '--no-stream', '--format',
|
||||
'stats',
|
||||
'--no-stream',
|
||||
'--format',
|
||||
'{{.Name}}|{{.CPUPerc}}|{{.MemUsage}}',
|
||||
cfg.appContainer, cfg.dbContainer,
|
||||
cfg.appContainer,
|
||||
cfg.dbContainer,
|
||||
]);
|
||||
out.docker = stdout.trim();
|
||||
} catch (e) {
|
||||
@@ -259,8 +272,15 @@ async function sampleResources() {
|
||||
|
||||
function psql(sql) {
|
||||
return execFileAsync('docker', [
|
||||
'exec', cfg.dbContainer, 'psql', '-U', 'eventsnap_test', '-d', 'eventsnap_test',
|
||||
'-tAc', sql,
|
||||
'exec',
|
||||
cfg.dbContainer,
|
||||
'psql',
|
||||
'-U',
|
||||
'eventsnap_test',
|
||||
'-d',
|
||||
'eventsnap_test',
|
||||
'-tAc',
|
||||
sql,
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -292,8 +312,13 @@ function summarize(nums) {
|
||||
const s = [...nums].sort((a, b) => a - b);
|
||||
const sum = s.reduce((a, b) => a + b, 0);
|
||||
return {
|
||||
n: s.length, min: s[0], max: s[s.length - 1], mean: Math.round(sum / s.length),
|
||||
p50: pct(s, 50), p95: pct(s, 95), p99: pct(s, 99),
|
||||
n: s.length,
|
||||
min: s[0],
|
||||
max: s[s.length - 1],
|
||||
mean: Math.round(sum / s.length),
|
||||
p50: pct(s, 50),
|
||||
p95: pct(s, 95),
|
||||
p99: pct(s, 99),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -490,7 +515,9 @@ async function main() {
|
||||
await sleep(cfg.windowSec * 1000 + 500);
|
||||
await Promise.all(burstPromises);
|
||||
clearInterval(ticker);
|
||||
console.log(`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`);
|
||||
console.log(
|
||||
`[run] all bursts issued. uploaded ${done}, ok ${uploads.filter((u) => u.status === 201).length}`
|
||||
);
|
||||
|
||||
// Drain: wait for compression backlog to clear (SSE processed ⊇ successful ids)
|
||||
console.log('[drain] waiting for compression backlog to clear…');
|
||||
@@ -589,21 +616,28 @@ async function main() {
|
||||
console.log('\n' + '━'.repeat(72));
|
||||
console.log('RESULTS');
|
||||
console.log('━'.repeat(72));
|
||||
console.log(`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`);
|
||||
console.log(
|
||||
`uploads: ${okUploads.length}/${uploads.length} ok — byStatus ${JSON.stringify(byStatus)}`
|
||||
);
|
||||
console.log(`upload latency ms: ${JSON.stringify(uploadLatency)}`);
|
||||
console.log(`pipeline latency ms (upload→preview ready): ${JSON.stringify(pipelineLatency)}`);
|
||||
console.log(`backlog drain: ${(drainMs / 1000).toFixed(1)}s, cleared=${report.drain.cleared}`);
|
||||
console.log(`final db compression status: ${JSON.stringify(finalCounts)}`);
|
||||
console.log(`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`);
|
||||
console.log(
|
||||
`sse: ${sseClients.length} conns, ${totalReconnects} reconnects, ${totalResyncs} resyncs`
|
||||
);
|
||||
console.log(`\nfull report → ${outPath}`);
|
||||
|
||||
// Heuristic pass/fail flags (validate shipping config)
|
||||
const flags = [];
|
||||
const err5xx = Object.entries(byStatus).filter(([s]) => +s >= 500).reduce((a, [, n]) => a + n, 0);
|
||||
const err5xx = Object.entries(byStatus)
|
||||
.filter(([s]) => +s >= 500)
|
||||
.reduce((a, [, n]) => a + n, 0);
|
||||
if (err5xx > 0) flags.push(`✗ ${err5xx} server errors (5xx)`);
|
||||
if (byStatus['507']) flags.push(`✗ ${byStatus['507']} quota rejections (507)`);
|
||||
if (byStatus['413']) flags.push(`⚠ ${byStatus['413']} too-large (413)`);
|
||||
if (byStatus['429']) flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`);
|
||||
if (byStatus['429'])
|
||||
flags.push(`⚠ ${byStatus['429']} rate-limited (429) — unexpected with limits off`);
|
||||
if (!report.drain.cleared) flags.push(`✗ backlog did NOT drain within ${cfg.drainTimeoutSec}s`);
|
||||
if (totalResyncs > sseClients.length) flags.push(`⚠ ${totalResyncs} SSE resyncs (consumer lag)`);
|
||||
if (pipelineLatency && pipelineLatency.p95 > 60000)
|
||||
|
||||
Reference in New Issue
Block a user