Files
archivmail/features/PROJ-75-db-performance-audit.md
T
sysopsandClaude Sonnet 5 d1b4497893 fix(PROJ-79): next lint kaputt seit Next-16-Upgrade repariert + 30 Findings gefixt
npm run lint rief next lint auf, das es in Next.js 16 nicht mehr gibt —
seit dem Next-16-Upgrade lief effektiv gar kein Lint mehr. Umgestellt auf
eslint . mit Flat-Config (eslint.config.mjs statt .eslintrc.json). Der
dadurch wieder sichtbare Lint-Lauf zeigte 30 Findings (25 Fehler,
5 Warnungen), alle gefixt:

- 19x react-hooks/set-state-in-effect: Loading-States wo möglich als
  echte Ableitung statt eigenem Effect-State (use-mobile.tsx komplett auf
  useSyncExternalStore umgebaut), sonst async-Wrapper mit Cancel-Guard um
  bestehende Loader — Timing/Ladeanzeige unverändert.
- react-hooks/refs (useSearch.ts): Ref-Schreibzugriff aus dem Render in
  einen Effect verschoben.
- 4x no-html-link-for-pages: <a href> durch next/link ersetzt in
  admin/login, forgot-password, signup.
- Rest (exhaustive-deps, no-img-element, unused disable) einzeln gefixt.
- 4 bewusst belassene disable-Kommentare mit Begründung (shadcn/ui-Datei,
  QR-Code-data-URL, Full-Reload nach Auth laut Projektregel).

eslint-Major-Upgrade auf 10 selbst bleibt blockiert: eslint-plugin-react/
jsx-a11y/import unterstützen ESLint 10 in ihrer aktuellen Latest-Version
noch nicht (Crash beim Laden), siehe Feature-Spec PROJ-79.

Verifiziert auf 132 (Build-Sandbox, kein Live-Deploy): npm ci/tsc/lint/
build grün, 8 Kern-Routen per Standalone-Server auf HTTP 200 geprüft.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
2026-08-05 21:14:17 +02:00

11 KiB
Raw Blame History

id, title, status, created
id title status created
PROJ-75 DB-Performance-Audit (Query-Index-Nutzung, Fan-out, pgxpool-Tuning) Deployed 2026-08-05

Problem

Der Performance-Check der Crash-Härtung-Session (PROJ-73-Vorarbeiten) wurde aus Zeitgründen nicht systematisch für die Datenbankschicht abgeschlossen. Beim Überfliegen auffällig:

  • internal/storage/storage.go:1094 und internal/storage/mark_deletion.go:101 — dynamisch zusammengebaute SQL-Queries, Index-Nutzung nicht verifiziert.
  • internal/api/search_handlers.go:209 und :491 — Parsing/Verarbeitung pro Treffer innerhalb der Ergebnis-Schleife, potenzieller Fan-out bei großen Trefferzahlen.
  • internal/reconciliation/ — noch nicht geprüft.
  • Kein explizites pgxpool.Config-Tuning (MaxConns, MinConns, MaxConnLifetime) — läuft aktuell auf pgx-Defaults, unklar ob für die Archivgröße auf 131 (Produktiv) und 132 (teilproduktiv, echte Daten) passend.

Kein akuter Crash-Bezug, aber relevant für Skalierung bei wachsendem Archiv-Volumen.

Lösung (Vorschlag)

  1. EXPLAIN ANALYZE für die Queries in storage.go:1094 und mark_deletion.go:101 auf 132 gegen realistische Datenmenge laufen lassen, fehlende Indizes identifizieren.
  2. search_handlers.go Fan-out-Pfad profilen (z.B. mit pprof bei großer Trefferzahl) — prüfen ob Batch-Verarbeitung statt Pro-Treffer- Parsing möglich ist.
  3. internal/reconciliation/ auf N+1-Muster durchsehen.
  4. pgxpool.Config explizit setzen basierend auf Serverkapazität (131/132) und beobachteter Connection-Auslastung.

