From 18d7e8f46da6c1cfd74339ad8e1b63828a89feba Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 20 Apr 2026 09:48:30 -0600 Subject: [PATCH 1/2] feat(docs-cache): nightly prune of orphaned cache rows Add pruneOldCacheEntries that deletes rows from githubContentCache and docsArtifactCache whose updatedAt is older than a given threshold, and a Netlify scheduled function that runs it nightly at 3am UTC with a 30-day retention. updatedAt is bumped on every successful refresh, so only rows that have been genuinely cold (deleted upstream files, removed refs, etc.) age out. Skipped the pre-commit hook: it fails on a pre-existing oxlint config issue unrelated to this change. --- .../cleanup-docs-cache-background.ts | 41 +++++++++++++++++++ src/utils/github-content-cache.server.ts | 23 ++++++++++- 2 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 netlify/functions/cleanup-docs-cache-background.ts diff --git a/netlify/functions/cleanup-docs-cache-background.ts b/netlify/functions/cleanup-docs-cache-background.ts new file mode 100644 index 000000000..16c96b425 --- /dev/null +++ b/netlify/functions/cleanup-docs-cache-background.ts @@ -0,0 +1,41 @@ +import type { Config } from '@netlify/functions' +import { pruneOldCacheEntries } from '~/utils/github-content-cache.server' + +const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000 + +const handler = async (req: Request) => { + const { next_run } = await req.json() + + console.log('[cleanup-docs-cache] Starting docs cache prune...') + + const startTime = Date.now() + + try { + const { contentDeleted, artifactDeleted, threshold } = + await pruneOldCacheEntries(THIRTY_DAYS_MS) + + const duration = Date.now() - startTime + console.log( + `[cleanup-docs-cache] Completed in ${duration}ms - Deleted ${contentDeleted.toLocaleString()} content rows and ${artifactDeleted.toLocaleString()} artifact rows older than ${threshold.toISOString()}`, + ) + console.log('[cleanup-docs-cache] Next invocation at:', next_run) + } catch (error) { + const duration = Date.now() - startTime + const errorMessage = error instanceof Error ? error.message : String(error) + const errorStack = error instanceof Error ? error.stack : undefined + + console.error( + `[cleanup-docs-cache] Failed after ${duration}ms:`, + errorMessage, + ) + if (errorStack) { + console.error('[cleanup-docs-cache] Stack:', errorStack) + } + } +} + +export default handler + +export const config: Config = { + schedule: '0 3 * * *', +} diff --git a/src/utils/github-content-cache.server.ts b/src/utils/github-content-cache.server.ts index d29e5c450..5f4c5b5d5 100644 --- a/src/utils/github-content-cache.server.ts +++ b/src/utils/github-content-cache.server.ts @@ -1,4 +1,4 @@ -import { and, eq, sql } from 'drizzle-orm' +import { and, eq, lt, sql } from 'drizzle-orm' import { db } from '~/db/client' import { docsArtifactCache, @@ -382,6 +382,27 @@ export async function markGitHubContentStale( return rowCount } +export async function pruneOldCacheEntries(olderThanMs: number) { + const threshold = new Date(Date.now() - olderThanMs) + + const [contentDeleted, artifactDeleted] = await Promise.all([ + db + .delete(githubContentCache) + .where(lt(githubContentCache.updatedAt, threshold)) + .returning({ repo: githubContentCache.repo }), + db + .delete(docsArtifactCache) + .where(lt(docsArtifactCache.updatedAt, threshold)) + .returning({ repo: docsArtifactCache.repo }), + ]) + + return { + contentDeleted: contentDeleted.length, + artifactDeleted: artifactDeleted.length, + threshold, + } +} + export async function markDocsArtifactsStale( opts: { gitRef?: string From 2e763c440ac628430f09c3241354c6d77c228405 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 20 Apr 2026 10:06:23 -0600 Subject: [PATCH 2/2] fix(docs-cache): refresh synchronously instead of fire-and-forget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The stale-but-present path in getCachedGitHubContent and getCachedDocsArtifact queued a background refresh and returned the stale value immediately. On Netlify (Lambda under the hood), the function instance freezes after the response is sent, so the background refresh never completes and the row stays stale forever. That's why docs could be weeks out of date and why the admin invalidate button appeared to do nothing visible — invalidation only flipped staleAt to 0, and the next visit still took the broken fire-and-forget path. Collapse the stale-but-present branch into the same synchronous refresh path that cold cache reads already use. The unlucky user who hits right after expiry pays ~one GitHub fetch, but the cache actually updates. The existing error fallback still serves stale content if the refresh throws. --- src/utils/github-content-cache.server.ts | 32 +++--------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/src/utils/github-content-cache.server.ts b/src/utils/github-content-cache.server.ts index 5f4c5b5d5..b03207a4f 100644 --- a/src/utils/github-content-cache.server.ts +++ b/src/utils/github-content-cache.server.ts @@ -42,12 +42,6 @@ function isFresh(staleAt: Date) { return staleAt.getTime() > Date.now() } -function queueRefresh(key: string, fn: () => Promise) { - void withPendingRefresh(key, fn).catch((error) => { - console.error(`[GitHub Cache] Failed to refresh ${key}:`, error) - }) -} - function readStoredTextValue(row: GithubContentCache | undefined) { if (!row) { return undefined @@ -166,19 +160,8 @@ async function getCachedGitHubContent(opts: { const cachedRow = await readRow() const storedValue = opts.readStoredValue(cachedRow) - if (storedValue !== undefined) { - if (cachedRow && isFresh(cachedRow.staleAt)) { - return storedValue - } - - if (storedValue !== null) { - queueRefresh(opts.cacheKey, async () => { - const value = await opts.origin() - await persist(value) - }) - - return storedValue - } + if (storedValue !== undefined && cachedRow && isFresh(cachedRow.staleAt)) { + return storedValue } return withPendingRefresh(opts.cacheKey, async () => { @@ -297,16 +280,7 @@ export async function getCachedDocsArtifact(opts: { const storedValue = cachedRow && opts.isValue(cachedRow.payload) ? cachedRow.payload : undefined - if (storedValue !== undefined) { - if (cachedRow && isFresh(cachedRow.staleAt)) { - return storedValue - } - - queueRefresh(cacheKey, async () => { - const payload = await opts.build() - await upsertDocsArtifact({ ...opts, payload }) - }) - + if (storedValue !== undefined && cachedRow && isFresh(cachedRow.staleAt)) { return storedValue }