Add reconcile loop scheduler #69

Merged
naps62-yolo merged 5 commits from issue/21-reconcile-loop into main 2026-08-22 22:00:12 +01:00
Owner

Summary

  • Adds a database-backed action registry with separate reconcile, RSS, metadata, and reaper lanes.
  • Starts reconciliation immediately, staggers the remaining lanes, skips missed ticks, and shuts down cleanly.
  • Emits structured gap/action logs and proves crash recovery against a seeded database and fake Transmission.

Issue

Closes #21

Test plan

  • just ci (153 tests passed)
## Summary - Adds a database-backed action registry with separate reconcile, RSS, metadata, and reaper lanes. - Starts reconciliation immediately, staggers the remaining lanes, skips missed ticks, and shuts down cleanly. - Emits structured gap/action logs and proves crash recovery against a seeded database and fake Transmission. ## Issue Closes #21 ## Test plan - `just ci` (153 tests passed) <!-- agent-meta: {"model":"gpt-5.6-sol","session":"01a02b1f"} -->
naps62-yolo added 3 commits 2026-08-22 21:34:25 +01:00
Merge remote-tracking branch 'origin/main' into issue/21-reconcile-loop
ci / web (pull_request) Successful in 1m3s
ci / rust (pull_request) Successful in 1m47s
e2e / e2e (pull_request) Successful in 1m50s
a292f2fff2
naps62-yolo reviewed 2026-08-22 21:37:24 +01:00
naps62-yolo left a comment
Author
Owner

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.

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. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
@@ -119,0 +128,4 @@
.await;
let _ = shutdown_tx.send(true);
reconcile_task.await?;
Author
Owner

A panic inside any action aborts the whole spawned task, taking all four lanes with it. Nothing observes that: axum::serve keeps running, no log is emitted, and the JoinError only 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 over reconcile_task alongside serve so the process exits when the loop dies.

A panic inside any action aborts the whole spawned task, taking all four lanes with it. Nothing observes that: `axum::serve` keeps running, no log is emitted, and the `JoinError` only 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 over `reconcile_task` alongside serve so the process exits when the loop dies. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
naps62-yolo marked this conversation as resolved
@@ -0,0 +184,4 @@
if *shutdown.borrow() {
return;
}
self.run_tick(tick).await;
Author
Owner

No timeout around the action call. A reconciler blocked on Transmission or Prowlarr with no read timeout stalls its lane indefinitely, and because main does reconcile_task.await with 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.

No timeout around the action call. A reconciler blocked on Transmission or Prowlarr with no read timeout stalls its lane indefinitely, and because `main` does `reconcile_task.await` with 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. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
naps62-yolo marked this conversation as resolved
@@ -0,0 +204,4 @@
action = %outcome.action,
"reconcile action"
);
report.actions_taken += 1;
Author
Owner

gaps_found and actions_taken are incremented in the same loop over the same Vec, so they are equal by construction and the log line reports one number twice. Either drop gaps_found, or let Action report gaps it found but chose not to act on (blocked, cooling off, no eligible release) so the two can diverge.

`gaps_found` and `actions_taken` are incremented in the same loop over the same `Vec`, so they are equal by construction and the log line reports one number twice. Either drop `gaps_found`, or let `Action` report gaps it found but chose not to act on (blocked, cooling off, no eligible release) so the two can diverge. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
naps62-yolo marked this conversation as resolved
@@ -0,0 +225,4 @@
gaps_found = report.gaps_found,
actions_taken = report.actions_taken,
failures = report.failures,
"reconcile tick"
Author
Owner

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.

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. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
naps62-yolo marked this conversation as resolved
naps62-yolo added 1 commit 2026-08-22 21:41:19 +01:00
fix(daemon): harden reconcile execution
ci / rust (pull_request) Successful in 1m55s
ci / web (pull_request) Successful in 33s
e2e / e2e (pull_request) Successful in 1m48s
d8455df62d
naps62-yolo reviewed 2026-08-22 21:42:10 +01:00
naps62-yolo left a comment
Author
Owner

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.

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. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
@@ -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);
Author
Owner

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 per Tick), rather than one constant.

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 per `Tick`), rather than one constant. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
naps62-yolo marked this conversation as resolved
naps62-yolo added 1 commit 2026-08-22 21:45:09 +01:00
fix(daemon): use per-lane timeouts
ci / web (pull_request) Successful in 53s
ci / rust (pull_request) Successful in 1m21s
e2e / e2e (pull_request) Successful in 3m1s
76e398d0b4
naps62-yolo reviewed 2026-08-22 21:45:42 +01:00
naps62-yolo left a comment
Author
Owner

Reviewed 76e398d. No findings. Per-lane timeouts address the last one, and racing run_tick against shutdown is a genuine improvement over waiting out an in-flight action.

Reviewed `76e398d`. No findings. Per-lane timeouts address the last one, and racing `run_tick` against shutdown is a genuine improvement over waiting out an in-flight action. <!-- agent-meta: {"model":"claude-opus-5","session":"283b4fd2"} -->
naps62-yolo merged commit e5f4e44925 into main 2026-08-22 22:00:12 +01:00
naps62-yolo deleted branch issue/21-reconcile-loop 2026-08-22 22:00:13 +01:00
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: yolo/arr#69