Files
archivmail/features/PROJ-56-last-entzerrung-hintergrundjobs.md
sysops be48a99af9 docs(PROJ-56): Status auf Deployed setzen
Erfolgreich auf 192.168.1.132 (Test) und 192.168.1.131 (Produktion)
deployed, keine Fehler.
2026-06-22 14:35:10 +02:00

103 lines
11 KiB
Markdown
Raw Permalink 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.
# PROJ-56: Last-Entzerrung für Hintergrundjobs (OCR, IMAP-Sync)
## Status: Deployed
**Created:** 2026-06-22
**Last Updated:** 2026-06-22
## Dependencies
- PROJ-35 (OCR & Anhang-Volltext-Indexierung)
- PROJ-8 (Automatischer IMAP-Sync)
## Hintergrund (Nutzerwunsch)
Vergleich mit Mailpiler: dort laufen Wartungsprozesse zeitlich entzerrt statt komplett parallel. Bei archivmail laufen OCR-Worker (sofort bei Import, 2 parallel, je tesseract-Mehrthread) und IMAP-Sync (alle Postfächer pollen unabhängig, ohne Jitter, exakt zum Intervall-Ablauf) unkoordiniert nebeneinander, was bei begrenztem RAM (z.B. 4GB-LXC) zu Lastspitzen führt.
Manticore-Reindex wurde geprüft, ist aber bereits ein rein manuelles CLI-Kommando (kein Scheduler) — keine automatische Last-Entzerrung nötig/möglich, daher außerhalb des Scopes dieses Tickets.
## Entscheidung (Nutzer, 2026-06-22)
Alle drei vorgeschlagenen Maßnahmen gewünscht, soweit technisch sinnvoll:
1. OCR zeitlich entzerren — konfigurierbares Zeitfenster, in dem OCR-Verarbeitung pausiert (z.B. Geschäftszeiten ausnehmen).
2. IMAP-Sync-Intervalle entzerren — deterministischer Jitter pro Postfach, damit nicht alle Accounts auf derselben Minutengrenze pollen.
3. Reindex in Nachtfenster — entfällt, da bereits manuell/CLI-only ohne automatischen Trigger.
## Acceptance Criteria
- [x] `config.yml` erlaubt ein optionales OCR-Zeitfenster (z.B. `ocr.paused_hours: [8, 18]` = pausiert 08:0018:00 Uhr); ohne Konfiguration: Verhalten unverändert (sofortige Verarbeitung, wie bisher).
- [x] Außerhalb des erlaubten Fensters werden neue OCR-Aufträge weiterhin in die Queue/DB als `pending` aufgenommen, aber nicht abgearbeitet, bis das Fenster sich öffnet (kein Datenverlust, kein Crash bei vollem Queue).
- [x] IMAP-Scheduler verteilt den tatsächlichen Sync-Start jedes Accounts über einen deterministischen Jitter (abgeleitet aus Account-ID, kein Zufall bei jedem Tick) innerhalb eines konfigurierbaren Fensters (Default z.B. 04 Minuten), sodass bei N Accounts mit gleichem Intervall nicht alle zur gleichen Minute synchronisieren.
- [x] Bestehende Funktionalität (Sync-Intervall pro Account, manuelles "Jetzt synchronisieren") bleibt unverändert nutzbar — Jitter gilt nur für den automatischen Scheduler-Trigger.
- [x] Kein Verhalten ändert sich für Installationen ohne neue Config-Werte (Default = bisheriges Verhalten, kein Opt-in nötig für Jitter da inhärent sinnvoll, aber abschaltbar via `jitter_seconds: 0`).
## Tech Design
Übersprungen (kleine, klar umrissene Performance-Änderung, kein architektonischer Schnitt nötig) — analog zum Vorgehen bei PROJ-55.
## Implementation Notes (2026-06-22)
### Geänderte/neue Dateien
- `config/config.go`: neue Sektionen `OCRConfig` (`paused_hours *[2]int`) und `IMAPSchedulerConfig` (`jitter_seconds *int`) in `Config`. Helper `ResolvedJitterSeconds()` + Konstante `DefaultIMAPJitterSeconds = 240`.
- `internal/ocr/worker.go`: `Options.PausedHours`, Worker-Feld `pausedHours`, `isPaused(now)`, Pause-Gate im `run`-Loop.
- `internal/imap/scheduler.go`: Feld `jitterSeconds`, `SetJitterSeconds()`, `jitterOffset()`, Anpassung der Fälligkeitsbedingung (`interval + jitterOffset(acc.ID)`).
- `cmd/archivmail/main.go`: `PausedHours` an OCR-Worker durchgereicht; `imapSched.SetJitterSeconds(cfg.IMAPScheduler.ResolvedJitterSeconds())`.
- `config/config.docker.yml.example`: auskommentierte Beispielsektionen `ocr` und `imap_scheduler`.
### OCR-Pausenmechanismus
Gewählt: **Worker konsumiert die Queue gar nicht erst, solange das Pausenfenster aktiv ist.** Vor jedem Dequeue prüft jeder Worker `isPaused(time.Now())`. Bei aktiver Pause wartet er per `select` (60s-Ticker via `pauseCheckInterval`, reagiert sofort auf `done`/`ctx.Done()`) und liest erst dann wieder aus dem Channel. Vorteile:
- Kein Datenverlust: Jobs bleiben im gepufferten Channel und v.a. als `ocr_status='pending'` in PostgreSQL.
- Kein Queue-Überlauf: Der Boot-Resume-Refill ist `QueueLen`-gesteuert und legt nichts nach, sobald der Channel voll ist; neue Submits werden wie bisher non-blocking verworfen (bleiben aber `pending` in der DB und werden beim nächsten Boot-Resume/Fensteröffnen nachgezogen).
- Kein Busy-Loop: Pollintervall 60s.
- `cmd_ocr_reprocess.go` setzt `PausedHours` bewusst nicht (nil) → manueller Admin-Befehl ignoriert das Fenster und läuft sofort.
- Wrap-around-Fenster (z.B. `[22, 6]`) werden in `isPaused` unterstützt (start>end → `h>=start || h<end`); `start==end` = No-op (nie pausieren).
### IMAP-Jitter
Deterministischer Offset `accountID % jitterSeconds` Sekunden, nur aus der Account-ID abgeleitet (stabil über alle Ticks, kein `rand()`). Fälligkeit erst bei `now.Sub(lastSync) >= interval + jitterOffset`. Default 240s wenn `imap_scheduler` fehlt (`jitter_seconds`-Pointer = nil); explizit `0` deaktiviert. `TriggerSync` (manuell) bleibt unberührt.
### Offene Risiken / Edge Cases
- Mitternachts-übergreifendes Fenster `[22,6]` ist abgedeckt; reine Stunden-Granularität (kein Minutenanteil) ist bewusst einfach gehalten.
- Server-Zeitzone: `time.Now().Hour()` nutzt lokale Serverzeit — bei UTC-Servern muss das Fenster entsprechend gesetzt werden.
- Jitter verlängert das effektive Intervall um bis zu `jitter_seconds` (max +4 Min bei Default); akzeptabel, da nur Spitzenglättung bezweckt ist.
- Lokal kein `go build` möglich (kein Toolchain) — nur statische Konsistenzprüfung erfolgt; Build-Verifikation auf 192.168.1.131.
## QA Test Results (Code-Review + Build-Verifikation, 2026-06-22)
Getestet von: QA / Red-Team. Methode: statisches Code-Review der geänderten Dateien
+ isolierte Build-Verifikation auf Test-Server 192.168.1.132 (Go 1.24.4), ohne
den produktiven Checkout / Dienst zu berühren (Tarball → /tmp/archivmail-qa56 →
`go build``version`-Smoke-Test + `go vet` → vollständige Bereinigung).
### Build-Verifikation (192.168.1.132)
- `CGO_ENABLED=0 go build -buildvcs=false -o /tmp/archivmail-test ./cmd/archivmail/`**EXIT 0** (20 MB Binary).
- Smoke-Test `archivmail-test version` → OK (`archivmail 0.9.1`, Modul-Liste wird ausgegeben).
- `go vet ./config/... ./internal/ocr/... ./internal/imap/... ./cmd/archivmail/...`**EXIT 0** (keine Befunde).
- Kein Eingriff in /opt/archivmail, kein `systemctl restart`. Temp-Artefakte auf 132 entfernt.
### Acceptance Criteria
- [PASS] AC1 — Optionales OCR-Zeitfenster: `OCRConfig.PausedHours *[2]int` (`yaml:"paused_hours,omitempty"`). `nil``isPaused()` gibt `false`, Verhalten unverändert.
- [PASS] AC2 — Außerhalb des Fensters bleiben Jobs `pending`: Worker konsumiert die Queue im Pausenfenster nicht (`run`-Loop: bei `isPaused` nur `select` mit 60s-Ticker, kein Dequeue). Jobs bleiben im gepufferten Channel bzw. `ocr_status='pending'` in der DB; Boot-Resume zieht sie nach. Kein Datenverlust, kein Crash bei vollem Channel (`Submit` ist non-blocking; verworfene Submits bleiben `pending` in DB).
- [PASS] AC3 — Deterministischer IMAP-Jitter: `jitterOffset(accountID) = (accountID % jitterSeconds) s`, nur aus Account-ID abgeleitet, stabil über alle Ticks (kein `rand()`). Fälligkeit erst bei `now.Sub(lastSync) >= interval + jitterOffset`.
- [PASS] AC4 — Bestehende Funktionalität unverändert: `TriggerSync` (manuell) umgeht `checkAccounts`/Jitter vollständig und startet sofort (`runSyncWithRetry` direkt). Sync-Intervall pro Account bleibt Basis.
- [PASS] AC5 — Default ohne Config: OCR `paused_hours` unset → nie Pause. `imap_scheduler` ganz weggelassen → `ResolvedJitterSeconds()` = 240s Default; `jitter_seconds: 0` → Jitter aus (Legacy-Tick exakt zum Intervall).
### Prüfpunkte
1. **Datenverlust bei Pause** — PASS. Jobs werden nicht verworfen; Channel-Buffer + `pending`-Status + Boot-Resume garantieren Nachzug.
2. **Race Conditions** — PASS (in der aktuellen Verdrahtung). `pausedHours` wird nur im Konstruktor gesetzt, danach nur gelesen → keine Concurrent-Writes. `jitterSeconds` wird in `main.go` per `SetJitterSeconds()` **vor** `imapSched.Start()` gesetzt; der Loop liest erst nach `Start()`. Kein Daten-Rennen im realen Pfad. Hinweis (LOW, kein Bug): `SetJitterSeconds`/`jitterOffset` greifen unsynchronisiert auf `s.jitterSeconds` zu — würde ein künftiger Aufrufer Jitter zur Laufzeit ändern, wäre es ein Race. Aktuell nicht der Fall. `go vet` meldet nichts; ein `-race`-Test wurde nicht ausgeführt (kein Test-Harness im Scope).
3. **Edge Cases** — PASS.
- Mitternachtsfenster `[22,6]`: `start>end``h>=start || h<end`, korrekt (pausiert 2205).
- `start==end`: explizit No-op (nie pausieren).
- Fehlende Config / nil-Pointer: `isPaused` prüft `pausedHours==nil`; `ResolvedJitterSeconds` prüft `JitterSeconds==nil`. Keine nil-Derefs.
- `jitter_seconds: 0`: `jitterOffset` gibt 0 → reines Intervall-Verhalten.
- Negativ: `SetJitterSeconds` und `ResolvedJitterSeconds` klemmen `<0` auf 0.
4. **Backward-Compat** — PASS (siehe AC5). Default-Pfad identisch zum Alt-Verhalten, mit einer bewussten Ausnahme: ohne `imap_scheduler`-Sektion ist nun **240s Jitter aktiv** (laut Spec gewollt, „abschaltbar via jitter_seconds: 0"). Verlängert das effektive Sync-Intervall um bis zu 4 Min — dokumentiert in den Implementation Notes als akzeptiert.
5. **`cmd_ocr_reprocess.go` erbt kein Pausenfenster** — PASS. Der `ocr.Options`-Block dort setzt `PausedHours` nicht (= nil) → manuelle Reprocessing-Läufe ignorieren das Fenster und laufen sofort. Verifiziert (Zeilen 103107).
6. **Manuelles "Jetzt synchronisieren" ignoriert Jitter** — PASS. `TriggerSync` ruft `runSyncWithRetry` direkt auf, ohne `dueAfter`/`jitterOffset`-Berechnung. Verifiziert.
### Findings
- **Keine Bugs (Severity Critical/High/Medium) gefunden.**
- **LOW / INFO 1 (kein Fix nötig):** `Scheduler.jitterSeconds` ist nicht mutex-geschützt. Unkritisch, da set-before-start. Nur relevant, falls künftig eine Laufzeit-Rekonfiguration eingeführt wird → dann `s.mu` o.ä. ergänzen.
- **LOW / INFO 2 (kein Fix nötig):** Pausenfenster nutzt lokale Serverzeit (`time.Now().Hour()`). Auf UTC-Servern muss das Fenster entsprechend gewählt werden — bereits in den Implementation Notes dokumentiert.
- **INFO 3 (vorbestehend, nicht Teil von PROJ-56):** `config.docker.yml.example` enthält weiterhin `xapian_path`/`backend: xapian`, obwohl der `StorageConfig` kein `xapian_path`-Feld hat und der Default-Backend Manticore ist. Unkritisch (YAML-Unmarshal ignoriert unbekannte Keys), aber irreführend. Außerhalb des Scopes dieses Tickets.
### Fazit
Production-Ready: **JA**. Alle 5 Acceptance Criteria erfüllt, alle 6 Sicherheits-/Korrektheits-Prüfpunkte bestanden, Build + vet + Smoke-Test auf 192.168.1.132 grün. Keine blockierenden Findings; nur 2 LOW-Hinweise (kein Fix nötig) und 1 vorbestehender, ticket-fremder Konfig-Hinweis.
## Deployment
- Test (192.168.1.132): `update.sh` (Commit `4dbf27c`), Backend+Frontend aktiv, Health-Checks bestanden, keine Config-Parsing-Fehler trotz fehlender OCRConfig/IMAPSchedulerConfig in der lokalen `config.yml` (Default-Verhalten bestätigt). 2026-06-22.
- Produktion (192.168.1.131): `update.sh` (Commit `12c4dc7`), Backend+Frontend aktiv, Health-Checks bestanden, keine Config-Parsing-Fehler. 2026-06-22.