Skip to content

fix(cache): retain software VOD read-ahead across cache-local seeks - #512

Merged
superuser404notfound merged 1 commit into
superuser404notfound:mainfrom
orut34iop:codex/pr-software-vod-cache-20260907
Sep 7, 2026
Merged

superuser404notfound merged 1 commit into
superuser404notfound:mainfrom
orut34iop:codex/pr-software-vod-cache-20260907

Conversation

@orut34iop

@orut34iop orut34iop commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Companion submissions, independently reviewable against main:

Give seekable software VOD a real compressed-packet read-ahead cache and a continuous selected A/V buffered frontier from startup. Retain packet chunks for cache-local forward/backward seeks instead of dropping the displayed frontier whenever the decoder repositions.

This is independent of the recovery-point routing and Matroska timestamp proposals. It does not change host progress-bar UI.

What changed

  • Worker-owned disk FIFO and lossless AVPacket envelopes; separate producer read-ahead from renderer/decode backpressure. Reuse upstream forwardBufferSegments and its existing session retention / volume-safety budget.
  • Exact selected A/V coverage. For H.264, use bounded presentation successors rather than AVPacket.duration, which may represent decode cadence instead of a VFR picture's display hold. Unknown/late timestamps and discontinuities never become guessed cached seconds.
  • Retained keyframe cursors provide available preroll on cached seeks. Cache hits keep the source epoch and existing producer frontier, including in-flight reads; misses reposition/reset the actual source.
  • Admission is checked under the demuxer lock and against host seek generations for packets, EOF, errors and delayed tail callbacks. A VOD consumer at EOF parks so a later seek can resume it.
  • Bounded record/chunk slack and exact-budget refill; per-session leases and symlink-safe bounded stale cleanup. Initialization failure retains direct playback; runtime corruption is explicit.
  • Optional LiveTelemetry.softwareCacheSeekHits, softwareCacheSeekMisses, softwareCacheSourceEpoch; software cachedBytes reports compressed residency and bufferedPosition reports continuous cache coverage. Native forwardBufferSeconds and decoded displayCushionSeconds keep their meanings.
  • Six deterministic standalone regression drivers added to CI, using synthetic records only.
  • Based on main 1af43f017ffca973e35f8fec1e700852f2953d55. Port combines the final accepted downstream cache state, not the earlier incomplete cache attempt. Downstream custom budget settings/public diagnostics were omitted; upstream's existing budget resolver is used. No UI, dependency pins, unrelated fork history or timestamp repair.

Why packet duration was insufficient

An observed H.264 VFR window at time base 1/30000 had presentation slots separated by 16016 ticks while the corresponding packet duration was 1001. The renderer holds the previous picture until its successor; treating every packet duration as a presentation interval left a false hole at about 42.075 s. This patch changes only cache coverage accounting, not the packets or their timestamps.

Seeking within buffered content also used to clear the forward cache display. Consumer generation and producer source epoch now have separate ownership, so moving a retained cursor does not discard data already downloaded.

Test plan

  • Device / OS: physical Apple TV 4K (3rd generation), tvOS 26.6 beta (23L773).
  • Media: private MP4/H.264 Main 1280×720, 8-bit yuv420p, nominal 30000/1001 fps with variable display holds, time base 1/30000; AAC-LC 44.1 kHz stereo. No HDR/DV signaling reported.
  • Accepted downstream core 9c045764bd2bfd4a8356866b768961723286a3e9, candidate 20260907131625. User verified cache progress from head, forward/backward cached seeks and an uncached seek.
  • At playback 0.913 s the cache frontier was 130.597 s; at 14.913 s it was 823.690 s. Five retained seeks, including a backward seek, gave hits=5, misses=0, source_epoch=0. A later out-of-cache seek advanced misses=1 and source_epoch=1.
  • A settled software window had measured enqueue rate 30.5–30.9 fps (median 30.6); the cumulative drop counter stayed at 40, i.e. no new drops during that window, not zero drops over the entire session.
  • Local six script checks all passed: packet coverage, H.264 VFR successor coverage, disk FIFO, read-ahead concurrency, host read admission and 128 AVPacket-envelope round trips (tracked packet balance zero).
  • Tests cover malformed/overflow cases, selected-A/V gaps, exact residency-budget refill, rapid and backward seeks, cache misses/eviction, stale consumer/source-lock admission, in-flight producer preservation, stop wakeups, EOF/errors, corrupted stores, bounded cleanup/live leases/symlinks.
  • Local arm64 tvOS package build passed with Xcode 26.6. Docs links and diff checks passed. Full Swift package tests run in upstream CI; the isolated upstream branch was not separately installed.

Risks / boundaries

The cache adds temporary disk I/O and compressed residency; the existing forward-window/volume budget remains the policy, with documented protected-record/chunk slack. H.264 successor accounting assumes its bounded picture reordering; late timestamps invalidate coverage rather than guessing. Other codecs use strict duration coverage. Live DVR and native resident-range APIs are not repurposed.

Private media, source paths/URLs, credentials, raw logs, device identifiers and screenshots are not included. No host UI changes are required.

Checklist

  • CHANGELOG.md, API and architecture docs updated
  • Conventional Commit
  • Engine cache/seek fix; no host UI workaround
  • Additive optional telemetry documented

@superuser404notfound
superuser404notfound merged commit c9b8d63 into superuser404notfound:main Sep 7, 2026
superuser404notfound pushed a commit that referenced this pull request Sep 7, 2026
…512)

