feat(PROJ-56): OCR-Pausenfenster per SIGHUP-Reload statt Restart

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 <noreply@anthropic.com>
This commit is contained in:
sysops
2026-06-24 14:46:13 +02:00
co-authored by Claude Sonnet 4.6
parent be48a99af9
commit dba9939880
5 changed files with 119 additions and 12 deletions
+30
View File
@@ -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 // Graceful shutdown
quit := make(chan os.Signal, 1) quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
@@ -485,6 +496,25 @@ func main() {
httpServer.Shutdown(ctx) 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. // submitToWorker parses a raw email and submits it to the async index worker.
// tenantID may be nil for global context. // tenantID may be nil for global context.
// If ocrWorker is non-nil and the mail has attachments, an OCR job is also // If ocrWorker is non-nil and the mail has attachments, an OCR job is also
+20
View File
@@ -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:0006: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
+42
View File
@@ -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 <from_hour> <to_hour>
# 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 <from_hour> <to_hour>|stop}" >&2
exit 1
;;
esac
systemctl reload "$SERVICE"
+1
View File
@@ -632,6 +632,7 @@ Group=${AM_USER}
AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN AmbientCapabilities=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_NET_ADMIN
ExecStart=${INSTALL_DIR}/archivmail --config ${CONFIG_DIR}/config.yml ExecStart=${INSTALL_DIR}/archivmail --config ${CONFIG_DIR}/config.yml
ExecReload=/bin/kill -HUP \$MAINPID
Restart=on-failure Restart=on-failure
RestartSec=5 RestartSec=5
StandardOutput=journal StandardOutput=journal
+26 -12
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"archivmail/internal/index" "archivmail/internal/index"
@@ -38,7 +39,11 @@ type Worker struct {
// hour falls inside it, workers stop consuming the queue (jobs stay buffered // 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. // in the channel / as ocr_status='pending' in the DB) until it reopens.
// nil = never pause (legacy behaviour). // 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 // 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 { if opts.Logger == nil {
opts.Logger = slog.Default() opts.Logger = slog.Default()
} }
return &Worker{ w := &Worker{
store: store, store: store,
idxMgr: idxMgr, idxMgr: idxMgr,
logger: opts.Logger, logger: opts.Logger,
queue: make(chan Job, opts.QueueSize), queue: make(chan Job, opts.QueueSize),
done: make(chan struct{}), done: make(chan struct{}),
workers: opts.Workers, workers: opts.Workers,
langs: opts.Langs, langs: opts.Langs,
pausedHours: opts.PausedHours,
} }
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 // isPaused reports whether the OCR worker should currently hold off processing
// because the local time falls inside the configured pause window. // because the local time falls inside the configured pause window.
// Supports wrap-around windows where start > end (e.g. [22, 6]). // Supports wrap-around windows where start > end (e.g. [22, 6]).
func (w *Worker) isPaused(now time.Time) bool { func (w *Worker) isPaused(now time.Time) bool {
if w.pausedHours == nil { hours := w.pausedHours.Load()
if hours == nil {
return false return false
} }
start, end := w.pausedHours[0], w.pausedHours[1] start, end := hours[0], hours[1]
if start == end { if start == end {
// Degenerate / no-op window — never pause. // Degenerate / no-op window — never pause.
return false return false