Ten requests the id-free set finds, and the six bugs behind them #86
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?
Ten requests the id-free set (#82) finds against a database copied off a running Jellyfin — 56,337 items, 43,466
UserDatarows, both servers on one copy each and one program data directory. None of them belongs to #76, #78, #79, #80, #81 or #84, all now closed; these are what is left, and they are what the benchmark gate drops.Measured on
ba8f44e: 60 requests, 50 agree, 4,604 differences over the other ten.Six look like distinct bugs, and four are one row or one field each.
A different page, from a filter
All fifty rows differ. Ours admits
(a) period.toNameStartsWith=A, so the comparison is against something that has already had its punctuation folded away — upstream matches on a different string than we do. 1,933 differences, every one downstream of the page being a different page.The same shape, 1,928 differences: our search admits a row whose name does not begin with the term and orders it first. Both of these were visible on the 25-item snapshot too —
SearchTerm=aselects 17 rows there against upstream's 10 — so they are not a property of the big library./Items/Filtersanswers 400The route is answered; something in the query it is given is refused. Also reproduces on the committed snapshot.
/Items/Filters2disagrees about languagesBoth the display name and the value. Upstream reports the tag as the stream carries it and names it from its own table; we normalise it first. 209 differences.
/Yearsis ordered the other wayAscending by sort name, upstream leads with the oldest. Ours leads with the newest, so the whole page is reversed. Also visible on the snapshot.
EnableImages=falseandEnableUserData=falseare ignoredTwo query parameters neither read nor honoured, on every row of the page.
Filters=IsResumableselects a different setNot a page-order difference — the sets differ. On the snapshot it is starker: ours returns 17 rows where upstream returns none.
A media source is a placeholder, and has no etag
All 25 rows, two fields each. Upstream calls a source with no readable file behind it a placeholder; this anchor has none of its media on this host, so every source is one.
The last two are one row each
/Artists/AlbumArtistsdiffers from index 23 — a different artist in that slot, 30 differences downstream of it. And/Items?…SortBy=AlbumArtist,SortNameis down to three:Items[25]is missingParentBackdropItemId,ParentBackdropImageTagsand the blurhash keyed by it — an inherited parent backdrop, the residue left after #78.Reproducing
Four of the ten also reproduce against
reference/snapshots/default, which is the cheaper place to work on them:/Items/Filters,SearchTerm,IsResumableand/Years./Items/Filters2is an ordering difference, not a normalisation oneThe diagnosis above — "upstream reports the tag as the stream carries it and
names it from its own table; we normalise it first" — is wrong, and the sample
line is what made it look that way.
SubtitleLanguages[7]differs in both thename and the value because the two lists are ordered differently, so
index 7 is a different language on each side. Nothing is normalised.
Measured against the anchor on
74482f6,?IncludeItemTypes=Series: bothservers return the same 95 subtitle codes and the same 32 audio codes, as sets,
value for value.
?IncludeItemTypes=Movieagrees on 51 and 16. All 209differences are positional.
There were two real bugs behind it, both now fixed on
wt-86d.A code the table does not name was named twice.
FilterControllerwritesculture is null ? language : $"{culture.DisplayName} ({language})", so a codeFindLanguageInforesolves nothing for is its own name, bare. We wrotees-ES (es-ES),khk (khk),scr (scr),unknown (unknown). That is theonly place the two disagreed on a name at all — five entries across the two
Serieslists, none in theMovieones.The order is case-insensitive, not byte order. Upstream finishes with
.OrderBy(l => l.Name)in memory, so it is .NET's culture-aware stringcomparison, not SQLite's
BINARY. Ours dida.name.cmp(&b.name). That putChinese (Simplified) (zh-Hans)beforeChinese (chi)—(Sbefore(c—and banished the three bare codes to the end of the list rather than
interleaving them (
es-ESbetweenEnglish (eng)andEstonian (est),khkbetweenKannada (kan)andKorean (ko),scrbetweenRussian (rus)andSerbian (srp)), which shifted every row from index 21 on.A case-insensitive comparison reproduces upstream's sequence exactly on all
four lists. It was taken over a real ICU collator: the names this route can
emit are
{display name} ({code})over the whole generated language table plusthe bare codes, and over all 722 of them ICU's root and
en-UScollationsproduce the identical total order — including
Occitan (post 1500); Provençal,Provençal, Old (to 1500)andVolapük, the only non-ASCII display namesthere are. So a collator would buy no agreement and would tie the order to
whichever ICU the host links.
The other two, for the record
/Artists/AlbumArtistsfrom index 23 was a tiebreak.Fall Out BoyandFall Out Boyboth carrySortNamefall out boyand their ownPresentationUniqueKey, so neither collapses into the other.ApplyOrdercarries
ThenBy(e => e.Name)behind a leading sort-name ordering and theby-name listing runs through it like any other query; ours settled the tie on
the id. Beside it,
GetItemValuespicks aMusicArtistgroup's representativeby whether its
TopParentIdis one the caller can reach before falling backon the lowest id, where every other kind takes the lowest id outright — so
MØL, which has both a row under the music library and a row beside themetadata, was listed as the metadata one: no path inside a library, and no
images. That is a second difference the 50-row page does not reach.
The
ParentBackdropresidue was not the artist being read without images —it is that we looked the artist up in the wrong set of names.
GetBaseItemDtosbuilds its artist batch from what the page's own items credit, every name
in
ArtistsandAlbumArtists, andGetBatchedAlbumArtistlooks an album'sfirst album artist up in that.
FindArtistsreads those rows without theirimages, so an album whose artist the page credits really does inherit nothing —
which is why a page of albums shows no
ParentBackdropItemIdon either server.A name the batch does not hold falls through to
GetMusicArtist, the artistabove the album, which does carry its pictures; and the singular DTO path
passes no batch at all, so an album read by id always falls through. Ours keyed
the batch on the very names it then looked up, so the lookup could never miss
and no album ever reached the artist above it.
Azblubsits on an albumcrediting
Spor, which none of the tracks on that page credits.Numbers
portable.jsonagainst the anchor, both servers restarted first:74482f6wt-86dThe three gone are
/Items/Filters2,/Artists/AlbumArtistsand/Items?…SortBy=AlbumArtist,SortName. Nothing new appeared./Items/Filtersand/Years, and what is left of eachBoth are fixed on
wt-86c.portable.jsonagainstreference/snapshots/defaultgoes 161 differences over 5 requests → 150 over 4; against the anchor, each server on its own copy and one program data directory, 4,604 over 10 → 4,501 over 9 (50 → 51 agreements). Both anchor runs were measured on a pair restarted for the measurement./Items/Filtersanswered 400 — closedThe parameter is
parentId, by its absence.GetParentItem(Emby.Server.Implementations/Library/LibraryManager.cs:3114) throws only for an id the request named and it could not find; with none it returns the user root, which is always a folder. Ours substituted the empty guid for "no parent" and then refused it. Two neighbouring cases in the sameifwent with it:includeItemTypesnaming exactlyTrailerorProgramresolves no parent at all, and a parent that is a row but not a folder is nothing to draw from — both are the four empty lists, not a refusal.3 → 0 differences on the snapshot, 3 → 0 on the anchor. Seven shapes checked live against the reference container, including a folder parent, which now agrees field for field.
/Yearswas ordered the other way — closed for the ordering, not closed for the countYearsControllergathers a year per item in walk order and then calls_libraryManager.Sortover the year items, whoseSortNameisModifySortChunksof the name — digits padded to ten, so0000001950leads. Ours sorted nothing.TotalRecordCount, upstream 18 against our 17./Years?Limit=50&SortBy=SortNamestill differs onreference/snapshots/default, so this route is not fully agreed there. The mechanism is settled: upstream reports whatFolder.AddChildrenFromCollectionsummed, which counts the folders it walked through with no de-duplication. Measured per folder on the reference — movies 3, shows 7, music 4, plus the user root's own four children (three collection folders and the Playlists folder) = 18. Ours counts the 17 rows its walk returned, which include the three physical folders and neither the collection folders nor the Playlists folder. Matching it means modelling that traversal, not changing a line.Items[n].Nameand all 50Items[n].UserData.Keydiffered; after, everyNameagrees positionally and the two servers answer the same set of years. What is left is 50 ×Items[n].Id, 50 ×Items[n].UserData.ItemId, and 1 ×TotalRecordCount(21,829 against 21,895). So the page is the right page, and the residue is an id difference rather than an ordering one.bc95b15a…is exactlyMD5("MediaBrowser.Controller.Entities.Year" + "/home/alex/.cache/anchor/metadata/year/1950"), the seed path hashed whole, written by an earlier run whose metadata directory sat outside its program data directory. Ours derives the correctb24306b2…=metadata\year\1950, finds no such row in its copy, falls back to the by-name lookup and returns the stale duplicate; upstream creates a row at the derived id during the run and answers with that. On the snapshot pair, arranged correctly, the year ids agree.Two things about
/Yearsthat no scenario asks and nothing here fixesSortBy, upstream leaves the walk's order, and so do we — but the two walks are not the same walk. Measured live against the reference:GET /Yearsis["2020","2022","2021","2019"]upstream and["2019","2020","2022","2021"]ours. Upstream's order is the DFSAddChildrenbuilds over the user root (movies, then shows, then music); ours is whatever the SQL returns. Nothing in this fix addresses it, and no scenario sees it —portable.jsonalways sendsSortBy=SortName.SortBy=Defaultrides on that. No comparer declaresItemSortBy.Default(LibraryManager.cs:2282), so upstream'sSortskips it and the walk order survives; ours readDefaultas the sort name and sorted, which made/Years?SortBy=Defaulta different page from/Years. That is fixed — the two now behave alike on each server — but because the walk orders differ, the two servers still disagree on both, for the reason above.Only two of upstream's comparers are modelled for this listing, the sort name and the name. Any other field leaves the walk's order, which is upstream's answer only where its own comparer holds the year items equal:
Randomshuffles, and a year'sDateCreatedis its metadata directory's creation time (LibraryManager.cs:1321), a tie on the snapshot only by accident of one scan.All ten requests are answered. Measured on
main, one pair,portable.jsonagainst the anchor:ba8f44e(this issue)mainThe one request left is
/Years, and it is the residue recorded below rather than any of the six bugs.What each turned out to be
NameStartsWithmatched the wrong column.ApplyNameFiltersmatchesSortName, not the punctuation-strippedCleanName— which is how(a) period.was admitted toA. 1,933 → 0.SearchTermandFilters=IsResumablewere never read at all. Neither field was set by the route, so both requests answered the plain unfiltered listing."Wuthering Heights"was not a ranking bug; it is simply row one of the whole library. Once the route set them, the predicates behind both had to be built: the provider's 100/80/75/50 scoring overCleanName, thelimit * 3candidate cut, the in-memory re-sort that puts the sort name ahead of the requested one, and for resume a position past zero, a version group cut to the version played last, and a series read from its episodes only where the request asked for series. 1,928 → 0 and 87 → 0.EnableImagesandEnableUserDatawere never read either. Both now drop what upstream drops, including thatImageTagsis omitted rather than sent empty whileImageBlurHashesstays as{}, and that the series' and album's posters survive an otherwise-emptied map.RecursiveItemCountgoes with the user's state regardless ofFields, because upstream assigns it only insideFillUserDataDtoValues.A media source with no file behind it is a placeholder, and a file source always carries an etag. The etag of a row with no modification date is the MD5 of
"0"— hashed UTF-16LE and byte-swapped into a guid, which is543b6ca4c9f21c87d81daf7a932499c0, the value upstream returned on all 25 rows. That constant is pinned in a test./Items/Filtersanswered 400 because ofparentId— by its absence.GetParentItemthrows only for an id it was given and could not find; with none it returns the user root. We substituted the empty guid and then refused it./Yearswas ordered the other way because upstream sorts the Year items in memory, and a year's sort name is its digits zero-padded to ten — so0000001950leads./Items/Filters2was not the bug this issue described. The issue said we normalise the language before naming it. We do not: both servers return the same 95 subtitle and 32 audio pairs, name for name and value for value. All 209 differences were positional, from.OrderBy(l => l.Name)running in memory — .NET's culture-aware comparison against our byte order. There was one real naming bug beside it: a codeFindLanguageInforesolves nothing for is named by the code alone, where we wrotees-ES (es-ES)./Artists/AlbumArtistswas aSortNametie, betweenFall Out BoyandFall Out Boy— upstream appendsThenBy(e => e.Name)after a sort-name ordering, and our by-name listing did not. A second bug fell out of it: upstream picks aMusicArtistgroup's representative by whether itsTopParentIdis reachable before the lowest id, so we were listing a metadata row with no library path and no images where upstream lists the one inside the library.The
ParentBackdropresidue was the opposite of what it looked like. Giving the named album artist its images makes the run worse — 4,604 → 4,679 — because upstream builds its artist batch from what the page's own items credit and reads those rows without images. An album whose artist the page credits inherits nothing; a name the batch does not hold falls through to the artist above the album, which does carry pictures; and the by-id path passes no batch at all. The batch is also keyed on the name as spelled, ASCII-lowercased — not folded — soMØLis reached by nothing, exactly as upstream reaches nothing.What is left, and where it went
/Yearsstill differs on 101: 50Idand 50UserData.ItemId, plusTotalRecordCount. EveryNameagrees positionally and both servers answer the same set of years, so the page is the right page — the ids are an artefact of this fixture, which carries two rows per year from an earlier run whose metadata directory sat outside its program data directory.TotalRecordCountis real and is not the fixture: upstream counts the folders its recursive walk passed through, with no de-duplication./Yearsis also not fully closed onreference/snapshots/default, for the sameTotalRecordCountreason, and the unsorted page order still differs on both fixtures — upstream's is the depth-first orderAddChildrenbuilds, ours is whatever SQL returns.portable.jsonnever sees it because it always sendsSortBy=SortName.Five further findings went to issues of their own rather than into these branches: #87 covers the by-name listings that ignore
SortBy/SortOrder, and the rest are filed separately.