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

218 lines
11 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: PROJ-75
title: DB-Performance-Audit (Query-Index-Nutzung, Fan-out, pgxpool-Tuning)
status: Deployed
created: 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`):
```sql
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`):
```sql
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):
```go
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
- [x] 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.
- [x] 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.