Skip to content

feat: download scheduler (WI-1721) - #245

Closed
thedancingdeveloper wants to merge 3 commits into
mainfrom
wi-1721-scheduler
Closed

thedancingdeveloper wants to merge 3 commits into
mainfrom
wi-1721-scheduler

Conversation

@thedancingdeveloper

Copy link
Copy Markdown
Collaborator

WI-1721, Polish J: the scheduler. Written by a DeepSeek worker; the handoff is on WI-1721. Full e2e run: 90 passed, 2 failed; the worker says the 2 failures are pre-existing on main.

🤖 Generated with Claude Code

@thedancingdeveloper

Copy link
Copy Markdown
Collaborator Author

Review of f4642f9: 2 blockers (behaviour), plus nits. The 2 e2e failures are not from this PR.

The e2e failures are on main too. This PR's e2e run (job 118688888911) has exactly 2 failures out of 92:

  • queue.spec.ts:124 (4.7 "selecting jobs shows bulk selection count"): strict mode violation: getByText(/2 selected/i) resolved to 2 elements.
  • downloads-mock.spec.ts:7 ("uploading the sample NZB…"): getByText(/added to queue/i) resolves to 2 elements.

main fails the same two tests with the same errors (runs 39428571230 and 39433041960). e2e passed on main at c570ea3 and started failing at b98ca89, the #239 merge, whose screen-reader live regions repeat the visible text. That needs its own fix on main. This PR's new settings.spec.ts tests pass.

Checks run locally:

  • cargo test: nzb-core schedule 13/13, nzb-web schedule 10/10, --test schedule_api 8/8.
  • cargo clippy --workspace --all-targets -D warnings is clean, and fmt --check is clean.
  • ng test passes 274/274 and lint is clean.

Blockers

  1. A manual Resume inside a pause window is undone within a minute. apply_schedule is level-triggered: if decision.pause && !self.is_paused() { pause_all_as(.., SCHEDULE) } runs on every minute tick. If the user resumes during the window, resume_all clears schedule_hold, and the next tick pauses again, so the user can't override the schedule. SABnzbd's scheduler is edge-triggered: a manual resume holds until the next scheduled event. Make the pause edge-triggered, pausing only when the decision changes from not-pause to pause (the monitor already keeps last). Otherwise, record a user override that lasts until the window closes. Add a test: schedule pause, resume_all, apply_schedule with the same decision, still running.
  2. A speed_limit_bps = 0 ("unlimited") rule doesn't give unlimited speed. ScheduleRule's doc, config.example.toml ("0 = unlimited, and overrides other active caps") and docs/CONFIGURATION.md all promise that an active 0 rule means unlimited. But evaluate() turns saw_unlimited into speed_limit_bps = None, which apply_effective_speed_limit reads as "the configured general.speed_limit_bps applies". With a configured 5 MB/s limit, an "unlimited overnight" rule still runs at 5 MB/s. The queue manager already handles Some(0) as unlimited (a_schedule_cap_of_zero_means_unlimited_even_over_a_configured_limit), but the evaluator never emits it, and the_lowest_applied_cap_wins_and_unlimited_overrides_one asserts None. Emit Some(0) when an unlimited rule is active and update that test, or change the docs if "fall back to configured" is what's intended.

Correct as written

  • Windows. Overnight windows belong to their start day, the tail is checked against yesterday, 24:00 is accepted, start == end is rejected, and the end minute is exclusive. Tests cover both edges and midnight.
  • Pause ownership. It's persisted as global_pause_owner, so a restart keeps a scheduled pause the schedule's. The schedule never lifts a user or disk-space pause, and a user Pause All during a window takes ownership. All three are tested.
  • Overlays. The speed cap and the server exclusions overlay the configured values without rewriting them. Disabling the schedule releases all of it.
  • The PUT API. It validates every rule first, rejects duplicate ids and unknown server ids, assigns UUIDs, and applies immediately. The wake uses notify_one, which stores a permit, so a save made while the monitor is mid-apply isn't lost.

Nits

  • The PUT handler calls apply_schedule directly on the async runtime. That does a SQLite write through pause_all_as/resume_all, while the rest of the handlers use qm_blocking. Consider wrapping it.
  • Deleting a server that a disable_servers rule names leaves a dangling id. It's harmless, but the settings UI could flag it.
  • A user's speed-limit change during a capped window is stored but has no visible effect until the window ends. Consider saying "Schedule cap active" next to the speed field.

thedancingdeveloper added a commit that referenced this pull request Oct 20, 2026
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
@thedancingdeveloper

Copy link
Copy Markdown
Collaborator Author

Re-review of 07e866b: no blockers. GitHub rust was still pending when I reviewed; e2e failed on the same two tests that fail on main (below).

e2e. Same 2 of 92 as before: queue.spec.ts:124 (4.7) and downloads-mock.spec.ts:7. Both are the strict-mode duplicates from #239's live regions, and main fails them identically. The other 90 pass.

Checks run locally:

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

Both blockers are fixed:

  1. Manual resume. apply_schedule is now edge-triggered on schedule_window.swap(decision.pause). It pauses only when a window opens and lifts only when one closes, and only if it still owns the pause. Otherwise it simply drops schedule_hold. restore_from_db rebuilds schedule_window = true with a schedule-owned pause, so a window ending after a restart still resumes. New tests cover five cases:
    • a resume holds through 5 ticks, and the next window pauses again;
    • a window opening during a user pause leaves the user as owner;
    • a restart inside a window;
    • disabling the schedule mid-window;
    • closing and reopening a window under a user pause.
  2. Unlimited. evaluate() now emits Some(0) for an active 0 rule. None still means "no cap rule", which a new test pins. The queue manager already treats Some(0) as unlimited over the configured limit. The docs are updated to match.

Should fix

  • A pause window that opens during a disk-space hold never pauses. At the rising edge is_paused() is true, because the disk guard holds it, so the schedule doesn't take the pause. The window is still marked open, so when the disk guard auto-resumes inside the window, nothing re-pauses: downloads run through a "pause" window. Before this fix the level trigger re-paused on the next tick. A disk-guard resume isn't a user override, so either re-arm the edge when the disk hold lifts, or record "schedule wanted a pause" when the edge lands on a non-user pause. A test with a disk hold at window open, released mid-window, would pin it.

Nits

  • The settings status pill renders an unlimited window as cap 0 B/s: formatSpeedValue(0) falls through to formatSpeed. Show "unlimited" for 0 as well as null, or label it "no cap (unlimited)". As it stands, it reads as "downloads stopped".
  • A manual resume inside a window doesn't survive a restart. schedule_window is only restored with a schedule-owned pause, so after a restart the first tick sees a rising edge and pauses again. That's acceptable and arguably what SABnzbd does too, but it's worth a sentence in docs/CONFIGURATION.md.
  • Still open from before: apply_schedule runs inline on the async runtime in the PUT handler.

thedancingdeveloper added a commit that referenced this pull request Oct 20, 2026
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
…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
@thedancingdeveloper

Copy link
Copy Markdown
Collaborator Author

Superseded: everything here is already on main via #249 and the follow-up merges (verified by rebase onto c31fee1).

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