From 76d8ad42a8177391ce1a257064adb9a3a2cd46ed Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Thu, 4 Jun 2026 00:35:25 -0400 Subject: [PATCH] fix(showcase): buffered producer/consumer for a steady cascade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cascade "burped" — chunks appeared unevenly — because the old pipeline coupled display to fetch timing: it trickled each batch right after its fetch and assumed the next round-trip would land inside the ~240ms trickle window. When a fetch ran long (TABLESAMPLE hits random, sometimes-cold blocks; RTT jitter) the animation starved, then a clump burst in. Decouple the two: - Producer (_fill) races ahead fetching batches into a buffer up to a target depth, refilling when it dips below BUFFER_MIN. - Consumer (_drain) reveals one item every CADENCE_MS regardless of when fetches land; it only waits if the buffer genuinely starves. A small PRIME buffer precedes the drain so it doesn't starve at the front; the buffer (BUFFER_MIN×CADENCE runway) absorbs per-fetch jitter so images appear at an even pace. Public store API (loadInitial/shuffle/fetchPage/ images/loading/hasMore/isEmpty) unchanged — ShowcaseView/MasonryGrid need no change. Test (fake timers): fire-order + dedup, one-item-per-cadence rate limit, empty-library flag. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/stores/showcase.js | 189 +++++++++++++++++--------------- frontend/test/showcase.spec.js | 72 ++++++++++++ 2 files changed, 173 insertions(+), 88 deletions(-) create mode 100644 frontend/test/showcase.spec.js diff --git a/frontend/src/stores/showcase.js b/frontend/src/stores/showcase.js index 78da18b..4f6847e 100644 --- a/frontend/src/stores/showcase.js +++ b/frontend/src/stores/showcase.js @@ -2,36 +2,33 @@ import { defineStore } from 'pinia' import { ref, computed } from 'vue' import { useApi } from '../composables/useApi.js' -// Operator-flagged 2026-05-30 (round 3): the all-parallel fetch was fast -// but risked later chunks arriving first — undesirable even when each -// chunk is a random sample. Switched to a PIPELINE: only one fetch in -// flight at any moment, but the next fetch kicks off as soon as the -// previous one resolves (NOT after its trickle finishes). The next RTT -// overlaps with the current batch's trickle, hiding the per-batch -// round-trip behind the visible animation cadence. Responses arrive in -// fire-order, so no out-of-order rendering surprises. -// -// Smaller PAGE (3 vs 5) → first chunk's items appear sooner: a chunk of -// 3 trickles in 240 ms, well within one RTT, so by the time chunk 2 is -// in-hand the trickle is just finishing. Total wall-clock is roughly -// RTT + N × max(trickle_time, RTT); APPEND_DELAY_MS keeps the visible -// cadence smooth throughout. +// Buffered producer/consumer so the cascade cadence is decoupled from fetch +// latency (operator-flagged 2026-06-04). The OLD pipeline trickled each batch +// right after its fetch and bet the next round-trip would finish inside the +// ~240ms trickle window; when a fetch ran long (TABLESAMPLE hits random, +// sometimes-cold blocks; RTT jitter) the animation starved and the view +// "burped" out a clump of images. Now: +// - PRODUCER (_fill): races ahead fetching batches into `queue` up to a +// target depth, refilling whenever the queue dips below BUFFER_MIN. +// - CONSUMER (_drain): pops ONE item every CADENCE_MS, independent of when +// fetches land. It only waits if the buffer genuinely starves. +// As long as fetches keep up on average (a 3-row fetch is usually well under +// the BUFFER_MIN×CADENCE runway), images appear at a perfectly even pace. +// The very first image still waits on the first fetch (a cold TABLESAMPLE is a +// separate, query-side concern); everything after it is buffer-smoothed. const PAGE = 3 -const INITIAL_BATCHES = 20 -const APPEND_DELAY_MS = 80 // ≈ the MasonryGrid stagger animation (70 ms) -// Operator-flagged 2026-06-01: scrolling the showcase eventually hit a -// premature "End." because /api/showcase returns a *random sample* and -// after enough scrolling the `seen` Set accumulated enough to fully -// collide with a 3-item batch. The showcase is supposed to be endless; -// only a genuinely empty API response (library has zero images) should -// mark it exhausted. Retry up to FETCH_RETRY_CAP times on all-dupe -// batches; only flip `exhausted` when the API returns 0 items OR every -// retry came back dupe-only (graceful fallback for tiny libraries -// where retries will keep returning the same handful of items). +const CADENCE_MS = 80 // ≈ the MasonryGrid 70ms stagger animation +const PRIME = 6 // items buffered before the drain starts +const BUFFER_TARGET = 30 // producer tops the queue up to this +const BUFFER_MIN = 12 // ...and refills once the queue dips below this +const INITIAL_COUNT = 60 // cascade length on load / shuffle +const SCROLL_COUNT = 15 // cascade length per infinite-scroll demand +// Showcase is endless by design (random sample); an unlucky all-duplicate +// batch must be retried — only a genuinely empty API response is exhaustion. const FETCH_RETRY_CAP = 8 -function _sleep(ms) { return new Promise(r => setTimeout(r, ms)) } +function _sleep(ms) { return new Promise((r) => setTimeout(r, ms)) } export const useShowcaseStore = defineStore('showcase', () => { @@ -42,95 +39,111 @@ export const useShowcaseStore = defineStore('showcase', () => { const exhausted = ref(false) const seen = new Set() - // Sequence token: every call to loadInitial bumps this. _trickleAppend - // bails between items if its captured seq is no longer current — guards - // against a fast shuffle / mount-then-shuffle from interleaving two - // trickles into the same images.value. + // Internal buffer (not reactive — the consumer is what feeds the UI via + // images.value). + let queue = [] + // Sequence token: shuffle / re-mount bumps it so in-flight producers and + // the drain bail instead of interleaving two runs into one images list. let _seq = 0 + let _filling = false + let _draining = false - async function _trickleAppend(items, mySeq) { - for (const item of items) { - if (mySeq !== _seq) return - if (seen.has(item.id)) continue - seen.add(item.id) - images.value.push(item) - await _sleep(APPEND_DELAY_MS) + // One batch, retried while the random sample comes back all-duplicates. + // Returns the fresh items, or null when the API is genuinely empty. + async function _fetchFresh(mySeq) { + for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) { + if (mySeq !== _seq) return [] + const body = await api + .get('/api/showcase', { params: { limit: PAGE } }) + .catch((e) => { + error.value = error.value || (e.message || String(e)) + return null + }) + if (mySeq !== _seq) return [] + const items = (body && body.images) || [] + if (items.length === 0) return null + const fresh = items.filter((i) => !seen.has(i.id)) + fresh.forEach((f) => seen.add(f.id)) + if (fresh.length) return fresh } + return null // retry cap hit → tiny library, treat as exhausted } - // Single batch — used by infinite-scroll appends. Trickles its items - // in for the same one-at-a-time cadence as the initial load. Retries - // up to FETCH_RETRY_CAP times when the API's random sample comes back - // all-duplicates (the showcase is endless by design; only a genuinely - // empty API response should mark it exhausted, not an unlucky sample). - async function fetchPage() { - if (loading.value) return - loading.value = true - error.value = null + // Producer: top the buffer up to `target`. Single-flight. + async function _fill(mySeq, target) { + if (_filling) return + _filling = true try { - for (let attempt = 0; attempt < FETCH_RETRY_CAP; attempt++) { - const body = await api.get('/api/showcase', { params: { limit: PAGE } }) - const items = body.images || [] - // API genuinely empty → library is empty / endpoint exhausted. - if (items.length === 0) { exhausted.value = true; return } - const fresh = items.filter(i => !seen.has(i.id)) - if (fresh.length > 0) { - await _trickleAppend(fresh, _seq) - return - } - // All-dupes batch — keep trying. Showcase is endless by intent. + while (mySeq === _seq && !exhausted.value && queue.length < target) { + const batch = await _fetchFresh(mySeq) + if (mySeq !== _seq) return + if (batch === null) { exhausted.value = true; return } + queue.push(...batch) } - // Retry cap hit with zero fresh items: library is probably much - // smaller than the running `seen` set, fall back to exhausted so - // the UI stops trying. Operator can shuffle to reset `seen`. - exhausted.value = true - } catch (e) { - error.value = e.message || String(e) } finally { - loading.value = false + _filling = false } } - function _fetchOne() { - return api.get('/api/showcase', { params: { limit: PAGE } }).catch(e => { - error.value = error.value || (e.message || String(e)) - return null - }) + // Consumer: show `count` items at a fixed cadence, topping up the buffer as + // it drains. Single-flight so the initial cascade and scroll appends can't + // interleave. + async function _drain(mySeq, count) { + if (_draining) return + _draining = true + loading.value = true + try { + for (let shown = 0; shown < count && mySeq === _seq; shown++) { + if (!exhausted.value && queue.length < BUFFER_MIN) { + _fill(mySeq, BUFFER_TARGET) // topup, no await + } + let guard = 0 + while (queue.length === 0 && !exhausted.value && mySeq === _seq) { + await _sleep(20) + if (++guard > 500) break // 10s starvation safety net + } + if (mySeq !== _seq) return + if (queue.length === 0) return // exhausted + empty + images.value.push(queue.shift()) + await _sleep(CADENCE_MS) + } + } finally { + if (mySeq === _seq) { + _draining = false + loading.value = false + } + } } - // Reset state and pipeline INITIAL_BATCHES fetches: only one in flight - // at a time, but kick off the next one as soon as the previous resolves - // (NOT after its trickle finishes), so the next RTT runs concurrently - // with the current batch's trickle. Responses arrive in fire-order, so - // items always render in the order they were fetched — no out-of-order - // surprises from parallel races. async function loadInitial() { _seq += 1 const mySeq = _seq images.value = [] + queue = [] seen.clear() exhausted.value = false error.value = null + _filling = false + _draining = false loading.value = true try { - let nextFetch = _fetchOne() - for (let i = 0; i < INITIAL_BATCHES; i++) { - if (mySeq !== _seq) return - const body = await nextFetch - // Fire the NEXT fetch immediately so its RTT overlaps the trickle. - if (i + 1 < INITIAL_BATCHES) nextFetch = _fetchOne() - if (!body || !body.images || body.images.length === 0) { - exhausted.value = true - break - } - await _trickleAppend(body.images, mySeq) - } - if (mySeq === _seq && images.value.length === 0) exhausted.value = true + // Prime a small buffer before the cascade starts so it doesn't starve + // at the front; the drain's own topup grows it to BUFFER_TARGET. + await _fill(mySeq, PRIME) + if (mySeq !== _seq) return + if (queue.length === 0 && exhausted.value) return // empty library + await _drain(mySeq, INITIAL_COUNT) } finally { if (mySeq === _seq) loading.value = false } } + async function fetchPage() { + // Infinite-scroll demand — append another cascade of SCROLL_COUNT. + if (_draining || exhausted.value) return + await _drain(_seq, SCROLL_COUNT) + } + async function shuffle() { await loadInitial() } diff --git a/frontend/test/showcase.spec.js b/frontend/test/showcase.spec.js new file mode 100644 index 0000000..961d2ee --- /dev/null +++ b/frontend/test/showcase.spec.js @@ -0,0 +1,72 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { setActivePinia, createPinia } from 'pinia' +import { useShowcaseStore } from '../src/stores/showcase.js' + +let nextId + +function stubShowcase({ empty = false } = {}) { + globalThis.fetch = vi.fn(async () => { + const images = empty + ? [] + : Array.from({ length: 3 }, () => ({ + id: nextId++, sha256: 's', mime: 'image/jpeg', + width: 1, height: 1, thumbnail_url: '/t', + })) + return { + ok: true, + status: 200, + statusText: '200', + text: async () => JSON.stringify({ images }), + } + }) +} + +describe('showcase store: buffered cascade', () => { + beforeEach(() => { + setActivePinia(createPinia()) + nextId = 1 + vi.useFakeTimers() + }) + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + }) + + it('drains buffered items in fire-order at a fixed cadence, no dupes', async () => { + stubShowcase() + const s = useShowcaseStore() + const p = s.loadInitial() + // Past INITIAL_COUNT (60) × CADENCE (80ms) plus fetch microtasks. + await vi.advanceTimersByTimeAsync(6000) + await p + expect(s.images.length).toBe(60) + const ids = s.images.map((i) => i.id) + expect(new Set(ids).size).toBe(60) // dedup held + expect(ids).toEqual([...ids].sort((a, b) => a - b)) // shown in fire order + }) + + it('reveals at most one item per cadence tick (decoupled from fetch)', async () => { + stubShowcase() + const s = useShowcaseStore() + const p = s.loadInitial() + await vi.advanceTimersByTimeAsync(1000) + const early = s.images.length + // Even though fetches resolve instantly, the consumer is rate-limited by + // the cadence — a 1s window must not dump the whole buffer at once. + expect(early).toBeGreaterThan(0) + expect(early).toBeLessThan(60) + await vi.advanceTimersByTimeAsync(6000) + await p + expect(s.images.length).toBe(60) + }) + + it('flags an empty library without starting the cascade', async () => { + stubShowcase({ empty: true }) + const s = useShowcaseStore() + const p = s.loadInitial() + await vi.advanceTimersByTimeAsync(1000) + await p + expect(s.images.length).toBe(0) + expect(s.isEmpty).toBe(true) + }) +})