Skip to content

fix(scheduler): pause windows during disk holds, unlimited label, resume across restart (WI-2033) - #249

Merged
thedancingdeveloper merged 7 commits into
mainfrom
wi-2033-scheduler-followups
Oct 20, 2026
Merged

thedancingdeveloper merged 7 commits into
mainfrom
wi-2033-scheduler-followups

Conversation

@thedancingdeveloper

Copy link
Copy Markdown
Collaborator

WI-2033: the follow-ups from the #245 review. Written by a DeepSeek worker.

🤖 Generated with Claude Code

@thedancingdeveloper

Copy link
Copy Markdown
Collaborator Author

Review of e491a71: 1 blocker (a restart undoes the overlap fix), plus 2 should-fix items. GitHub rust, frontend and e2e were still pending; CodeQL passes.

Scope. This branch is #245's head (07e866b, already reviewed) plus one commit, e491a71. Landing #249 supersedes #245. There are no Cargo.lock changes.

Checks run locally:

  • nzb-core schedule passes 16/16, nzb-web --lib passes 281/281, and --test schedule_api passes 11/11.
  • cargo clippy --workspace --all-targets -D warnings and fmt --check are clean.
  • ng test passes 275/275 and lint is clean.

What e491a71 gets right

  • Additive holds. PauseHolds { user, disk, schedule } replaces the single owner. Each holder lifts only its own flag through resume_hold, and the queue resumes only when !any(). A window that opens during a disk hold now takes effect when the guard lets go, which was my should-fix on feat: download scheduler (WI-1721) #245. A test covers it at both the queue-manager and API level.
  • Occurrence key. pause_window = "rule@start-date", sorted and persisted as schedule_pause_window. Re-ticking the same window isn't a new window, so a manual resume survives a restart (tested). An overnight tail keys to its start date (tested). saved lets a settings save adopt the open window instead of re-opening it.
  • Legacy records. A globally_paused = true record with an empty owner and no automatic hold restores as a user pause.
  • The pause_all regression the worker caught. pause_for_hold clears pause_until on every pause, including one that lands while the queue is already held, so a second pause can't inherit an earlier "pause for…" deadline. It's correct, but I found no test that pins it. Add one: pause_for(60), then pause_all(), then assert pause_until is None and that the old timer doesn't resume.
  • Unlimited label. An unlimited window now renders as "unlimited", not "cap 0 B/s", and a frontend test covers it.

Blocker

  1. A restart during a disk hold that overlaps a window drops the schedule hold. reason_owner() ranks user > schedule > disk, so with disk and schedule both set, the persisted owner is "schedule". restore_from_db then rebuilds schedule: disk_hold.is_none() && pause_owner == PAUSE_OWNER_SCHEDULE, which is false because the disk hold is restored too. The occurrence key is restored as well, so the first tick sees the same window, no rising edge, and the hold is never re-added. When the guard later lifts, resume_hold finds no hold and downloads run through the rest of the pause window. That is exactly the overlap this commit fixes, and a restart undoes it. Old code never persisted both, so the disk_hold.is_none() guard isn't needed for legacy records. Use schedule: pause_owner == PAUSE_OWNER_SCHEDULE, or better, persist the three flags explicitly instead of deriving them from a ranked owner string. Add a test: a disk hold plus a window, restore_from_db, lift the disk hold, assert still paused.

Should fix

  1. The timed pause's auto-resume lifts every hold. The pause_for timer calls resume_all(), which clears user, disk and schedule. Two cases:

    • "Pause for 2h" at 21:00 with a 22:00 window: the window opens while the user hold covers it, so per the design it takes no hold. The timer at 23:00 then resumes into the window.
    • A timed pause expiring during a disk hold resumes with the disk still full, until the guard trips again.

    An expiring timer is the user's own pause ending, not a manual override, so it should call resume_hold(|h| h.user = false), and ideally re-arm the schedule hold if a window is open.

  2. Overlapping windows re-pause when one closes. The occurrence key is the joined set of every open pause rule, and any change counts as rising. With A (20:00–24:00) and B (22:00–02:00) both open and a manual resume, A closing at midnight changes the key from A@d,B@d to B@d, which reads as a new window and pauses again. Rising should mean "the new key contains an occurrence not in the old one" (a set difference), not "the key changed".

