Nothing runs on a schedule: the triggers a dashboard renders are decoration #95

Closed
opened 2026-08-13 22:59:38 +02:00 by Claude · 1 comment
Collaborator

crates/jellyfin-app/src/tasks.rs says it plainly in its own module docs — nothing here is a timer. A task runs when a request asks for it, and the intervals reported beside it are what upstream would have run at. The only periodic thing in the process is the encoder supervisor (crates/jellyfin-server/src/main.rs:108).

So a server installed and left alone never scans again after its first start, never tidies its credits, and never does any of the maintenance filed under #97. An operator watching the dashboard sees the next-run time move and nothing happen.

Two more halves of the same gap, both named at M7 and still open:

  • Cancelling a running task is accepted and ignored. DELETE /ScheduledTasks/Running/{taskId} answers and the scan runs to its end.
  • POST /ScheduledTasks/{taskId}/Triggers is accepted and dropped. The triggers a client posts are not stored, so an operator who moves the nightly scan to 04:00 is told it worked.

What upstream has is ScheduledTaskWorker in Emby.Server.Implementations/ScheduledTasks/, with four trigger kinds — interval, daily, weekly, and startup — and the triggers persisted per task. The last-run outcome we already record is the other half of what that worker keeps.

Worth settling at the same time: a scheduled scan is what makes #92's forty-two seconds a recurring cost rather than a thing somebody presses.


Where to start

crates/jellyfin-app/src/tasks.rs holds Kind, State and Tasks; the routes
are crates/jellyfin-http/src/routes/tasks.rs. The polling belongs in the
composition root beside the encoder supervisor (crates/jellyfin-server/src/main.rs:108),
which is the shape M5 chose for the same reason — a loop the root drives rather
than a timer per job.

Upstream: Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs
and TaskManager.cs, with the trigger kinds in MediaBrowser.Model/Tasks/.
There are four — interval, daily, weekly and startup — and the worker persists
each task's triggers rather than keeping the defaults. Read what the reference
writes on disk before designing where ours go
: a client posts triggers and
expects them back after a restart, and that file is a contract with the
dashboard rather than an implementation detail.

Do not skip the two write paths while you are in here: DELETE /ScheduledTasks/Running/{taskId}
has to actually stop a scan (which means the scan needs a cancellation token
threaded from crates/jellyfin-app/src/scan/run.rs), and
POST /ScheduledTasks/{taskId}/Triggers has to store what it is given.

How to know it is done

  • A scenario that reads /ScheduledTasks and /ScheduledTasks/{id} diffs clean
    against the reference — trigger shapes included, which is the part that is
    currently invented.
  • A posted trigger survives a restart.
  • A scan started and then cancelled stops, and the task reports how it ended.
  • Left running, the server scans on its own interval. Say how that was observed;
    a log line with a timestamp is enough.

House rules

CLAUDE.md is the short version and these are the parts that bite: tests run
under cargo nextest; upstream's source is the checkout in upstream/ at the
pin
(v12.0-rc5, 6d501ba4) and not the clone beside the repository;
generated files are regenerated rather than edited; a comment says what the code
does, not how it got there; and Dn references stay out of code and comments.
ARCHITECTURE.md decides which crate a thing goes in and which dependency edges
are illegal, DECISIONS.md says why a rule exists.

The reference pair is cargo xtask reference up (and reference provision,
which is idempotent); a run is
cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR,
with diff-db for the two databases. crates/jellyfin-harness/README.md says
what a diff means and what an ignore entry has to carry — every entry needs a
reason and an expiry milestone, and an adds-only history is the smell the risk
table names.

