Add reconcile loop scheduler #69
Merged
naps62-yolo
merged 5 commits from 2026-08-22 22:00:12 +01:00
issue/21-reconcile-loop into main
Labels
Clear labels
area/api
area/ci
area/compat
area/core
area/daemon
area/db
area/dl
area/indexer
area/infra
area/meta
area/parse
area/probe
area/subs
area/web
difficulty/easy
difficulty/hard
difficulty/moderate
difficulty/trivial
phase/1-skeleton
phase/2-logic
phase/3-sourcing
phase/4-movies
phase/5-ui
phase/6-tv
phase/7-people
phase/8-compat
phase/9-subtitles
type/bug
type/chore
type/feature
type/test
arr-api
CI and test harness
arr-compat
arr-core
arr-daemon
arr-db
arr-dl
arr-indexer
workspace and tooling
arr-meta
arr-parse
arr-probe
arr-subs crate: providers, extraction, translation, sync
web/
bounded, obvious approach, few files
subtle correctness or cross-cutting
multiple files, judgement, an interface to design
one file, mechanical, no design decisions
workspace, CI, config, database, empty API and SPA
parsing and policy engine, pure, no network
TMDB and Prowlarr, read-only
movies end to end
search, buckets, library views, queues
seasons, episodes, tracking, derived status
owner tags and notifications
Jellyseerr shim
build order: subtitles, DESIGN.md §15
No labels
Milestone
No items
No Milestone
Projects
Clear projects
No projects
No Assignees
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: yolo/arr#69
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Issue
Closes #21
Test plan
just ci(153 tests passed)Reviewed
a292f2f. Four anchored findings.Nothing outside the tests calls
register(), so the loop the daemon actually starts has no actions in it and every tick is a no-op. That reads as deliberate for a scheduler-only issue — flagging it only so it isn't mistaken for wired-up reconciliation.CI is still pending on this SHA; I didn't run anything from the branch.
@@ -119,0 +128,4 @@.await;let _ = shutdown_tx.send(true);reconcile_task.await?;A panic inside any action aborts the whole spawned task, taking all four lanes with it. Nothing observes that:
axum::servekeeps running, no log is emitted, and theJoinErroronly surfaces here — after shutdown. The daemon then serves HTTP for hours with reconciliation dead and looks healthy.Either catch per-action panics in
run_tick(AssertUnwindSafe(...).catch_unwind(), log, count as a failure), or select overreconcile_taskalongside serve so the process exits when the loop dies.@@ -0,0 +184,4 @@if *shutdown.borrow() {return;}self.run_tick(tick).await;No timeout around the action call. A reconciler blocked on Transmission or Prowlarr with no read timeout stalls its lane indefinitely, and because
maindoesreconcile_task.awaitwith no bound, SIGTERM never completes — systemd ends up killing the process on its stop timeout.Wrap in
tokio::time::timeout(something under the lane interval) and count an expiry as a failure.@@ -0,0 +204,4 @@action = %outcome.action,"reconcile action");report.actions_taken += 1;gaps_foundandactions_takenare incremented in the same loop over the sameVec, so they are equal by construction and the log line reports one number twice. Either dropgaps_found, or letActionreport gaps it found but chose not to act on (blocked, cooling off, no eligible release) so the two can diverge.@@ -0,0 +225,4 @@gaps_found = report.gaps_found,actions_taken = report.actions_taken,failures = report.failures,"reconcile tick"This fires on every tick regardless of outcome, so an idle instance writes ~2880 all-zero lines a day from the reconcile lane alone, plus the other three. Log at info only when
gaps_found + failures > 0, and drop the quiet case to debug.Reviewed
d8455df. All four earlier findings are addressed — the loop is supervised, the duplicate metric is gone, actions are bounded, and idle ticks dropped to debug. One new finding on the timeout constant.@@ -0,0 +20,4 @@const METADATA_START_DELAY: Duration = Duration::from_secs(5);const REAPER_START_DELAY: Duration = Duration::from_secs(10);const RSS_START_DELAY: Duration = Duration::from_secs(20);const ACTION_TIMEOUT: Duration = Duration::from_secs(25);One 25 s timeout covers every lane. It fits reconcile (30 s interval), but the metadata lane runs daily and a full TMDB refresh over the library is minutes of work, so that lane will time out on every tick and never complete once there is real data behind it — logged as a failure with no partial progress.
Make the timeout per-lane, e.g. derived from the lane interval (
interval - slack, or an explicit value perTick), rather than one constant.Reviewed
76e398d. No findings. Per-lane timeouts address the last one, and racingrun_tickagainst shutdown is a genuine improvement over waiting out an in-flight action.