fix(showcase): buffered producer/consumer for a steady cascade
CI / lint (push) Successful in 3s
CI / backend-lint-and-test (push) Successful in 13s
CI / frontend-build (push) Successful in 20s
CI / intimp (push) Successful in 3m32s
CI / intapi (push) Successful in 7m35s
CI / intcore (push) Successful in 9m8s

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:
2026-06-04 00:35:25 -04:00
parent 6d630d13d6
commit 76d8ad42a8
2 changed files with 173 additions and 88 deletions
+101 -88
View File
@@ -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()
}
+72
View File
@@ -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)
})
})