fix(library): let a superseded load seed an empty cache - #213
Merged
Merged
Conversation
A user reported a source that loaded on one device and not another. Their diagnostics export showed the whole failure and, decisively, *no* `source:<id>` ingestion line: that M3U source had never once written a cache. `_loadChannels` returned without calling `replaceLibrary` whenever its `LoadToken` had been cancelled mid-fetch. On a 66 MB playlist that costs ~55 s on a low-end TV box (29 s download + 22-26 s isolate parse), and any repository rebuild cancels the load -- the load-time M3U->Xtream probe alone does one per app start. So the finished catalog was thrown away, the cache stayed empty, and the next attempt paid the full 55 s again, forever. The same source loaded instantly on the device that happened to have a cache. Refusing the write outright was too strong, but writing unconditionally is unsafe: `upgradeM3uToXtream` deliberately keeps the source id, so an M3U load still in flight can outlive the conversion and write playlist-shaped rows under a source that is now Xtream -- whose `resolve` falls back to `channel.id` for the stream id, i.e. every channel silently unplayable, with a fresh `synced_at` and no age check to heal it. Commit order is not fetch order either. So the rule is narrower: a superseded load seeds an empty cache but never overwrites a populated one. `replaceLibrary` and `replaceMediaLibrary` take `onlyIfAbsent`, whose predicate is evaluated *inside* the write transaction and is the exact complement of the matching cache-read gate. That settles both commit orders with no sequence numbers and no identity on the token: the stale writer commits only while there is nothing to lose, and a fresh catalog that already landed makes it a no-op. Rejected: putting source identity on the `LoadToken`. A `LibraryRepository` is bound to one source for its lifetime, so that discriminator is always "yes" and fixes nothing. The right question is "has a newer write already landed", not "who cancelled". `loadMoreMedia` keeps the plain skip. It guards page bookkeeping rather than freshness (`appendMediaItems` writes back paging state read at the top of the method), and it returns early when `sync == null`, so it can never reach an empty cache to seed. A superseded load also no longer schedules an EPG refresh: `EpgIngestCoordinator` is last-start-wins, so a late one would take the slot from the correct refresh already running. Separately, the failure logs threw away the diagnosis. `LiveController` and `MediaTabController` recorded only `sourceLoadErrorMessage`'s bucketed text, so seven distinct failures in that export printed identically and a provider's HTTP 403 was indistinguishable from a closed client. Both now append `detail=` with the redacted exception, matching the existing `_refreshEpg` call site. Also corrects two doc claims that were already stale: `loadToken` does not reach the EPG batch path at all (the coordinator mints its own tokens for that), and it stops *overwriting*, not writing. The pre-existing pin in `epg_batch_cancel_test.dart` passes unmodified -- it already seeded a prior cache before asserting the skip, which was always the load-bearing half of it; renamed to say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7XZbZDht55FaTjerT8fdU
`MediaDetailsSheet`'s season and episode drill-downs are the only `loadMedia` callers that are not a `MediaTabController`. Every controller path sets `repo.loadToken` in its own synchronous prologue, as the field's contract requires; the sheet set nothing, so it inherited whatever was left on the repository. The repository is shared. The movie and series controllers are built over one `LibraryRepository`, each cancels only its own token, and `MediaTabController.dispose` cancels one outright -- so a drill-down could begin already-cancelled through no act of its own, and quietly decline to cache its result. Before the seed rule landed that was a drill-down re-hitting the provider on every open. Both sites now claim the field, setting it to null: a token would only earn its keep if something could supersede these loads, and nothing can -- they write to their own `(kind, parentId)` cache key, which no other caller touches, so there is no staler writer to lose a race to. The caching half of the new test passes either way today, because a superseded `loadMedia` still seeds an empty cache. That is precisely the implicit coupling worth removing: tightening the seed rule would otherwise silently break the drill-down rather than fail a test. The token half fails against the old shape, verified. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F7XZbZDht55FaTjerT8fdU
Merged
8 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
From a field report: a source that loaded on one device and refused on another. The user's diagnostics export showed the whole failure and, decisively, no
source:<id>ingestion line at all — that M3U source had never once written a cache.LibraryRepository._loadChannelsreturned without callingreplaceLibrarywhenever itsLoadTokenhad been cancelled mid-fetch. On a 66 MB playlist that costs ~55 s on a low-end Android TV box (29 s download + 22–26 scompute()parse), and essentially any repository rebuild cancels the load — the load-time M3U→Xtream probe alone does one per app start. The finished catalog was discarded, the cache stayed empty, and the next attempt paid the full 55 s again, forever. The user's other device loaded instantly because it happened to hold a cache.Refusing the write outright was too strong. Writing unconditionally is unsafe:
upgradeM3uToXtreamdeliberately keeps the source id, so an M3U load still in flight can outlive the conversion and write playlist-shaped rows under a source that is now Xtream — whoseresolvefalls back tochannel.idfor the stream id, i.e. every channel silently unplayable, with a freshsynced_atand no age check to heal it. Commit order is not fetch order either.So the rule is narrower: a superseded load seeds an empty cache, but never overwrites a populated one.
replaceLibraryandreplaceMediaLibrarytakeonlyIfAbsent, whose predicate runs inside the write transaction and is the exact complement of the matching cache-read gate. Both commit orders then resolve correctly with no sequence numbers and no identity on the token:Rejected alternatives
LoadToken. ALibraryRepositoryis bound to one source for its lifetime, so that discriminator is always "yes" and fixes nothing. The right question is "has a newer write already landed", not "who cancelled".Also in this change
loadMoreMediakeeps the plain skip (comment only). It guards page bookkeeping rather than freshness —appendMediaItemswrites back paging state read at the top of the method — and it returns early whensync == null, so it can never reach an empty cache to seed.EpgIngestCoordinatoris last-start-wins, so a late one would take the slot from the correct refresh already running.LiveControllerandMediaTabControllerrecorded onlysourceLoadErrorMessage's bucketed text, so seven distinct failures in that export printed identically and a provider's HTTP 403 was indistinguishable from a closed client. Both now appenddetail=with the redacted exception, matching the existing_refreshEpgcall site.MediaDetailsSheet's season/episode drill-downs were the onlyloadMediacallers that are not aMediaTabController, and they set nothing — so they inherited whatever was left on the shared repository. The movie and series controllers are built over oneLibraryRepository, each cancels only its own token, anddisposecancels one outright, so a drill-down could begin already-cancelled through no act of its own. Both sites now setloadToken = null: a token would only earn its keep if something could supersede these loads, and nothing can — they write to their own(kind, parentId)key.loadTokendoes not reach the EPG batch path at all —EpgIngestCoordinatormints its own tokens for that — and it stops overwriting, not writing.Type of change
Testing
flutter analyzeis cleanflutter testis green — 1161 passed, 34 skipped (the documented libmpv skips on a Windows dev box;channel_list_focus_testruns for real only on CI's Linux runner)The pre-existing pin —
epg_batch_cancel_test.dart, "a token cancelled between the fetch and the cache write skips the stale write" — passes unmodified. It already seeded a prior cache before asserting the skip, which was always the load-bearing half of it; renamed to say so.New coverage:
epg_batch_cancel_test.dart: a cancelled token with nothing cached still seeds; a superseded load schedules no EPG refresh.media_details_episode_test.dart: the series browser claimsloadTokeninstead of inheriting a cancelled one, and its drill-down caches under its own key. Verified failing against the old shape.persistence_test.dart, new "AppDatabase.replaceLibrary onlyIfAbsent" group: seeds when absent; leaves a populated cache byte-identical and reportsfalse; another source id does not block the seed;synced_atset with zero channel rows still seeds (the boundary that keeps the guard the exact complement of the read gate — a guard testing onlysynced_atwould leave such a source permanently un-seedable); and the media mirror seeding per(kind, category, parent)key.Notes
Docs updated in the same diff per the Upkeep rule:
CLAUDE.md("Async publishes are generation-guarded"),docs/sources.md(why the load-time upgrade probe kills a large playlist's first load, and why the same-id upgrade bars overwriting),docs/validation-baseline.md(the ~55 s field measurement against the 493 ms host row for 250k channels), and a decision + progress entry indocs/implementation-plan.md.Not fixed here, and worth knowing: the five fast (~200 ms) failures in that export are still unexplained. Best read is the playlist URL returning a non-200, which the new
detail=will name outright in the user's next export. If that URL turns out to be an Xtreamget.phplink, the real fix is the upgrade path — there is no[m3u]line anywhere in the export, so it never ran, and the source may be cloud-managed.On the series-browser fix: its caching assertion passes either way today, because a superseded
loadMediastill seeds an empty cache — that is exactly the implicit coupling worth removing, since tightening the seed rule would otherwise silently break the drill-down rather than fail a test. The token assertion is what regresses, and it was verified failing against the old shape.Peak RSS on that TV box — with the 66 MB buffer, the isolate's copy, and the resulting channel list all live at once — is unmeasured and worth a soak.
🤖 Generated with Claude Code
https://claude.ai/code/session_01F7XZbZDht55FaTjerT8fdU