diff --git a/app/pages/index.vue b/app/pages/index.vue index beaf516a..ce72a3de 100644 --- a/app/pages/index.vue +++ b/app/pages/index.vue @@ -87,7 +87,7 @@ const [ })), // Server-rendered rather than lazy: the section is one of the few places on // the homepage whose content changes hourly, and the endpoint is cached at - // the edge for 5 minutes, so it costs a cache read rather than a query. + // the edge for 30 minutes, so it costs a cache read rather than a query. withHomeDataTiming('home-trending', useFetch('/api/feed/trending', { key: 'home-trending-v3', // The hero texture prints Skill names from every repo it gets, so it asks diff --git a/layers/marketing/app/pages/skills/index.vue b/layers/marketing/app/pages/skills/index.vue index 965283dd..17a9de05 100644 --- a/layers/marketing/app/pages/skills/index.vue +++ b/layers/marketing/app/pages/skills/index.vue @@ -114,7 +114,7 @@ const tagBySlug = computed(() => { * it must never hold up the registry render or fail it. If the feed is slow or * errors, rows draw without flames and nothing else changes. * - * The endpoint caches for 300s, so this costs a shared read rather than a + * The endpoint caches for 1800s, so this costs a shared read rather than a * per-visitor query on the site's highest-traffic surface. */ const { data: trendingFeed } = useFetch('/api/feed/trending', { diff --git a/layers/registry/server/api/v1/trending.get.ts b/layers/registry/server/api/v1/trending.get.ts index 4ec74cca..77d6ef50 100644 --- a/layers/registry/server/api/v1/trending.get.ts +++ b/layers/registry/server/api/v1/trending.get.ts @@ -7,7 +7,7 @@ import { findSkillsByKeys } from '../../utils/skills-registry' /** * Reads the trending feed in process with the exact query the trending page - * sends, so v1 ranks the same board from the same five-minute cache, then + * sends, so v1 ranks the same board from the same 30-minute cache, then * slices it to `limit`. */ export default defineApiOperation({ diff --git a/nuxt.config.ts b/nuxt.config.ts index d1fad7c4..22cdda55 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -596,7 +596,7 @@ export default defineNuxtConfig({ // named in Vary. The cache key includes the query string, so each `?range=` // board is its own entry. Browsers still get no-store and ask the edge. // - // 60s fresh: the board's feed already caches for 300s, so this adds at most + // 60s fresh: the board's feed already caches for 1800s, so this adds at most // a minute. 3600s stale: the board's inputs move every 15 minutes at most // (X mentions) and hourly (social mentions, engagement), so a quiet colo // may serve one copy up to an hour old while it refreshes in the diff --git a/server/api/feed/trending.get.ts b/server/api/feed/trending.get.ts index a9c1739a..6c9f018f 100644 --- a/server/api/feed/trending.get.ts +++ b/server/api/feed/trending.get.ts @@ -7,6 +7,7 @@ * catalog. The admin review surface reads the same loader without the filter. */ +import type { FeedCacheWindow } from '#server/utils/feed-cache' import type { TrendingBoard } from '#shared/server/trending-board' import type { FallbackSkill } from '#shared/server/trending-fallback' import type { TrendingRepo } from '#shared/server/trending-repos' @@ -168,6 +169,16 @@ export interface TrendingFeedResponse { computedAt: number } +/** + * The board's fastest input is an X post, and those land every 15 minutes at + * most (`sync-x-mentions` runs on a 15-minute cron). A five-minute recompute + * therefore reran the full post-window scan three times per cadence that + * could change the answer, measured at 3,196 executions and 112.9 million + * row reads in one week. Half an hour sits a full cadence above ingest; + * `staleTtl` still keeps the last good board through a bounded D1 outage. + */ +const TRENDING_FEED_WINDOW: FeedCacheWindow = { ttl: 1800, staleTtl: 3600 } + function toSkillItem(entry: TrendingSkill, starSeries: StarPoint[]): TrendingSkillFeedItem { return { owner: entry.owner, @@ -302,6 +313,6 @@ export default defineEventHandler( const fallback = fallbackSkills.map(entry => toFallbackItem(entry, seriesOf(entry))) return { items, namedSkills: skillItems, fallback, computedAt: now } - }, [limit, windowHours]) + }, [limit, windowHours], TRENDING_FEED_WINDOW) }, ) diff --git a/test/unit/feed-cache-resilience.test.ts b/test/unit/feed-cache-resilience.test.ts index f2a96a73..b50d0756 100644 --- a/test/unit/feed-cache-resilience.test.ts +++ b/test/unit/feed-cache-resilience.test.ts @@ -47,12 +47,15 @@ function request(route: string, query = '', deployment = 'release-a'): H3Event { } const routes = [ - { route: 'recent-updates', load: () => import('../../server/api/feed/recent-updates.get') }, - { route: 'recent-publishes', load: () => import('../../server/api/feed/recent-publishes.get') }, - { route: 'trending', load: () => import('../../server/api/feed/trending.get') }, + // `staleMs` is one second past the feed's fresh window; `deadMs` is one + // second past fresh + stale. Trending rides a 30-minute window (X posts + // land every 15 minutes at most), the others the 300 s default. + { route: 'recent-updates', load: () => import('../../server/api/feed/recent-updates.get'), staleMs: 301_000, deadMs: 3_901_000 }, + { route: 'recent-publishes', load: () => import('../../server/api/feed/recent-publishes.get'), staleMs: 301_000, deadMs: 3_901_000 }, + { route: 'trending', load: () => import('../../server/api/feed/trending.get'), staleMs: 1_801_000, deadMs: 5_401_000 }, ] -describe.each(routes)('$route feed cache', ({ route, load }) => { +describe.each(routes)('$route feed cache', ({ route, load, staleMs, deadMs }) => { it('serves its last good response when an expired entry cannot refresh', async () => { const handler = (await load()).default const fresh = await handler(request(route)) @@ -61,9 +64,9 @@ describe.each(routes)('$route feed cache', ({ route, load }) => { boundary.board.mockRejectedValue(new Error('D1 DB is overloaded')) await expect(handler(request(route))).resolves.toEqual(fresh) - vi.advanceTimersByTime(301_000) + vi.advanceTimersByTime(staleMs) await expect(handler(request(route))).resolves.toEqual(fresh) - vi.advanceTimersByTime(3600_000) + vi.advanceTimersByTime(deadMs - staleMs) await expect(handler(request(route))).rejects.toThrow('D1 DB is overloaded') }) @@ -73,17 +76,28 @@ describe.each(routes)('$route feed cache', ({ route, load }) => { boundary.all.mockRejectedValue(new Error('D1 DB is overloaded')) boundary.board.mockRejectedValue(new Error('D1 DB is overloaded')) - vi.advanceTimersByTime(301_000) + vi.advanceTimersByTime(staleMs) if (route === 'trending') await expect(handler(request(route, '?window=24'))).rejects.toThrow('D1 DB is overloaded') else await expect(handler(request(route, '?ignored=24'))).resolves.toEqual({ items: [] }) await expect(handler(request(route, '', 'release-b'))).rejects.toThrow('D1 DB is overloaded') - vi.advanceTimersByTime(3600_000) + vi.advanceTimersByTime(deadMs - staleMs) await expect(handler(request(route))).rejects.toThrow('D1 DB is overloaded') }) }) +it('trending serves one compute across a 15-minute ingest cadence', async () => { + // X posts land every 15 minutes (`sync-x-mentions` runs `*/15 * * * *`), so + // the board's inputs cannot change inside this gap. A second compute there + // only repeats the full post-window scan. + const handler = (await import('../../server/api/feed/trending.get')).default + const first = await handler(request('trending')) + vi.advanceTimersByTime(900_000) + await expect(handler(request('trending'))).resolves.toEqual(first) + expect(boundary.board).toHaveBeenCalledTimes(1) +}) + it('shares equivalent trending options without sharing a different window', async () => { const handler = (await import('../../server/api/feed/trending.get')).default const fresh = await handler(request('trending', '?limit=500&window=900'))