Skip to content

perf(sync): port the Xtream refresh work to m3u and cap the import's memory - #202

Open
bilipp wants to merge 1 commit into
mainfrom
feature/m3u-sync-perf
Open

perf(sync): port the Xtream refresh work to m3u and cap the import's memory#202
bilipp wants to merge 1 commit into
mainfrom
feature/m3u-sync-perf

Conversation

@bilipp

@bilipp bilipp commented Sep 1, 2026

Copy link
Copy Markdown
Owner

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, no ETag, no Last-Modified, no Content-Length, no Accept-Ranges (HTTP 200 to a Range request), 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 on context.hasChanges. The fetch-before-write upsert stays untouched — a blind insert on the unique id wipes isFavorite / watchProgress / enrichment, the bug f12b4b1 fixed. Seen-ids became Set<UInt64> of M3UIdentity.hash64 (~19–33 MB against ~337 MB), each released the moment its sweep returns. sweepPaged's membership test is generalised to an isSeen closure, so the shared pruneStale* signatures are unchanged for Xtream and Stalker. A hash collision keeps a stale row and can never delete a live one.

num is the subtle one. m3u num is 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 leaves SortOption.playlist to 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. episodeInfo declared its pattern inside the function, recompiling ICU 1,719,199 times, and cleanEpisodeTitle then re-ran the identical pattern over all ~1.48M episode names that classify had just matched — ~40 s + ~35 s per import. Now one hoisted NSRegularExpression, with episodeInfo returning the title from the same match. Verified behaviour-identical against all 1,719,199 real names, 0 mismatches. cleanEpisodeTitle itself is untouched for its Xtream and Stalker callers. Also: series.categoryId was 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 no autoreleasepool: 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 the sweepIsAllowed coverage floor that protects Xtream; the only gate was totalImported > 0. With chunked transfer and no Content-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 that UserContentState on 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 to SyncedPlaylist); 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 Xtream get.php endpoint 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

  • Acceptance criteria met — the refresh path is dirty-checked, gated and memory-capped
  • Tests pass — 1016 tests in 103 suites, serial, zero failures (both known flakes green)
  • SwiftLint clean (--strict) and SwiftFormat clean on every file this branch touches
  • Tests added — 3 new suites (M3UFieldApplicationTests, M3USeriesFieldApplicationTests, M3UDigestSkipTests), plus m3u cases in ContentSyncPruneTests / M3USyncTests / M3UParserTests, an episode-heavy scale test, and 3 new store benchmarks in M3UPersistenceBenchmarks

Platforms

  • iOS / iPadOS — builds clean, full suite green
  • tvOS — builds clean (Apple TV 4K 3rd gen simulator)
  • macOS — compiles and links clean (unsigned; no developer account on this machine, so signing/entitlements are not covered)
  • visionOS — could not be built. visionOS 26.5 is not installed on this machine, so no destination exists and xcodebuild never reached compilation. Not a code issue and not the ../LumeEngine pairing problem — package resolution succeeded first. Needs xcodebuild -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 xctrace pass can attribute the rest; the recipe is in LumePerformanceTests/README.md.

Three things deliberately left out of scope, all written up in the perf README rather than silently dropped:

  1. Eager materialisation of ~1.48M Episode rows 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.
  2. Whether setting Episode.series on ~1.48M inserts faults the Series.episodes inverse 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's PersistentIdentifier … remapped to a temporary identifier crash.
  3. Issue feat: Option to skip VOD content when syncing a playlist (Live TV only) #109 (live-only import) would remove 96.7% of this provider's entries — two orders of magnitude beyond any micro-optimisation here — and is the better answer for playlists this size.

Recorded dead levers, so nobody re-derives them: reordering M3UClassifier.classify to test URL shape first (turns ~1.48M episodes into movies and measured slower), the type=m3u variant (normalizedPlaylistURL rewrites to m3u_plus deliberately), and every HTTP-layer idea.

Two behaviour changes a reviewer should agree with explicitly: playlist order drifting for m3u (the num trade 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.pbxproj is untouched — verified by the new suites actually compiling and running.

Related

Follows #196, which did the same work for the Xtream pipeline.

…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.
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