diff --git a/backend/server/plugins/mail-retention-cron.ts b/backend/server/plugins/mail-retention-cron.ts index 6a0ad31..efe74a5 100644 --- a/backend/server/plugins/mail-retention-cron.ts +++ b/backend/server/plugins/mail-retention-cron.ts @@ -1,26 +1,26 @@ /** * Mail-Retention-Cron — Phase 1 Data-Foundation. * - * Zweck: DSGVO-konforme Datensparsamkeit (Art. 5 Abs. 1e — Speicherbegrenzung). + * Zweck: DSGVO-konforme Datensparsamkeit (Art. 5 Abs. 1e — Speicherbegrenzung) + * + Disk-Budget-Kontrolle (Staging + Prod). * - * Zwei Retention-Jobs: + * Drei Retention-Jobs: * * 1. Subject-Nullification (täglich): - * Raw-Subject in MailClassificationSample auf null setzen wenn älter als 30 Tage. - * Das sanitisierte Subject (features.subjectSanitized) bleibt erhalten — - * nur der raw-Text wird entfernt. Begründung: raw-Subject kann noch PII - * enthalten die sanitizeSubjectForTraining() übersehen hat (z.B. Names die - * nicht ALL-CAPS sind). Nach 30 Tagen ist der Klassifikations-Nutzen gering. + * Raw-Subject auf null setzen wenn älter als 30 Tage. Das sanitisierte + * Subject (features.subjectSanitized) bleibt erhalten. * * 2. Sample-Purge (monatlich): - * MailClassificationSamples älter als 12 Monate werden hart gelöscht. - * Begründung: Trainingsdaten werden im Phase-2-Export batch-extrahiert - * (Colab). Nach 12 Monaten sind ältere Samples für Modell-Updates weniger - * relevant als neue Samples. User-Lösch-Recht (Art. 17) ist davon unabhängig - * und wird via deleteUserMailClassificationSamples() behandelt. + * 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). - * Beide Jobs laufen beim Server-Start einmalig (initial sweep) + dann periodisch. + * Alle Jobs laufen beim Server-Start einmalig + dann periodisch. */ import { consola } from "consola"; @@ -28,22 +28,24 @@ import { usePrisma } from "../utils/prisma"; // Subject-Nullification: täglich const SUBJECT_NULLIFY_INTERVAL_MS = 24 * 60 * 60 * 1000; -// Subject wird nach 30 Tagen auf null gesetzt const SUBJECT_NULLIFY_AGE_DAYS = 30; // Sample-Purge: alle 30 Tage (monatlich) const SAMPLE_PURGE_INTERVAL_MS = 30 * 24 * 60 * 60 * 1000; -// Samples älter als 12 Monate werden gelöscht 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"); + consola.info("[mail-retention-cron] Starting — subject-nullify daily, sample-purge monthly, row-cap daily"); - // Einmaliger initialer Sweep bei Server-Start (deckt verpasste Runs ab) void runSubjectNullification().catch(() => {}); void runSamplePurge().catch(() => {}); + void runRowCap().catch(() => {}); const nullifyInterval = setInterval(() => { void runSubjectNullification().catch(() => {}); @@ -53,9 +55,14 @@ export default defineNitroPlugin((nitro) => { 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); }); }); @@ -84,7 +91,6 @@ async function runSubjectNullification(): Promise { /** * Löscht MailClassificationSamples älter als 12 Monate. - * Gibt die Anzahl gelöschter Rows zurück (für Logging). */ async function runSamplePurge(): Promise { const db = usePrisma(); @@ -98,3 +104,32 @@ async function runSamplePurge(): Promise { 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 { + 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})`); +}