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:
co-authored by
Claude Sonnet 4.6
parent
be48a99af9
commit
dba9939880
@@ -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
|
||||
|
||||
@@ -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
|
||||
Executable
+42
@@ -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"
|
||||
@@ -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
|
||||
|
||||
+26
-12
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user