Skip to content

Phase 3: capped background preload of recordings - #259

Merged
neuromechanist merged 4 commits into
feature/issue-256-epic-viewer-workbenchfrom
feature/issue-254-phase3-streaming
Sep 1, 2026
Merged

Phase 3: capped background preload of recordings#259
neuromechanist merged 4 commits into
feature/issue-256-epic-viewer-workbenchfrom
feature/issue-254-phase3-streaming

Conversation

@neuromechanist

Copy link
Copy Markdown
Contributor

Summary

Part of epic #256. Implements #254.

Adds an optional, off-by-default "Preload full recording" gear toggle. When
enabled, a low-priority background scheduler walks the recording outward
from the current playhead at whatever pyramid level the interactive path is
currently rendering, filling a byte-capped in-memory LRU cache so paging and
scrubbing elsewhere in the recording become instant once the walk has
reached that region — the clinician scenario in the issue (reviewing a whole
recording, wanting paging/scrubbing to never wait on a fetch).

  • src/lib/eeg-viewer/prefetch.ts (new, pure module — no zarr/DOM
    dependency):
    • ByteCappedLRUCache<T>: byte-accounted LRU with two insert modes.
      put() (would-be interactive use) evicts LRU entries to fit. putIfRoom()
      (the background scheduler) never evicts — it fails once the cache is
      full so the walk stops rather than evicting what the viewport is
      showing, per the issue's memory-cap requirement.
    • outwardOrder / segmentIndexForTime: pure scheduling helpers.
    • PrefetchController<T>: walks the recording's segment grid outward from
      the playhead, yields to interactive reads (depth-counted, so a fast
      scrub holds it the whole time) and to document.hidden, and aborts
      cleanly via AbortController on stop().
  • src/lib/eeg-viewer/store.ts: additive-only. readWindow takes an
    optional trailing AbortSignal forwarded to zarr.get; readLevel0 and
    readViewLevel are now exported (previously private) so the preloader can
    target an explicit level directly instead of re-deriving readWindow's
    pixel-width heuristic for a window that isn't on screen. No existing call
    site changes behavior. Also adds windowDataBytes, a pure byte-accounting
    helper.
  • src/lib/eeg-viewer/viewer.ts:
    • Segments are windowLengthS-wide reads at lastWinLevel (the level the
      interactive path actually rendered last), fetched via the newly-exported
      level readers.
    • The interactive read path opportunistically serves an exact
      grid-aligned window from the cache before falling back to a network
      read — Page back/forward and Home/End move in exact multiples of the
      window length, so they hit the grid; an arbitrary scrub position
      usually doesn't, and falls through to a normal read, identical to
      preload being off. See "Known limitations" below.
    • Gear menu gets a new, self-contained "Preload" group at the end (see
      coordination note below) with the toggle and a 250 MB / 500 MB / 1 GB
      cache-limit select. Both persist in localStorage under
      nemar:eeg-preload / nemar:eeg-preload-cap-mb, every read/write
      guarded with try/catch.
    • Progress affordance: a thin buffered-region strip on the existing
      overview minimap (like a video player's buffer bar), shading segments
      the walk has already cached. This was cheap with the current markup
      (the minimap already redraws every render and on group switch), so it's
      included rather than skipped.
    • Preload pauses on visibilitychange (tab hidden) and the controller's
      stop() is wired into the viewer's existing destroy() cleanup path —
      same abort discipline eeg-viewer: superseded and closed mounts leave zarr fetches in flight #208 asks for, applied to this mount's own
      background reads (network-level abort for the interactive path is
      still open in eeg-viewer: superseded and closed mounts leave zarr fetches in flight #208 itself; this PR doesn't touch that).

Design decisions / known limitations

  • Cache granularity is decoded windows, not raw zarr chunks. Wrapping
    the zarr.FetchStore itself (so any overlapping interactive read gets a
    byte-level cache hit regardless of alignment) would need the store's
    generic type threaded through store.ts more broadly than the issue's
    "minimal changes to store.ts" allows, and zarr.FetchStore has private
    fields that prevent a duck-typed substitute from type-checking. Caching
    decoded WindowData per fixed-width segment is the smaller, additive
    change; the tradeoff is that only exact grid-aligned interactive reads
    are cache hits (see above) rather than every overlapping read.
  • The recording's last (partial) segment never opportunistically hits the
    cache
    on the interactive path — its span is shorter than
    windowLengthS by construction, which the exact-match check treats as
    "not a grid window." Correctness is unaffected (it just falls through to
    a normal read); a future refinement could special-case it.
  • Gear-menu addition is a single grouped("Preload", ...) block appended
    last, to stay out of the way of Viewer: navigate between recordings (next run/task/subject) with entity dropdowns #253's concurrent gear-menu work.

Test plan

  • bun run test — 1142 passed (33 new, all in prefetch.test.ts):
    LRU eviction order/recency, cap enforcement (put vs putIfRoom),
    setCapacity grow/shrink, outward scheduling order and edge clamping,
    exact-grid segmentIndexForTime, priority yielding (depth-counted),
    hidden-pause, clean abort via stop(), stop-when-full, and a
    superseded-retarget race (stale in-flight fetch discarded). Uses a
    fake transport as the network boundary (real-shape byte-accounted
    payloads), not a mock of business logic.
  • bun run typecheck — 0 errors.
  • bun run lint — clean.
  • bun run build — clean. One transient failure seen locally was
    api.nemar.org intermittently 500ing on an unrelated catalog
    pagination offset during OG-image generation (confirmed independently
    via direct curl against production, unrelated to this branch); a
    retry built clean.
  • Manual verification against a real dev-server viewer session was not
    done in this pass (no interactive browser available in this environment);
    the pure scheduling/cache logic is covered by the unit tests above, and
    the store.ts/viewer.ts wiring is additive and type-checked. Recommend a
    /design-review-style pass against test.nemar.org after this lands on
    staging, specifically: toggling preload on a long recording, watching the
    buffered strip grow, confirming Page back/forward feels instant once
    buffered, and confirming the tab-hidden pause (Network tab quiets down
    when backgrounded).

Coordination

Per the task brief: #253 (recording navigation) is also adding a gear-menu
item concurrently. This PR's menu addition is a single appended group, so a
rebase should be low-risk, but flagging in case of a conflict on the menu
construction block in viewer.ts.

readWindow/readLevel0/readViewLevel take an optional AbortSignal forwarded
to zarr.get, and readLevel0/readViewLevel are now exported so a caller can
target a specific pyramid level directly instead of readWindow's pixel-width
heuristic. Both are additive (default undefined signal, new exports) with
no behavior change to existing call sites.

Prep for the website#254 background preloader, which needs to read an
explicit level and abort cleanly on teardown. Also adds windowDataBytes, a
pure byte-accounting helper for WindowData.

Tested: bun run test (1109 passed), bun run typecheck (0 errors).
Adds an optional "Preload full recording" gear toggle (off by default) for
clinicians who want the whole recording resident so paging/scrubbing never
waits on a fetch. src/lib/eeg-viewer/prefetch.ts is a pure module with no
zarr/DOM dependency:

- ByteCappedLRUCache<T>: byte-accounted LRU with two insert modes. put()
  (interactive path) evicts LRU entries to fit; putIfRoom() (background
  scheduler) never evicts, failing once full so the walk stops rather than
  displacing what the viewport is showing.
- outwardOrder/segmentIndexForTime: pure helpers for the segment grid.
- PrefetchController<T>: walks the recording outward from the playhead one
  segment at a time, yielding to interactive reads (depth-counted) and to
  document.hidden, aborting cleanly via AbortController on stop().

viewer.ts wires it up:
- Segments are windowLengthS-wide reads at whatever pyramid level the
  interactive path last actually rendered (lastWinLevel), fetched via the
  newly-exported store.ts readLevel0/readViewLevel rather than re-deriving
  readWindow's pixel-width heuristic for off-screen windows.
- The interactive read path opportunistically serves an exact grid-aligned
  window from the cache (Page back/forward, Home/End land on the grid; an
  arbitrary scrub position falls through to a normal network read, same as
  preload off).
- Both settings (enabled, cache cap: 250/500/1000 MB) persist in
  localStorage under nemar:eeg-preload / nemar:eeg-preload-cap-mb, guarded
  with try/catch.
- The minimap gets a thin buffered-region strip (like a video player's
  buffer bar) shading cached segments.
- Preload pauses on visibilitychange (tab hidden) and stop()s on viewer
  destroy, matching the abort discipline from website#208.

Tested: bun run test (1142 passed, 33 new in prefetch.test.ts covering LRU
eviction/cap enforcement, outward scheduling order, priority yielding,
hidden-pause, abort, and stop-when-full), bun run typecheck (0 errors),
bun run lint (clean), bun run build (clean; the one build failure seen
locally was api.nemar.org intermittently 500ing on an unrelated catalog
page during OG image generation, confirmed by direct curl, not this
change).
A restart over an already-warm cache (cache-cap change, or switching back
to a previously-visited group/level) hit cache.has(key) for every segment
with no await in between: the while(this.yielding) guard is a no-op when
nothing is contending, so a multi-hour recording's worth of segments could
confirm in one synchronous microtask, firing onProgress (a canvas redraw
downstream) on every single one -- a real main-thread freeze rather than a
fast no-op.

The cache-hit branch now batches: it counts confirmed hits and only flushes
progress + awaits idle() every PROGRESS_BATCH_SIZE (32) segments, plus a
final partial flush at the end of the walk and before every early-return
(halted-full, superseded). The fetch path is unchanged -- a real network
call plus the existing idle() await already yields every iteration, so it
never needed batching.

Tested: bun run test (new case asserts idleCalls > 5 and far fewer
onProgress calls than segments across a 300-segment fully-cached restart,
confirming batched yielding rather than one synchronous burst).
Three fixes from review:

- retryingFetch no longer retries an already-aborted request. A caller's
  AbortController firing (background preloader stop()/destroy()) was
  previously treated as a transient failure and retried through up to ~10s
  of backoff, keeping the controller and up to the full cache cap alive
  well past teardown for a response nothing would use. Both the
  resolved-transient-status branch and the catch block now check
  request.signal?.aborted and propagate immediately instead.

- The interactive opportunistic cache-hit path (website#254) trusted
  lastWinLevel -- the level the *previous* frame rendered -- without
  re-deriving it for the CURRENT plotWidth. A resize with no remount (e.g.
  the Enlarge dialog) could serve a stale-level cached window and then
  reassign lastWinLevel back to it, self-reinforcing. Added
  store.ts#chooseWindowLevel, a pure function mirroring readWindow's own
  level-selection heuristic without fetching, and the cache check now only
  serves a hit when it agrees with lastWinLevel; otherwise it falls through
  to a real read (which corrects lastWinLevel for next time). Confirmed the
  filtered/forceLevel0 path was already structurally isolated from the
  cache (the check lives only inside the `!hasFilters(filters)` branch).

- windowDataBytes had zero test coverage despite being the byte-accounting
  foundation for the whole cache. Added line/band/mixed/empty cases.

Also exported retryingFetch and added coverage for the abort-vs-transient
distinction, and added chooseWindowLevel coverage (natural pick, forceLevel0
override, the LEVEL0_MAX_SAMPLES boundary, and no-pyramid fallback).

Tested: bun run test (1122 passed, all new cases green), bun run typecheck
(0 errors), bun run lint (clean), bun run build (clean).
@neuromechanist

Copy link
Copy Markdown
Contributor Author

Addressed all four findings from review, in commits 5d8880d and 621fb74.

1. CRITICAL — synchronous burst over a warm cache (prefetch.ts loop). Fixed.
The cache-hit branch now counts confirmed hits and batches: it only flushes
onProgress and awaits idle() every PROGRESS_BATCH_SIZE (32) segments,
plus a final partial flush at loop end and before every early return
(halted-full, superseded). The fetch path was already fine (a real network
await + the existing idle() await yield every iteration) and is unchanged.
Added a test that pre-seeds 300 segments into an already-warm cache, restarts
the walk, and asserts idle() is called repeatedly (not 0/1 times) and
onProgress fires in far fewer batches than segments, with the final batch
reporting full coverage.

2. IMPORTANT — retryingFetch retrying an aborted request (store.ts). Fixed.
Both the resolved-transient-status branch and the catch block now check
request.signal?.aborted and propagate immediately instead of scheduling
another attempt. Exported retryingFetch and added three tests: an aborted
request is not retried on a transient 5xx, an aborted request's thrown
AbortError is not retried either, and (regression guard) a non-aborted
transient 5xx still retries through to a successful response.

3. IMPORTANT — stale-level cache hit after a resize (viewer.ts). Fixed.
Added store.ts#chooseWindowLevel, a pure function mirroring readWindow's
own level-selection heuristic (including the LEVEL0_MAX_SAMPLES sample cap
and forceLevel0) without fetching anything. The interactive cache check now
re-derives the level for the current plotWidth and only serves a hit when
it agrees with lastWinLevel; a mismatch falls through to a real read, which
also corrects lastWinLevel, so it's self-healing rather than a permanent
miss. Separately confirmed (not a code change, just verification) that the
filtered/forceLevel0 path can't reach the cache at all: the cache check
lives only inside the readFrame's !hasFilters(filters) branch, and
forceLevel0=true is only ever passed from the filtered branch's own
readWindow call, which is a structurally separate path. Added
chooseWindowLevel coverage: natural pick, forceLevel0 override, the
LEVEL0_MAX_SAMPLES boundary (20000 samples, at/over), and the no-pyramid
fallback.

4. IMPORTANT — windowDataBytes had zero coverage (store.ts). Fixed.
Added line-channel, band-channel, mixed, and empty-window cases.

All four gates green on the pushed tip (621fb74): bun run test (1122
passed), bun run typecheck (0 errors), bun run lint (clean), bun run build (clean).

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