From 4dbf27cc1d9e5230506819433dd4ca7bb2db5fcf Mon Sep 17 00:00:00 2001 From: sysops Date: Mon, 22 Jun 2026 14:21:09 +0200 Subject: [PATCH] =?UTF-8?q?feat(PROJ-56):=20Last-Entzerrung=20f=C3=BCr=20O?= =?UTF-8?q?CR=20und=20IMAP-Sync?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- cmd/archivmail/main.go | 12 ++- config/config.docker.yml.example | 16 +++ config/config.go | 41 ++++++++ features/INDEX.md | 3 +- ...PROJ-56-last-entzerrung-hintergrundjobs.md | 98 +++++++++++++++++++ internal/imap/scheduler.go | 38 ++++++- internal/ocr/worker.go | 65 ++++++++++-- 7 files changed, 261 insertions(+), 12 deletions(-) create mode 100644 features/PROJ-56-last-entzerrung-hintergrundjobs.md diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index 3c4cc89..cf203a4 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -190,10 +190,15 @@ func main() { // into the storage-dir which is guaranteed-RW. ocr.SetTempDir(cfg.Storage.StorePath + "/ocr-tmp") ocrWorker := ocr.NewWorker(mailStore, idxMgr, ocr.Options{ - Workers: 2, - QueueSize: 1000, - Logger: logger, + Workers: 2, + QueueSize: 1000, + Logger: logger, + PausedHours: cfg.OCR.PausedHours, // PROJ-56: optional local-time pause window }) + if cfg.OCR.PausedHours != nil { + logger.Info("ocr worker: pause window configured", + "from_hour", cfg.OCR.PausedHours[0], "to_hour", cfg.OCR.PausedHours[1]) + } ocrWorker.Start(context.Background()) defer ocrWorker.Stop() if !ocr.IsAvailable() { @@ -433,6 +438,7 @@ func main() { }) } imapSched := imapstore.NewScheduler(imapSt, imapImp, logger) + imapSched.SetJitterSeconds(cfg.IMAPScheduler.ResolvedJitterSeconds()) // PROJ-56: deterministic per-account sync spread imapSched.SetAuditLogger(audlog) // PROJ-45: tenant-visible UIDVALIDITY-reset audit entries imapSched.Start() defer imapSched.Stop() diff --git a/config/config.docker.yml.example b/config/config.docker.yml.example index de4d7e5..687ac58 100644 --- a/config/config.docker.yml.example +++ b/config/config.docker.yml.example @@ -54,6 +54,22 @@ imap_server: enabled: false bind: "0.0.0.0:1143" +# PROJ-56: OCR-Last-Entzerrung (optional). +# paused_hours definiert ein lokales Zeitfenster [start, end), in dem der +# OCR-Worker NICHT verarbeitet (Aufträge bleiben als pending erhalten). +# Wrap-around über Mitternacht wird unterstützt, z.B. [22, 6] = Pause 22:00–06:00. +# Ohne Sektion / ohne paused_hours: altes Verhalten (immer aktiv). +# ocr: +# paused_hours: [8, 18] # OCR pausiert während der Geschäftszeiten + +# PROJ-56: IMAP-Sync-Jitter (optional). +# jitter_seconds verteilt den tatsächlichen Sync-Start jedes Accounts +# deterministisch (abgeleitet aus Account-ID) über dieses Fenster, damit nicht +# alle Postfächer auf derselben Minutengrenze pollen. +# Weglassen der Sektion = Default 240s (4 Min). jitter_seconds: 0 = deaktiviert. +# imap_scheduler: +# jitter_seconds: 240 + audit: log_path: /var/archivmail/audit.log retention_days: 365 diff --git a/config/config.go b/config/config.go index f33417b..d040a72 100644 --- a/config/config.go +++ b/config/config.go @@ -39,6 +39,47 @@ type Config struct { Logging LoggingConfig `yaml:"logging"` IMAPServer IMAPServerConfig `yaml:"imap_server"` Metrics MetricsConfig `yaml:"metrics"` + // PROJ-56: load-spreading for background jobs. + OCR OCRConfig `yaml:"ocr"` + IMAPScheduler IMAPSchedulerConfig `yaml:"imap_scheduler"` +} + +// OCRConfig holds settings for the background OCR worker (PROJ-56). +type OCRConfig struct { + // PausedHours optionally defines a local-time window [start, end) during + // which the OCR worker pauses processing (e.g. [8, 18] = paused 08:00–18:00). + // Wrap-around windows are supported, e.g. [22, 6] = paused 22:00–06:00. + // nil / unset = never pause (legacy behaviour: process immediately). + PausedHours *[2]int `yaml:"paused_hours,omitempty"` +} + +// IMAPSchedulerConfig holds settings for the automatic IMAP sync scheduler (PROJ-56). +type IMAPSchedulerConfig struct { + // JitterSeconds spreads the per-account sync start over a deterministic + // offset derived from the account ID, so N accounts on the same interval + // don't all poll on the same minute boundary. + // A pointer so the unset case (use default) is distinguishable from an + // explicit 0 (disable jitter). + // nil -> DefaultIMAPJitterSeconds (240s window) + // 0 -> jitter disabled (legacy behaviour) + // >0 -> jitter window in seconds + JitterSeconds *int `yaml:"jitter_seconds,omitempty"` +} + +// DefaultIMAPJitterSeconds is the jitter window applied when imap_scheduler +// is omitted entirely from the config (4 minutes). +const DefaultIMAPJitterSeconds = 240 + +// ResolvedJitterSeconds returns the effective jitter window. nil falls back to +// the default; an explicit 0 (or negative) means jitter is disabled. +func (c IMAPSchedulerConfig) ResolvedJitterSeconds() int { + if c.JitterSeconds == nil { + return DefaultIMAPJitterSeconds + } + if *c.JitterSeconds < 0 { + return 0 + } + return *c.JitterSeconds } // IMAPServerConfig holds settings for the embedded read-only IMAP archive server. diff --git a/features/INDEX.md b/features/INDEX.md index b97f847..9fb8e42 100644 --- a/features/INDEX.md +++ b/features/INDEX.md @@ -72,7 +72,8 @@ | PROJ-53 | Konfigurierbare Listenanzahl pro Seite | Deployed | [PROJ-53](PROJ-53-konfigurierbare-listenanzahl.md) | 2026-06-14 | | PROJ-54 | Fix Listenansicht/Pagination für Rolle "user" (Nachbesserung PROJ-6/PROJ-21) | Deployed | [PROJ-54](PROJ-54-fix-listenansicht-total.md) | 2026-06-14 | | PROJ-55 | Fix Tenant-Isolation für Rolle "auditor" + Audit-Log (Sicherheitsbug, DSGVO-relevant) | Deployed | [PROJ-55](PROJ-55-fix-auditor-tenant-isolation.md) | 2026-06-21 | +| PROJ-56 | Last-Entzerrung für Hintergrundjobs (OCR-Zeitfenster, IMAP-Sync-Jitter) | In Review | [PROJ-56](PROJ-56-last-entzerrung-hintergrundjobs.md) | 2026-06-22 | -## Next Available ID: PROJ-56 +## Next Available ID: PROJ-57 diff --git a/features/PROJ-56-last-entzerrung-hintergrundjobs.md b/features/PROJ-56-last-entzerrung-hintergrundjobs.md new file mode 100644 index 0000000..059ed8b --- /dev/null +++ b/features/PROJ-56-last-entzerrung-hintergrundjobs.md @@ -0,0 +1,98 @@ +# 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 +- [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= 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= interval { + // PROJ-56: spread the actual sync start with a deterministic per-account + // offset so accounts sharing an interval don't all poll on the same + // minute boundary. Offset is 0 when jitter is disabled. + dueAfter := interval + s.jitterOffset(acc.ID) + + if now.Sub(lastSync) >= dueAfter { s.mu.Lock() s.running[acc.ID] = true s.mu.Unlock() diff --git a/internal/ocr/worker.go b/internal/ocr/worker.go index 921e1f2..783c360 100644 --- a/internal/ocr/worker.go +++ b/internal/ocr/worker.go @@ -6,6 +6,7 @@ import ( "log/slog" "strings" "sync" + "time" "archivmail/internal/index" "archivmail/internal/storage" @@ -32,14 +33,28 @@ type Worker struct { wg sync.WaitGroup workers int langs []string + + // PROJ-56: optional local-time pause window [start, end). When the current + // hour falls inside it, workers stop consuming the queue (jobs stay buffered + // in the channel / as ocr_status='pending' in the DB) until it reopens. + // nil = never pause (legacy behaviour). + pausedHours *[2]int } +// pauseCheckInterval is how often a paused worker re-checks whether the pause +// window has closed. +const pauseCheckInterval = 60 * time.Second + // Options configures a Worker. Zero values are replaced with sensible defaults. type Options struct { QueueSize int // default 1000 Workers int // default 2 Langs []string // default ["deu", "eng"] Logger *slog.Logger + // PausedHours optionally pauses processing during a local-time window + // [start, end) (PROJ-56). Wrap-around windows (e.g. [22, 6]) are supported. + // nil = never pause. The manual reprocess command leaves this nil on purpose. + PausedHours *[2]int } // NewWorker constructs a worker that reads mails from store, runs OCR on @@ -59,16 +74,37 @@ func NewWorker(store *storage.Store, idxMgr index.TenantIndexer, opts Options) * opts.Logger = slog.Default() } return &Worker{ - store: store, - idxMgr: idxMgr, - logger: opts.Logger, - queue: make(chan Job, opts.QueueSize), - done: make(chan struct{}), - workers: opts.Workers, - langs: opts.Langs, + store: store, + idxMgr: idxMgr, + logger: opts.Logger, + queue: make(chan Job, opts.QueueSize), + done: make(chan struct{}), + workers: opts.Workers, + langs: opts.Langs, + pausedHours: opts.PausedHours, } } +// isPaused reports whether the OCR worker should currently hold off processing +// because the local time falls inside the configured pause window. +// Supports wrap-around windows where start > end (e.g. [22, 6]). +func (w *Worker) isPaused(now time.Time) bool { + if w.pausedHours == nil { + return false + } + start, end := w.pausedHours[0], w.pausedHours[1] + if start == end { + // Degenerate / no-op window — never pause. + return false + } + h := now.Hour() + if start < end { + return h >= start && h < end + } + // Wrap-around window, e.g. [22, 6): paused 22,23,0,1,...,5. + return h >= start || h < end +} + // Submit enqueues a job. Drops with a warning if the queue is full so the // caller (mail intake) is never blocked. func (w *Worker) Submit(mailID string, tenantID *int64) { @@ -108,6 +144,21 @@ func (w *Worker) Stop() { func (w *Worker) run(ctx context.Context, id int) { defer w.wg.Done() for { + // PROJ-56: while inside the pause window, do NOT consume the queue. + // Jobs stay buffered in the channel (and as ocr_status='pending' in the + // DB), so nothing is lost. We re-check periodically and still react to + // shutdown / context cancellation immediately. + if w.isPaused(time.Now()) { + select { + case <-w.done: + return + case <-ctx.Done(): + return + case <-time.After(pauseCheckInterval): + } + continue + } + select { case job, ok := <-w.queue: if !ok {