The six maintenance jobs: nothing sweeps the transcodes, the cache or the logs, and nothing optimizes the database #103

Closed
opened 2026-08-13 22:59:40 +02:00 by Claude · 2 comments
Collaborator

Six of upstream's tasks are housekeeping, and none of them exists here. Together they are what keeps a long-running install from filling its disk.

  • DeleteTranscodeFiles (startup + 24 h) — sweeps the transcoding directory. Without it, every abandoned HLS session's segments stay on disk forever. This is the one that bites first on a real server.
  • DeleteCacheFiles (24 h) — sweeps the cache directory. Our subtitle conversions are cached by a key that names every argument (M7), and nothing ever removes one.
  • CleanLogFiles (24 h) — trims the log directory against the configured retention.
  • CleanActivityLog — trims the activity table against its retention. Nothing here writes a row yet either.
  • CleanupUserDataTask — drops UserData rows whose item is gone. Ours accumulate the same way upstream's would.
  • OptimizeDatabaseTask (6 h) — VACUUM/ANALYZE on the SQLite file. Relevant to us specifically: report.md's read-path numbers come partly from mapping the database, and a file that is never analysed gives the planner stale statistics.

All six are small, and all six are pure server behaviour with no upstream wire contract to diff — which is worth saying out loud, because it means the harness cannot check them and a test has to.


Where to start

Six small ones, all in Emby.Server.Implementations/ScheduledTasks/Tasks/:
DeleteTranscodeFileTask, DeleteCacheFileTask, DeleteLogFileTask,
CleanActivityLogTask, CleanupUserDataTask, OptimizeDatabaseTask. Read each
for its retention rule and its trigger — DeleteTranscodeFiles runs at startup
and every 24 h, which is deliberate: a server that was killed mid-transcode
leaves segments nobody will ever ask for.

Ours: the directories come from [paths] in the configuration
(crates/jellyfin-server/src/config.rs), the transcode directory is what
crates/jellyfin-app/src/streaming/transcode.rs and
crates/jellyfin-app/src/streaming/encoder/ write into, and the
subtitle conversion cache is the one M7 keyed by every argument and never
sweeps.

OptimizeDatabaseTask is worth doing early for a reason of ours rather than
upstream's: report.md's read-path numbers come partly from mapping the
database, and a file that is never ANALYZEd hands the planner stale statistics.

How to know it is done

These have no wire contract, so the harness cannot check them and a test has
to
. Each sweep gets a test that puts files in a temporary directory, runs the
task against a clock the test controls, and asserts what is left. VACUUM gets
one that shows the file shrank after deletions.

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.

Six of upstream's tasks are housekeeping, and none of them exists here. Together they are what keeps a long-running install from filling its disk. - **`DeleteTranscodeFiles`** (startup + 24 h) — sweeps the transcoding directory. Without it, every abandoned HLS session's segments stay on disk forever. This is the one that bites first on a real server. - **`DeleteCacheFiles`** (24 h) — sweeps the cache directory. Our subtitle conversions are cached by a key that names every argument (M7), and nothing ever removes one. - **`CleanLogFiles`** (24 h) — trims the log directory against the configured retention. - **`CleanActivityLog`** — trims the activity table against its retention. Nothing here writes a row yet either. - **`CleanupUserDataTask`** — drops `UserData` rows whose item is gone. Ours accumulate the same way upstream's would. - **`OptimizeDatabaseTask`** (6 h) — `VACUUM`/`ANALYZE` on the SQLite file. Relevant to us specifically: `report.md`'s read-path numbers come partly from mapping the database, and a file that is never analysed gives the planner stale statistics. All six are small, and all six are pure server behaviour with no upstream wire contract to diff — which is worth saying out loud, because it means the harness cannot check them and a test has to. --- ## Where to start Six small ones, all in `Emby.Server.Implementations/ScheduledTasks/Tasks/`: `DeleteTranscodeFileTask`, `DeleteCacheFileTask`, `DeleteLogFileTask`, `CleanActivityLogTask`, `CleanupUserDataTask`, `OptimizeDatabaseTask`. Read each for its retention rule and its trigger — `DeleteTranscodeFiles` runs at startup *and* every 24 h, which is deliberate: a server that was killed mid-transcode leaves segments nobody will ever ask for. Ours: the directories come from `[paths]` in the configuration (`crates/jellyfin-server/src/config.rs`), the transcode directory is what `crates/jellyfin-app/src/streaming/transcode.rs` and `crates/jellyfin-app/src/streaming/encoder/` write into, and the subtitle conversion cache is the one M7 keyed by every argument and never sweeps. `OptimizeDatabaseTask` is worth doing early for a reason of ours rather than upstream's: `report.md`'s read-path numbers come partly from mapping the database, and a file that is never `ANALYZE`d hands the planner stale statistics. ## How to know it is done These have no wire contract, so **the harness cannot check them and a test has to**. Each sweep gets a test that puts files in a temporary directory, runs the task against a clock the test controls, and asserts what is left. `VACUUM` gets one that shows the file shrank after deletions. ### 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 (d28d318). All six, at upstream's own keys, categories, descriptions and triggers.