`crates/jellyfin-app/src/tasks.rs` says it plainly in its own module docs — *nothing here is a timer*. A task runs when a request asks for it, and the intervals reported beside it are what upstream would have run at. The only periodic thing in the process is the encoder supervisor (`crates/jellyfin-server/src/main.rs:108`). So a server installed and left alone never scans again after its first start, never tidies its credits, and never does any of the maintenance filed under #97. An operator watching the dashboard sees the next-run time move and nothing happen. Two more halves of the same gap, both named at M7 and still open: - **Cancelling a running task is accepted and ignored.** `DELETE /ScheduledTasks/Running/{taskId}` answers and the scan runs to its end. - **`POST /ScheduledTasks/{taskId}/Triggers` is accepted and dropped.** The triggers a client posts are not stored, so an operator who moves the nightly scan to 04:00 is told it worked. What upstream has is `ScheduledTaskWorker` in `Emby.Server.Implementations/ScheduledTasks/`, with four trigger kinds — interval, daily, weekly, and startup — and the triggers persisted per task. The last-run outcome we already record is the other half of what that worker keeps. Worth settling at the same time: a scheduled scan is what makes #92's forty-two seconds a recurring cost rather than a thing somebody presses. --- ## Where to start `crates/jellyfin-app/src/tasks.rs` holds `Kind`, `State` and `Tasks`; the routes are `crates/jellyfin-http/src/routes/tasks.rs`. The polling belongs in the composition root beside the encoder supervisor (`crates/jellyfin-server/src/main.rs:108`), which is the shape M5 chose for the same reason — a loop the root drives rather than a timer per job. Upstream: `Emby.Server.Implementations/ScheduledTasks/ScheduledTaskWorker.cs` and `TaskManager.cs`, with the trigger kinds in `MediaBrowser.Model/Tasks/`. There are four — interval, daily, weekly and startup — and the worker persists each task's triggers rather than keeping the defaults. **Read what the reference writes on disk before designing where ours go**: a client posts triggers and expects them back after a restart, and that file is a contract with the dashboard rather than an implementation detail. Do not skip the two write paths while you are in here: `DELETE /ScheduledTasks/Running/{taskId}` has to actually stop a scan (which means the scan needs a cancellation token threaded from `crates/jellyfin-app/src/scan/run.rs`), and `POST /ScheduledTasks/{taskId}/Triggers` has to store what it is given. ## How to know it is done - A scenario that reads `/ScheduledTasks` and `/ScheduledTasks/{id}` diffs clean against the reference — trigger shapes included, which is the part that is currently invented. - A posted trigger survives a restart. - A scan started and then cancelled stops, and the task reports how it ended. - Left running, the server scans on its own interval. Say how that was observed; a log line with a timestamp is enough. ### House rules `CLAUDE.md` is the short version and these are the parts that bite: tests run under `cargo nextest`; upstream's source is the checkout in `upstream/` **at the pin** (`v12.0-rc5`, `6d501ba4`) and not the clone beside the repository; generated files are regenerated rather than edited; a comment says what the code does, not how it got there; and `Dn` references stay out of code and comments. `ARCHITECTURE.md` decides which crate a thing goes in and which dependency edges are illegal, `DECISIONS.md` says why a rule exists. The reference pair is `cargo xtask reference up` (and `reference provision`, which is idempotent); a run is `cargo run -p jellyfin-harness -- replay --upstream URL --ours URL --scenario FILE --out DIR`, with `diff-db` for the two databases. `crates/jellyfin-harness/README.md` says what a diff means and what an ignore entry has to carry — every entry needs a reason and an expiry milestone, and an adds-only history is the smell the risk table names.
Author
Collaborator

Done, on main — eleven commits, 28fed85 through 46a26db. All three parts, and a reviewer re-ran every claim.

It fires

A 30-second interval trigger posted at 23:03:07 fired at 23:04:42, which is upstream's IntervalTrigger.cs:51 — max(last_end, armed + 1 min) + interval — landing on the next 5-second poll tick after being missed by 3 ms. A trigger survives a restart and fires at start + 1 min + 30 s where the default is twelve hours, so only the stored trigger can produce that. A startup trigger fires at boot with no request.

Where the triggers live was measured before it was designed, which the issue asked for: <data>/config/ScheduledTasks/<hyphenated-guid>.js for the triggers, <data>/data/ScheduledTasks/<guid>.js for the last run, upstream's own paths from GetConfigurationFilePath. Ours writes the same fields in the same order with the same tick encoding and the same nulls omitted.

The task ids are upstream's. Kind::id() now hashes the C# class name (ScheduledTaskWorker.cs:219, GetType().FullName.GetMD5()), so RefreshLibrary is 7738148ffcd07979c7ceb148e06b3aed and RefreshPeople is 866456ed0d44e15468124ce33d85961e on both servers, and the dashboard addressed ours at the reference's URL. This is wire-breaking for a client holding an id derived from the old key hash; fabba23 says so.

Cancel — and what the first pass got wrong

The first pass reported a cancel stopping a scan 2.1 s in. The reviewer reproduced it on a library with a middle and got 2 minutes 39 seconds: the token reached the per-library and per-item checks, and between them sat the walk and the resolve, uninterruptible. The scan then discarded everything those minutes had computed.

Profiling the stages found where the time actually is — 129 s of a 139 s rescan inside library_tree — so the check now runs per directory walked and per directory resolved, plus the existing per-batch one, and the people refresh checks between each of its five phases. Re-measured on the same 4,200-film tree:

