A library scan is quadratic in the size of one library: 42 s to conclude 20,000 films have not moved #92
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
A library scan does not cost linear time in the number of films. Doubling the library multiplies a pass over an unchanged tree by 3.8 — an exponent of 1.9, near enough quadratic. Measured for the first time on 2026-08-13; nothing has been done about it, and it was left where it was found rather than fixed in a hurry.
Correctness stopped being the only metric when M3 exited, and until now the only fixture was six films. This is the first number over a library that is a library.
The measurement
A generated tree of films, one directory and one file each, every file a hard link to the same 98 KB mp4 — so the whole tree costs 96 KB of disk while each path is still stat-ed, resolved and probed on its own. Release build, sixteen cores, thirteen files read at once, one library, one
POST /Library/Refresh, timed from the request to thescannedlog line.Doubling multiplies the first pass by 2.3 then 2.5, and the unchanged pass by 3.8 both times.
Forty-two seconds to conclude that a library has not moved. At 40,000 films that is not eighty — extrapolating the measured exponent it is around 2.8 minutes, and around 11 at 80,000. This is the number that matters for a running server, because it is what every scheduled pass costs when nothing has changed.
The first pass is roughly half prober: 20,000
ffproberuns at ~34 ms warm, thirteen at a time, is about 53 s of the 105. The unchanged pass runs no prober at all, so it is the other half measured on its own — and it is the half that is quadratic.What is already ruled out
Please do not re-derive these; they are measured, and they are why this is narrow.
That leaves the step that turns one library's walk into rows and decides what moved —
crates/jellyfin-app/src/scan/— as the only place the time can be. Look for a per-entry lookup against aVec(or any repeated linear scan of the filed set) rather than a map: an O(n) probe inside an O(n) loop over one library's entries is exactly the shape that splits in half when the library does.Reproducing
The tree is not in the repository and must not go in — 20,000 directories is a load, not a fixture. Rebuild it deterministically:
seed.mp4is any small real video — the fixture'sreference/var/media/movies/Blue Field (2020)/Blue Field (2020).mp4does. As of writing the tree is still at~/.cache/bigscan/movies, and~/.cache/bigscan/run/holds the previous scripts and raw numbers.Point a scratch config at it — a copy of
config.tomlwith its own[paths]and one[[libraries]]— and drive it withPOST /Library/Refresh. Do not add it to the repository'sconfig.tomland do not scan it into the reference.Use
--release; the numbers above are release and a debug build is not the same shape.Done when
There is a number, and it is a better one. Concretely:
docs/benchmarks.mdcarries the new table beside the old one — the old numbers stay, so the change is legible.JELLYFIN_RS_REQUIRE_FIXTURES=1 cargo nextest run --workspacegreen (2092 at the time of writing).If it turns out the shape is inherent rather than accidental, say so with the evidence and write that down instead — an honest "this is why" is a better outcome than a change that moves the constant and leaves the exponent.
Before you start
CLAUDE.mdis the contract.cargo nextest, notcargo test. Comments describe the current state, never the history, and carry no milestone terminology.upstream/in this repo — the pinned checkout. Not../jellyfin, which is on a divergent branch. Upstream's own scan cost over the same tree has never been taken; if you take it, that is a useful number in its own right and belongs indocs/benchmarks.md.git add -A, and never symlinkreference/var,reference/snapshots,reference/mediaorreference/publishedinto a worktree — they are gitignored, and committing such a link deletes the real fixture. That has happened.:8096.Localized to one function, while working on #95. Not fixed — measured, and narrower than this issue could say when it was written.
Stage timings over a 4,200-film tree, all-unchanged rescan, debug build:
So the walk really is innocent (this issue already said 50 ms over 40,000 entries), the database really is innocent, and the whole cost is the resolve. Inside
crates/jellyfin-app/src/scan/tree/mod.rs,files_in/directories_inscan the entry list once per directory — an O(n) probe inside an O(n) loop over one library's entries, which is exactly the shape this issue predicted and is why the cost halves when the same films are declared as two libraries.The tree is reproducible, and cheaper to build than the one in the issue body since every file is a copy of one 98 KB sample:
Declare it as a
movieslibrary, let the boot scan finish, then start a second scan — the second one runs no prober at all, so what it measures is this and nothing else.One thing that changed underneath this issue: a scan is now cancellable at the grain of a directory walked and a directory resolved (#95), so 129 seconds of resolve is interruptible where it was not. That makes the wait escapable; it does not make it short, and this issue is still the one that makes it short.