The forward-second limit was keyed on the coverage frontier, and both coverage
models can stop answering. The H.264 successor model invalidates permanently on
one presentation timestamp below its watermark; the duration model's containing
span ends at the first hole and cannot grow past it while the clock sits behind
it. With no frontier the seconds limit stopped existing and only the disk budget
still bounded the producer. Measured over the real types with a 10 s window and
a 400 kB budget: 283 packets read with clean timestamps, 1316 with a single late
one. On the shipping budget that is a 40 second window turning into a whole-file
prefetch, while `bufferedPosition` falls back to the cushion at the same moment,
so nothing says it happened.

The limit is measured on the reservoir instead, from the packet the consumer
last took to the newest one stored. Two timestamps say it, a hole does not move
it, and a late one moves it in the safe direction. The frontier keeps the job it
is good at, which is `bufferedPosition`.

Two more on the same subsystem:

`isSourceSeekable` is true for a local open too, so a `file://` session wrote a
second copy of its compressed stream into the temporary directory (14 MB in 25 s
at the source bitrate) to save re-reads that are page-cache hits. A source
libavformat opened itself has no AVIOProvider, and those sessions stay on the
direct loop.

The miss path reset the spool inside the seek. Reset removes every retained
chunk one at a time: 4.5 ms at 64 MB, 18.9 ms at 256 MB, 60.9 ms at 512 MB,
against a ceiling of 2 GiB, so a miss got slower the longer the session had been
running. The producer is parked on `sourceRepositioning` until `endSeek` and
takes `resetPending` before anything else, so the worker does it and no seek
waits on it.

Verified: 2759 swift-testing plus 605 XCTest green, the six standalone drivers
pass, doc links resolve. Two of the four new tests fail on c9b8d63. On the same
600 s source over the same 16 Mbit origin, the 198 s backward seek is still a
cache hit with no request behind it (landing 63 ms) and the published frontier
is still the playhead plus the 40 s window; the `file://` arm now creates no
spool directory at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQ8MerNbZQdWBpDStitoD
superuser404notfound pushed a commit that referenced this pull request Sep 7, 2026
… how deep it is still stops (#512)

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014tQ8MerNbZQdWBpDStitoD
@superuser404notfound

Copy link
Copy Markdown
Owner

Merged in c9b8d63a, with three follow-ups of mine on top (62ec0758).

The measurement that decided it, on a 600 s H.264 source through a rate-limited 16 Mbit origin with play --sw, seeking back 198 s after 200 s of playback:

main this branch
origin requests after the seek two 4 MB detour fetches none
blocking reads on the demux thread 2658 ms and 2623 ms none
display cushion after the landing 0.00 s for four ticks untouched

That is the AE#295 stutter signature on the seek path. The reservoir the software loop never had is worth as much again on its own: it read on renderer backpressure alone, which is 0.33 s to 0.36 s deep on real hardware, so any source hiccup longer than a frame interval was already a picture. Both runners green on the merge (2755 swift-testing, 605 XCTest), the six standalone drivers pass, and a 24 seek storm costs 3.6% more origin bytes than main with landings in the same class.

Three things I changed afterwards rather than sending back.

The forward-second limit failed open. shouldParkLocked was keyed on the coverage frontier, and both coverage models can stop answering: the H.264 successor model invalidates permanently on one presentation timestamp below its watermark, and the duration model's containing span ends at the first hole and cannot grow past it while the clock sits behind it. With no frontier the seconds limit stops existing and only the disk budget bounds the producer. Measured over the real types, 10 s window and a 400 kB budget: 283 packets read with clean timestamps, 1316 with a single late one. On the shipping budget that is a 40 second window turning into a whole-file prefetch, and bufferedPosition falls back to the cushion in the same moment, so nothing says it happened. The limit is now measured on the reservoir itself, from the packet the consumer last took to the newest one stored: two timestamps say it, a hole does not move it, and a late one moves it in the safe direction. The frontier keeps the job it is good at, which is bufferedPosition. Four tests in SoftwarePacketReadAheadParkTests, two of which fail on the merge commit.

A local path spooled for nothing. isSourceSeekable is true for a file:// open too, so a local source wrote a second copy of its compressed stream into the temporary directory, 14 MB in 25 s at the source bitrate, to save re-reads that are page-cache hits. A source libavformat opened itself has no AVIOProvider, and Demuxer.readsSourceDirectly now keeps those sessions on the direct loop.

The miss path reset the spool inside the seek. prepareSeek called fifo.reset() before returning, and reset removes every retained chunk one at a time: 4.5 ms at 64 MB, 18.9 ms at 256 MB, 60.9 ms at 512 MB, against a ceiling of 2 GiB. So a miss got slower the longer the session had been running, for work no seek waits on. The producer is parked on sourceRepositioning until endSeek and takes resetPending first, so it does the reset instead.

One thing I left alone: the six drivers under Scripts/tests are a second test system beside swift test, which is where the package's other 3300 tests live and where the next person will look. They run and they pass, so they stay, but new tests for this subsystem go in Tests/AetherEngineTests.

Thanks for splitting the three submissions apart, it made this one reviewable on its own merits. The packet envelope, the lease handling and the separation of consumer generation from source epoch all held up under everything I pointed at them.

@superuser404notfound

Copy link
Copy Markdown
Owner

Released in 6.74.0 (c9b8d63a plus the hardening in 62ec0758). Both consumers are bumped.

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.

2 participants