From 5b57bea9c0ec4b13bedba2feb53c98950256c021 Mon Sep 17 00:00:00 2001 From: chahinebrini Date: Fri, 5 Jun 2026 10:38:06 +0200 Subject: [PATCH] perf(mail): kill redundant 30min scan-cron + in-flight scan guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend-Lag-Fix Phase 1 — entlastet die CPU-Dauerschleife im Mail-Stack: - delete mail-scan-cron.ts: der 30-Min-Nitro-Cron scannte alle User parallel (Promise.allSettled) und war redundant zum IMAP-IDLE-Daemon (Single Source of Truth). Reine Dauerlast ohne Mehrwert. - imap-idle: In-Flight-Guard (scanInFlight + coalescePending). triggerScan ist jetzt re-entry-safe — pro Connection max. 1 aktiver + 1 pending Scan statt bis zu 8 gestapelt pro 2-Min-NOOP-Tick. Gilt für NOOP + exists-Event. - plan-features: Pro mailAgents 3->2 (+ Math.min-Hack in coach/message aufgeräumt). Co-Authored-By: Claude Opus 4.8 --- backend/imap-idle/index.mjs | 40 +++++++++++++++++++++++- backend/server/api/coach/message.post.ts | 5 +-- backend/server/plugins/mail-scan-cron.ts | 37 ---------------------- backend/server/utils/plan-features.ts | 2 +- 4 files changed, 41 insertions(+), 43 deletions(-) delete mode 100644 backend/server/plugins/mail-scan-cron.ts diff --git a/backend/imap-idle/index.mjs b/backend/imap-idle/index.mjs index 1d93731..d41af49 100644 --- a/backend/imap-idle/index.mjs +++ b/backend/imap-idle/index.mjs @@ -477,9 +477,38 @@ const sessions = new Map(); let shuttingDown = false; -// ─── Scan-Trigger ───────────────────────────────────────────────────────────── +// ─── In-Flight-Guard ───────────────────────────────────────────────────────── +// Verhindert gestapelte scan-internal-Aufrufe für dieselbe Connection. +// +// scanInFlight: Map — läuft gerade ein Scan? +// coalescePending: Map — kam während des laufenden Scans +// ein weiterer Trigger? Wenn ja, einmalig nachholen. +// +// Verhalten: +// - Kein laufender Scan: sofort feuern, inFlight=true setzen +// - Läuft Scan bereits: pending=true setzen (coalesce — nicht stapeln) +// - Nach Scan-Ende: wenn pending=true → einen weiteren Scan starten, +// dann pending=false. Maximal EIN gestapelter Trigger bleibt hängen. +// +// Gilt für NOOP-Tick UND exists-Event (beide rufen triggerScan auf). + +const scanInFlight = new Map(); // Map +const coalescePending = new Map(); // Map async function triggerScan(conn) { + const connId = conn.id; + + if (scanInFlight.get(connId)) { + // Scan läuft bereits — coalescer merkt sich den Wunsch, stapelt nicht + if (!coalescePending.get(connId)) { + log(conn.email, "scan-trigger coalesced (in-flight)"); + coalescePending.set(connId, true); + } + return; + } + + scanInFlight.set(connId, true); + try { const res = await fetch(`${BACKEND_URL}/api/mail/scan-internal`, { method: "POST", @@ -500,6 +529,15 @@ async function triggerScan(conn) { } } catch (err) { logError(conn.email, "scan-trigger failed", err); + } finally { + scanInFlight.set(connId, false); + + // Coalesced Trigger nachholen: einmalig, fire-and-forget + if (coalescePending.get(connId)) { + coalescePending.set(connId, false); + log(conn.email, "scan-trigger coalesced fire (post-flight)"); + triggerScan(conn).catch(() => {}); + } } } diff --git a/backend/server/api/coach/message.post.ts b/backend/server/api/coach/message.post.ts index c66c02d..a3ef02e 100644 --- a/backend/server/api/coach/message.post.ts +++ b/backend/server/api/coach/message.post.ts @@ -329,10 +329,7 @@ function generatePlanDetails(): string { ? "(rückfüllbar – Slot wird wieder frei wenn die Domain global aufgenommen ODER von der Community abgelehnt wurde)" : "(NICHT rückfüllbar – einmal belegt, bleibt für immer belegt)"; - // Hinweis: PLAN_LIMITS.pro.mailAgents = 3 widerspricht aktuell dem Briefing - // (Pro = max 2 Mail-Konten). rebreak-backend muss das angleichen — Lyra - // beschreibt hier den Briefing-Stand (2 Konten Pro), damit User korrekt informiert wird. - const proMailCount = Math.min(pro.mailAgents, 2); + const proMailCount = pro.mailAgents; const legendMailFairUse = 10; return `Pro (3,99 € / Monat — Stripe-Web-Checkout, kein In-App-Kauf): diff --git a/backend/server/plugins/mail-scan-cron.ts b/backend/server/plugins/mail-scan-cron.ts deleted file mode 100644 index 13ab574..0000000 --- a/backend/server/plugins/mail-scan-cron.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { consola } from "consola"; -import { getAllActiveMailUserIds } from "../db/mail"; - -const SCAN_INTERVAL = 30 * 60 * 1000; // every 30 minutes (proxy EXISTS handles real-time) - -export default defineNitroPlugin((nitro) => { - if (import.meta.dev) return; - - consola.info("[mail-scan-cron] Starting – scanning due accounts every 30 min"); - - const interval = setInterval(runScan, SCAN_INTERVAL); - nitro.hooks.hook("close", () => clearInterval(interval)); -}); - -async function runScan() { - let userIds: string[]; - try { - userIds = await getAllActiveMailUserIds(); - } catch { - return; - } - - if (userIds.length === 0) return; - - const adminSecret = process.env.NUXT_ADMIN_SECRET || process.env.ADMIN_SECRET; - if (!adminSecret) return; - - await Promise.allSettled( - userIds.map((userId) => - $fetch("/api/mail/scan-internal", { - method: "POST", - headers: { "x-admin-secret": adminSecret }, - body: { userId }, - }).catch(() => {}) - ) - ); -} diff --git a/backend/server/utils/plan-features.ts b/backend/server/utils/plan-features.ts index 22f1a04..3009f61 100644 --- a/backend/server/utils/plan-features.ts +++ b/backend/server/utils/plan-features.ts @@ -82,7 +82,7 @@ export const PLAN_LIMITS: Record, PlanLimits> = { pro: { customDomains: 10, domainRefill: true, - mailAgents: 3, + mailAgents: 2, mailIntervalOptions: [1, 4, 8], globalBlocklist: "full", canPost: true,