Files
archivmail/features/PROJ-56-last-entzerrung-hintergrundjobs.md
T
sysops 4dbf27cc1d feat(PROJ-56): Last-Entzerrung für OCR und IMAP-Sync
OCR-Worker pausieren optional in konfigurierbarem Zeitfenster
(paused_hours), Jobs bleiben pending statt verworfen zu werden.
IMAP-Scheduler verteilt Sync-Starts via deterministischem
Pro-Account-Jitter, um Lastspitzen bei vielen Postfächern mit
gleichem Intervall zu vermeiden. Beides per Config opt-out,
Default-Verhalten unverändert. Build + Smoke-Test auf 132 verifiziert.
2026-06-22 14:21:09 +02:00

11 KiB
Raw Blame History

PROJ-56: Last-Entzerrung für Hintergrundjobs (OCR, IMAP-Sync)

Status: In Review

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

  • 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).
  • 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).
  • 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.
  • Bestehende Funktionalität (Sync-Intervall pro Account, manuelles "Jetzt synchronisieren") bleibt unverändert nutzbar — Jitter gilt nur für den automatischen Scheduler-Trigger.
  • 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 buildversion-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"). nilisPaused() 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>endh>=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.