Measured against the reference, not the C#

Each of the six was diffed by id against the pinned image with the pair up — /ScheduledTasks/{id} for all six, 0 differences: the key, the name, the category, the description and the triggers agree byte for byte. The six ids are in tasks.json now, so they go on being diffed.

That caught the one description carrying a setting: CleanLogFiles reads "Deletes log files that are more than 3 days old", formatted from the retention. Kind::description takes it rather than returning a constant, so an operator who moves the retention is told the number they set.

Key Trigger What it sweeps
DeleteTranscodeFiles startup + 24 h the transcode directory, at a day
DeleteCacheFiles 24 h the cache at thirty days, cache/temp at one
CleanLogFiles 24 h the log directory, at the configured retention
CleanActivityLog none the audit table, at the configured retention
CleanupUserDataTask none detached playback state, at ninety days
OptimizeDatabaseTask 6 h wal_checkpoint, optimize, VACUUM, wal_checkpoint

How it is checked

No wire contract, so the tests are it. The three file sweeps get a directory the test fills and a clock it controls — a clock set days ahead is what ages a file written a moment ago, which is cheaper and steadier than setting mtimes. The two database ones live in jellyfin-db, where the code does: the vacuum is asserted on the file actually shrinking, and the user-data sweep builds its fixture by deleting an item rather than hand-writing the detached row, so the detaching half and the sweeping half are held to agreeing about the placeholder id and the retention column.

Three decisions worth reading

  • cache/temp is swept at a day, not thirty. Upstream's second pass targets the OS temp directory, which this server does not use; cache/temp is where it puts a concat list and a probe's scratch file, so that is the directory the one-day rule is applied to.
  • The log sweep covers the files this server's appender writes. Upstream excludes log_* because serilog trims those itself; tracing-appender trims nothing, so excluding them would make the task sweep nothing at all on a real install. A day is the floor under the retention, which is what keeps the file being written to.
  • OptimizeDatabaseTask stands down while a scan runs, which is upstream's own IsScanRunning check — a vacuum wants an exclusive lock and would stall the pass.

ActivityLogRetentionDays and LogFileRetentionDays are [maintenance] in the config and are reported and taken on /System/Configuration. Everything else — a day for the transcodes, thirty for the cache, ninety for the detached playback state — is upstream's own constant, named beside the sweep that reads it.

cargo nextest run --workspace: 2373 passed, 0 skipped.