Nit

  • reason_owner and reason rank disk and schedule differently (owner: schedule before disk; reason: disk before schedule). That's harmless once the blocker fix stops deriving flags from the owner, but worth a comment.

thedancingdeveloper added a commit that referenced this pull request Oct 20, 2026
…ependent

Address the #249 review: a restart undid the disk/schedule overlap, a timed
pause expiring lifted every hold, and one of two overlapping windows closing
re-paused a manual resume.

Blocker — restart dropped the schedule hold during a disk hold. Holds were
persisted as one ranked owner string, so a disk hold overlapping a window
restored only one of them; the guard lifting its own hold then resumed
downloads through the rest of the window. Each hold is now stored as its own
flag, and the window bookkeeping as its own keys; the owner string is kept
only to read records written before this change.

Should-fix — a timed pause expiring lifted every hold. `pause_for`'s timer
called `resume_all`, which cleared the user, disk and schedule holds. It now
lifts only the user's, so a window that opened underneath the timed pause
takes effect and a disk hold stays. An indefinite `Pause All` still takes
over the windows it covers, as before.

Should-fix — overlapping windows re-paused on any change. The occurrence key
was the joined set of open rules, so one of two windows closing read as a new
window. A decision now carries the occurrences individually, and only an
occurrence not seen before is a new window. A manual resume records the
occurrences it overrode, so a settings save or the next tick cannot restore
them; the next window still pauses.

The `saved` flag on `apply_schedule` is gone: the occurrence bookkeeping
already distinguishes the same window from a new one, and keeping it meant a
first save never admitted its own window.

Tests: a restart during a disk hold keeps the overlapping window (reproduced
against the previous commit before fixing), a timed pause expiring pauses into
an open window and leaves a disk hold, one of two overlapping windows closing
does not re-pause (queue-manager and API level), a second pause cancels the
first timer's auto-resume, and the evaluator reports per-rule occurrences.

Refs WI-2033
@thedancingdeveloper

Copy link
Copy Markdown
Collaborator Author

Re-review of 213f732: the three reported items are fixed, but 2 new blockers: a regression in the restart handling, and CI is red against current main.

Checks run locally on the PR head alone:

  • nzb-core schedule passes 17/17, nzb-web --lib passes 288/288, and --test schedule_api passes 12/12.
  • cargo clippy --workspace --all-targets -D warnings and fmt --check are clean.

GitHub, which builds the merge ref with main, fails rust, frontend and e2e. See blocker 2.

The three items from my last review: fixed

  1. A restart during a disk hold keeps the window (was the blocker). Each hold is now persisted as its own flag (user_pause_hold, schedule_pause_hold, plus the disk timestamp), and the window bookkeeping as its own JSON lists. The ranked owner string is only read for legacy records. I verified this with a scratch test using a real restart, a file-backed Database and a fresh QueueManager: a disk hold plus a window, restore, lift the disk hold, and the queue is still paused. ✔
  2. A timed pause expiring lifts only the user's hold. The pause_for timer now calls expire_user_hold(). That clears user and user_paused_over, re-derives the schedule hold from the open windows, and resumes only if nothing is left. A window that opened under the timed pause takes effect, and a disk hold stays. ✔
  3. Overlapping windows. Decisions carry per-rule occurrences (pause_windows), and only an occurrence not in triggered_windows is a new window. With A and B open and a manual resume, A closing no longer re-pauses. Tested at queue-manager and API level. ✔

