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
|
// 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
|
||||||
|
|||||||
@@ -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
|
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
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user