Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/pages/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion layers/marketing/app/pages/skills/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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', {
Expand Down
2 changes: 1 addition & 1 deletion layers/registry/server/api/v1/trending.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
2 changes: 1 addition & 1 deletion nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 12 additions & 1 deletion server/api/feed/trending.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
},
)
30 changes: 22 additions & 8 deletions test/unit/feed-cache-resilience.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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')
})

Expand All @@ -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'))
Expand Down
Loading