Dropping the saved flag in favour of occurrence bookkeeping is a good simplification. There's also a test for the earlier pause_all/timer regression: a second pause cancels the first timer's auto-resume.

Blockers

  1. A manual Resume inside a window no longer survives a real restart. restore_from_db loads the persisted holds and window lists into pause_holds only if was_paused. After a manual Resume, globally_paused is false, so triggered_windows and resumed_windows are never loaded into a fresh process. The first tick then sees the still-open window as newly opened and pauses again. I reproduced it with the same real-restart scratch test:

    1. Open a window.
    2. resume_all().
    3. Drop the manager.
    4. Build a new QueueManager on the same DB file, then restore_from_db() and apply_schedule(same window).

    The result: paused ("fresh manager re-paused a resumed window"). That was e491a71's headline behaviour.

    The existing a_manual_resume_inside_a_window_survives_a_restart (and every other "restart" test in global_pause_tests) doesn't catch it. They use Database::open_memory() and let restarted = Arc::clone(&manager), so the in-memory pause_holds survives no matter what restore_from_db reads. Always restore the window lists (open_windows, triggered_windows, resumed_windows, user_paused_over), and only gate the paused flags on was_paused. Then make the restart tests construct a fresh QueueManager over a file-backed database, as the disk-plus-window case shows they need to.

  2. The PR doesn't build on current main. feat(ui): toasts with undo for destructive actions (WI-1721) #248 (0b9f013) landed and replaced MatSnackBar in settings-view.component.ts with ToastService (this.toast); there is no snack field any more. The schedule UI in this PR still calls this.snack, for example showHttpError(this.snack, err, 'Failed to save the s…') at line 2585 of the merged file. Git merges it cleanly, but the bundle fails: "Application bundle generation failed" in both the rust job's frontend build and frontend, and e2e fails with them. Rebase onto main and switch the new calls to this.toast (success, error and showHttpError(this.toast, …)).

Nit

  • expire_user_hold can call pause_jobs_for_global_hold() while the queue is already globally paused under the user hold, in the case where a window opened under the timed pause. That looks idempotent, but it's worth a guard (&& !paused) for symmetry with apply_schedule_hold.

…1721)

Add a download scheduler: rules name weekdays and a window and either pause
downloads, cap the speed or take servers out of rotation. Windows may cross
midnight and belong to the day they start on.

- `nzb-core::schedule` evaluates rules as a pure function of wall-clock time,
  so window edges and midnight are tested without a clock or a queue.
- `nzb-web` applies the decision every minute boundary and immediately on a
  config save. Pause ownership is tracked and persisted, so the schedule
  only lifts a pause it applied; a user pause, a timed pause or a disk-space
  hold survives. Speed caps and server exclusions are overlays: the
  configured limit and `enabled` flags are never rewritten.
- `GET/PUT /api/config/schedule` validates every rule before saving, assigns
  ids and returns what is active now.
- Settings gains a Scheduler tab (enable toggle, rule list, day/window/action
  editors, live status); the queue shows "paused · schedule" and the reason
  survives a reload.
- Config example and CONFIGURATION.md document the section, including TZ.

Tests: 13 evaluator unit tests, monitor and queue-manager ownership tests,
8 API integration tests, settings and queue component specs, and Playwright
journeys for add/save/reload/remove and validation.
The scheduler tests asserted the stored schedule cap and the configured
limit, but never the value the downloader actually reads. A cap that was
recorded and never applied would have passed.

Assert `get_speed_limit()` through a cap being applied, lifted when the
window ends, and overridden by an explicit 0 (unlimited). Also cover a
disabled schedule releasing a cap and a server exclusion it had applied,
since `apply_schedule` substitutes an idle decision in that path.

Refs WI-1721
Two behaviour blockers from the review of #245.

A manual Resume inside a pause window was undone within a minute, because
apply_schedule was level-triggered and re-paused on every tick. The pause is
now edge-triggered: the window pauses when it opens and leaves a manual resume
alone until the next window. Ownership still decides who may lift it, and a
window restored after a restart still lifts its own pause when it ends.

