The committed scenarios cannot be replayed against a library they were not written against #82

Closed
opened 2026-08-04 20:22:01 +02:00 by Claude · 1 comment
Collaborator

The committed scenarios cannot be replayed against a library they were not
written against.

scenarios/browsing.json carries 13 hardcoded 32-hex ids, playback.json 11,
playstate.json 8, items.json 3. They are the ids of the default snapshot,
so against any other library those requests address rows that are not there.

Replayed against a database copied from a running Jellyfin (55,523 items), 45 of
browsing.json's 50 requests come back identical from both servers — and 36 of
those 45 are identical because both servers answer 404. Nine requests
actually return data.

That is not a failing diff; it is worse than one. A replay against an unfamiliar
library reports near-perfect agreement while exercising the router and almost
nothing else, and nothing in the output distinguishes "both servers agree" from
"neither server has this row".

Two things would fix it, and they are separate:

  • A replay should say how many of its agreements were 404s. A run whose
    requests mostly miss is a run whose result means nothing, and only the replay
    can know that.
  • A request set that names no row. Listings by type with Limit and
    StartIndex, the sorts, the by-name tables, search, /UserViews,
    /Items/Counts — everything the read path does that does not need to know an
    id in advance. That set is replayable against any library, which is what
    makes a second library worth having.

The second is what turns "diff against a bigger library" from a manual exercise
into something repeatable, and a bigger library is where the ordering, counting
and cost problems live — see the issues filed alongside this one, none of which
the 25-item snapshot can show.

The committed scenarios cannot be replayed against a library they were not written against. `scenarios/browsing.json` carries 13 hardcoded 32-hex ids, `playback.json` 11, `playstate.json` 8, `items.json` 3. They are the ids of the `default` snapshot, so against any other library those requests address rows that are not there. Replayed against a database copied from a running Jellyfin (55,523 items), 45 of `browsing.json`'s 50 requests come back identical from both servers — and 36 of those 45 are identical because **both servers answer 404**. Nine requests actually return data. That is not a failing diff; it is worse than one. A replay against an unfamiliar library reports near-perfect agreement while exercising the router and almost nothing else, and nothing in the output distinguishes "both servers agree" from "neither server has this row". Two things would fix it, and they are separate: - **A replay should say how many of its agreements were `404`s.** A run whose requests mostly miss is a run whose result means nothing, and only the replay can know that. - **A request set that names no row.** Listings by type with `Limit` and `StartIndex`, the sorts, the by-name tables, search, `/UserViews`, `/Items/Counts` — everything the read path does that does not need to know an id in advance. That set is replayable against any library, which is what makes a second library worth having. The second is what turns "diff against a bigger library" from a manual exercise into something repeatable, and a bigger library is where the ordering, counting and cost problems live — see the issues filed alongside this one, none of which the 25-item snapshot can show.
Author
Collaborator

Both halves are on main, and the accounting turned out to want a wider rule than the issue asked for.

A run says what it never reached

Every replay now prints, whether the count is zero or the whole run:

46 of 46 agreed — 43 at 404 from both servers, 3 unanswered otherwise, 0 on an empty collection
46 of 46 responses (100% of the run) went unanswered by both servers; 0 (0%) matched nothing

and exits 2 when that share crosses --max-unanswered — its own code, so a caller can tell asked nothing from the two servers disagree. Differences still outrank it and still exit 1. A machine-readable summary.json carries the same, per request.

The issue asked for 404s. Writing it that way left the nearest sibling invisible: two servers failing identically. A 500/500 pair with matching bodies read as a perfect run in every printed number and in the exit code. So the gating rule is neither server answered — neither status is 2xx — with 404-on-both named as the subset it usually is. The three counts (missing, failed, empty) are disjoint subsets of the agreements, and every one is private behind an accessor so record is the only way in: the invariant the whole change is about is now structural rather than remembered.

A 404 pair whose bodies differ stays a difference, not a missing row.

An agreement on an empty collection is counted beside them and gates nothing — a query matching no rows is a real, if weak, agreement, and honest on a 25-item library.

Broadening the rule made two honest runs fragile, so a scenario may declare its own bar: handshake.json carries "max_unanswered": 100, because 11 of its 24 responses are refusals by design. Nothing else declares one.

jellyfin-bench uses the same predicate. It had a second, different notion of a vacuous agreement, printed and never acted on; there is now one function, called from both.

What the committed scenarios actually were

scenario against the anchor
playback.json 46 of 46 agreed, 100% unanswered now exit 2 — it was a clean pass
browsing.json 48 of 50 agreed, 64% at 404
portable.json 44 of 60 agreed, 0% unanswered

On the 25-item snapshot the same sweep reads: smoke 0%, items 8%, browsing 28%, library 37%, handshake 45%, bundle 0%, playback 60%.

A request set that names no row

scenarios/portable.json — 60 requests, no id anywhere, and a test fails the build if 32 hex characters (dashed or not) ever appear in a path. It replaces anchor.json, which was named after one library, and it leads DEFAULT_WORKLOADS because it is the only committed scenario that measures the query path of a fixture it was not written against.

