From affdbd1d3770dd7f0cd36413109eb64922ea7207 Mon Sep 17 00:00:00 2001 From: cplieger <917744+cplieger@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:43:19 +0200 Subject: [PATCH] feat: schedule the next pass from the previous one across restarts The interval timer restarted its period at container boot: with the startup pass skipped, the first tick landed one full interval after boot rather than one interval after the recorded previous run, so a restart delayed the cadence by up to one interval. scheduler v4.2.0 adds the phase half of the last-run record, and this wires it in: RunLoop's first tick now fires at the record's remaining time, so a run 30 minutes old on a 1h schedule ticks 30 minutes after boot. Restarts neither add runs nor delay the cadence. The README alert example's absence window re-derives back down accordingly, since a restart no longer stretches the longest legal gap: 50h to 26h (one DEEP_SCAN_INTERVAL plus pass runtime, plus margin). --- README.md | 22 ++++++++++------------ go.mod | 2 +- go.sum | 4 ++-- internal/deepscan/deepscan.go | 12 ++++++++---- 4 files changed, 21 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 923830e..25747a3 100644 --- a/README.md +++ b/README.md @@ -185,30 +185,28 @@ groups: # stays connected and healthy, and neither notices. This one fires on # silence instead, keyed on the reconcile plane's completion line. # - # 50h, not 30h, and the size comes from the RESTART behaviour rather than - # the interval. The startup pass runs only when the last completion is - # already older than one interval; otherwise it starts a fresh interval - # from boot. So a restart shortly before the deadline defers the next pass - # by almost a full interval, and two consecutive completions can sit ~47h - # apart with nothing wrong. 50h absorbs one such restart plus runtime. It - # does NOT absorb repeated restarts inside the interval, which no - # completion-only window can; a container that restart-loops needs its own - # alert, not a wider window here. + # 26h: one interval plus runtime. The startup pass runs only when the + # last completion is already older than one interval, and the schedule + # keeps its phase across restarts (the last-run record on /config + # carries it), so two consecutive completions sit at most ~24h plus a + # pass's runtime apart, restarts included. A container that + # restart-loops faster than the time left in its period still starves + # the pass, and this window is what catches it. # # DROP this rule if you set DEEP_SCAN_INTERVAL to off/disabled/0, which # runs the app WebSocket-only with no periodic pass, so there is no # heartbeat to miss and the rule would fire forever. - alert: PlexLanguageSyncDeepScanStalled expr: | - absent_over_time({container="plex-language-sync"} |= `deep analysis completed` [50h]) + absent_over_time({container="plex-language-sync"} |= `deep analysis completed` [26h]) for: 1h labels: severity: warning annotations: - summary: "no plex-language-sync deep-analysis heartbeat in 50h" + summary: "no plex-language-sync deep-analysis heartbeat in 26h" description: > The reconcile plane logs `deep analysis completed` at the end of - every pass, and none has arrived in 50h (DEEP_SCAN_INTERVAL defaults + every pass, and none has arrived in 26h (DEEP_SCAN_INTERVAL defaults to 24h). The usual cause is the periodic safety net wedged, so replayed history items stop being reconciled while the container stays healthy and connected. Check the container logs for the last diff --git a/go.mod b/go.mod index 2e83baa..5e217e4 100644 --- a/go.mod +++ b/go.mod @@ -13,7 +13,7 @@ require ( github.com/cplieger/langtag/v2 v2.0.1 github.com/cplieger/plexapi/v2 v2.0.7 github.com/cplieger/runesafe/v2 v2.0.1 - github.com/cplieger/scheduler/v4 v4.1.0 + github.com/cplieger/scheduler/v4 v4.2.0 github.com/cplieger/slogx v1.6.4 golang.org/x/sync v0.22.0 pgregory.net/rapid v1.3.0 diff --git a/go.sum b/go.sum index c394550..7bce367 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/cplieger/plexapi/v2 v2.0.7 h1:JqdhD2DR2lIXjKOizbQjq2z30IrqhREjvYhZd45 github.com/cplieger/plexapi/v2 v2.0.7/go.mod h1:8og/b/O2B4U+wlQaF2r2TkMqR7k1vRSsTBYejfCoGYY= github.com/cplieger/runesafe/v2 v2.0.1 h1:TWTX4OPjz4pqgidBavkbglEh3XdwrJgq8dC/LFf89u0= github.com/cplieger/runesafe/v2 v2.0.1/go.mod h1:cB0JYmCjfU+VzKAFWnhYfJL8OK0+d196ZSwh+Ohr+0k= -github.com/cplieger/scheduler/v4 v4.1.0 h1:au9QsJ/nVNO1fAuHZn6E7IQf9IYf9UXc8ivQNp+OvHs= -github.com/cplieger/scheduler/v4 v4.1.0/go.mod h1:vKd4UEjZHOgi0JcASWhPo9gCDPgN4OWKXOZjo7r49qI= +github.com/cplieger/scheduler/v4 v4.2.0 h1:eSi4KUkKJChczPF0vyNAtujep4/to96wqW+gNwZ8TEM= +github.com/cplieger/scheduler/v4 v4.2.0/go.mod h1:vKd4UEjZHOgi0JcASWhPo9gCDPgN4OWKXOZjo7r49qI= github.com/cplieger/slogx v1.6.4 h1:3VqDvuVPI15rK6RZ6dmnnjfQYhTxiDOWnFMZHSOxJZ0= github.com/cplieger/slogx v1.6.4/go.mod h1:Ac6vL8Ymaa8USg++ELEQKwNIBgk0nG2HWO+6wDuQNmw= github.com/cplieger/xmlx v1.0.4 h1:4j/igac38D0ZGShrl1wj30Xj3cB8ZjuA+T2q0hzRfJw= diff --git a/internal/deepscan/deepscan.go b/internal/deepscan/deepscan.go index 37bf7e1..c0ae9f8 100644 --- a/internal/deepscan/deepscan.go +++ b/internal/deepscan/deepscan.go @@ -186,13 +186,17 @@ func (s *Scheduler) Run(ctx context.Context) { // docker-*-scheduler convention). FireOnStart is false: the conditional // startup pass above already handled the immediate run (RunLoop's // unconditional FireOnStart would ignore the last-run stamp and double-run - // on a recent restart). Overlapping ticks collapse via the singleflight in - // deepAnalysis, RunLoop is sequential, so no wall-clock slot-dedup is needed - // and no local wall-clock time is read. + // on a recent restart), and FirstDelay phases the first tick from the + // recorded previous pass so a restart does not delay the cadence. + // Overlapping ticks collapse via the singleflight in deepAnalysis, RunLoop + // is sequential, so no wall-clock slot-dedup is needed. scheduler.RunLoop(ctx, func(ctx context.Context) { slog.Info("scheduled deep analysis starting") s.deepAnalysis(ctx) - }, scheduler.LoopOptions{Interval: s.cfg.Interval}) + }, scheduler.LoopOptions{ + Interval: s.cfg.Interval, + FirstDelay: s.stamp.Remaining(s.cfg.Interval, time.Now(), scheduler.CountFailed), + }) } // deepAnalysis runs the recent-history replay + recently-added sweep,