rebreak-monorepo/backend/server/plugins/mail-retention-cron.ts
chahinebrini 24044c3a0c feat(backend): mail_classification_samples row-cap (100k max)
Tabelle war auf 13GB gewachsen und hat heute den Disk voll gemacht.
Neuer täglicher Row-Cap-Job hält die Tabelle unter 100k Rows —
löscht älteste Samples wenn Cap überschritten. CTE-basierter Delete
nutzt created_at-Index, kein Full-Table-Scan.

Bestehende Jobs bleiben: Subject-Nullification (30 Tage) + Sample-Purge
(12 Monate). Row-Cap ist die harte Schranke gegen Disk-Wachstum.
100k Rows ≈ ~500MB — nachhaltig für Staging + Prod.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-01 09:44:55 +02:00

136 lines
4.4 KiB
TypeScript

/**
* Mail-Retention-Cron — Phase 1 Data-Foundation.
*
* Zweck: DSGVO-konforme Datensparsamkeit (Art. 5 Abs. 1e — Speicherbegrenzung)
* + Disk-Budget-Kontrolle (Staging + Prod).
*
* Drei Retention-Jobs:
*
* 1. Subject-Nullification (täglich):
* Raw-Subject auf null setzen wenn älter als 30 Tage. Das sanitisierte
* Subject (features.subjectSanitized) bleibt erhalten.
*
* 2. Sample-Purge (monatlich):
* Samples älter als 12 Monate hart löschen (für LLM-Training irrelevant).
* User-Lösch-Recht (Art. 17) → deleteUserMailClassificationSamples().
*
* 3. Row-Cap (täglich):
* Hält die Tabelle auf MAX_SAMPLE_ROWS. Löscht älteste Rows wenn Cap
* überschritten. Verhindert unbegrenztes Disk-Wachstum (Staging + Prod).
* Cap = 100k Rows ≈ ~500MB bei durchschnittlicher Row-Größe.
*
* Läuft nur in Production (import.meta.dev guard).
* Alle Jobs laufen beim Server-Start einmalig + dann periodisch.
*/
import { consola } from "consola";
import { usePrisma } from "../utils/prisma";
// Subject-Nullification: täglich
const SUBJECT_NULLIFY_INTERVAL_MS = 24 * 60 * 60 * 1000;
const SUBJECT_NULLIFY_AGE_DAYS = 30;
// Sample-Purge: alle 30 Tage (monatlich)
const SAMPLE_PURGE_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000;
const SAMPLE_PURGE_AGE_DAYS = 365;
// Row-Cap: täglich — hält Tabelle unter diesem Limit
const ROW_CAP_INTERVAL_MS = 24 * 60 * 60 * 1000;
const MAX_SAMPLE_ROWS = 100_000;
export default defineNitroPlugin((nitro) => {
if (import.meta.dev) return;
consola.info("[mail-retention-cron] Starting — subject-nullify daily, sample-purge monthly, row-cap daily");
void runSubjectNullification().catch(() => {});
void runSamplePurge().catch(() => {});
void runRowCap().catch(() => {});
const nullifyInterval = setInterval(() => {
void runSubjectNullification().catch(() => {});
}, SUBJECT_NULLIFY_INTERVAL_MS);
const purgeInterval = setInterval(() => {
void runSamplePurge().catch(() => {});
}, SAMPLE_PURGE_INTERVAL_MS);
const capInterval = setInterval(() => {
void runRowCap().catch(() => {});
}, ROW_CAP_INTERVAL_MS);
nitro.hooks.hook("close", () => {
clearInterval(nullifyInterval);
clearInterval(purgeInterval);
clearInterval(capInterval);
});
});
/**
* Setzt raw-Subject-Feld auf null für Samples älter als 30 Tage.
* features.subjectSanitized bleibt erhalten (wurde bei Insert geschrieben).
*
* Verwendet $executeRaw weil Prisma updateMany kein
* WHERE subject IS NOT NULL direkt unterstützt (kein affected-rows-count).
*/
async function runSubjectNullification(): Promise<void> {
const db = usePrisma();
const cutoff = new Date(Date.now() - SUBJECT_NULLIFY_AGE_DAYS * 86_400_000);
const result = await db.$executeRaw`
UPDATE "rebreak"."mail_classification_samples"
SET "subject" = NULL
WHERE "subject" IS NOT NULL
AND "created_at" < ${cutoff}::timestamptz
`;
if (result > 0) {
consola.info(`[mail-retention-cron] subject-nullify: ${result} samples anonymized`);
}
}
/**
* Löscht MailClassificationSamples älter als 12 Monate.
*/
async function runSamplePurge(): Promise<void> {
const db = usePrisma();
const cutoff = new Date(Date.now() - SAMPLE_PURGE_AGE_DAYS * 86_400_000);
const result = await db.mailClassificationSample.deleteMany({
where: { createdAt: { lt: cutoff } },
});
if (result.count > 0) {
consola.info(`[mail-retention-cron] sample-purge: ${result.count} old samples deleted`);
}
}
/**
* Row-Cap: hält mail_classification_samples unter MAX_SAMPLE_ROWS.
*
* Löscht älteste Rows wenn der Cap überschritten wird. Nutzt einen
* CTE-basierten Delete der nur einen Index-Scan braucht (created_at INDEX).
* Kein Full-Table-Scan — auch auf großen Tabellen effizient.
*/
async function runRowCap(): Promise<void> {
const db = usePrisma();
const count = await db.mailClassificationSample.count();
if (count <= MAX_SAMPLE_ROWS) return;
const toDelete = count - MAX_SAMPLE_ROWS;
// Löscht die `toDelete` ältesten Rows via Subquery auf created_at-Index.
const result = await db.$executeRaw`
DELETE FROM "rebreak"."mail_classification_samples"
WHERE "id" IN (
SELECT "id"
FROM "rebreak"."mail_classification_samples"
ORDER BY "created_at" ASC
LIMIT ${toDelete}
)
`;
consola.info(`[mail-retention-cron] row-cap: ${result} samples pruned (cap=${MAX_SAMPLE_ROWS}, was=${count})`);
}