It covers /UserViews, /Items/Counts, listings for every item type, paging including a page past the end, the sorts, every filter, all the by-name tables, search, /Items/Latest, /UserItems/Resume, /Shows/NextUp, the filter endpoints, and the Fields= variants. Two deliberate choices:

  • Fields=ProviderIds is the last request, and a test asserts there is exactly one and it is last. Upstream's user-data cache is filled by the first query to touch an item, so a request asking for external ids earlier changes what every request after it reports.
  • The sorts carry ,SortName as a tiebreak. A set pointed at an unfamiliar library must not report differences that are only about how two servers break a tie.

Not in it, and the README says why: /Items/Root, whose Path and image tag hash the program data directory the server was given and so report where the pair was stood up rather than anything about the port, and /Items/Suggestions, which upstream orders at random.

What it found

Six differences on the committed 25-item snapshot, where every existing scenario reports zero: /Items/Filters answers 400 where upstream answers a body; SearchTerm=a selects 17 rows against upstream's 10; Filters=IsResumable returns 17 against upstream's 0; /Years orders differently; and EnableUserData=false / EnableImages=false are ignored. Those are not this issue's, and want one of their own.

**Both halves are on `main`, and the accounting turned out to want a wider rule than the issue asked for.** ## A run says what it never reached Every replay now prints, whether the count is zero or the whole run: ``` 46 of 46 agreed — 43 at 404 from both servers, 3 unanswered otherwise, 0 on an empty collection 46 of 46 responses (100% of the run) went unanswered by both servers; 0 (0%) matched nothing ``` and exits **`2`** when that share crosses `--max-unanswered` — its own code, so a caller can tell *asked nothing* from *the two servers disagree*. Differences still outrank it and still exit `1`. A machine-readable `summary.json` carries the same, per request. The issue asked for 404s. Writing it that way left the nearest sibling invisible: **two servers failing identically**. A 500/500 pair with matching bodies read as a perfect run in every printed number and in the exit code. So the gating rule is *neither server answered* — neither status is 2xx — with 404-on-both named as the subset it usually is. The three counts (`missing`, `failed`, `empty`) are disjoint subsets of the agreements, and every one is private behind an accessor so `record` is the only way in: the invariant the whole change is about is now structural rather than remembered. A 404 pair whose *bodies* differ stays a difference, not a missing row. An agreement on an empty collection is counted beside them and gates nothing — a query matching no rows is a real, if weak, agreement, and honest on a 25-item library. Broadening the rule made two honest runs fragile, so a scenario may declare its own bar: `handshake.json` carries `"max_unanswered": 100`, because 11 of its 24 responses are refusals by design. Nothing else declares one. **`jellyfin-bench` uses the same predicate.** It had a second, different notion of a vacuous agreement, printed and never acted on; there is now one function, called from both. ## What the committed scenarios actually were | scenario | against the anchor | | |---|---|---| | `playback.json` | 46 of 46 agreed, **100% unanswered** | now exit 2 — it was a clean pass | | `browsing.json` | 48 of 50 agreed, **64% at 404** | | | `portable.json` | 44 of 60 agreed, **0% unanswered** | | On the 25-item snapshot the same sweep reads: smoke 0%, items 8%, browsing 28%, library 37%, handshake 45%, bundle 0%, playback 60%. ## A request set that names no row `scenarios/portable.json` — 60 requests, no id anywhere, and a test fails the build if 32 hex characters (dashed or not) ever appear in a path. It replaces `anchor.json`, which was named after one library, and it leads `DEFAULT_WORKLOADS` because it is the only committed scenario that measures the query path of a fixture it was not written against. It covers `/UserViews`, `/Items/Counts`, listings for every item type, paging including a page past the end, the sorts, every filter, all the by-name tables, search, `/Items/Latest`, `/UserItems/Resume`, `/Shows/NextUp`, the filter endpoints, and the `Fields=` variants. Two deliberate choices: - **`Fields=ProviderIds` is the last request**, and a test asserts there is exactly one and it is last. Upstream's user-data cache is filled by the first query to touch an item, so a request asking for external ids earlier changes what every request after it reports. - **The sorts carry `,SortName` as a tiebreak.** A set pointed at an unfamiliar library must not report differences that are only about how two servers break a tie. Not in it, and the README says why: `/Items/Root`, whose `Path` and image tag hash the program data directory the server was given and so report where the pair was stood up rather than anything about the port, and `/Items/Suggestions`, which upstream orders at random. ## What it found Six differences on the committed 25-item snapshot, where every existing scenario reports zero: `/Items/Filters` answers `400` where upstream answers a body; `SearchTerm=a` selects 17 rows against upstream's 10; `Filters=IsResumable` returns 17 against upstream's 0; `/Years` orders differently; and `EnableUserData=false` / `EnableImages=false` are ignored. Those are not this issue's, and want one of their own.
Sign in to join this conversation.
No description provided.