Implementation Notes

Audit durchgeführt am 2026-08-05, rein lesend gegen 192.168.1.132 (teilproduktiv, echte Daten). Keine Schema-Änderungen vorgenommen — Vorschläge unten stehen zur Rücksprache.

Servereckdaten 132: 5 vCPU, 4 GiB RAM, max_connections = 100, aktuell 12 aktive Verbindungen auf archivmail. Tabellengrößen (n_live_tup): storage_objects 84139, emails 52895, email_refs 51209, email_attachments 14712, attachments 9102.

1. Query-Pläne (storage.go:1094, mark_deletion.go:101)

  • GetUnindexedMails (storage.go, SELECT id, tenant_id FROM emails WHERE indexed_at IS NULL ORDER BY received_at DESC LIMIT $1): Seq Scan bestätigt. EXPLAIN ANALYZE zeigt vollen Scan über alle 52895 Zeilen (Rows Removed by Filter: 52895), aktuell 13.3ms weil alles bereits indexiert ist (0 Treffer) — d.h. der Scan berührt bei jedem Cron-Lauf (index-pending, PROJ-58) immer die komplette Tabelle, unabhängig davon wie wenige unindizierte Mails es gibt. Auf emails existiert kein Index auf indexed_at. Mit wachsendem Archiv (aktuell 53k, Zielgröße deutlich größer) skaliert das linear mit — Index-Vorschlag (partial index, da fast immer indexed_at IS NOT NULL):
    CREATE INDEX IF NOT EXISTS idx_emails_unindexed
        ON emails (received_at DESC) WHERE indexed_at IS NULL;
    
    Partial Index hält den Index winzig (nur die tatsächlich unindizierten Zeilen), passt exakt zur WHERE- und ORDER-BY-Klausel der Query.

Umsetzung Index (2026-08-05)

Index in initSchema ergänzt (internal/storage/storage.go, direkt nach idx_emails_retain_until, idempotent via CREATE INDEX IF NOT EXISTS):

CREATE INDEX IF NOT EXISTS idx_emails_unindexed
    ON emails (received_at DESC) WHERE indexed_at IS NULL;

Ausgeführt direkt via psql auf 192.168.1.132 (nicht auf 131 — nur Testserver laut Auftrag). Kein Backend-Restart nötig, CREATE INDEX IF NOT EXISTS ohne CONCURRENTLY war bei 52.895 Zeilen unkritisch und ohne spürbare Downtime.

Validierung — EXPLAIN ANALYZE gegen die Query aus GetUnindexedMails:

Vorher (Seq Scan, aus dem ursprünglichen Audit oben): Rows Removed by Filter: 52895, 13.3ms.

Nachher:

Limit  (cost=0.12..12.10 rows=2 width=81) (actual time=0.069..0.070 rows=1 loops=1)
  ->  Index Scan using idx_emails_unindexed on emails  (cost=0.12..12.10 rows=2 width=81) (actual time=0.068..0.069 rows=1 loops=1)
Planning Time: 1.012 ms
Execution Time: 0.086 ms

Seq Scan → Index Scan bestätigt, Ausführungszeit von 13.3ms auf 0.086ms gesunken (~150x). Migration erfolgreich, nur auf 132 angewendet — 131 bewusst nicht angefasst.

Deployed auf 131 (Produktiv) am 2026-08-05. Der Index idx_emails_unindexed wurde beim Backend-Start automatisch über initSchema (idempotent via CREATE INDEX IF NOT EXISTS) angelegt — verifiziert per pg_indexes-Abfrage auf 131, kein manueller SQL-Schritt nötig. Startup-Log lief ohne Fehler durch, keine Auffälligkeiten trotz größerer Produktivtabelle.

  • ListExpiredMails (mark_deletion.go, WHERE retain_until IS NOT NULL AND retain_until < NOW() [AND tenant_id = $N] ORDER BY retain_until ASC LIMIT 500): Index Scan bestätigt, keine Änderung nötig. Nutzt bereits idx_emails_retain_until (partial index WHERE retain_until IS NOT NULL), 0.4ms Ausführungszeit. Der optionale tenant_id-Filter wird nicht separat geprüft, ist aber bei der geringen Selektivität (max. 500 Zeilen via LIMIT) unkritisch — idx_emails_tenant existiert als Fallback.

