perf(sync): port the Xtream refresh work to m3u and cap the import's memory - #202
Open
bilipp wants to merge 1 commit into
Open
perf(sync): port the Xtream refresh work to m3u and cap the import's memory#202bilipp wants to merge 1 commit into
bilipp wants to merge 1 commit into
Conversation
…memory The m3u pipeline never got PR #196's levers. Measured against a real provider export — 520 MB, 1,719,199 entries (1,484,110 episodes across ~47.4k series, 178,231 movies, 56,858 live) — it assigned every field unconditionally, saved every batch unconditionally, recompiled the same ICU pattern once per entry, and held ~337 MB of seen-id strings live across the whole import and all four sweeps. What changed: - Dirty-checked field application (applyM3ULiveStreamFields / applyM3UMovieFields / applyM3USeriesFields / applyM3UEpisodeFields) with the per-batch write gated on context.hasChanges, mirroring ContentSyncManager+Helpers. The fetch-before-write upsert stays: a blind insert wipes isFavorite / watchProgress / enrichment (f12b4b1). - num is assigned only on insert, seeded one past the playlist's stored maximum. m3u num is a file position, so re-assigning it every sync dirties the whole tail after any provider reordering and the dirty check delivers nothing — but handing an insert the raw position collides with the row already holding it, leaving SortOption.playlist to break the tie. Accepted cost: new content sorts at the end, so playlist order drifts from the provider's file over time. - series.categoryId is written once per series per batch instead of once per episode — up to 2,799 writes for a single show per sync. - One hoisted NSRegularExpression instead of a per-call compile, and episodeInfo now returns the title from the same match, so cleanEpisodeTitle no longer re-runs the identical pattern over every episode name. ~40 s + ~35 s per import on a fast Mac; verified behaviour-identical over all 1,719,199 real names, 0 mismatches. cleanEpisodeTitle itself is untouched for its Xtream and Stalker callers. - Seen-ids are Set<UInt64> of M3UIdentity.hash64 (~19-33 MB against ~337 MB) and each set is released the moment its sweep returns. sweepPaged's membership test is generalised to an isSeen closure; the shared pruneStale* signatures are unchanged for Xtream and Stalker. A collision keeps a stale row and can never delete a live one. - M3UParser.parse's chunk loop body runs in an autoreleasepool: 502 MB peak RSS to 10 MB. The import is one uninterrupted synchronous stretch inside an actor job, so the enclosing pool never drained. - The five m3u sweeps route through the same sweepIsAllowed coverage gate the Xtream ones use. This is a correctness fix, not a perf one: the provider sends chunked transfer with no Content-Length, so a connection cut mid-file used to parse as a valid short playlist, pass the old totalImported > 0 check, and sweep the rest of the catalog — which the following reconcile turns into permanent cross-device deletion of favourites and watch progress. - Skip-if-unchanged: SHA-256 of the downloaded file, device-local in UserDefaults, skips the import and the sweeps on a match. Two downloads 30 minutes apart were byte-identical, so it fires. A skip still reads the #EXTM3U header alone, or clearing the guide URL would strand the playlist without one until the provider's bytes changed. - The import is cancellable (it was not), and reports an honest bytes-consumed fraction instead of an unmoving screen. Per-batch progress publishes and log lines are throttled — there were ~860 of each. - 10 sub-phase signposts split the import into parse / classify / upsert / prune, nested inside M3UImport. Attribution had to come first; the whole import was one opaque number. - An m3u URL that is an Xtream get.php endpoint now says so on the add-playlist screen. Hint only, no conversion: content identity differs between the pipelines, so switching would orphan every favourite and watch position. The download itself is irreducible — no gzip, no ETag, no Last-Modified, no Content-Length, no Accept-Ranges, 37 s to first byte, 2m04s on a fast Mac. Recorded as a dead lever along with reordering classify (which would reclassify 1.48M episodes as movies, and measured slower) and the type=m3u variant. Also recorded: the same account's Xtream API serves the equivalent catalog in ~135 MB / 282k rows. Eager materialisation of ~1.48M Episode rows is deferred, not decided, and the Series.episodes inverse-fault on those inserts is still unmeasured — both written up in the perf README with the migration hazards. Every number here is from a Mac or simulator harness. No Apple TV trace has been taken, so these are a floor, not a device result. No schema change, no new stored properties, no container changes.
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
A real provider export fetched as m3u — 520 MB, 1,719,199 entries (1,484,110 episodes across ~47.4k series, 178,231 movies, 56,858 live) — took minutes to sync on an Apple TV 4K. PR #196 made the Xtream refresh ~4x cheaper; the m3u pipeline never got any of it. It assigned every field unconditionally, saved every batch unconditionally, recompiled the same ICU pattern once per entry, and held ~337 MB of seen-id strings live across the whole import and all four sweeps.
One thing up front, because it frames everything else: the download is irreducible. Probed directly — no gzip despite
Accept-Encoding, noETag, noLast-Modified, noContent-Length, noAccept-Ranges(HTTP 200 to aRangerequest), 37 s to first byte, 2m04s for the full 520 MB on a fast Mac. No HTTP-layer lever exists. And the same account's Xtream API serves the equivalent catalog in ~135 MB / 282k rows, because Xtream stores 47,568 series shells and fetches episodes on demand where an m3u file enumerates every episode inline. This PR makes the import dramatically cheaper; it cannot make this playlist small.Implementation
#196's levers, ported. Dirty-checked field application (
applyM3ULiveStreamFields/Movie/Series/Episode) with the per-batch write gated oncontext.hasChanges. The fetch-before-write upsert stays untouched — a blind insert on the unique id wipesisFavorite/watchProgress/ enrichment, the bugf12b4b1fixed. Seen-ids becameSet<UInt64>ofM3UIdentity.hash64(~19–33 MB against ~337 MB), each released the moment its sweep returns.sweepPaged's membership test is generalised to anisSeenclosure, so the sharedpruneStale*signatures are unchanged for Xtream and Stalker. A hash collision keeps a stale row and can never delete a live one.numis the subtle one. m3unumis a file position, not a provider value, so re-assigning it every sync dirties the entire tail after any reordering and the dirty check delivers nothing. It is now assigned only on insert — but seeded one past the playlist's stored maximum, because handing an insert the raw file position collides with the row already holding it and leavesSortOption.playlistto break the tie arbitrarily. Accepted cost: new content sorts at the end, so playlist order drifts from the provider's file over a playlist's life. Uniqueness is pinned by tests.The largest non-SwiftData cost was regex.
episodeInfodeclared its pattern inside the function, recompiling ICU 1,719,199 times, andcleanEpisodeTitlethen re-ran the identical pattern over all ~1.48M episode names thatclassifyhad just matched — ~40 s + ~35 s per import. Now one hoistedNSRegularExpression, withepisodeInforeturning the title from the same match. Verified behaviour-identical against all 1,719,199 real names, 0 mismatches.cleanEpisodeTitleitself is untouched for its Xtream and Stalker callers. Also:series.categoryIdwas written once per episode — up to 2,799 times for one show per sync — now once per series per batch.M3UParser.parse's chunk loop had noautoreleasepool: 502 MB peak RSS → 10 MB. The import is one uninterrupted synchronous stretch inside an actor job, so the enclosing pool never drained.A correctness fix found on the way. The m3u sweeps called
pruneStale*directly, bypassing thesweepIsAllowedcoverage floor that protects Xtream; the only gate wastotalImported > 0. With chunked transfer and noContent-Length, a connection cut at 60% parsed as a valid short playlist and swept the rest of the catalog — and the reconcile firing seconds later converts each delete into.pushToCloud(nil), permanently destroying thatUserContentStateon every device. All five sweeps now route through the same gate. Deliberate trade: a provider that genuinely drops a section keeps dead rows for up to two extra syncs.Also in scope: SHA-256 skip-if-unchanged (device-local in
UserDefaults, never mirrored toSyncedPlaylist); the import is now cancellable (it was not —Task.checkCancellation()appeared in the Xtream and Stalker loops and nowhere here) and reports an honest bytes-consumed fraction; ~860 per-batch progress publishes and log lines are throttled; 10 sub-phase signposts split the import into parse / classify / upsert / prune, because attribution had to come first; and an m3u URL that is an Xtreamget.phpendpoint now says so on the add-playlist screen — hint only, since content identity differs between the pipelines and a conversion would orphan every favourite and watch position.Testing
--strict) and SwiftFormat clean on every file this branch touchesM3UFieldApplicationTests,M3USeriesFieldApplicationTests,M3UDigestSkipTests), plus m3u cases inContentSyncPruneTests/M3USyncTests/M3UParserTests, an episode-heavy scale test, and 3 new store benchmarks inM3UPersistenceBenchmarksPlatforms
visionOS 26.5 is not installedon this machine, so no destination exists andxcodebuildnever reached compilation. Not a code issue and not the../LumeEnginepairing problem — package resolution succeeded first. Needsxcodebuild -downloadPlatform visionOS.Notes for the reviewer
Every number here is from a Mac or simulator harness. No Apple TV trace has been taken, so they are a floor, not a device result — which matters, because the original complaint is a device one. The new signposts exist so a single
xctracepass can attribute the rest; the recipe is inLumePerformanceTests/README.md.Three things deliberately left out of scope, all written up in the perf README rather than silently dropped:
Episoderows is deferred, not decided. Converging on the Xtream lazy shape would touch Continue Watching, Up Next,NextEpisodeResolver, offline browsing, search, Downloads and Trakt, and every existing m3u user has CloudKit watch progress keyed by episode id.Episode.serieson ~1.48M inserts faults theSeries.episodesinverse is unmeasured and could dominate everything above. It is also the shape behind closed issue bug: app stuck on sync screen after adding a playlist (tvOS) #45'sPersistentIdentifier … remapped to a temporary identifiercrash.Recorded dead levers, so nobody re-derives them: reordering
M3UClassifier.classifyto test URL shape first (turns ~1.48M episodes into movies and measured slower), thetype=m3uvariant (normalizedPlaylistURLrewrites tom3u_plusdeliberately), and every HTTP-layer idea.Two behaviour changes a reviewer should agree with explicitly: playlist order drifting for m3u (the
numtrade above), and the sweep gate keeping dead rows for up to two extra syncs.No schema change, no new stored properties, no container changes. New files rely on the project's synchronized filesystem groups, so
project.pbxprojis untouched — verified by the new suites actually compiling and running.Related
Follows #196, which did the same work for the Xtream pipeline.