Skip to content

Commit f9dffb6

Browse files
feat(db): per-day status/audit for the replay archive (Postgres, no deletion) (#423)
* feat(db): per-day status/audit for the replay archive (Postgres, no deletion) Track the session-replay CH->Azure-Blob archive per day so we can see what's archived/verified/failed and later verify by hand before any deletion. The job records; it never drops a CH partition. - Prisma model ReplayArchiveDay (@@Map replay_archive_days) + migration. One row per day (= the CH partition unit toYYYYMMDD(started_at) and the verifyDay unit). deletedAt column reserved for a future delete phase; never written here. - replay-archive-day.service.ts: reconcileArchiveDay / markArchiving / markVerified / markVerifyFailed / listArchiveDays. - archive-replay-chunks.ts wiring: * reconcileAllStatus() at start upserts EVERY settled day from counts (so days archived before this existed are recorded too). * per processed day: archiving -> archived+verifiedAt (verify passed) / verify_failed+reason. * all status writes are best-effort (status() wrapper): a Postgres hiccup or a pod without DATABASE_URL logs a WARN and NEVER fails an archive run. - status:replay CLI prints the table (day, status, ch/blob, drift, verifiedAt, error) + a summary. Read-only. No DROP PARTITION, no DELETE_AFTER_DAYS, no delete gate — observability only. Rollout: run the migration first, then deploy; add DATABASE_URL to the archive cron/backfill pods (openpanel repo manifests) so they can write. * fix(db): address CodeRabbit — export-throw + unreadable-count status writes - archiveProject signals failure by THROWING (never returns false), so the old `if (!archiveProject)` branch was dead code and an export failure left the day stuck at status='archiving'. Wrap the project loop in try/catch: record the failure (without clobbering counts) and return false so the day retries and the other days still run. - verifyDay: reject unreadable/sentinel counts (idxN=-1, srcN=-2) before the completeness check — otherwise `idxN < srcN` was false and a day with no readable counts was marked archived with negative counts. - markVerifyFailed: counts are now optional so the export-throw path doesn't overwrite a real blob count with 0.
1 parent 74327b7 commit f9dffb6

6 files changed

Lines changed: 339 additions & 7 deletions

File tree

packages/db/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"migrate:deploy": "pnpm migrate:deploy:db && pnpm migrate:deploy:code",
1212
"materialize:analyze": "pnpm with-env jiti ./src/cli/materialize.ts",
1313
"archive:replay": "pnpm with-env jiti ./src/cli/archive-replay-chunks.ts",
14+
"status:replay": "pnpm with-env jiti ./src/cli/replay-archive-status.ts",
1415
"duplicate-events": "pnpm with-env jiti ./scripts/find-duplicate-events.ts",
1516
"typecheck": "tsc --noEmit",
1617
"with-env": "dotenv -e ../../.env -c --"
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
-- CreateTable
2+
CREATE TABLE "replay_archive_days" (
3+
"day" DATE NOT NULL,
4+
"status" TEXT NOT NULL,
5+
"chChunks" BIGINT,
6+
"blobChunks" BIGINT,
7+
"driftChunks" INTEGER,
8+
"sessions" INTEGER,
9+
"archivedAt" TIMESTAMP(3),
10+
"verifiedAt" TIMESTAMP(3),
11+
"verifyError" TEXT,
12+
"lastRunAt" TIMESTAMP(3) NOT NULL,
13+
"deletedAt" TIMESTAMP(3),
14+
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
15+
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
16+
17+
CONSTRAINT "replay_archive_days_pkey" PRIMARY KEY ("day")
18+
);

packages/db/prisma/schema.prisma

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -569,3 +569,29 @@ model Cohort {
569569
@@map("cohorts")
570570
@@index([projectId])
571571
}
572+
573+
/// Per-day lifecycle record for the session-replay CH→Azure-Blob archive.
574+
/// Populated by src/cli/archive-replay-chunks.ts; observational + audit only —
575+
/// the archive job never drops CH partitions. Deletion (when built) will
576+
/// re-verify freshly at delete time and set deletedAt; the job does not.
577+
/// Granularity is per-day because a CH partition is toYYYYMMDD(started_at)
578+
/// (one partition per day, all projects) — the same unit as verifyDay and the
579+
/// eventual DROP PARTITION.
580+
model ReplayArchiveDay {
581+
day DateTime @id @db.Date // the archived day (UTC)
582+
/// pending | archiving | archived | verify_failed
583+
status String
584+
chChunks BigInt? // CH source chunk count at last check
585+
blobChunks BigInt? // archive-index chunk count at last check
586+
driftChunks Int? // blobChunks - chChunks (benign blob-superset, if > 0)
587+
sessions Int? // archived session count for the day
588+
archivedAt DateTime? // first time the day was count-complete (index >= ch)
589+
verifiedAt DateTime? // last time the full verify passed (count + blob sample)
590+
verifyError String? // reason when status = verify_failed
591+
lastRunAt DateTime // last time the archive job touched this day
592+
deletedAt DateTime? // reserved for the future delete phase; job never sets it
593+
createdAt DateTime @default(now())
594+
updatedAt DateTime @default(now()) @updatedAt
595+
596+
@@map("replay_archive_days")
597+
}

packages/db/src/cli/archive-replay-chunks.ts

Lines changed: 91 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@
3232
*/
3333
import type { ClickHouseSettings } from '@clickhouse/client';
3434
import { ch, chQuery } from '../clickhouse/client';
35+
import {
36+
markArchiving,
37+
markVerified,
38+
markVerifyFailed,
39+
reconcileArchiveDay,
40+
} from '../services/replay-archive-day.service';
3541

3642
const CONTAINER = process.env.REPLAY_ARCHIVE_CONTAINER || 'clickhouse-export';
3743
const CONN = process.env.AZURE_BLOB_CONNECTION_STRING || '';
@@ -415,7 +421,9 @@ async function archiveProject(day: DayPlan, p: ProjectPlan): Promise<boolean> {
415421
return true;
416422
}
417423

418-
async function verifyDay(day: DayPlan): Promise<boolean> {
424+
type VerifyResult = { ok: boolean; idxN: number; srcN: number };
425+
426+
async function verifyDay(day: DayPlan): Promise<VerifyResult> {
419427
// Gate: everything we indexed for this day must equal the source. Both count
420428
// physical day-N chunks (populateSliceIndex filters started_at to the day), so
421429
// they match even for midnight-crossing sessions. This is a cheap aggregate —
@@ -431,6 +439,13 @@ async function verifyDay(day: DayPlan): Promise<boolean> {
431439
);
432440
const idxN = Number(idx?.n ?? -1);
433441
const srcN = Number(src?.n ?? -2);
442+
// Unreadable counts (missing row / non-numeric) must NOT slip through as
443+
// verified — the sentinels (-1, -2) would otherwise satisfy `idxN < srcN ===
444+
// false` and mark the day archived with negative counts. Treat as failed.
445+
if (!Number.isFinite(idxN) || !Number.isFinite(srcN) || idxN < 0 || srcN < 0) {
446+
log(` verify day: unreadable counts index=${idxN} ch=${srcN} — failing`);
447+
return { ok: false, idxN, srcN };
448+
}
434449
// Completeness is one-directional: the archive must hold AT LEAST every source
435450
// chunk. index < ch => some chunks were never written to a blob => INCOMPLETE,
436451
// fail (loud, blocks deletion of this day). index >= ch => nothing missing;
@@ -442,7 +457,7 @@ async function verifyDay(day: DayPlan): Promise<boolean> {
442457
log(
443458
` verify day: index=${idxN} ch=${srcN} INCOMPLETE — ${srcN - idxN} chunk(s) not archived`,
444459
);
445-
return false;
460+
return { ok: false, idxN, srcN };
446461
}
447462
// Independent spot-check: actually read a few of the smallest per-session
448463
// blobs back and confirm their row counts. Guards against an indexed-but-
@@ -457,13 +472,26 @@ async function verifyDay(day: DayPlan): Promise<boolean> {
457472
const got = await countBlob(s.blob_path);
458473
if (got !== Number(s.chunks)) {
459474
log(` verify day: sample ${s.blob_path} blob=${got} idx=${s.chunks} MISMATCH`);
460-
return false;
475+
return { ok: false, idxN, srcN };
461476
}
462477
}
463478
log(
464479
` verify day: index=${idxN} ch=${srcN} OK${idxN > srcN ? ` (+${idxN - srcN} blob-superset drift)` : ''} (+${samples.length} blob samples)`,
465480
);
466-
return true;
481+
return { ok: true, idxN, srcN };
482+
}
483+
484+
/**
485+
* Best-effort status write. Postgres status is observability, not the archive's
486+
* job — a DB hiccup (or a pod without DATABASE_URL) must NEVER fail an archive
487+
* run, so every write is swallowed here with a loud log.
488+
*/
489+
async function status(fn: () => Promise<void>): Promise<void> {
490+
try {
491+
await fn();
492+
} catch (err) {
493+
log(` WARN: status write failed (non-fatal): ${String(err)}`);
494+
}
467495
}
468496

469497
async function archiveDay(day: DayPlan): Promise<boolean> {
@@ -479,23 +507,79 @@ async function archiveDay(day: DayPlan): Promise<boolean> {
479507
}
480508
return true;
481509
}
482-
for (const p of projects) {
483-
if (!(await archiveProject(day, p))) return false;
510+
await status(() => markArchiving(day.date));
511+
// archiveProject signals failure by THROWING (exportSlice / populateSliceIndex),
512+
// never by returning false — so catch it here: record the failure (without
513+
// clobbering counts) and return false so this day is retried and the OTHER
514+
// days still run, instead of the throw aborting the whole batch.
515+
try {
516+
for (const p of projects) {
517+
await archiveProject(day, p);
518+
}
519+
} catch (err) {
520+
log(` ${day.date} export failed: ${String(err)}`);
521+
await status(() =>
522+
markVerifyFailed(day.date, `export failed: ${String(err)}`),
523+
);
524+
return false;
484525
}
485-
if (!(await verifyDay(day))) {
526+
const v = await verifyDay(day);
527+
if (!v.ok) {
486528
log(` STOP: ${day.date} count mismatch — leaving unarchived for retry`);
529+
await status(() =>
530+
markVerifyFailed(day.date, `verify failed: index=${v.idxN} ch=${v.srcN}`, {
531+
chChunks: v.srcN,
532+
blobChunks: v.idxN,
533+
}),
534+
);
487535
return false;
488536
}
537+
const sessions = projects.reduce((n, p) => n + p.sessions, 0);
538+
await status(() => markVerified(day.date, v.srcN, v.idxN, sessions));
489539
log(` ${day.date} done`);
490540
return true;
491541
}
492542

543+
/**
544+
* Upsert a status row for EVERY settled day from counts alone, so the table is
545+
* complete — including days already fully archived (which the archiver skips and
546+
* would otherwise never record). Count-only: `blob >= ch` → archived, else
547+
* pending; verifiedAt is left for the per-day verify path. Best-effort.
548+
*/
549+
async function reconcileAllStatus(): Promise<void> {
550+
const cutoff = new Date();
551+
cutoff.setUTCDate(cutoff.getUTCDate() - SETTLE_DAYS);
552+
const cutoffStr = cutoff.toISOString().slice(0, 10);
553+
const parts = await chQuery<{ partition: string; rows: string }>(
554+
`SELECT partition, sum(rows) AS rows
555+
FROM system.parts
556+
WHERE table = '${TABLE}' AND database = currentDatabase() AND active
557+
GROUP BY partition`,
558+
LIGHT_SETTINGS,
559+
);
560+
const indexed = await chQuery<{ dt: string; chunks: string }>(
561+
`SELECT toString(dt) AS dt, sum(chunks) AS chunks FROM ${INDEX} FINAL GROUP BY dt`,
562+
LIGHT_SETTINGS,
563+
);
564+
const idxMap = new Map(indexed.map((r) => [r.dt, Number(r.chunks)]));
565+
for (const p of parts) {
566+
const date = partitionToDate(p.partition);
567+
if (!date || date < MIN_DAY || date > cutoffStr) continue;
568+
const rows = Number(p.rows);
569+
if (rows === 0) continue;
570+
await reconcileArchiveDay(date, rows, idxMap.get(date) ?? 0);
571+
}
572+
}
573+
493574
async function main(): Promise<number> {
494575
if (!CONN) throw new Error('AZURE_BLOB_CONNECTION_STRING is required');
495576
log(
496577
`start container=${CONTAINER} settleDays=${SETTLE_DAYS} sessionsPerBatch=${TARGET_SESSIONS_PER_BATCH} maxDays=${MAX_DAYS_PER_RUN} blockSize=${MAX_BLOCK_SIZE} rearchive=${REARCHIVE} dryRun=${DRY_RUN}`,
497578
);
498579

580+
// Refresh the per-day status table for all settled days (observability only).
581+
await status(() => reconcileAllStatus());
582+
499583
const plans = await planDays();
500584
if (plans.length === 0) {
501585
log('nothing to archive — all settled days already indexed');
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Print the session-replay archive status table (Postgres `replay_archive_days`).
3+
* Read-only — no ClickHouse, no Blob, nothing dropped.
4+
*
5+
* pnpm --filter @openpanel/db status:replay
6+
*/
7+
import { listArchiveDays } from '../services/replay-archive-day.service';
8+
9+
const ts = (d: Date | null): string =>
10+
d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '—';
11+
const ymd = (d: Date): string => new Date(d).toISOString().slice(0, 10);
12+
13+
async function main(): Promise<void> {
14+
const days = await listArchiveDays();
15+
if (days.length === 0) {
16+
// eslint-disable-next-line no-console
17+
console.log('replay_archive_days is empty — run archive:replay first.');
18+
return;
19+
}
20+
21+
const cols = [
22+
'day'.padEnd(10),
23+
'status'.padEnd(13),
24+
'ch'.padStart(10),
25+
'blob'.padStart(10),
26+
'drift'.padStart(6),
27+
'sessions'.padStart(8),
28+
'verifiedAt'.padEnd(16),
29+
'error',
30+
].join(' ');
31+
// eslint-disable-next-line no-console
32+
console.log(cols);
33+
for (const r of days) {
34+
// eslint-disable-next-line no-console
35+
console.log(
36+
[
37+
ymd(r.day).padEnd(10),
38+
r.status.padEnd(13),
39+
(r.chChunks?.toString() ?? '—').padStart(10),
40+
(r.blobChunks?.toString() ?? '—').padStart(10),
41+
String(r.driftChunks ?? 0).padStart(6),
42+
String(r.sessions ?? '—').padStart(8),
43+
ts(r.verifiedAt).padEnd(16),
44+
r.verifyError ?? '',
45+
].join(' '),
46+
);
47+
}
48+
49+
const by = (s: string): number => days.filter((d) => d.status === s).length;
50+
// eslint-disable-next-line no-console
51+
console.log(
52+
`\n${days.length} day(s): archived=${by('archived')} pending=${by('pending')} archiving=${by('archiving')} verify_failed=${by('verify_failed')}`,
53+
);
54+
}
55+
56+
main()
57+
.then(() => process.exit(0))
58+
.catch((err) => {
59+
// eslint-disable-next-line no-console
60+
console.error('[status:replay]', err);
61+
process.exit(1);
62+
});

0 commit comments

Comments
 (0)