From dba9939880707bac1c2f9c877ea2745564b1ab4c Mon Sep 17 00:00:00 2001 From: sysops Date: Wed, 24 Jun 2026 14:46:13 +0200 Subject: [PATCH] feat(PROJ-56): OCR-Pausenfenster per SIGHUP-Reload statt Restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit paused_hours konnte bisher nur über einen vollen Prozess-Restart geändert werden, was SMTP/IMAP/API unnötig unterbricht. Worker.pausedHours ist jetzt ein atomic.Pointer mit SetPausedHours(); SIGHUP liest config.yml neu und aktualisiert nur die OCR-Pausenzeit im laufenden Prozess. Neue deploy/cron.d/archivmail-ocr-pause(.sh) lässt Admins die Pausenzeiten direkt in der Cron-Datei pflegen und löst per systemctl reload statt restart aus. Co-Authored-By: Claude Sonnet 4.6 --- cmd/archivmail/main.go | 30 +++++++++++++++++++ deploy/cron.d/archivmail-ocr-pause | 20 +++++++++++++ deploy/cron.d/archivmail-ocr-pause.sh | 42 +++++++++++++++++++++++++++ install.sh | 1 + internal/ocr/worker.go | 38 ++++++++++++++++-------- 5 files changed, 119 insertions(+), 12 deletions(-) create mode 100644 deploy/cron.d/archivmail-ocr-pause create mode 100755 deploy/cron.d/archivmail-ocr-pause.sh diff --git a/cmd/archivmail/main.go b/cmd/archivmail/main.go index cf203a4..1dc6c65 100644 --- a/cmd/archivmail/main.go +++ b/cmd/archivmail/main.go @@ -474,6 +474,17 @@ func main() { } }() + // PROJ-56b: SIGHUP reloads only the OCR pause window from config.yml, so + // admins/cron can change paused_hours without a full service restart + // (which would otherwise drop SMTP/IMAP connections unnecessarily). + reload := make(chan os.Signal, 1) + signal.Notify(reload, syscall.SIGHUP) + go func() { + for range reload { + reloadOCRPauseWindow(*configPath, ocrWorker, logger) + } + }() + // Graceful shutdown quit := make(chan os.Signal, 1) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) @@ -485,6 +496,25 @@ func main() { httpServer.Shutdown(ctx) } +// reloadOCRPauseWindow re-reads config.yml and applies any change to +// ocr.paused_hours to the already-running OCR worker, without restarting +// the process. Other config sections are intentionally ignored — a full +// reload of e.g. DB DSN or listen ports would require a restart anyway. +func reloadOCRPauseWindow(configPath string, ocrWorker *ocr.Worker, logger *slog.Logger) { + cfg, err := config.Load(configPath) + if err != nil { + logger.Warn("sighup reload: config read failed, keeping current pause window", "err", err) + return + } + ocrWorker.SetPausedHours(cfg.OCR.PausedHours) + if cfg.OCR.PausedHours != nil { + logger.Info("sighup reload: ocr pause window updated", + "from_hour", cfg.OCR.PausedHours[0], "to_hour", cfg.OCR.PausedHours[1]) + } else { + logger.Info("sighup reload: ocr pause window cleared") + } +} + // submitToWorker parses a raw email and submits it to the async index worker. // tenantID may be nil for global context. // If ocrWorker is non-nil and the mail has attachments, an OCR job is also diff --git a/deploy/cron.d/archivmail-ocr-pause b/deploy/cron.d/archivmail-ocr-pause new file mode 100644 index 0000000..313ddd3 --- /dev/null +++ b/deploy/cron.d/archivmail-ocr-pause @@ -0,0 +1,20 @@ +# archivmail — OCR-Pausenfenster (PROJ-56) +# +# Steuert paused_hours in /etc/archivmail/config.yml und löst per +# "systemctl reload" (SIGHUP) einen Reload aus, damit die neue Pausenzeit +# sofort greift — ohne den Prozess neu zu starten (kein Verbindungsabbruch +# für SMTP/IMAP/API). Mails gehen dabei nie verloren: ocr_status='pending' +# bleibt in der DB bestehen und wird von der Backfill-Goroutine laufend +# nachgezogen, unabhängig von Pausen oder Neustarts. +# +# Anpassen: einfach die beiden Uhrzeiten unten ändern (Minute Stunde * * *) +# und an die crontab-Syntax halten. Bei wrap-around Fenstern (z.B. 22:00–06:00) +# einfach "start" auf den späteren, "stop" auf den früheren Zeitpunkt legen. +# +# Format: Minute Stunde Tag Monat Wochentag User Befehl +# +# Pause starten um 22:00 Uhr +0 22 * * * root /usr/local/bin/archivmail-ocr-pause.sh start 22 6 +# +# Pause beenden um 06:00 Uhr +0 6 * * * root /usr/local/bin/archivmail-ocr-pause.sh stop diff --git a/deploy/cron.d/archivmail-ocr-pause.sh b/deploy/cron.d/archivmail-ocr-pause.sh new file mode 100755 index 0000000..a20ce65 --- /dev/null +++ b/deploy/cron.d/archivmail-ocr-pause.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +# archivmail — schreibt paused_hours nach /etc/archivmail/config.yml und +# stößt per SIGHUP (systemctl reload) einen Reload an, damit der OCR-Worker +# das neue Fenster übernimmt — ohne SMTP/IMAP/API-Verbindungen zu kappen. +# +# Aufruf (siehe deploy/cron.d/archivmail-ocr-pause): +# archivmail-ocr-pause.sh start +# archivmail-ocr-pause.sh stop +set -euo pipefail + +CONFIG="/etc/archivmail/config.yml" +SERVICE="archivmail" + +if [ ! -f "$CONFIG" ]; then + echo "archivmail-ocr-pause: $CONFIG nicht gefunden" >&2 + exit 1 +fi + +cmd="${1:-}" + +case "$cmd" in +start) + from="${2:?from_hour fehlt}" + to="${3:?to_hour fehlt}" + if ! grep -q "^ocr:" "$CONFIG"; then + printf '\nocr:\n paused_hours: [%s, %s]\n' "$from" "$to" >> "$CONFIG" + elif grep -q "paused_hours:" "$CONFIG"; then + sed -i -E "s/^([[:space:]]*paused_hours:).*/\1 [${from}, ${to}]/" "$CONFIG" + else + sed -i "/^ocr:/a\\ paused_hours: [${from}, ${to}]" "$CONFIG" + fi + ;; +stop) + sed -i -E "s/^([[:space:]]*paused_hours:).*/\1 null/" "$CONFIG" + ;; +*) + echo "Usage: $0 {start |stop}" >&2 + exit 1 + ;; +esac + +systemctl reload "$SERVICE" diff --git a/install.sh b/install.sh index 572bd52..2326256 100755 --- a/install.sh +++ b/install.sh @@ -632,6 +632,7 @@ Group=${AM_USER} AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN ExecStart=${INSTALL_DIR}/archivmail --config ${CONFIG_DIR}/config.yml +ExecReload=/bin/kill -HUP \$MAINPID Restart=on-failure RestartSec=5 StandardOutput=journal diff --git a/internal/ocr/worker.go b/internal/ocr/worker.go index 783c360..e1daf86 100644 --- a/internal/ocr/worker.go +++ b/internal/ocr/worker.go @@ -6,6 +6,7 @@ import ( "log/slog" "strings" "sync" + "sync/atomic" "time" "archivmail/internal/index" @@ -38,7 +39,11 @@ type Worker struct { // 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 + // + // Stored as atomic.Pointer so SetPausedHours can be called concurrently + // from a signal handler (SIGHUP reload) while run() goroutines read it, + // without requiring a process restart to change the window (PROJ-56b). + pausedHours atomic.Pointer[[2]int] } // pauseCheckInterval is how often a paused worker re-checks whether the pause @@ -73,26 +78,35 @@ func NewWorker(store *storage.Store, idxMgr index.TenantIndexer, opts Options) * if opts.Logger == nil { 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, - pausedHours: opts.PausedHours, + w := &Worker{ + store: store, + idxMgr: idxMgr, + logger: opts.Logger, + queue: make(chan Job, opts.QueueSize), + done: make(chan struct{}), + workers: opts.Workers, + langs: opts.Langs, } + w.pausedHours.Store(opts.PausedHours) + return w +} + +// SetPausedHours updates the pause window at runtime, without requiring a +// worker/process restart. Pass nil to disable pausing. Safe to call +// concurrently with running workers (e.g. from a SIGHUP reload handler). +func (w *Worker) SetPausedHours(hours *[2]int) { + w.pausedHours.Store(hours) } // 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 { + hours := w.pausedHours.Load() + if hours == nil { return false } - start, end := w.pausedHours[0], w.pausedHours[1] + start, end := hours[0], hours[1] if start == end { // Degenerate / no-op window — never pause. return false