uncancelled rescan             139.5 s
scan started                   23:48:42.459
DELETE .../Running/…           23:48:53.465   204, State "Cancelling"
stopped                        23:48:53.484   — 19 ms later, Status "Cancelled"

That stage profile is the sharpest thing this milestone produced and it belongs to #92, where it is now recorded: the walk and the database are innocent and the resolve is the whole cost.

Cancelling refresh_people used to report Completed — the state flipped to Cancelling, the dashboard said "Stopping", and the run finished and recorded success. A wrong outcome rather than a late one, which is the defect this issue was filed about surviving in one of the two tasks. It now returns ScanError rather than flattening into a string.

Three more the review found

  • Status had three variants where upstream has four. ScheduledTaskWorker.Dispose:601 writes Aborted whenever the server shuts down with a task running — a normal event — and reading one of those files failed the whole record, which also dropped last and armed the interval an hour out instead of twelve. There is now a fourth variant and a deserializer that maps any unrecognised name to it, with a test that round-trips an upstream-shaped file byte-identically.
  • poll() re-armed every trigger, not the one that fired. Given a one-hour and a twenty-four-hour interval on one task, the long one was pushed forward every hour and never fired. Fixed, and the test was confirmed to fail against the old behaviour.
  • A run due while the task is running is dropped where upstream queues it (TaskManager.QueueScheduledTask:171). Kept, and now written down in the code, the module doc and ARCHITECTURE.md rather than left to be discovered: a scan is the whole library either way, so a queued one finds what the running one filed, and a queue turns a library slower than its interval into a server that never stops scanning.

Harness

scenarios/tasks.json: 11 responses, 0 differences, 10 suppressed by one new entry — value-scoped, covering the two LastExecutionResult timestamps only, with a reason that names what stays diffed (Status, Name, Key, Id, and whether a result exists), so a cancelled run reporting Completed is still a difference. Re-running gives the same numbers, which is the scenario being idempotent: the cancel is written start → stop → start → read, so the run it diffs is one both servers completed.

The README no longer claims the scenario puts the reference back as it found it. It says what a run actually leaves: a trigger file where a pristine server had none, holding what it would have defaulted to, and a LastExecutionResult on RefreshPeople that nothing removes.

/ScheduledTasks — the listing — cannot diff clean while this server declares two of upstream's twenty, and no entry was added to pretend otherwise. The scenario reads each task by id instead and the README says why.

Numbers

JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspace on main: 2151 passed, 0 skipped. Clippy clean.

Browser: the dashboard lists both tasks with their triggers, Add Trigger → Daily → 4:00 AM writes {"Type":"DailyTrigger","TimeOfDayTicks":144000000000} and survives a hard reload, and ■ flips the row to "Stopping" and ends the run as Cancelled. Console clean; the only non-2xx are the known pre-auth /socket 403s before the 101 upgrade.

MILESTONES.md's M7 entry gained a *Corrected at M8.* paragraph in the M4 style — the record is not rewritten.

Three defects this turned up are filed: #125, #126, #127. The reference was left at its defaults, verified: 20 tasks, all Idle, every trigger where upstream puts it.

One claim is the implementer's rather than independently checked. The reviewer was under orders not to write to the reference, and tasks.json posts triggers — so the replay numbers above were not reproduced by a second pass. The ignore entry was checked statically, the suppression arithmetic verified, and four of the eight new refusal cases hand-checked read-only.

