Skip to content
Merged
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
19 changes: 7 additions & 12 deletions server/middleware/skill-source-gone.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,5 @@
import { writeCache } from '#shared/server/cache'
import {
GONE_SKILLS_CACHE_KEY,
GONE_SKILLS_CACHE_TTL,
selectGoneSkillKeys,
resolveGoneSkillKeys,
skillKeyFromPath,
} from '../utils/source-gone-skills'

Expand All @@ -18,6 +15,10 @@ import {
* The key set is cached because this runs on every `/gh` request, and an
* uncached per-request D1 read on a hot path is exactly what produced the
* 2026-08-04 overload burst. One query an hour returns about 130 rows.
*
* `resolveGoneSkillKeys` answers null when it cannot reach either store, and
* that leaves the response status alone. A middleware that only ever upgrades a
* status must never be the reason a page fails.
*/
export default defineEventHandler(async (event) => {
const key = skillKeyFromPath(getRequestURL(event).pathname)
Expand All @@ -28,13 +29,7 @@ export default defineEventHandler(async (event) => {
if (!db)
return

const cache = useStorage('cache')
let keys = await cache.getItem<string[]>(GONE_SKILLS_CACHE_KEY)
if (!Array.isArray(keys)) {
keys = await selectGoneSkillKeys(db)
await writeCache(cache, GONE_SKILLS_CACHE_KEY, keys, { ttl: GONE_SKILLS_CACHE_TTL })
}

if (keys.includes(key))
const keys = await resolveGoneSkillKeys(useStorage('cache'), db)
if (keys?.includes(key))
setResponseStatus(event, 410)
})
44 changes: 44 additions & 0 deletions server/utils/source-gone-skills.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
/// <reference types="@cloudflare/workers-types" />

import type { ReadThroughCache } from '#shared/server/cache'
import { readCache, writeCache } from '#shared/server/cache'

/**
* Skills whose SKILL.md was deleted upstream still serve a page, because the
* cached render survives the deletion. The page is worth keeping: it shows the
Expand Down Expand Up @@ -79,3 +82,44 @@ export async function selectGoneSkillKeys(db: D1Database): Promise<string[]> {
const rows = await db.prepare(GONE_SKILLS_SQL).bind().all<GoneSkillRow>()
return goneSkillKeys(rows.results ?? [])
}

/**
* The gone-skill key set for this request, or null when it cannot be resolved.
*
* This runs on every `/gh` request and its only job is to upgrade a 200 to a
* 410. Neither the KV read nor the D1 fallback is worth the page. Workers KV
* answered a `getItem` with `KV GET failed: 500 Internal Server Error` and the
* rejection travelled out of the bare read-through cache here and 500'd the
* page (Sentry SKILLD-S, `/gh/dylantarre/animation-principles/educator-teacher`,
* still live 2026-08-18). The D1 read behind it fails the same way, and D1
* answers `{"D1_RESET_DO":true}` on its own schedule (Sentry SKILLD-Y).
*
* Failing to resolve the set costs one stale 200 on a deleted skill until the
* next request. Propagating the failure costs every `/gh` visitor the page.
*
* This is deliberately not a silent catch: the wide event carries the reason,
* so an outage shows up as a spike in unresolved lookups rather than as
* nothing at all.
*/
export async function resolveGoneSkillKeys(
cache: ReadThroughCache,
db: D1Database,
): Promise<string[] | null> {
const cached = await readCache<string[]>(cache, GONE_SKILLS_CACHE_KEY)
if (Array.isArray(cached))
return cached

try {
const keys = await selectGoneSkillKeys(db)
await writeCache(cache, GONE_SKILLS_CACHE_KEY, keys, { ttl: GONE_SKILLS_CACHE_TTL })
return keys
}
catch (error) {
emitOperationalEvent(createWideEvent({
operation: 'source-gone-keys',
outcome: 'failed',
reason: error instanceof Error ? error.message : String(error),
}))
return null
}
}
7 changes: 5 additions & 2 deletions shared/server/cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
* The write half of unstorage's `Storage`, structurally typed so this module
* does not depend on unstorage's own types being resolvable.
*/
interface CacheStorage {
export interface CacheStorage {
setItem: (key: string, value: never, options?: { ttl?: number }) => Promise<void>
}

Expand Down Expand Up @@ -33,10 +33,13 @@ export async function writeCache<T>(
* The read half of unstorage's `Storage`, structurally typed for the same
* reason as {@link CacheStorage}.
*/
interface CacheReadStorage {
export interface CacheReadStorage {
getItem: <T>(key: string) => Promise<T | null>
}

/** A cache a read-through helper both reads and writes. */
export type ReadThroughCache = CacheReadStorage & CacheStorage

/**
* Read from the KV-backed cache without letting the read decide whether the
* request succeeds.
Expand Down
66 changes: 64 additions & 2 deletions test/unit/source-gone-skills.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { goneSkillKeys, skillKeyFromPath } from '../../server/utils/source-gone-skills'
import { describe, expect, it, vi } from 'vitest'
import { goneSkillKeys, resolveGoneSkillKeys, skillKeyFromPath } from '../../server/utils/source-gone-skills'

const microsoft = {
owner: 'microsoft',
Expand Down Expand Up @@ -77,3 +77,65 @@ describe('goneSkillKeys', () => {
expect(goneSkillKeys([])).toEqual([])
})
})

describe('resolveGoneSkillKeys', () => {
const goneRow = {
owner: 'microsoft',
repo: 'skills',
name: 'entra-app-registration',
slug: null,
name_collides_with_repo: 0,
}

function stubDb(all: () => Promise<{ results: typeof goneRow[] }>) {
return {
prepare: () => ({ bind: () => ({ all }) }),
} as unknown as Parameters<typeof resolveGoneSkillKeys>[1]
}

it('returns the cached key set without touching the database', async () => {
const all = vi.fn()
const keys = await resolveGoneSkillKeys(
{ getItem: async () => ['acme/skills/deploy'], setItem: async () => {} },
stubDb(all as never),
)

expect(keys).toEqual(['acme/skills/deploy'])
expect(all).not.toHaveBeenCalled()
})

it('treats a KV read failure as a miss and answers from the database', async () => {
// Sentry SKILLD-S: `KV GET failed: 500 Internal Server Error` travelled out
// of this read-through cache and 500'd every `/gh` page.
const keys = await resolveGoneSkillKeys(
{
getItem: async () => { throw new Error('KV GET failed: 500 Internal Server Error') },
setItem: async () => {},
},
stubDb(async () => ({ results: [goneRow] })),
)

expect(keys).toEqual(['microsoft/skills/entra-app-registration'])
})

it('answers null when the database read fails, rather than failing the page', async () => {
const keys = await resolveGoneSkillKeys(
{ getItem: async () => null, setItem: async () => {} },
stubDb(async () => { throw new Error('D1_ERROR: {"D1_RESET_DO":true}') }),
)

expect(keys).toBeNull()
})

it('still answers when the cache write fails after a database read', async () => {
const keys = await resolveGoneSkillKeys(
{
getItem: async () => null,
setItem: async () => { throw new Error('KV PUT failed: 429 Too Many Requests') },
},
stubDb(async () => ({ results: [goneRow] })),
)

expect(keys).toEqual(['microsoft/skills/entra-app-registration'])
})
})