Done, on `main` (`d28d318`). All six, at upstream's own keys, categories, descriptions and triggers. ## Measured against the reference, not the C# Each of the six was diffed by id against the pinned image with the pair up — `/ScheduledTasks/{id}` for all six, **0 differences**: the key, the name, the category, the description and the triggers agree byte for byte. The six ids are in `tasks.json` now, so they go on being diffed. That caught the one description carrying a setting: `CleanLogFiles` reads *"Deletes log files that are more than 3 days old"*, formatted from the retention. `Kind::description` takes it rather than returning a constant, so an operator who moves the retention is told the number they set. | Key | Trigger | What it sweeps | |---|---|---| | `DeleteTranscodeFiles` | startup **+** 24 h | the transcode directory, at a day | | `DeleteCacheFiles` | 24 h | the cache at thirty days, `cache/temp` at one | | `CleanLogFiles` | 24 h | the log directory, at the configured retention | | `CleanActivityLog` | none | the audit table, at the configured retention | | `CleanupUserDataTask` | none | detached playback state, at ninety days | | `OptimizeDatabaseTask` | 6 h | `wal_checkpoint`, `optimize`, `VACUUM`, `wal_checkpoint` | ## How it is checked No wire contract, so the tests are it. The three file sweeps get a directory the test fills and a clock it controls — a clock set days ahead is what ages a file written a moment ago, which is cheaper and steadier than setting mtimes. The two database ones live in `jellyfin-db`, where the code does: the vacuum is asserted on the file actually shrinking, and the user-data sweep builds its fixture by *deleting an item* rather than hand-writing the detached row, so the detaching half and the sweeping half are held to agreeing about the placeholder id and the retention column. ## Three decisions worth reading - **`cache/temp` is swept at a day, not thirty.** Upstream's second pass targets the OS temp directory, which this server does not use; `cache/temp` is where it puts a concat list and a probe's scratch file, so that is the directory the one-day rule is applied to. - **The log sweep covers the files this server's appender writes.** Upstream excludes `log_*` because serilog trims those itself; `tracing-appender` trims nothing, so excluding them would make the task sweep nothing at all on a real install. A day is the floor under the retention, which is what keeps the file being written to. - **`OptimizeDatabaseTask` stands down while a scan runs**, which is upstream's own `IsScanRunning` check — a vacuum wants an exclusive lock and would stall the pass. `ActivityLogRetentionDays` and `LogFileRetentionDays` are `[maintenance]` in the config and are reported and taken on `/System/Configuration`. Everything else — a day for the transcodes, thirty for the cache, ninety for the detached playback state — is upstream's own constant, named beside the sweep that reads it. `cargo nextest run --workspace`: 2373 passed, 0 skipped.
Author
Collaborator

The review round (0ff1992)

One finding here, and it was a real one: a vacuum that failed was reporting the task as failed. Upstream's OptimizeDatabaseTask.ExecuteAsync catches the exception and only logs it, so it never reports a failure — and now that ActivityLogs is diffed, reporting one would be a TaskFailed row upstream does not write, on top of a LastExecutionResult that disagrees. It answers zero and logs instead.

The same finding's other half: upstream runs the task and returns early when a scan is in progress, recording a completed run, where this refused to start it at all and left the previous result standing. Since /ScheduledTasks/{id} is diffed for all six now, that was a difference waiting to happen. The stand-down is a run that happened and did nothing; the people refresh keeps its refusal, which is ours and is about rows a pass is mid-write on.

Verified against the reference

  • /ScheduledTasks/{id} for all six: 0 differences — key, name, category, description and triggers agree byte for byte
  • cargo nextest run --workspace: 2376 passed, 0 skipped
  • No response regressions: the same scenario sequence against a build of f3b9015 gives the identical counts

Closing. CleanActivityLog has rows to sweep because #104 landed beside it; the umbrella is #97, which stays open on the two trickplay rows in batch D.

## The review round (`0ff1992`) One finding here, and it was a real one: **a vacuum that failed was reporting the task as failed.** Upstream's `OptimizeDatabaseTask.ExecuteAsync` catches the exception and only logs it, so it never reports a failure — and now that `ActivityLogs` is diffed, reporting one would be a `TaskFailed` row upstream does not write, on top of a `LastExecutionResult` that disagrees. It answers zero and logs instead. The same finding's other half: upstream *runs* the task and returns early when a scan is in progress, recording a completed run, where this refused to start it at all and left the previous result standing. Since `/ScheduledTasks/{id}` is diffed for all six now, that was a difference waiting to happen. The stand-down is a run that happened and did nothing; the people refresh keeps its refusal, which is ours and is about rows a pass is mid-write on. ## Verified against the reference - `/ScheduledTasks/{id}` for all six: **0 differences** — key, name, category, description and triggers agree byte for byte - `cargo nextest run --workspace`: **2376 passed, 0 skipped** - No response regressions: the same scenario sequence against a build of `f3b9015` gives the identical counts Closing. `CleanActivityLog` has rows to sweep because #104 landed beside it; the umbrella is #97, which stays open on the two trickplay rows in batch D.
Sign in to join this conversation.
No description provided.