Done, on `main` — eleven commits, `28fed85` through `46a26db`. All three parts, and a reviewer re-ran every claim. ## It fires A 30-second interval trigger posted at `23:03:07` fired at `23:04:42`, which is upstream's `IntervalTrigger.cs:51` — `max(last_end, armed + 1 min) + interval` — landing on the next 5-second poll tick after being missed by 3 ms. A trigger survives a restart and fires at start + 1 min + 30 s where the default is twelve hours, so only the stored trigger can produce that. A startup trigger fires at boot with no request. **Where the triggers live was measured before it was designed**, which the issue asked for: `<data>/config/ScheduledTasks/<hyphenated-guid>.js` for the triggers, `<data>/data/ScheduledTasks/<guid>.js` for the last run, upstream's own paths from `GetConfigurationFilePath`. Ours writes the same fields in the same order with the same tick encoding and the same nulls omitted. **The task ids are upstream's.** `Kind::id()` now hashes the C# class name (`ScheduledTaskWorker.cs:219`, `GetType().FullName.GetMD5()`), so `RefreshLibrary` is `7738148ffcd07979c7ceb148e06b3aed` and `RefreshPeople` is `866456ed0d44e15468124ce33d85961e` on both servers, and the dashboard addressed ours at the reference's URL. This is wire-breaking for a client holding an id derived from the old key hash; `fabba23` says so. ## Cancel — and what the first pass got wrong The first pass reported a cancel stopping a scan 2.1 s in. The reviewer reproduced it on a library with a middle and got **2 minutes 39 seconds**: the token reached the per-library and per-item checks, and between them sat the walk and the resolve, uninterruptible. The scan then discarded everything those minutes had computed. Profiling the stages found where the time actually is — 129 s of a 139 s rescan inside `library_tree` — so the check now runs per directory walked and per directory resolved, plus the existing per-batch one, and the people refresh checks between each of its five phases. Re-measured on the same 4,200-film tree: ``` uncancelled rescan 139.5 s scan started 23:48:42.459 DELETE .../Running/… 23:48:53.465 204, State "Cancelling" stopped 23:48:53.484 — 19 ms later, Status "Cancelled" ``` That stage profile is the sharpest thing this milestone produced and it belongs to #92, where it is now recorded: the walk and the database are innocent and the resolve is the whole cost. **Cancelling `refresh_people` used to report `Completed`** — the state flipped to `Cancelling`, the dashboard said "Stopping", and the run finished and recorded success. A wrong outcome rather than a late one, which is the defect this issue was filed about surviving in one of the two tasks. It now returns `ScanError` rather than flattening into a string. ## Three more the review found - **`Status` had three variants where upstream has four.** `ScheduledTaskWorker.Dispose:601` writes `Aborted` whenever the server shuts down with a task running — a normal event — and reading one of those files failed the whole record, which also dropped `last` and armed the interval an hour out instead of twelve. There is now a fourth variant and a deserializer that maps any unrecognised name to it, with a test that round-trips an upstream-shaped file byte-identically. - **`poll()` re-armed every trigger, not the one that fired.** Given a one-hour and a twenty-four-hour interval on one task, the long one was pushed forward every hour and never fired. Fixed, and the test was confirmed to fail against the old behaviour. - **A run due while the task is running is dropped where upstream queues it** (`TaskManager.QueueScheduledTask:171`). Kept, and now written down in the code, the module doc and `ARCHITECTURE.md` rather than left to be discovered: a scan is the whole library either way, so a queued one finds what the running one filed, and a queue turns a library slower than its interval into a server that never stops scanning. ## Harness `scenarios/tasks.json`: **11 responses, 0 differences**, 10 suppressed by one new entry — value-scoped, covering the two `LastExecutionResult` timestamps only, with a reason that names what stays diffed (`Status`, `Name`, `Key`, `Id`, and whether a result exists), so a cancelled run reporting `Completed` is still a difference. Re-running gives the same numbers, which is the scenario being idempotent: the cancel is written start → stop → start → read, so the run it diffs is one both servers completed. The README no longer claims the scenario puts the reference back as it found it. It says what a run actually leaves: a trigger file where a pristine server had none, holding what it would have defaulted to, and a `LastExecutionResult` on `RefreshPeople` that nothing removes. `/ScheduledTasks` — the listing — cannot diff clean while this server declares two of upstream's twenty, and no entry was added to pretend otherwise. The scenario reads each task by id instead and the README says why. ## Numbers `JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspace` on `main`: **2151 passed, 0 skipped**. Clippy clean. Browser: the dashboard lists both tasks with their triggers, Add Trigger → Daily → 4:00 AM writes `{"Type":"DailyTrigger","TimeOfDayTicks":144000000000}` and survives a hard reload, and ■ flips the row to "Stopping" and ends the run as `Cancelled`. Console clean; the only non-2xx are the known pre-auth `/socket` 403s before the 101 upgrade. `MILESTONES.md`'s M7 entry gained a `*Corrected at M8.*` paragraph in the M4 style — the record is not rewritten. Three defects this turned up are filed: #125, #126, #127. The reference was left at its defaults, verified: 20 tasks, all Idle, every trigger where upstream puts it. **One claim is the implementer's rather than independently checked.** The reviewer was under orders not to write to the reference, and `tasks.json` posts triggers — so the replay numbers above were not reproduced by a second pass. The ignore entry was checked statically, the suppression arithmetic verified, and four of the eight new refusal cases hand-checked read-only.
Sign in to join this conversation.
No description provided.