2. Fan-out in search_handlers.go (:196235, :460508)

Kein N+1 gegen PostgreSQL. Der Treffer-Loop ab Zeile 205 macht pro Treffer:

  • s.store.Load(h.ID) — Dateisystem-Read (verschlüsselter Store), keine DB-Query.
  • mailparser.Parse(raw) — reines In-Memory-Parsing.

GetThreadInfo(ctx, hitIDs) und GetReceivedAts(ctx, hitIDs) werden einmal vor der Schleife mit der vollen hitIDs-Liste aufgerufen und intern per WHERE id = ANY($1) batched (storage.go:600ff, :796) — kein Pro-Treffer-Query. Bei page_size=100 sind das weiterhin 2 DB-Queries gesamt, nicht 100+2. Der eigentliche Kostenfaktor bei großer Trefferzahl ist der Datei-I/O (store.Load pro Treffer) und CPU-gebundenes Parsing, nicht die Datenbank — das ist außerhalb des DB-Audit-Scopes (ggf. separates Ticket für Store-I/O- Profiling, falls das in der Praxis auffällt). Für PROJ-75 (DB-Fokus): kein Handlungsbedarf.

3. internal/reconciliation/ — N+1 gefunden

DashboardData (reconciliation.go:74113) iteriert über alle Source-Buckets (agg, typischerweise wenige Quellen: SMTP, IMAP-Accounts, POP3-Accounts, Import — Größenordnung einstellig bis niedrig zweistellig pro Tenant) und ruft pro Bucket s.trailingAverage(ctx, ...) auf (Zeile 102), was eine eigene DB-Query pro Source ist (compute.go:287320, SELECT ... LIMIT 7). Gleiches Muster in ComputeForDate (compute.go:116139) — ein trailingAverage-Call pro Report-Zeile.

Das ist klassisches N+1, aber bei der aktuellen Bucket-Kardinalität (Anzahl Quellen pro Tenant, nicht Anzahl Mails) unkritisch — 504 Zeilen in reconciliation_reports insgesamt, die Query selbst ist ein günstiger Index-Scan mit LIMIT 7 auf idx_recon_reports_lookup. Bei sehr vielen Tenants mit vielen IMAP/POP3-Accounts könnte das spürbar werden (Dashboard- Aufruf mit z.B. 200 Quellen = 200 Queries), aktuell aber kein akuter Engpass. Empfehlung: nicht jetzt umsetzen, aber im Auge behalten falls Multi-Tenant-Wachstum die Quellenzahl deutlich erhöht. Bei Bedarf ließe sich trailingAverage zu einer gebatchten Query (ein SELECT mit ROW_NUMBER() OVER (PARTITION BY source ...) oder ein Window-Function-Ansatz) umbauen.

4. pgxpool-Setup

