feat(PROJ-56): Last-Entzerrung für OCR und IMAP-Sync

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.
This commit is contained in:
sysops
2026-06-22 14:21:09 +02:00
parent a55faf74b1
commit 4dbf27cc1d
7 changed files with 261 additions and 12 deletions
+37 -1
View File
@@ -26,6 +26,11 @@ type Scheduler struct {
mu sync.Mutex
running map[int64]bool // in-memory guard against concurrent syncs
// PROJ-56: deterministic per-account jitter window in seconds. 0 disables
// jitter. The offset for an account is derived solely from its ID so the
// effective sync time is stable across ticks (no rand() per tick).
jitterSeconds int
cancel context.CancelFunc
}
@@ -39,6 +44,32 @@ func NewScheduler(store *Store, importer *Importer, logger *slog.Logger) *Schedu
}
}
// SetJitterSeconds configures the deterministic per-account sync jitter window
// (PROJ-56). 0 disables jitter (legacy behaviour: sync exactly at interval).
// Negative values are clamped to 0.
func (s *Scheduler) SetJitterSeconds(seconds int) {
if seconds < 0 {
seconds = 0
}
s.jitterSeconds = seconds
}
// jitterOffset returns the deterministic delay added on top of the sync
// interval for a given account. The offset is in [0, jitterSeconds) and
// depends only on the account ID, so it never changes between ticks.
func (s *Scheduler) jitterOffset(accountID int64) time.Duration {
if s.jitterSeconds <= 0 {
return 0
}
// Account IDs are positive sequential integers; a simple modulo spreads
// them evenly across the window. Use the absolute value defensively.
id := accountID
if id < 0 {
id = -id
}
return time.Duration(id%int64(s.jitterSeconds)) * time.Second
}
// SetAuditLogger wires an audit.Logger into the scheduler so that
// UIDVALIDITY-reset events (PROJ-45) are persisted as tenant-visible
// audit entries. Optional — when nil, only structured logs are emitted.
@@ -123,7 +154,12 @@ func (s *Scheduler) checkAccounts(ctx context.Context) {
lastSync = *acc.LastSyncAt
}
if now.Sub(lastSync) >= 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()