# 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:00–18: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. 0–4 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 22–05).
   - `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 103–107).
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.