Kein zentraler Pool — jeder Store öffnet seinen eigenen pgxpool.New(dsn) ohne explizites pgxpool.Config: internal/storage/storage.go:86, internal/tenantstore/store.go:93, internal/tokenstore/store.go (über übergebenen Pool, s.u.), internal/smtpoutconfig/store.go:62, internal/ldapconfig/store.go:73 + tenant_store.go:54, internal/pop3/store.go:71, internal/imap/store.go:105, internal/audit/audit.go:105, internal/userstore/userstore.go:73, internal/reconciliation/reconciliation.go:47, plus cmd/archivmail/main.go:372 (separater tokenPool) und zwei weitere in cmd_status.go/cmd_migrate_tenants.go für CLI-Subkommandos. Das sind ~10 unabhängige Pools im laufenden Backend-Prozess, jeder mit pgx-Default (MaxConns = max(4, runtime.NumCPU()) → auf 132 mit 5 vCPU: 5 Conns/Pool, theoretisches Maximum ~50 gleichzeitige Verbindungen aus einem einzigen archivmail-Prozess). Aktuell unkritisch (max_connections=100, 12 aktive Verbindungen beobachtet), aber:

  • Kein MinConns gesetzt → Pools bauen Connections erst bei Bedarf auf statt vorzuhalten, kostet Cold-Start-Latenz auf selten genutzten Stores (z.B. ldapconfig, smtpoutconfig).
  • Kein MaxConnLifetime/MaxConnIdleTime → Connections werden nie proaktiv recycelt (relevant falls PgBouncer/Connection-Pooler später vorgeschaltet wird oder Postgres restart-bedingt Verbindungen killt).
  • 10 separate Pools statt einem gemeinsamen ist strukturell unnötig (jeder Pool reserviert eigene Health-Check-Goroutinen, eigenes Idle-Handling) — architektonisch sauberer wäre ein gemeinsamer *pgxpool.Pool, der an alle Stores durchgereicht wird (Ansatz existiert bereits teilweise: tokenstore.New(pool) nimmt einen fertigen Pool entgegen). Das ist aber ein größerer Refactor, kein reines Tuning — separat einplanen, nicht Teil dieses Audits.

Empfohlene Werte (pro Pool, bis der Pool-Zusammenlegung-Refactor ansteht) für 132 (5 vCPU/4 GiB) und 131 (Produktiv, vermutlich vergleichbare oder größere Kapazität — vor Umsetzung auf 131 nproc/free -h gegenchecken):

cfg, _ := pgxpool.ParseConfig(dsn)
cfg.MaxConns = 8          // statt Default 5 auf 132 — Headroom für Backend + Cron-Jobs
cfg.MinConns = 2          // 2 warme Connections je Store statt Cold-Start
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)

Summe über alle ~10 Pools bei MaxConns=8: bis zu 80 Verbindungen möglich — noch unter max_connections=100, aber knapper. Vor Umsetzung: entweder max_connections in postgresql.conf moderat erhöhen (z.B. 150) oder die MaxConns pro Pool niedriger ansetzen (z.B. 6) — Vorschlag zur Rücksprache, keine Änderung vorgenommen.

Acceptance Criteria

  • Query-Pläne für die genannten Storage-Queries dokumentiert, fehlende Indizes identifiziert oder Fehlanzeige begründet. → GetUnindexedMails Seq-Scan bestätigt, Index umgesetzt und auf 132 validiert (Seq Scan → Index Scan, 13.3ms → 0.086ms). ListExpiredMails nutzt bereits einen passenden Index, kein Handlungsbedarf.
  • Fan-out-Verhalten in search_handlers.go bei großen Trefferzahlen gemessen, Optimierung umgesetzt falls Engpass bestätigt. → Kein DB-seitiger N+1 (Batch-Queries bereits vorhanden), Engpass falls vorhanden liegt bei Datei-I/O/Parsing, nicht DB — kein DB-seitiger Optimierungsbedarf, daher keine Umsetzung nötig.
  • pgxpool.Config-Werte explizit gesetzt und begründet (nicht mehr stillschweigend Default). → Empfehlung dokumentiert (siehe oben), Umsetzung steht noch aus (Folge-Schritt, nicht Teil dieses Audits).

Zusätzlicher Befund außerhalb der ursprünglichen Lösung-Skizze: internal/reconciliation/DashboardData/ComputeForDate haben ein N+1-Muster (trailingAverage pro Source-Bucket), aktuell bei geringer Quellenzahl unkritisch — dokumentiert, keine Umsetzung empfohlen.