Persisted feeds keep only episodes from the last 30 days (plus completed downloads); older episodes live in volatile memory and survive refreshes via union merge, with per-feed in-memory caches capped at 500. Refresh batches run at FETCH_CONCURRENCY=4 with per-feed incremental apply (no Promise.all barrier), config.json writes are trailing-edge debounced (250ms, immediate flushPendingSave for unsubscribes), and cold fetch-more refetches abort at FETCH_TIMEOUT_MS. A shared activity store powers a global top-right indicator covering refresh, fetch-more, subscribe, search, and downloads. Also includes the in-flight incremental RSS parsing (chunked with event-loop yields) and refresh spinner work this tree already carried.
9.0 KiB
9.0 KiB
03. Make refresh/fetch-more/persistence nonblocking — bounded fetch concurrency, incremental per-feed apply, debounced saves
meta: id: bounded-feed-lifecycle-03 feature: bounded-feed-lifecycle priority: P1 depends_on: [bounded-feed-lifecycle-01, bounded-feed-lifecycle-02] tags: [implementation, tests-required]
objective:
- Feed loading must never block or stall the UI: refresh results render as each feed lands instead of after a
Promise.allbarrier, fetch concurrency is capped so 50 subscriptions don't fire 50 simultaneous requests, andconfig.jsonwrites (full file read-modify-write on every change today) collapse into one debounced trailing write per settle window.
background (read this before touching code):
- Work lands in
src/stores/feed.tsonly (plus its tests). Current posture:refreshAllFeeds()firesfetchEpisodesfor every feed at once viaPromise.alland applies results in ONEsetFeedsat the end — the user sees nothing until the slowest feed resolves or hitsFETCH_TIMEOUT_MS(20s).parseEpisodesIncrementalalready chunks XML parsing and yields to the event loop via MessageChannel — keep that mechanism untouched; the blocking/stall risk today is the fetch barrier and the save path.loadMoreEpisodesForFeed's cold-cache refetch has NO timeout (copy theAbortSignal.timeout(FETCH_TIMEOUT_MS)pattern fromfetchEpisodes).- Persistence:
saveFeeds(updated)→saveFeedsToFile→updateConfig, a serialized full-file read-JSON.parse-stringify-Bun.writechain insrc/utils/config.ts. Called fromrefreshFeed,refreshAllFeeds,loadMoreEpisodesForFeed,addFeed,removeFeed*,updateFeed,togglePinned. - The boot IIFE calls
refreshAllFeeds()right afterloadFeedsFromFile()— this is the cold-start refresh users currently feel; first paint already happens because module init is async, but nothing renders per-feed until the barrier resolves. - Single feed
refreshFeedapplies its ownsetFeedsimmediately — reuse exactly that shape (fetch → apply-if-changed → mark save dirty) for the incremental batch path.
- Tasks 01+02 must be merged first: this task debounces the pruned save path (01) and applies per-feed results through
applyRefreshedEpisodes/mergeEpisodes(02). - Style: tab-indented WITH semicolons, JSDoc comments on non-obvious functions, section dividers
// ── Name ──…per repo convention. - Tests here use
vi.useFakeTimers()—setTimeout-based debounce must therefore be advanced withvi.advanceTimersByTimein tests; don't usequeueMicrotask-style scheduling for the debounce.
deliverables:
src/stores/feed.ts:- New constant
FETCH_CONCURRENCY = 4(comment: bounds simultaneous RSS requests; a hung feed burns at most one slot forFETCH_TIMEOUT_MS). - New module-level async helper
mapWithConcurrency<T, R>(items: T[], limit: number, fn: (item: T) => Promise<R>): Promise<R[]>— classic worker-pool:limitworkers pulling indexes from a shared counter, results in input order. Pure and generic enough to unit-test. - Rewritten
refreshAllFeeds():setIsLoadingFeeds(true)…finally setIsLoadingFeeds(false)as today.- Process feeds through
mapWithConcurrency(feeds(), FETCH_CONCURRENCY, async (feed) => ...). - Inside the per-feed callback:
fetchEpisodes(feed.podcast.feedUrl, MAX_EPISODES_REFRESH, feed.id); if non-null, immediatelysetFeeds(prev => { const updated = applyRefreshedEpisodes(prev, feed.id, episodes); if (updated !== prev) scheduleSaveFeeds(); return updated; }). Failed feeds (null) stay untouched, as today. - After all workers settle: ONE
runAutoDownload()(as today), andflushPendingSave()(below) so a refresh batch always ends with a persisted write when anything changed.
- Debounced save plumbing (module scope, replacing direct calls):
let pendingSaveTimer: ReturnType<typeof setTimeout> | null = null; const SAVE_DEBOUNCE_MS = 250;scheduleSaveFeeds()— after a state-changing update, mark dirty: set asavePending = trueflag and (re)arm the trailing timer to fireflushPendingSave().flushPendingSave()— ifsavePending, snapshotfeeds(), callsaveFeeds(snapshot), clear flag/timer. Export it on the store's returned object (tests need it; also lets task 04/a future quit hook force a write).- Convert ALL direct
saveFeeds(updated)/saveFeeds(newList)call sites insidesetFeedscallbacks toscheduleSaveFeeds()EXCEPTremoveFeed/removeFeedByUrl, which must call bothscheduleSaveFeeds()ANDflushPendingSave()(an unsubscribe intent should not sit unsaved through the debounce window if the process exits). Keep the change mechanical: same call sites, new indirection.
loadMoreEpisodesForFeed: addsignal: AbortSignal.timeout(FETCH_TIMEOUT_MS)to the cold refetch and return early on non-OK/throw (wrap in try/catch mirroringfetchEpisodes).
- New constant
tests/feed-nonblocking.test.ts(new) — see tests section.
steps:
- Read
src/stores/feed.tsand confirm tasks 01/02 are merged (episodeIsPersistableinsrc/utils/feeds-persistence.ts,mergeEpisodesinsrc/utils/episode-merge.ts). - Add
FETCH_CONCURRENCY,mapWithConcurrency, and the debounce plumbing. - Rewrite
refreshAllFeedsper deliverables; convert the save call sites. - Add the fetch timeout to
loadMoreEpisodesForFeed's cold refetch. - Export
flushPendingSavefrom the store's return object (Actions section). - Write
tests/feed-nonblocking.test.ts; run new + existing feed tests; full suite; lint.
tests:
- Harness conventions: copy
tests/feed-refresh.test.ts(tempXDG_CONFIG_HOMEBEFORE store imports;Bun.serveport 0;vi.useFakeTimers()inbeforeEach). Notevi.advanceTimersByTime(...)also drives the debounce timer and the MessageChannel yields used by the parser are real task-queue turns (safe under fake timers per the comment onyieldToUI). - New
tests/feed-nonblocking.test.ts:- Concurrency bound: server records concurrent in-flight requests (increment on entry,
await new Promise(r => setTimeout(r, 50_000))under fake-timer awareness: use a gate promise the test controls instead of real sleeps — release gates withvi.advanceTimersByTimeafter asserting). Register 10 feeds; startrefreshAllFeeds()(don't await); assert the server's max-concurrent counter never exceeded 4; release all gates and await completion. - Incremental apply: 2 feeds — one served instantly, one gated. Start refresh; resolve the fast gate only; assert the fast feed's
lastUpdated/episodes already updated infeeds()BEFORE the slow feed resolves (this is the acceptance proof thePromise.allbarrier is gone). Then release the slow gate and assert both applied. - Debounce: mock-observe writes by seeding the temp config dir and spawning two rapid refreshes whose content changed;
awaitboth, thenvi.advanceTimersByTime(SAVE_DEBOUNCE_MS); read rawconfig.jsonONCE — assert both new episodes are present in a single coherent write. (Counting writes precisely is brittle againstupdateConfig's chain; asserting final content + that the pre-debounce file lacks the episodes is the binary check: before advancing the debounce,config.jsonmust NOT yet contain the new episodes; after, it must.) flushPendingSave: refresh with changed content, callstore.flushPendingSave()without advancing timers, assertconfig.jsonalready contains the new episode.
- Concurrency bound: server records concurrent in-flight requests (increment on entry,
- Existing suites must pass unchanged:
feed-refresh.test.ts,feed-pagination.test.ts,feed-volatile-merge.test.ts,feed-refresh-spinner.test.tsx,restore-session.test.ts.
acceptance_criteria:
- During a refresh batch, no more than
FETCH_CONCURRENCYHTTP requests are ever in flight. - Each feed's refreshed episodes are visible in
feeds()as soon as its own fetch resolves — no waiting for the slowest feed. - Writes to
config.jsonare trailing-edge debounced: rapid successive updates produce one final write after the settle window, andflushPendingSave()persists immediately. loadMoreEpisodesForFeed's refetch aborts atFETCH_TIMEOUT_MSinstead of hanging forever.bun testfull suite passes;bun run lintclean.
validation:
bun test tests/feed-nonblocking.test.ts tests/feed-refresh.test.ts tests/feed-pagination.test.ts tests/feed-volatile-merge.test.tsbun testbun run lint- Manual smoke:
bun startwith several subscriptions; holdjduring the startup refresh — selection moves smoothly and per-feed results appear as they land; quit/relaunch and confirm the last refresh's episodes persisted.
notes:
- The background refresh timer (
scheduleNextRefresh) already skips ticks whileisLoadingFeeds()is true — unchanged. - Do not introduce a real "sleep" anywhere in tests; gates + fake timers only, matching existing suites.
mapWithConcurrencyis generic; keep it module-private infeed.ts(no premature new util file).updateConfigsnapshots its patch at call time (JSON.parse(JSON.stringify(patch))), so debouncing by delaying thesaveFeedsCALL is correct — a pending write always serializes the latest feeds it was handed.