Skip to content

fix(library): let a superseded load seed an empty cache - #213

Merged
GCHOfficial merged 2 commits into
mainfrom
fix/superseded-load-seeds-empty-cache
Sep 4, 2026
Merged

GCHOfficial merged 2 commits into
mainfrom
fix/superseded-load-seeds-empty-cache

Conversation

@GCHOfficial

@GCHOfficial GCHOfficial commented Sep 3, 2026

Copy link
Copy Markdown
Owner

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._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 Android TV box (29 s download + 22–26 s compute() 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: 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 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:

  • stale commits first → nothing cached → it writes; fresh commits second → fresh wins.
  • fresh commits first → stale finds a populated cache → skips.

Rejected alternatives

  • Always write. Unsafe for the same-id upgrade reason above.
  • Put 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".

Also in this change

  • loadMoreMedia keeps the plain skip (comment only). 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 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.
  • Load-failure logs now carry 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.
  • The series browser no longer inherits a cancelled load token. MediaDetailsSheet's season/episode drill-downs were the only loadMedia callers that are not a MediaTabController, and they set nothing — so they inherited whatever was left on the shared repository. The movie and series controllers are built over one LibraryRepository, each cancels only its own token, and dispose cancels one outright, so a drill-down could begin already-cancelled through no act of its own. Both sites now set loadToken = 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.
  • Two stale doc claims corrected (both wrong before this change): loadToken does not reach the EPG batch path at all — EpgIngestCoordinator mints its own tokens for that — and it stops overwriting, not writing.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Docs / CI / tooling

Testing

  • flutter analyze is clean
  • flutter test is green — 1161 passed, 34 skipped (the documented libmpv skips on a Windows dev box; channel_list_focus_test runs for real only on CI's Linux runner)
  • Manually verified on at least one target platform

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 claims loadToken instead 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 reports false; another source id does not block the seed; synced_at set with zero channel rows still seeds (the boundary that keeps the guard the exact complement of the read gate — a guard testing only synced_at would 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 in docs/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 Xtream get.php link, 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 loadMedia still 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

George-Cosmin Hanta and others added 2 commits September 4, 2026 02:22
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
@GCHOfficial
GCHOfficial merged commit 5dd14bd into main Sep 4, 2026
7 checks passed
@GCHOfficial
GCHOfficial deleted the fix/superseded-load-seeds-empty-cache branch September 4, 2026 06:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant