fix(showcase): buffered producer/consumer for a steady cascade
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) <noreply@anthropic.com>
This commit is contained in:
+101
-88
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user