A speed_limit_bps = 0 rule promised unlimited but evaluate() emitted None,
which apply_effective_speed_limit reads as "no scheduled cap, use the
configured general.speed_limit_bps". It now emits Some(0), so an unlimited
window overrides both the configured limit and any lower cap.

Tests: evaluator distinguishes unlimited from no-cap-rule; queue manager
covers manual resume holding inside a window, a window opening while the user
is paused, restart inside a window, and disabling the schedule; API tests
cover the same two bugs end to end. Docs describe the edge-triggered pause and
the 0-cap precedence.

Refs WI-1721
…llowed

A pause window that opened while the disk-space guard held the queue never
took effect: the pause had a single owner, so the guard lifting its hold
resumed downloads straight through the window. A manual resume inside a
window was also lost on restart, because the queue could not tell "the same
window, ticked again" from "a newly opened window".

- Replace the single pause owner with additive holds (user, disk, schedule).
  The queue is paused while any hold is set; each lifts only its own, so a
  window that opens during a disk hold keeps downloads paused once the hold
  lifts, and a window ending never lifts a user pause or a disk hold.
- Give the evaluator's decision an occurrence key (`rule id@window start
  date` for each open pause rule, sorted) and persist it. The schedule pauses
  only on a new occurrence, so a restart inside an open window does not
  re-pause what the user resumed, while the next window pauses again.
- Distinguish a settings save from a monitor tick when applying a decision,
  so saving inside a window adopts the open window instead of re-opening it.
- Report `paused` from the API whenever an open window is keeping the queue
  paused, including when a disk hold overlaps it.
- Render an unlimited window (`0`) as "unlimited" in settings instead of
  "cap 0 B/s", which read as a cap that stops every download.
- Keep cancelling a pending timed resume on every pause, as before: a second
  pause must not inherit the previous one's deadline.

Adds evaluator, queue-manager and API tests for the overlap, the restart and
the occurrence key, and a frontend test for the unlimited cap label.

Refs WI-2033
…ependent

Address the #249 review: a restart undid the disk/schedule overlap, a timed
pause expiring lifted every hold, and one of two overlapping windows closing
re-paused a manual resume.

Blocker — restart dropped the schedule hold during a disk hold. Holds were
persisted as one ranked owner string, so a disk hold overlapping a window
restored only one of them; the guard lifting its own hold then resumed
downloads through the rest of the window. Each hold is now stored as its own
flag, and the window bookkeeping as its own keys; the owner string is kept
only to read records written before this change.

Should-fix — a timed pause expiring lifted every hold. `pause_for`'s timer
called `resume_all`, which cleared the user, disk and schedule holds. It now
lifts only the user's, so a window that opened underneath the timed pause
takes effect and a disk hold stays. An indefinite `Pause All` still takes
over the windows it covers, as before.

Should-fix — overlapping windows re-paused on any change. The occurrence key
was the joined set of open rules, so one of two windows closing read as a new
window. A decision now carries the occurrences individually, and only an
occurrence not seen before is a new window. A manual resume records the
occurrences it overrode, so a settings save or the next tick cannot restore
them; the next window still pauses.

The `saved` flag on `apply_schedule` is gone: the occurrence bookkeeping
already distinguishes the same window from a new one, and keeping it meant a
first save never admitted its own window.

Tests: a restart during a disk hold keeps the overlapping window (reproduced
against the previous commit before fixing), a timed pause expiring pauses into
an open window and leaves a disk hold, one of two overlapping windows closing
does not re-pause (queue-manager and API level), a second pause cancels the
first timer's auto-resume, and the evaluator reports per-rule occurrences.

Refs WI-2033
… toast service

Two re-review blockers on #249.

A manual Resume inside a window did not survive a real restart: the window
lists were only loaded when `globally_paused` was true, so after a resume the
first tick read the still-open occurrence as newly opened and paused again.
The lists (`open_windows`, `triggered_windows`, `resumed_windows`,
`user_paused_over`) are now always restored; only the live holds are gated on
`was_paused`.

The restart tests built their "restarted" manager by cloning the `Arc`, so the
in-memory holds and an in-memory database were shared and no persisted state
was ever read. They now use a file-backed database and a fresh
`QueueManager`, which is what makes the blocker reproducible; the
manual-resume test fails on the previous restore logic and passes now.

The schedule settings form still called `this.snack`, which #248 replaced with
`this.toast` (`ToastService`). The bundle failed on the merged tree; the three
calls now go through `this.toast` and the spec asserts on it.

Also guards `expire_user_hold` against pausing an already-paused queue, as
`apply_schedule_hold` does.
@thedancingdeveloper
thedancingdeveloper force-pushed the wi-2033-scheduler-followups branch from 213f732 to f51caf2 Compare October 20, 2026 17:53
restore_from_db published every hold setting, and the schedule monitor's
startup tick did the same even when its decision changed nothing. A fresh
database therefore gained the default pause rows on first start, which
collided with the e2e seed's own globally_paused row.

Only the in-memory gate is published now on load, and a tick whose holds are
unchanged writes nothing. Both paths have a regression test.
@thedancingdeveloper

Copy link
Copy Markdown
Collaborator Author

Re-review of d2d6bdf: no blockers. Both blockers from my last review are fixed and verified, and CI is green.

GitHub passes rust, frontend, e2e, container-smoke, CodeQL and both Analyze jobs.

main has since moved 4 commits past this branch's base, landing #246 (keyboard shortcuts), which also touches queue-view.component.ts. So I also checked the actual merged tree (current main + d2d6bdf). There's no textual conflict, and:

  • nzb-core schedule passes 17/17, nzb-web --lib passes 292/292, and --test schedule_api passes 12/12.
  • cargo clippy --workspace --all-targets -D warnings and fmt --check are clean.
  • The production frontend build passes, ng test passes 323/323, and lint is clean.

No Cargo.lock changes.

Blocker 1: a manual Resume survives a real restart. Fixed.

f51caf2 restores the window bookkeeping (open_windows, triggered_windows, resumed_windows, user_paused_over) unconditionally; only the live holds are gated on was_paused. The restart tests now use a file-backed database and a fresh QueueManager instead of Arc::clone, so they actually read persisted state.

I re-ran my own independent real-restart scratch tests on the merged tree. These are fresh managers over a file database, not taken from the PR:

  • Window, Resume, restart, same-window tick: stays running. The next day's occurrence still pauses. ✔
  • Window, Resume, restart, restart again: still running after both restarts. ✔
  • Disk hold plus window, restart, disk lift: stays paused (the earlier blocker, still fixed). ✔

Blocker 2: CI against main. Fixed.

The schedule settings form now uses this.toast (ToastService) in all three places, and the spec asserts on it. The bundle builds on the merged tree locally and in CI.

Also in this round

  • expire_user_hold now guards against pausing an already-paused queue (my previous nit).
  • d2d6bdf stops boot from writing pause rows. restore_from_db loads into memory only (store_holds), and a tick whose holds are unchanged writes nothing (if *holds != before). That fixes the collision with the e2e seed's own globally_paused row, and a_fresh_database_gets_no_pause_settings_from_boot pins it. Writes still happen whenever any hold or window list changes, so nothing that needs persisting is skipped. My restart tests above exercise that path.

Ready from my side. Since main has moved, rerun CI on the merge ref when landing.

@thedancingdeveloper
thedancingdeveloper added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit c31fee1 Oct 20, 2026
12 checks passed
@thedancingdeveloper
thedancingdeveloper deleted the wi-2033-scheduler-followups branch October 20, 2026 19:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant