From fb7a792b21ab7e208977ecd9da7d9ede77e81876 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Thu, 1 Oct 2026 22:04:40 +0000 Subject: [PATCH 1/3] perf: reuse immutable evidence and share public cache keys --- src/app/api/cron/codex-stats/route.ts | 6 +- src/app/api/cron/github-worker/route.ts | 6 +- src/lib/codex/public-stats.ts | 125 +++++---- src/lib/github-activity-feed.ts | 107 ++++---- src/lib/github-file-evidence-cache.ts | 91 +++++++ src/lib/github-work-unit-store.ts | 187 ++++++++++--- src/lib/runtime-cache.ts | 65 +++++ tests/codex-cache.integration.test.ts | 126 +-------- tests/codex-public-cache.test.ts | 224 +++++++++++++++ tests/cron-publication.test.ts | 10 +- tests/github-activity-cache.test.ts | 333 +++++++++++++++++------ tests/github-cache.integration.test.ts | 48 +--- tests/github-file-evidence-cache.test.ts | 158 +++++++++++ tests/github-projection-cache.test.ts | 174 ++++++++++++ tests/helpers.ts | 25 ++ tests/runtime-cache.test.ts | 51 ++++ 16 files changed, 1340 insertions(+), 396 deletions(-) create mode 100644 src/lib/github-file-evidence-cache.ts create mode 100644 src/lib/runtime-cache.ts create mode 100644 tests/codex-public-cache.test.ts create mode 100644 tests/github-file-evidence-cache.test.ts create mode 100644 tests/github-projection-cache.test.ts create mode 100644 tests/runtime-cache.test.ts diff --git a/src/app/api/cron/codex-stats/route.ts b/src/app/api/cron/codex-stats/route.ts index 9fcd1ea3..eb78f4ec 100644 --- a/src/app/api/cron/codex-stats/route.ts +++ b/src/app/api/cron/codex-stats/route.ts @@ -1,4 +1,4 @@ -import { revalidateTag } from "next/cache"; +import { revalidatePath } from "next/cache"; import { after } from "next/server"; import { env } from "@/env"; @@ -17,7 +17,9 @@ export async function POST(request: Request) { try { const result = await syncCodexAccounts(); if (result.updated > 0) { - revalidateTag("public-codex-stats", "max"); + revalidatePath("/"); + revalidatePath("/tokens"); + after(async () => { await getPublicCodexStats(); }); diff --git a/src/app/api/cron/github-worker/route.ts b/src/app/api/cron/github-worker/route.ts index 7306d72a..62f778b9 100644 --- a/src/app/api/cron/github-worker/route.ts +++ b/src/app/api/cron/github-worker/route.ts @@ -1,4 +1,4 @@ -import { revalidateTag } from "next/cache"; +import { revalidatePath } from "next/cache"; import { after } from "next/server"; import { env } from "@/env"; @@ -42,7 +42,9 @@ export async function POST(request: Request) { }), }); if (activity.projection?.feedRevisionChanged === true) { - revalidateTag("public-github-activity", { expire: 0 }); + revalidatePath("/"); + revalidatePath("/work"); + after(async () => { await getInitialGitHubActivity(); }); diff --git a/src/lib/codex/public-stats.ts b/src/lib/codex/public-stats.ts index a18eb625..968e1986 100644 --- a/src/lib/codex/public-stats.ts +++ b/src/lib/codex/public-stats.ts @@ -1,5 +1,4 @@ import "server-only"; -import { unstable_cache } from "next/cache"; import { cache } from "react"; import { tokenPreferences } from "@/content/tokens"; @@ -12,69 +11,97 @@ import { import type { ClosedCodexHistory } from "@/lib/codex/public-stats-store"; import { reportOperationalError } from "@/lib/operational-error"; import { readPublicSnapshot } from "@/lib/public-snapshot"; +import { readRuntimeCache, writeRuntimeCache } from "@/lib/runtime-cache"; -const readVersionedClosedDays = unstable_cache( - async (today: string, _historyRevision: string) => - await readClosedCodexHistory(today), - ["codex-closed-history-versioned-v1"], - { revalidate: 86_400, tags: ["codex-history"] } -); - -const readViews = async ( +type PublicViews = Awaited>; +const preferencesKey = JSON.stringify(tokenPreferences); +const viewsKey = ( today: string, - closedHint?: ClosedCodexHistory, - revision?: Awaited> + revision: Pick ) => - await unstable_cache( - // History is only a validated query-saving hint. The transaction stamps - // the result with the revision of the data it actually read. - async () => await readCodexPublicViews(today, closedHint), - [ - revision === undefined - ? "public-codex-fallback-v1" - : "public-codex-versioned-v1", - today, - JSON.stringify(tokenPreferences), - ...(revision === undefined - ? [] - : [revision.viewsRevision, revision.historyRevision]), - ], - { - revalidate: 900, - ...(revision === undefined ? {} : { tags: ["public-codex-stats"] }), - } - )(); + JSON.stringify([ + "public-codex-v2", + today, + preferencesKey, + revision.viewsRevision, + revision.historyRevision, + ]); +const historyKey = (today: string, historyRevision: string) => + JSON.stringify(["codex-closed-history-v2", today, historyRevision]); const getViews = cache(async () => { if (!tokenPreferences.enabled || !isDatabaseConfigured()) { return null; } const today = new Date().toISOString().slice(0, 10); - try { - return await readPublicSnapshot(async () => { - const revision = await readCodexPublicRevision(today); - // Next bypasses nested unstable_cache reads. Prefetch history outside the views cache. - const closed = await readVersionedClosedDays( - today, - revision.historyRevision + const fallbackKey = JSON.stringify([ + "public-codex-fallback-v2", + today, + preferencesKey, + ]); + const fallbackRead = readRuntimeCache(fallbackKey); + let bodyRead: Promise | undefined; + const readBody = async (closed?: ClosedCodexHistory) => + await (bodyRead ??= readCodexPublicViews(today, closed)); + const healthyRead = (async () => { + const revision = await readCodexPublicRevision(today); + const [versioned, fallback] = await Promise.all([ + readRuntimeCache(viewsKey(today, revision)), + fallbackRead, + ]); + const covers = (value: PublicViews | null) => + value !== null && + BigInt(value.viewsRevision) >= BigInt(revision.viewsRevision) && + BigInt(value.historyRevision) >= BigInt(revision.historyRevision); + const cached = covers(fallback) ? fallback : versioned; + let value = cached; + if (!covers(value)) { + let closed = await readRuntimeCache( + historyKey(today, revision.historyRevision) ); - const fallback = await readViews(today, closed); - if ( - BigInt(fallback.viewsRevision) >= BigInt(revision.viewsRevision) && - BigInt(fallback.historyRevision) >= BigInt(revision.historyRevision) - ) { - return fallback.views; + if (closed === null) { + closed = await readClosedCodexHistory(today); + await writeRuntimeCache( + historyKey(today, closed.historyRevision), + closed, + 86_400 + ); } - return (await readViews(today, closed, revision)).views; - }); + value = await readBody(closed); + // Both store reads stamp their actual transaction revision. A publication + // between reads must never put newer data under an older revision key. + await writeRuntimeCache(viewsKey(today, value), value, 900); + } + if (value === null) { + throw new Error("Missing Codex public views"); + } + if ( + fallback?.viewsRevision !== value.viewsRevision || + fallback.historyRevision !== value.historyRevision + ) { + await writeRuntimeCache(fallbackKey, value); + } + return value.views; + })(); + try { + return await readPublicSnapshot(async () => await healthyRead); } catch (error) { reportOperationalError("public_codex_stats", error); - // Publication never invalidates this successful outage snapshot. Failed - // rebuilds throw rather than replacing it with null. + const fallback = await fallbackRead; + if (fallback !== null) { + return fallback.views; + } try { - return (await readViews(today)).views; + // Keep a cold timeout on the same history/body read already in flight. + return await healthyRead; } catch { - return null; + try { + const value = await readBody(); + await writeRuntimeCache(fallbackKey, value); + return value.views; + } catch { + return null; + } } } }); diff --git a/src/lib/github-activity-feed.ts b/src/lib/github-activity-feed.ts index f2eefbd2..b1911fae 100644 --- a/src/lib/github-activity-feed.ts +++ b/src/lib/github-activity-feed.ts @@ -1,6 +1,4 @@ import "server-only"; -import { unstable_cache } from "next/cache"; - import type { GitHubActivityCursor } from "@/lib/github-activity-cursor"; import { PUBLIC_GITHUB_ACTIVITY_DAY_PAGE_SIZE, @@ -10,61 +8,76 @@ import { import type { PublicGitHubActivityPage } from "@/lib/github-activity-types"; import { reportOperationalError } from "@/lib/operational-error"; import { readPublicSnapshot } from "@/lib/public-snapshot"; +import { readRuntimeCache, writeRuntimeCache } from "@/lib/runtime-cache"; -// Keep a successful snapshot available even when the current head cannot be read. -// Publication invalidation must not discard this outage fallback. -// Capture the successful page without putting it in the stable cache key. -const readCachedInitialGitHubActivity = async ( - snapshot?: Promise +const FALLBACK_KEY = "public-github-activity-fallback-v3"; +const contentKey = ( + page: Pick ) => - await unstable_cache( - async () => - await (snapshot ?? - readPublicGitHubActivityPage( - null, - PUBLIC_GITHUB_ACTIVITY_DAY_PAGE_SIZE - )), - ["public-github-activity-fallback-v2"], - { revalidate: 60 } - )(); + JSON.stringify([ + "public-github-activity-v3", + page.head.feedRevision, + page.orderingRevision, + ]); -const readVersionedInitialGitHubActivity = unstable_cache( - async (_feedRevision: string, _orderingRevision: string) => - await readPublicGitHubActivityPage( +export const getInitialGitHubActivity = async () => { + const fallbackRead = readRuntimeCache(FALLBACK_KEY); + let bodyRead: Promise | undefined; + const readBody = async () => + await (bodyRead ??= readPublicGitHubActivityPage( null, PUBLIC_GITHUB_ACTIVITY_DAY_PAGE_SIZE - ), - ["public-github-activity-versioned-v2"], - { revalidate: 3600, tags: ["public-github-activity"] } -); - -export const getInitialGitHubActivity = async () => { + )); + const healthyRead = (async () => { + const live = await readPublicGitHubActivityHead(); + const [versioned, fallback] = await Promise.all([ + readRuntimeCache(contentKey(live)), + fallbackRead, + ]); + const cached = + fallback?.head.feedRevision === live.head.feedRevision && + fallback.orderingRevision === live.orderingRevision + ? fallback + : versioned; + const snapshot = cached ?? (await readBody()); + const page = + snapshot.head.feedRevision === live.head.feedRevision && + snapshot.orderingRevision === live.orderingRevision && + BigInt(live.head.revision) >= BigInt(snapshot.head.revision) + ? { ...snapshot, head: live.head } + : snapshot; + await Promise.all([ + cached === null + ? writeRuntimeCache(contentKey(snapshot), snapshot, 3600) + : undefined, + fallback?.head.feedRevision === page.head.feedRevision && + fallback.head.revision === page.head.revision && + fallback.orderingRevision === page.orderingRevision + ? undefined + : writeRuntimeCache(FALLBACK_KEY, page), + ]); + return page; + })(); try { - return await readPublicSnapshot(async () => { - const { head, orderingRevision } = await readPublicGitHubActivityHead(); - const snapshot = readVersionedInitialGitHubActivity( - head.feedRevision, - orderingRevision - ).then((page) => - page.head.feedRevision === head.feedRevision && - page.orderingRevision === orderingRevision && - BigInt(head.revision) >= BigInt(page.head.revision) - ? { ...page, head } - : page - ); - // Check both caches concurrently; an expired fallback waits for the same successful snapshot. - const [page] = await Promise.all([ - snapshot, - readCachedInitialGitHubActivity(snapshot), - ]); - return page; - }); + return await readPublicSnapshot(async () => await healthyRead); } catch (error) { reportOperationalError("github_activity_initial", error); + const fallback = await fallbackRead; + if (fallback !== null) { + return fallback; + } + // A timeout leaves the original read running. Reuse it instead of downloading + // the page again. If metadata failed, one direct page read can still succeed. try { - return await readCachedInitialGitHubActivity(); + return await healthyRead; } catch { - return null; + try { + const page = await readBody(); + await writeRuntimeCache(FALLBACK_KEY, page); + return page; + } catch { + return null; + } } } }; diff --git a/src/lib/github-file-evidence-cache.ts b/src/lib/github-file-evidence-cache.ts new file mode 100644 index 00000000..0d4bd209 --- /dev/null +++ b/src/lib/github-file-evidence-cache.ts @@ -0,0 +1,91 @@ +import { readRuntimeCache, writeRuntimeCache } from "@/lib/runtime-cache"; + +export interface GitHubFileEvidenceHeader { + digest: string | null; + group: string; + id: string; + present: boolean; +} + +export const readGitHubFileEvidence = async ( + kind: "stats" | "commit" | "pull-request", + headers: readonly GitHubFileEvidenceHeader[], + read: (ids: readonly string[]) => Promise>, + parse: (value: unknown) => T +): Promise> => { + const result = new Map(); + const groups = Map.groupBy(headers, (header) => header.group); + const misses: { key: string | null; headers: GitHubFileEvidenceHeader[] }[] = + []; + await Promise.all( + [...groups.values()].map(async (group) => { + const manifest = group + .map(({ digest, id, present }) => [id, digest, present]) + .toSorted((left, right) => + String(left[0]).localeCompare(String(right[0])) + ); + const key = group.some( + (header) => header.present && header.digest === null + ) + ? null + : JSON.stringify(["github-file-evidence-v1", kind, manifest]); + const cached = key === null ? null : await readRuntimeCache(key); + try { + if (!Array.isArray(cached) || cached.length !== group.length) { + throw new Error("Missing cached file evidence"); + } + const values = new Map(); + for (const entry of cached) { + if ( + !Array.isArray(entry) || + entry.length !== 2 || + typeof entry[0] !== "string" + ) { + throw new Error("Invalid cached file evidence"); + } + values.set(entry[0], entry[1] === null ? null : parse(entry[1])); + } + for (const header of group) { + if ( + !values.has(header.id) || + (values.get(header.id) !== null) !== header.present + ) { + throw new Error("Cached file evidence does not match its manifest"); + } + } + for (const [id, value] of values) { + result.set(id, value); + } + } catch { + misses.push({ key, headers: group }); + } + }) + ); + const missingHeaders = misses.flatMap((group) => group.headers); + const ids = missingHeaders + .filter((header) => header.present) + .map((header) => header.id); + const rows = ids.length === 0 ? new Map() : await read(ids); + for (const header of missingHeaders) { + if ( + header.present && + (!rows.has(header.id) || rows.get(header.id) === null) + ) { + throw new Error(`Missing GitHub file evidence: ${header.id}`); + } + result.set(header.id, header.present ? parse(rows.get(header.id)) : null); + } + await Promise.all( + misses.map(async ({ key, headers: group }) => { + if (key !== null) { + // Cache raw values, so the same validator runs on cache hits and DB reads. + await writeRuntimeCache( + key, + group.map(({ id }) => [id, rows.get(id) ?? null]), + 7 * 86_400 + ); + } + }) + ); + return result; +}; diff --git a/src/lib/github-work-unit-store.ts b/src/lib/github-work-unit-store.ts index 63545fc5..9668c546 100644 --- a/src/lib/github-work-unit-store.ts +++ b/src/lib/github-work-unit-store.ts @@ -31,6 +31,8 @@ import type { GitHubWorkUnitFileFact, } from "@/lib/github-change-evidence"; import { TRACKED_GITHUB_USER_IDS } from "@/lib/github-commits-core"; +import { readGitHubFileEvidence } from "@/lib/github-file-evidence-cache"; +import type { GitHubFileEvidenceHeader } from "@/lib/github-file-evidence-cache"; import { chooseEffectivePullRequest, hasPendingGitHubPullRequest, @@ -188,6 +190,7 @@ interface CommitSummaryEvaluationEvidence { } interface PullRequestSummaryEvaluationEvidence { + fileFactsPresent: boolean; fileFactsComplete: boolean; fileFactsDigest: string | null; versionId: string; @@ -573,6 +576,7 @@ const hydrateSelectedSummaryEvidence = async ( transaction: GitHubWorkUnitTransaction, input: GitHubWorkUnitProjectionInput, units: readonly GitHubProjectedWorkUnit[], + commitEvidenceHeaders: ReadonlyMap, pullRequestEvidenceByNodeId: ReadonlyMap< string, PullRequestSummaryEvaluationEvidence @@ -604,58 +608,79 @@ const hydrateSelectedSummaryEvidence = async ( } } - const memberRows = - compositeMembers.size === 0 - ? [] - : await transaction - .select({ - fileFacts: githubCommits.fileFacts, - repositoryId: githubCommits.repositoryId, - sha: githubCommits.sha, - }) - .from(githubCommits) - .where( - or( - ...[...compositeMembers.values()].map((member) => - and( - eq(githubCommits.repositoryId, member.repositoryId), - eq(githubCommits.sha, member.sha) - ) - ) - ) - ); - const rawCommitFactsByLogicalKey = new Map( - memberRows.map((row) => [ - logicalKeyFrom(row.repositoryId, row.sha), - checkedFileFacts(row.fileFacts), - ]) + const rawCommitFactsByLogicalKey = await readGitHubFileEvidence( + "commit", + [...compositeMembers.keys()].map((id) => { + const header = commitEvidenceHeaders.get(id); + if (header === undefined) { + throw new Error(`A summary lacks commit evidence: ${id}`); + } + return header; + }), + async (ids) => { + const rows = await transaction + .select({ + fileFacts: githubCommits.fileFacts, + repositoryId: githubCommits.repositoryId, + sha: githubCommits.sha, + }) + .from(githubCommits) + .where( + or( + ...ids.map((id) => { + const member = compositeMembers.get(id); + if (member === undefined) { + throw new Error(`A summary lacks commit identity: ${id}`); + } + return and( + eq(githubCommits.repositoryId, member.repositoryId), + eq(githubCommits.sha, member.sha) + ); + }) + ) + ); + return new Map( + rows.map((row) => [ + logicalKeyFrom(row.repositoryId, row.sha), + row.fileFacts, + ]) + ); + }, + checkedFileFacts ); - const versionIds = [...netPullRequestNodeIds].map((nodeId) => { + const versionHeaders = [...netPullRequestNodeIds].map((nodeId) => { const evidence = pullRequestEvidenceByNodeId.get(nodeId); if (evidence === undefined) { throw new Error( `A pull-request summary lacks version evidence: ${nodeId}` ); } - return evidence.versionId; + return { + digest: evidence.fileFactsDigest, + group: evidence.versionId, + id: evidence.versionId, + present: evidence.fileFactsPresent, + }; }); - const versionRows = - versionIds.length === 0 - ? [] - : await transaction - .select({ - fileFacts: githubPullRequests.fileFacts, - id: githubPullRequests.snapshotId, - }) - .from(githubPullRequests) - .where(inArray(githubPullRequests.snapshotId, versionIds)); - const rawPullRequestFactsByVersionId = new Map( - versionRows.flatMap((row) => - row.id === null - ? [] - : [[row.id, checkedFileFacts(row.fileFacts)] as const] - ) + const rawPullRequestFactsByVersionId = await readGitHubFileEvidence( + "pull-request", + versionHeaders, + async (ids) => { + const rows = await transaction + .select({ + fileFacts: githubPullRequests.fileFacts, + id: githubPullRequests.snapshotId, + }) + .from(githubPullRequests) + .where(inArray(githubPullRequests.snapshotId, [...ids])); + return new Map( + rows.flatMap((row) => + row.id === null ? [] : [[row.id, row.fileFacts] as const] + ) + ); + }, + checkedFileFacts ); return { @@ -814,7 +839,8 @@ const loadProjectionSnapshot = async ( committerAt: githubCommits.committerAt, deletions: githubCommits.deletions, enrichmentState: githubCommits.enrichmentState, - fileFacts: githubCommits.fileStats, + fileFactsPresent: sql`${githubCommits.fileStats} is not null`, + fileFactsPrunedAt: githubCommits.fileFactsPrunedAt, fileFactsComplete: githubCommits.fileFactsComplete, fileFactsDigest: githubCommits.fileFactsDigest, firstObservedAt: githubCommits.firstObservedAt, @@ -833,6 +859,72 @@ const loadProjectionSnapshot = async ( ) ) .orderBy(asc(githubCommits.repositoryId), asc(githubCommits.sha)); + const commitHeaders = commitRows.map((row) => ({ + digest: checkedDigest(row.fileFactsDigest), + group: `${row.repositoryId}:${row.sha[0]}`, + id: logicalKeyFrom(row.repositoryId, row.sha), + present: row.fileFactsPresent, + })); + const compactFactsByLogicalKey = await readGitHubFileEvidence( + "stats", + commitHeaders, + async (ids) => { + const selectedIds = new Set(ids); + const selectedByRepository = Map.groupBy( + commitRows.filter((row) => + selectedIds.has(logicalKeyFrom(row.repositoryId, row.sha)) + ), + (row) => row.repositoryId + ); + const rows = await transaction + .select({ + fileFacts: githubCommits.fileStats, + repositoryId: githubCommits.repositoryId, + sha: githubCommits.sha, + }) + .from(githubCommits) + .where( + or( + ...[...selectedByRepository].map(([repositoryId, commits]) => + and( + eq(githubCommits.repositoryId, repositoryId), + inArray( + githubCommits.sha, + commits.map((commit) => commit.sha) + ) + ) + ) + ) + ); + return new Map( + rows.map((row) => [ + logicalKeyFrom(row.repositoryId, row.sha), + row.fileFacts, + ]) + ); + }, + checkedCompactFileFacts + ); + const commitEvidenceHeaders = new Map( + commitRows.map((row, index) => { + const header = commitHeaders[index]; + return [ + header.id, + { + ...header, + // Pruning preserves the original digest while removing patches. + digest: + header.digest === null + ? null + : JSON.stringify([ + header.digest, + row.fileFactsPrunedAt?.toISOString() ?? null, + ]), + group: header.id, + }, + ] as const; + }) + ); const associationRows = await transaction .select({ pullRequestNodeId: githubCommitPullRequestAssociations.pullRequestNodeId, @@ -869,7 +961,9 @@ const loadProjectionSnapshot = async ( associationsByLogicalKey.set(key, nodeIds); } const changes: GitHubLogicalChange[] = commitRows.map((row) => { - const fileFacts = checkedCompactFileFacts(row.fileFacts); + const fileFacts = + compactFactsByLogicalKey.get(logicalKeyFrom(row.repositoryId, row.sha)) ?? + null; const parentShas = checkedParentShas(row.parentShas); const pullRequestCoverageComplete = row.pullRequestDiscoveryState === "complete"; @@ -934,6 +1028,7 @@ const loadProjectionSnapshot = async ( commitCount: githubPullRequests.commitCount, createdAt: githubPullRequests.createdAt, fileFactsComplete: githubPullRequests.fileFactsComplete, + fileFactsPresent: sql`${githubPullRequests.fileFacts} is not null`, fileFactsDigest: githubPullRequests.fileFactsDigest, headSha: githubPullRequests.headSha, mergeSnapshot: githubPullRequests.mergeSnapshot, @@ -1050,6 +1145,7 @@ const loadProjectionSnapshot = async ( row.nodeId, { fileFactsComplete: row.fileFactsComplete, + fileFactsPresent: row.fileFactsPresent, fileFactsDigest: checkedDigest(row.fileFactsDigest), versionId: row.versionId, }, @@ -1168,6 +1264,7 @@ const loadProjectionSnapshot = async ( transaction, compactInput, selectedSummaryEvaluations, + commitEvidenceHeaders, pullRequestSummaryEvidenceByNodeId ); const selectedSummaryIdentities = new Set( diff --git a/src/lib/runtime-cache.ts b/src/lib/runtime-cache.ts new file mode 100644 index 00000000..902917ef --- /dev/null +++ b/src/lib/runtime-cache.ts @@ -0,0 +1,65 @@ +import { createHash } from "node:crypto"; +import { gzipSync, gunzipSync } from "node:zlib"; + +import { getCache } from "@vercel/functions"; + +// Explicit keys are shared by separately compiled cron and page bundles. +const cache = getCache({ + // Hobby teams share a cache; previews can also use different databases. + namespace: `f0rr0-v1-${createHash("sha256") + .update( + JSON.stringify([process.env.VERCEL_PROJECT_ID, process.env.DATABASE_URL]) + ) + .digest("hex")}`, + keyHashFunction: (key) => createHash("sha256").update(key).digest("hex"), +}); + +const withCacheDeadline = async (operation: Promise) => { + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + // oxlint-disable-next-line promise/avoid-new -- Bound optional cache I/O independently of database reads. + new Promise((_resolve, reject) => { + timer = setTimeout(() => { + reject(new Error("Cache timeout")); + }, 400); + }), + ]); + } finally { + clearTimeout(timer); + } +}; + +export const readRuntimeCache = async (key: string): Promise => { + try { + const value = await withCacheDeadline(cache.get(key)); + return typeof value === "string" + ? (JSON.parse( + gunzipSync(Buffer.from(value, "base64"), { + maxOutputLength: 64 * 1024 * 1024, + }).toString("utf-8") + ) as T) + : null; + } catch { + return null; + } +}; + +export const writeRuntimeCache = async ( + key: string, + value: unknown, + ttl?: number +) => { + try { + const compressed = gzipSync(JSON.stringify(value)).toString("base64"); + // Vercel limits entries to 2 MB. Oversized values remain ordinary DB reads. + if (compressed.length < 1_900_000) { + await withCacheDeadline( + cache.set(key, compressed, { name: "f0rr0", ttl }) + ); + } + } catch { + // A cache outage must not turn a successful database read into a failure. + } +}; diff --git a/tests/codex-cache.integration.test.ts b/tests/codex-cache.integration.test.ts index 24f0114b..46c109c2 100644 --- a/tests/codex-cache.integration.test.ts +++ b/tests/codex-cache.integration.test.ts @@ -1,4 +1,4 @@ -import { afterAll, expect, test } from "bun:test"; +import { expect, test } from "bun:test"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; @@ -6,7 +6,6 @@ import { Client } from "pg"; import { closeDatabase } from "../src/db/client"; import { utcOffset } from "../src/lib/codex/analytics"; -import { getPublicCodexStats } from "../src/lib/codex/public-stats"; import { readClosedCodexHistory, readCodexPublicRevision, @@ -275,126 +274,3 @@ integrationTest( }, 20_000 ); - -integrationTest( - "native Next caches refresh backfills and preserve a warm outage snapshot", - async () => { - if (databaseUrl === undefined) { - throw new Error("Missing validation database"); - } - const { IncrementalCache } = - await import("next/dist/server/lib/incremental-cache/index.js"); - const { nodeFs } = await import("next/dist/server/lib/node-fs-methods.js"); - const cache = new IncrementalCache({ - dev: false, - fs: nodeFs, - flushToDisk: false, - maxMemoryCacheSize: 1_000_000, - serverDistDir: "/tmp/codex-cache-validation", - requestHeaders: {}, - getPrerenderManifest: () => ({ - version: 4, - routes: {}, - dynamicRoutes: {}, - notFoundRoutes: [], - preview: { - previewModeId: "local-cache-test", - previewModeSigningKey: "", - previewModeEncryptionKey: "", - }, - }), - }); - Object.assign(globalThis, { __incrementalCache: cache }); - const client = new Client({ connectionString: databaseUrl }); - await client.connect(); - let revisionTableRenamed = false; - try { - const today = new Date().toISOString().slice(0, 10); - await client.query( - "DELETE FROM codex_usage_days; DELETE FROM codex_accounts;" - ); - const snapshot = createCodexAccountSnapshot( - { stats: { lifetime_tokens: 100, daily_usage_buckets: [] } }, - {} - ); - await client.query( - "INSERT INTO codex_accounts(id,snapshot,snapshot_at) VALUES ('native',$1,now())", - [snapshot] - ); - const insert = async (day: string, tokens: number) => - await client.query( - "INSERT INTO codex_usage_days VALUES ('native',$1,$2,now())", - [ - day, - { - dailyUsageBuckets: [{ startDate: day, tokens }], - cumulativeDailyUsageBuckets: null, - analytics: {}, - }, - ] - ); - await insert(utcOffset(today, -2), 10); - // Adjacent versions above Number's safe range must still choose distinct snapshots. - await client.query( - "UPDATE codex_public_revisions SET revision=9007199254740992 WHERE scope='views'" - ); - const initial = await getPublicCodexStats(); - expect(initial?.totals.last7Days.value).toBe(10); - expect(await getPublicCodexStats()).toEqual(initial); - await insert(utcOffset(today, -3), 5); - expect((await getPublicCodexStats())?.totals.last7Days.value).toBe(15); - await cache.revalidateTag(["public-codex-stats", "codex-history"], { - expire: 0, - }); - // The real revision query fails, while Next must retain its successful fallback. - await client.query( - "ALTER TABLE codex_public_revisions RENAME TO cache_validation_revisions_offline" - ); - revisionTableRenamed = true; - expect(await getPublicCodexStats()).toEqual(initial); - await client.query( - "ALTER TABLE cache_validation_revisions_offline RENAME TO codex_public_revisions" - ); - revisionTableRenamed = false; - expect((await getPublicCodexStats())?.totals.last7Days.value).toBe(15); - // Metadata can succeed while a cold history/body read stalls. A warm - // snapshot must remain available in both cases, not just metadata outages. - for (const tag of ["codex-history", "public-codex-stats"]) { - await cache.revalidateTag([tag], { expire: 0 }); - await client.query( - "BEGIN; LOCK TABLE codex_usage_days IN ACCESS EXCLUSIVE MODE" - ); - const pending = getPublicCodexStats(); - let result: Awaited | "stalled"; - try { - result = await Promise.race([ - pending, - Bun.sleep(1800).then(() => "stalled" as const), - ]); - } finally { - await client.query("ROLLBACK"); - await pending; - } - expect(result).not.toBe("stalled"); - expect(result).toEqual(initial); - expect((await getPublicCodexStats())?.totals.last7Days.value).toBe(15); - } - } finally { - if (revisionTableRenamed) { - await client.query( - "ALTER TABLE cache_validation_revisions_offline RENAME TO codex_public_revisions" - ); - } - await client.end(); - await closeDatabase(); - Reflect.deleteProperty(globalThis, "__incrementalCache"); - } - }, - 20_000 -); - -afterAll(async () => { - if (databaseUrl !== undefined) { - await closeDatabase(); - } -}); diff --git a/tests/codex-public-cache.test.ts b/tests/codex-public-cache.test.ts new file mode 100644 index 00000000..7df78bbd --- /dev/null +++ b/tests/codex-public-cache.test.ts @@ -0,0 +1,224 @@ +import { expect, spyOn, test } from "bun:test"; + +import { tokenPreferences } from "../src/content/tokens"; +import * as database from "../src/db/client"; +import { buildTokenDetails } from "../src/lib/codex/analytics"; +import { + getPublicCodexStats, + getPublicTokenDetails, +} from "../src/lib/codex/public-stats"; +import * as store from "../src/lib/codex/public-stats-store"; +import { + buildPublicCodexStats, + createCodexAccountSnapshot, +} from "../src/lib/codex/stats"; +import { readRuntimeCache } from "../src/lib/runtime-cache"; +import { installRuntimeCache } from "./helpers"; + +const today = new Date().toISOString().slice(0, 10); +const now = new Date(`${today}T12:00:00Z`); +const stats = (tokens: number) => + buildPublicCodexStats( + [ + { + label: "one", + snapshot: createCodexAccountSnapshot( + { + stats: { + lifetime_tokens: tokens, + daily_usage_buckets: [{ start_date: today, tokens }], + }, + }, + {} + ), + }, + ], + now, + 1 + ); +const initial = { + viewsRevision: "9007199254740992", + historyRevision: "1", + views: { + stats: stats(100), + details: Object.fromEntries( + [7, 30, 365].map((days) => [ + days, + buildTokenDetails([], days, now, tokenPreferences), + ]) + ), + }, +}; +const closed = { + today, + viewsRevision: initial.viewsRevision, + historyRevision: initial.historyRevision, + rows: [], +}; + +const setup = () => ({ + cache: installRuntimeCache(), + configured: spyOn(database, "isDatabaseConfigured").mockReturnValue(true), + revision: spyOn(store, "readCodexPublicRevision").mockResolvedValue({ + viewsRevision: initial.viewsRevision, + historyRevision: "1", + }), + history: spyOn(store, "readClosedCodexHistory").mockResolvedValue(closed), + body: spyOn(store, "readCodexPublicViews").mockResolvedValue(initial), +}); +const restore = (state: ReturnType) => { + state.configured.mockRestore(); + state.revision.mockRestore(); + state.history.mockRestore(); + state.body.mockRestore(); + state.cache.restore(); +}; + +test("views and token details share content; recent updates reuse history, backfills refresh it", async () => { + const state = setup(); + try { + expect(await getPublicCodexStats()).toEqual(initial.views.stats); + expect(await getPublicTokenDetails(7)).toEqual(initial.views.details[7]); + expect(state.body).toHaveBeenCalledTimes(1); + expect(state.history).toHaveBeenCalledTimes(1); + const recent = { + ...initial, + viewsRevision: "9007199254740993", + views: { ...initial.views, stats: stats(101) }, + }; + state.revision.mockResolvedValue({ + viewsRevision: recent.viewsRevision, + historyRevision: "1", + }); + state.body.mockResolvedValue(recent); + expect(await getPublicCodexStats()).toEqual(recent.views.stats); + expect(state.body).toHaveBeenLastCalledWith(today, closed); + expect(state.history).toHaveBeenCalledTimes(1); + const backfill = { + ...recent, + viewsRevision: "9007199254740994", + historyRevision: "2", + views: { ...initial.views, stats: stats(107) }, + }; + state.revision.mockResolvedValue({ + viewsRevision: backfill.viewsRevision, + historyRevision: "2", + }); + state.history.mockResolvedValue({ ...closed, historyRevision: "2" }); + state.body.mockResolvedValue(backfill); + expect((await getPublicCodexStats())?.totals.last7Days.value).toBe(107); + expect(state.history).toHaveBeenCalledTimes(2); + expect(state.body).toHaveBeenCalledTimes(3); + state.revision.mockRejectedValue(new Error("Database offline")); + expect(await getPublicCodexStats()).toEqual(backfill.views.stats); + expect(state.body).toHaveBeenCalledTimes(3); + } finally { + restore(state); + } +}); + +test("racing publications store history and views under their actual transaction revisions", async () => { + const state = setup(); + const newer = { + ...initial, + viewsRevision: "9007199254740993", + historyRevision: "2", + }; + state.history.mockResolvedValue({ ...closed, historyRevision: "2" }); + state.body.mockResolvedValue(newer); + try { + await getPublicCodexStats(); + const preferences = JSON.stringify(tokenPreferences); + expect( + await readRuntimeCache( + JSON.stringify(["codex-closed-history-v2", today, "1"]) + ) + ).toBeNull(); + expect( + await readRuntimeCache( + JSON.stringify(["codex-closed-history-v2", today, "2"]) + ) + ).toEqual({ ...closed, historyRevision: "2" }); + expect( + await readRuntimeCache( + JSON.stringify([ + "public-codex-v2", + today, + preferences, + initial.viewsRevision, + "1", + ]) + ) + ).toBeNull(); + expect( + await readRuntimeCache( + JSON.stringify([ + "public-codex-v2", + today, + preferences, + newer.viewsRevision, + "2", + ]) + ) + ).toEqual(newer); + } finally { + restore(state); + } +}); + +for (const warm of [false, true]) { + test(`slow history preserves ${warm ? "warm availability" : "one cold history and body read"}`, async () => { + const state = setup(); + const release = Promise.withResolvers(); + let pending: ReturnType | undefined; + try { + if (warm) { + await getPublicCodexStats(); + } + state.revision.mockResolvedValue({ + viewsRevision: initial.viewsRevision, + historyRevision: "2", + }); + state.history.mockReturnValue(release.promise); + state.body.mockResolvedValue({ + ...initial, + historyRevision: "2", + views: { ...initial.views, stats: stats(101) }, + }); + pending = getPublicCodexStats(); + await Bun.sleep(1100); + if (warm) { + expect(await pending).toEqual(initial.views.stats); + expect(state.body).toHaveBeenCalledTimes(1); + expect(state.history).toHaveBeenCalledTimes(2); + } else { + expect(state.body).not.toHaveBeenCalled(); + expect(state.history).toHaveBeenCalledTimes(1); + } + release.resolve({ ...closed, historyRevision: "2" }); + await pending; + // Allow the original healthy read to finish after a warm fallback returned. + await Bun.sleep(20); + expect((await getPublicCodexStats())?.totals.last7Days.value).toBe(101); + expect(state.body).toHaveBeenCalledTimes(warm ? 2 : 1); + expect(state.history).toHaveBeenCalledTimes(warm ? 2 : 1); + } finally { + release.resolve(closed); + await pending; + restore(state); + } + }); +} + +test("cache outages keep Codex data available without duplicate history reads", async () => { + const state = setup(); + try { + state.cache.get.mockRejectedValue(new Error("Cache offline")); + state.cache.set.mockRejectedValue(new Error("Cache offline")); + expect(await getPublicCodexStats()).toEqual(initial.views.stats); + expect(state.history).toHaveBeenCalledTimes(1); + expect(state.body).toHaveBeenCalledTimes(1); + } finally { + restore(state); + } +}); diff --git a/tests/cron-publication.test.ts b/tests/cron-publication.test.ts index a5215833..6f521628 100644 --- a/tests/cron-publication.test.ts +++ b/tests/cron-publication.test.ts @@ -70,7 +70,7 @@ test("cron authentication rejects missing, wrong and unconfigured secrets before test("Codex publication warms only after a successful sync changes data", async () => { env.CRON_SECRET = secret; const sync = spyOn(tokenSync, "syncCodexAccounts"); - const invalidate = spyOn(nextCache, "revalidateTag").mockImplementation( + const invalidate = spyOn(nextCache, "revalidatePath").mockImplementation( () => {} ); const after = spyOn(nextServer, "after").mockImplementation(() => {}); @@ -90,7 +90,7 @@ test("Codex publication warms only after a successful sync changes data", async sync.mockResolvedValue({ updated: 1 }); expect((await syncTokens(request("codex-stats"))).status).toBe(200); - expect(invalidate.mock.calls).toEqual([["public-codex-stats", "max"]]); + expect(invalidate.mock.calls).toEqual([["/"], ["/tokens"]]); expect(after).toHaveBeenCalledTimes(1); expect(warm).not.toHaveBeenCalled(); const [[warmAfterResponse]] = after.mock.calls; @@ -129,7 +129,7 @@ test("GitHub rejects invalid requests and warms only a changed public feed", asy const worker = spyOn(workWorker, "runGitHubActivityWorker").mockResolvedValue( result ); - const invalidate = spyOn(nextCache, "revalidateTag").mockImplementation( + const invalidate = spyOn(nextCache, "revalidatePath").mockImplementation( () => {} ); const after = spyOn(nextServer, "after").mockImplementation(() => {}); @@ -185,9 +185,7 @@ test("GitHub rejects invalid requests and warms only a changed public feed", asy refLimit: 1, }) ); - expect(invalidate.mock.calls).toEqual([ - ["public-github-activity", { expire: 0 }], - ]); + expect(invalidate.mock.calls).toEqual([["/"], ["/work"]]); expect(after).toHaveBeenCalledTimes(1); expect(warm).not.toHaveBeenCalled(); const [[warmAfterResponse]] = after.mock.calls; diff --git a/tests/github-activity-cache.test.ts b/tests/github-activity-cache.test.ts index a3a46858..905027b6 100644 --- a/tests/github-activity-cache.test.ts +++ b/tests/github-activity-cache.test.ts @@ -1,93 +1,272 @@ -import { expect, mock, spyOn, test } from "bun:test"; +import { expect, spyOn, test } from "bun:test"; import { getInitialGitHubActivity } from "../src/lib/github-activity-feed"; import * as store from "../src/lib/github-activity-store"; import type { PublicGitHubActivityPage } from "../src/lib/github-activity-types"; +import { readRuntimeCache } from "../src/lib/runtime-cache"; +import { installRuntimeCache } from "./helpers"; -for (const fallbackCached of [true, false]) { - test(`cache reads overlap and share fresh content with fallback ${fallbackCached ? "cached" : "missing"}`, async () => { - const cached: PublicGitHubActivityPage = { - days: [], - head: { - feedRevision: "10", - lastPublishedAt: null, - revision: "7", - summarizing: false, - }, - nextCursor: null, - orderingRevision: "4", +const page: PublicGitHubActivityPage = { + days: [ + { + day: "2026-10-01", + repositories: [ + { + repository: { + key: "1", + label: "example/repo", + url: "https://github.com/example/repo", + avatarUrl: null, + }, + items: [ + { + id: "issue:1", + kind: "issue", + activityAt: "2026-10-01T12:00:00Z", + title: "Work", + destination: { + label: "Issue", + url: "https://github.com/example/repo/issues/1", + }, + }, + ], + }, + ], + }, + ], + head: { + feedRevision: "10", + lastPublishedAt: null, + revision: "9007199254740992", + summarizing: false, + }, + nextCursor: null, + orderingRevision: "4", +}; + +test("cron and page readers share content while status, ordering and visibility revisions stay fresh", async () => { + const cache = installRuntimeCache(); + const head = spyOn(store, "readPublicGitHubActivityHead").mockResolvedValue({ + etag: "head", + head: page.head, + orderingRevision: page.orderingRevision, + }); + const body = spyOn(store, "readPublicGitHubActivityPage").mockResolvedValue( + page + ); + try { + expect(await getInitialGitHubActivity()).toEqual(page); + expect(await getInitialGitHubActivity()).toEqual(page); + expect(body).toHaveBeenCalledTimes(1); + const status = { + ...page.head, + revision: "9007199254740993", + summarizing: true, }; - const head = { ...cached.head, revision: "8", summarizing: true }; - const readHead = spyOn( - store, - "readPublicGitHubActivityHead" - ).mockResolvedValue({ - etag: "fresh-head", - head, + head.mockResolvedValue({ + etag: "status", + head: status, orderingRevision: "4", }); - const readBody = spyOn( - store, - "readPublicGitHubActivityPage" - ).mockRejectedValue(new Error("Unexpected database read")); - const bothStarted = Promise.withResolvers(); - const release = Promise.withResolvers(); - const started = new Set(); - const set = mock(async () => {}); - const previousCache: unknown = Reflect.get( - globalThis, - "__incrementalCache" - ); - Object.assign(globalThis, { - __incrementalCache: { - generateSimpleCacheKey: async (key: string) => key, - get: async (key: string) => { - const fallback = key.includes("public-github-activity-fallback-v2"); - started.add(fallback ? "fallback" : "content"); - if (started.size === 2) { - bothStarted.resolve(null); - } - await release.promise; - return fallback && !fallbackCached - ? null - : { - value: { - kind: "FETCH", - data: { body: JSON.stringify(cached) }, - }, - }; - }, - set, - }, + expect(await getInitialGitHubActivity()).toEqual({ ...page, head: status }); + expect(body).toHaveBeenCalledTimes(1); + const masked = { + ...page, + days: page.days.map((day) => ({ + ...day, + repositories: day.repositories.map((group) => ({ + repository: { ...group.repository, label: "Private", url: null }, + items: group.items.map((item) => ({ ...item, destination: null })), + })), + })), + head: { ...status, feedRevision: "11", revision: "9007199254740994" }, + orderingRevision: "5", + }; + // Ordering changes independently, and a visibility publication changes the feed. + for (const next of [ + { ...page, head: status, orderingRevision: "5" }, + masked, + ]) { + head.mockResolvedValue({ + etag: "next", + head: next.head, + orderingRevision: next.orderingRevision, + }); + body.mockResolvedValue(next); + expect(await getInitialGitHubActivity()).toEqual(next); + expect(await getInitialGitHubActivity()).toEqual(next); + } + expect(body).toHaveBeenCalledTimes(3); + head.mockRejectedValue(new Error("Database offline")); + expect(await getInitialGitHubActivity()).toEqual(masked); + expect(body).toHaveBeenCalledTimes(3); + } finally { + head.mockRestore(); + body.mockRestore(); + cache.restore(); + } +}); + +test("publication races cache the body's actual revision and never replace its newer head", async () => { + const cache = installRuntimeCache(); + const head = spyOn(store, "readPublicGitHubActivityHead").mockResolvedValue({ + etag: "old", + head: page.head, + orderingRevision: "4", + }); + const newer = { + ...page, + head: { ...page.head, feedRevision: "11", revision: "9007199254740993" }, + orderingRevision: "5", + }; + const body = spyOn(store, "readPublicGitHubActivityPage").mockResolvedValue( + newer + ); + try { + expect(await getInitialGitHubActivity()).toEqual(newer); + expect( + await readRuntimeCache( + JSON.stringify(["public-github-activity-v3", "10", "4"]) + ) + ).toBeNull(); + expect( + await readRuntimeCache( + JSON.stringify(["public-github-activity-v3", "11", "5"]) + ) + ).toEqual(newer); + head.mockResolvedValue({ + etag: "new", + head: newer.head, + orderingRevision: "5", }); - const result = getInitialGitHubActivity(); + expect(await getInitialGitHubActivity()).toEqual(newer); + expect(body).toHaveBeenCalledTimes(1); + } finally { + head.mockRestore(); + body.mockRestore(); + cache.restore(); + } +}); + +for (const warm of [false, true]) { + test(`a slow head uses ${warm ? "a warm outage snapshot" : "one shared cold body read"}`, async () => { + const cache = installRuntimeCache(); + const head = spyOn(store, "readPublicGitHubActivityHead").mockResolvedValue( + { etag: "head", head: page.head, orderingRevision: "4" } + ); + const body = spyOn(store, "readPublicGitHubActivityPage").mockResolvedValue( + page + ); + const release = + Promise.withResolvers< + Awaited> + >(); + let pending: Promise | undefined; try { - // Neither cache lookup completes until both have started. - await bothStarted.promise; - release.resolve(null); - expect(await result).toEqual({ ...cached, head }); - expect(readBody).not.toHaveBeenCalled(); - if (!fallbackCached) { - expect(set).toHaveBeenCalledWith( - expect.any(String), - expect.objectContaining({ - data: expect.objectContaining({ - body: JSON.stringify({ ...cached, head }), - }), - }), - expect.anything() - ); + if (warm) { + await getInitialGitHubActivity(); } - } finally { - release.resolve(null); - await result; - readHead.mockRestore(); - readBody.mockRestore(); - if (previousCache === undefined) { - Reflect.deleteProperty(globalThis, "__incrementalCache"); + head.mockReturnValue(release.promise); + pending = getInitialGitHubActivity(); + await Bun.sleep(1100); + if (warm) { + expect(await pending).toEqual(page); + expect(body).toHaveBeenCalledTimes(1); } else { - Reflect.set(globalThis, "__incrementalCache", previousCache); + expect(body).not.toHaveBeenCalled(); } + release.resolve({ etag: "head", head: page.head, orderingRevision: "4" }); + expect(await pending).toEqual(page); + expect(body).toHaveBeenCalledTimes(1); + } finally { + release.resolve({ etag: "head", head: page.head, orderingRevision: "4" }); + await pending; + head.mockRestore(); + body.mockRestore(); + cache.restore(); } }); } + +test("cold metadata failure can recover through one page read; total failure returns null", async () => { + const cache = installRuntimeCache(); + const head = spyOn(store, "readPublicGitHubActivityHead").mockRejectedValue( + new Error("Head missing") + ); + const body = spyOn(store, "readPublicGitHubActivityPage").mockResolvedValue( + page + ); + try { + expect(await getInitialGitHubActivity()).toEqual(page); + cache.values.clear(); + body.mockRejectedValue(new Error("Page missing")); + expect(await getInitialGitHubActivity()).toBeNull(); + expect(body).toHaveBeenCalledTimes(2); + } finally { + head.mockRestore(); + body.mockRestore(); + cache.restore(); + } +}); + +test("fallback and versioned cache checks overlap instead of delaying navigation", async () => { + const cache = installRuntimeCache(); + const head = spyOn(store, "readPublicGitHubActivityHead").mockResolvedValue({ + etag: "head", + head: page.head, + orderingRevision: "4", + }); + const body = spyOn(store, "readPublicGitHubActivityPage").mockResolvedValue( + page + ); + const both = Promise.withResolvers(); + const release = Promise.withResolvers(); + let pending: ReturnType | undefined; + try { + await getInitialGitHubActivity(); + let started = 0; + cache.get.mockImplementation(async (key) => { + started++; + if (started === 2) { + both.resolve("overlapping"); + } + await release.promise; + return cache.values.get(key) ?? null; + }); + pending = getInitialGitHubActivity(); + expect( + await Promise.race([both.promise, Bun.sleep(200).then(() => "blocked")]) + ).toBe("overlapping"); + release.resolve(null); + expect(await pending).toEqual(page); + expect(body).toHaveBeenCalledTimes(1); + } finally { + release.resolve(null); + await pending; + head.mockRestore(); + body.mockRestore(); + cache.restore(); + } +}); + +test("cache outages do not hide a healthy database page", async () => { + const cache = installRuntimeCache(); + const head = spyOn(store, "readPublicGitHubActivityHead").mockResolvedValue({ + etag: "head", + head: page.head, + orderingRevision: "4", + }); + const body = spyOn(store, "readPublicGitHubActivityPage").mockResolvedValue( + page + ); + try { + cache.get.mockRejectedValue(new Error("Cache offline")); + cache.set.mockRejectedValue(new Error("Cache offline")); + expect(await getInitialGitHubActivity()).toEqual(page); + expect(body).toHaveBeenCalledTimes(1); + } finally { + head.mockRestore(); + body.mockRestore(); + cache.restore(); + } +}); diff --git a/tests/github-cache.integration.test.ts b/tests/github-cache.integration.test.ts index 69565397..2af96ee1 100644 --- a/tests/github-cache.integration.test.ts +++ b/tests/github-cache.integration.test.ts @@ -28,43 +28,11 @@ if (databaseUrl !== undefined) { const integrationTest = databaseUrl === undefined ? test.skip : test; integrationTest( - "hourly content caching preserves status, visibility and outage behavior", + "revision content caching preserves status, visibility and outage behavior", async () => { - const { IncrementalCache } = - await import("next/dist/server/lib/incremental-cache/index.js"); - const { nodeFs } = await import("next/dist/server/lib/node-fs-methods.js"); - const cache = new IncrementalCache({ - dev: false, - fs: nodeFs, - flushToDisk: false, - maxMemoryCacheSize: 16_000_000, - serverDistDir: "/tmp/github-long-cache-native", - requestHeaders: {}, - getPrerenderManifest: () => ({ - version: 4, - routes: {}, - dynamicRoutes: {}, - notFoundRoutes: [], - preview: { - previewModeId: "local", - previewModeSigningKey: "", - previewModeEncryptionKey: "", - }, - }), - }); - Object.assign(globalThis, { __incrementalCache: cache }); const realDateNow = Date.now; - const realPerformanceNow = performance.now.bind(performance); let offset = 0; Date.now = () => realDateNow() + offset; - const performanceDescriptor = Object.getOwnPropertyDescriptor( - performance, - "now" - ); - Object.defineProperty(performance, "now", { - configurable: true, - value: () => realPerformanceNow() + offset, - }); const client = new Client({ connectionString: process.env.DATABASE_URL }); await client.connect(); let bodyReads = 0; @@ -253,12 +221,12 @@ integrationTest( } expect(first.days).toHaveLength(5); expect(group(first).repository.label).toBe("example/cache-investigation"); - // Advance both clocks used by native Next's expiry calculation. Timers/DB stay real. + // Exercise stable revision reuse across the versioned entry's TTL. for (let step = 1; step <= 60; step++) { offset += 61_000; expect((await read())?.days).toEqual(first.days); } - expect(bodyReads).toBe(2); + expect(bodyReads).toBe(1); // Status changes independently of content: refresh it without rereading the body. await client.query( "UPDATE github_public_feed_head SET head_content_revision=head_content_revision+1,summarizing=true" @@ -267,7 +235,7 @@ integrationTest( const liveStatus = await readPublicGitHubActivityHead(); expect(status?.head).toEqual(liveStatus.head); expect(status?.days).toEqual(first.days); - expect(bodyReads).toBe(2); + expect(bodyReads).toBe(1); // A publication between the metadata read and the body snapshot must not // replace that body's newer head with the earlier metadata. for (const update of [ @@ -436,7 +404,7 @@ integrationTest( await secondWriter.end(); } { - // The outage fallback keeps its original 60-second cadence and is updated from cached successes. + // The successful fallback remains available independently of versioned entry expiry. const lastSuccess = await read(); offset += 61_000; await read(); @@ -454,14 +422,8 @@ integrationTest( } } finally { Date.now = realDateNow; - if (performanceDescriptor) { - Object.defineProperty(performance, "now", performanceDescriptor); - } else { - Reflect.deleteProperty(performance, "now"); - } await closeDatabase(); await client.end(); - Reflect.deleteProperty(globalThis, "__incrementalCache"); } }, 20_000 diff --git a/tests/github-file-evidence-cache.test.ts b/tests/github-file-evidence-cache.test.ts new file mode 100644 index 00000000..586571ed --- /dev/null +++ b/tests/github-file-evidence-cache.test.ts @@ -0,0 +1,158 @@ +import { expect, mock, test } from "bun:test"; +import { rejects } from "node:assert/strict"; + +import { readGitHubFileEvidence } from "../src/lib/github-file-evidence-cache"; +import type { GitHubFileEvidenceHeader } from "../src/lib/github-file-evidence-cache"; +import { installRuntimeCache } from "./helpers"; + +const header = (id: string, group = id): GitHubFileEvidenceHeader => ({ + id, + group, + digest: "a".repeat(64), + present: true, +}); +const parse = (value: unknown) => { + if (!Array.isArray(value)) { + throw new TypeError("Invalid evidence"); + } + return value; +}; + +test("unchanged evidence is reused; additions, replacements and removed members invalidate only their group", async () => { + const cache = installRuntimeCache(); + const source = new Map([ + ["1/a", [["a.ts", 1, 0]]], + ["1/b", []], + ["2/a", [["fork.ts", 5, 1]]], + ]); + const read = mock( + async (ids: readonly string[]) => + new Map(ids.map((id) => [id, source.get(id)])) + ); + const headers = [ + header("1/a", "1:a"), + header("1/b", "1:b"), + header("2/a", "2:a"), + ]; + try { + const initial = await readGitHubFileEvidence("stats", headers, read, parse); + expect(initial).toEqual(source); + expect( + await readGitHubFileEvidence("stats", headers.toReversed(), read, parse) + ).toEqual(initial); + expect(read).toHaveBeenCalledTimes(1); + source.set("1/a2", [["new.ts", 2, 0]]); + const added = [...headers, header("1/a2", "1:a")]; + expect(await readGitHubFileEvidence("stats", added, read, parse)).toEqual( + source + ); + expect(read.mock.calls[1]?.[0].toSorted()).toEqual(["1/a", "1/a2"]); + source.set("1/b", [["updated.ts", 8, 0]]); + const replaced = added.map((item) => + item.id === "1/b" ? { ...item, digest: "b".repeat(64) } : item + ); + expect( + (await readGitHubFileEvidence("stats", replaced, read, parse)).get("1/b") + ).toEqual(source.get("1/b")); + expect(read.mock.calls[2]).toEqual([["1/b"]]); + const removed = replaced.filter((item) => item.id !== "1/a"); + const afterRemoval = await readGitHubFileEvidence( + "stats", + removed, + read, + parse + ); + expect(afterRemoval.has("1/a")).toBe(false); + expect(read.mock.calls[3]).toEqual([["1/a2"]]); + expect(afterRemoval.get("2/a")).toEqual([["fork.ts", 5, 1]]); + } finally { + cache.restore(); + } +}); + +test("pruning and absent evidence cannot reuse old patches; empty arrays remain valid", async () => { + const cache = installRuntimeCache(); + let facts: unknown[] = [{ filename: "a.ts", patch: "+original" }]; + const read = mock(async () => new Map([["1/a", facts]])); + const full = { + ...header("1/a"), + digest: JSON.stringify(["a".repeat(64), null]), + }; + try { + expect( + (await readGitHubFileEvidence("commit", [full], read, parse)).get("1/a") + ).toEqual(facts); + facts = [{ filename: "a.ts", patch: null }]; + const pruned = { + ...full, + digest: JSON.stringify(["a".repeat(64), "2026-10-02T00:00:00Z"]), + }; + expect( + (await readGitHubFileEvidence("commit", [pruned], read, parse)).get("1/a") + ).toEqual(facts); + expect(read).toHaveBeenCalledTimes(2); + expect( + ( + await readGitHubFileEvidence( + "commit", + [{ ...pruned, present: false }], + read, + parse + ) + ).get("1/a") + ).toBeNull(); + expect(read).toHaveBeenCalledTimes(2); + facts = []; + const restored = { ...full, digest: "c".repeat(64) }; + expect( + (await readGitHubFileEvidence("commit", [restored], read, parse)).get( + "1/a" + ) + ).toEqual([]); + expect(read).toHaveBeenCalledTimes(3); + } finally { + cache.restore(); + } +}); + +test("unknown digests and malformed cache entries fall through, while missing DB evidence fails closed", async () => { + const cache = installRuntimeCache(); + const read = mock( + async () => new Map([["PR/1", ["valid"]]]) + ); + try { + const unknown = { ...header("PR/1"), digest: null }; + for (let index = 0; index < 2; index++) { + expect( + ( + await readGitHubFileEvidence("pull-request", [unknown], read, parse) + ).get("PR/1") + ).toEqual(["valid"]); + } + expect(cache.set).not.toHaveBeenCalled(); + await readGitHubFileEvidence("pull-request", [header("PR/1")], read, parse); + for (const key of cache.values.keys()) { + cache.values.set(key, "broken gzip"); + } + expect( + ( + await readGitHubFileEvidence( + "pull-request", + [header("PR/1")], + read, + parse + ) + ).get("PR/1") + ).toEqual(["valid"]); + expect(read).toHaveBeenCalledTimes(4); + cache.values.clear(); + read.mockResolvedValue(new Map()); + await rejects( + readGitHubFileEvidence("pull-request", [header("PR/1")], read, parse), + /Missing GitHub file evidence/u + ); + expect(cache.values.size).toBe(0); + } finally { + cache.restore(); + } +}); diff --git a/tests/github-projection-cache.test.ts b/tests/github-projection-cache.test.ts new file mode 100644 index 00000000..a36cdec3 --- /dev/null +++ b/tests/github-projection-cache.test.ts @@ -0,0 +1,174 @@ +import { expect, spyOn, test } from "bun:test"; + +import type { SQL } from "drizzle-orm"; +import { PgDialect } from "drizzle-orm/pg-core"; + +import * as database from "../src/db/client"; +import * as schema from "../src/db/schema"; +import { TRACKED_GITHUB_USER_IDS } from "../src/lib/github-commits-core"; +import { readGitHubWorkUnitProjectionEvidence } from "../src/lib/github-work-unit-store"; +import { installRuntimeCache } from "./helpers"; + +test("the production projection loader reuses file bodies and still observes live coverage, authors, visibility and counters", async () => { + const cache = installRuntimeCache(); + const at = new Date("2026-10-01T12:00:00Z"); + const commits = Array.from({ length: 64 }, (_, index) => ({ + additions: 1, + authorUserId: Object.values(TRACKED_GITHUB_USER_IDS)[0], + committedAt: at, + committerAt: at, + deletions: 0, + enrichmentState: "complete", + fileFactsPresent: true, + fileFactsPrunedAt: null, + fileFactsComplete: true, + fileFactsDigest: "a".repeat(64), + firstObservedAt: at, + parentShas: [], + providerFileCapReached: false, + pullRequestDiscoveryState: "complete", + repositoryId: "1", + sha: `${(index % 16).toString(16)}${index.toString(16).padStart(39, "0")}`, + verifiedMergeLanding: false, + fileFacts: [[`src/file-${index}.ts`, 1, 0]], + })); + const repository = { + defaultBranch: "main", + description: null, + factsVerifiedAt: at, + fullName: "example/repo", + headGenerationComplete: true, + homepageUrl: null, + id: "1", + topics: [], + visibility: "public" as string | null, + }; + let transferredBodies = 0; + const read = (selection: Record) => { + let table: unknown; + let condition: SQL | undefined; + const query = { + from(value: unknown) { + table = value; + return query; + }, + where(value: SQL | undefined) { + condition = value; + return query; + }, + orderBy() { + return query; + }, + groupBy() { + return query; + }, + innerJoin() { + return query; + }, + leftJoin() { + return query; + }, + // oxlint-disable-next-line unicorn/no-thenable -- Drizzle query builders are awaitable; this mock exercises the production loader without a database. + async then( + resolve: (value: unknown[]) => unknown, + reject: (error: unknown) => unknown + ) { + let rows: Record[] = []; + if (table === schema.githubRepositories) { + rows = [repository]; + } + if (table === schema.githubRefGenerations) { + rows = [ + { + branchLineageId: "11111111-1111-4111-8111-111111111111", + completedAt: at, + headSha: commits.at(-1)?.sha, + refName: "refs/heads/main", + repositoryId: "1", + members: commits.map((commit) => ["1", commit.sha]), + }, + ]; + } + if (table === schema.githubCommits) { + const params = + condition === undefined + ? [] + : new PgDialect().sqlToQuery(condition).params; + if (selection.fileFacts === schema.githubCommits.fileStats) { + rows = commits.filter( + (commit) => + params.includes(commit.repositoryId) && + params.includes(commit.sha) + ); + transferredBodies += rows.length; + } else { + expect(selection).not.toHaveProperty("fileFacts"); + rows = commits.filter((commit) => + params.includes(commit.authorUserId) + ); + } + } + return await Promise.resolve( + rows.map((row) => + Object.fromEntries( + Object.keys(selection).map((key) => [key, row[key]]) + ) + ) + ).then(resolve, reject); + }, + }; + return query; + }; + const transaction = { select: read, selectDistinct: read }; + const get = spyOn(database, "getDatabase").mockReturnValue({ + transaction: async ( + run: (value: typeof transaction) => Promise, + options: unknown + ) => { + expect(options).toEqual({ + accessMode: "read only", + isolationLevel: "repeatable read", + }); + return await run(transaction); + }, + } as unknown as ReturnType); + try { + const cold = await readGitHubWorkUnitProjectionEvidence(); + expect(cold.units).toHaveLength(1); + expect(cold.units[0]?.facts.memberCount).toBe(64); + expect(transferredBodies).toBe(64); + expect(await readGitHubWorkUnitProjectionEvidence()).toEqual(cold); + expect(transferredBodies).toBe(64); + commits[0].additions = 2; + const counter = await readGitHubWorkUnitProjectionEvidence(); + expect(counter.units[0]?.facts.additions).toBe(65); + expect(transferredBodies).toBe(64); + commits[0].verifiedMergeLanding = true; + expect( + (await readGitHubWorkUnitProjectionEvidence()).units[0]?.facts.memberCount + ).toBe(63); + commits[0].verifiedMergeLanding = false; + repository.visibility = "private"; + expect( + (await readGitHubWorkUnitProjectionEvidence()).units[0]?.visibility + ).toBe("private"); + repository.visibility = null; + expect((await readGitHubWorkUnitProjectionEvidence()).units).toEqual([]); + repository.visibility = "public"; + repository.headGenerationComplete = false; + expect((await readGitHubWorkUnitProjectionEvidence()).units).toEqual([]); + repository.headGenerationComplete = true; + commits[0].authorUserId = "untracked"; + expect( + (await readGitHubWorkUnitProjectionEvidence()).units[0]?.facts.memberCount + ).toBe(63); + // Removing one author changes one four-row SHA bucket, not all 64 bodies. + expect(transferredBodies).toBe(67); + cache.values.clear(); + const uncached = await readGitHubWorkUnitProjectionEvidence(); + expect(uncached.units[0]?.facts.memberCount).toBe(63); + } finally { + get.mockRestore(); + cache.restore(); + } +}); diff --git a/tests/helpers.ts b/tests/helpers.ts index 8a8bf73d..32d560e2 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -1,3 +1,5 @@ +import { mock } from "bun:test"; + import { env as appEnv } from "../src/env.ts"; export const env = appEnv as { @@ -10,3 +12,26 @@ export const mockFetch = ( ) => ReturnType ): typeof fetch => Object.assign(implementation, { preconnect: fetch.preconnect }); + +export const installRuntimeCache = () => { + const values = new Map(); + const get = mock(async (key: string) => values.get(key) ?? null); + const set = mock(async (key: string, value: unknown, _options?: unknown) => { + values.set(key, value); + }); + const symbol = Symbol.for("@vercel/request-context"); + const previous = Reflect.get(globalThis, symbol); + Reflect.set(globalThis, symbol, { get: () => ({ cache: { get, set } }) }); + return { + get, + set, + values, + restore: () => { + if (previous === undefined) { + Reflect.deleteProperty(globalThis, symbol); + } else { + Reflect.set(globalThis, symbol, previous); + } + }, + }; +}; diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts new file mode 100644 index 00000000..1f075990 --- /dev/null +++ b/tests/runtime-cache.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { randomBytes } from "node:crypto"; + +import { readRuntimeCache, writeRuntimeCache } from "../src/lib/runtime-cache"; +import { installRuntimeCache } from "./helpers"; + +test("explicit keys, compression and provider limits preserve large evidence without caching oversized entries", async () => { + const cache = installRuntimeCache(); + try { + const value = { + patch: "@@ diff with repeatable context\n".repeat(350_000), + empty: [], + absent: null, + }; + await writeRuntimeCache("evidence", value, 3600); + expect(await readRuntimeCache("evidence")).toEqual(value); + expect(cache.set.mock.calls[0]?.[0]).toMatch( + /^f0rr0-v1-[\w-]+\$[a-f0-9]{64}$/u + ); + expect(cache.set.mock.calls[0]?.[2]).toEqual({ name: "f0rr0", ttl: 3600 }); + await writeRuntimeCache( + "too-large", + randomBytes(2_000_000).toString("base64"), + 3600 + ); + expect(cache.set).toHaveBeenCalledTimes(1); + expect(await readRuntimeCache("too-large")).toBeNull(); + cache.values.set(cache.set.mock.calls[0][0], "broken gzip"); + expect(await readRuntimeCache("evidence")).toBeNull(); + } finally { + cache.restore(); + } +}); + +test("failed and stalled cache I/O remains optional and bounded", async () => { + const cache = installRuntimeCache(); + const release = Promise.withResolvers(); + try { + cache.get.mockRejectedValue(new Error("Cache offline")); + expect(await readRuntimeCache("offline")).toBeNull(); + cache.set.mockRejectedValue(new Error("Cache offline")); + await writeRuntimeCache("offline", "valid data", 60); + cache.get.mockReturnValue(release.promise); + const started = performance.now(); + expect(await readRuntimeCache("stalled")).toBeNull(); + expect(performance.now() - started).toBeLessThan(750); + } finally { + release.resolve(null); + cache.restore(); + } +}); From 5dbe82e2e31e6d2f3656185947ef1306250f21ae Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Thu, 1 Oct 2026 22:22:29 +0000 Subject: [PATCH 2/3] perf: prewarm rendered pages after publication --- src/app/api/cron/codex-stats/route.ts | 5 +- src/app/api/cron/github-worker/route.ts | 5 +- src/lib/warm-public-pages.ts | 30 ++++++++++++ tests/warm-public-pages.test.ts | 65 +++++++++++++++++++++++++ 4 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 src/lib/warm-public-pages.ts create mode 100644 tests/warm-public-pages.test.ts diff --git a/src/app/api/cron/codex-stats/route.ts b/src/app/api/cron/codex-stats/route.ts index eb78f4ec..a40ca569 100644 --- a/src/app/api/cron/codex-stats/route.ts +++ b/src/app/api/cron/codex-stats/route.ts @@ -6,6 +6,7 @@ import { getPublicCodexStats } from "@/lib/codex/public-stats"; import { syncCodexAccounts } from "@/lib/codex/sync"; import { reportOperationalError } from "@/lib/operational-error"; import { hasBearerSecret } from "@/lib/request-auth"; +import { warmPublicPages } from "@/lib/warm-public-pages"; export const maxDuration = 60; @@ -21,7 +22,9 @@ export async function POST(request: Request) { revalidatePath("/tokens"); after(async () => { - await getPublicCodexStats(); + if ((await getPublicCodexStats()) !== null) { + await warmPublicPages(["/", "/tokens"]); + } }); } return Response.json({ ok: true, result }); diff --git a/src/app/api/cron/github-worker/route.ts b/src/app/api/cron/github-worker/route.ts index 62f778b9..1710475b 100644 --- a/src/app/api/cron/github-worker/route.ts +++ b/src/app/api/cron/github-worker/route.ts @@ -9,6 +9,7 @@ import { GITHUB_WORKER_EXECUTION_DURATION_MS } from "@/lib/github-cron-config"; import type { GITHUB_WORKER_MAX_DURATION_SECONDS } from "@/lib/github-cron-config"; import { reportOperationalError } from "@/lib/operational-error"; import { hasBearerSecret } from "@/lib/request-auth"; +import { warmPublicPages } from "@/lib/warm-public-pages"; export const dynamic = "force-dynamic"; export const maxDuration = @@ -46,7 +47,9 @@ export async function POST(request: Request) { revalidatePath("/work"); after(async () => { - await getInitialGitHubActivity(); + if ((await getInitialGitHubActivity()) !== null) { + await warmPublicPages(["/", "/work"]); + } }); } return Response.json({ diff --git a/src/lib/warm-public-pages.ts b/src/lib/warm-public-pages.ts new file mode 100644 index 00000000..b523e012 --- /dev/null +++ b/src/lib/warm-public-pages.ts @@ -0,0 +1,30 @@ +import { env } from "@/env"; +import { reportOperationalError } from "@/lib/operational-error"; +import { CANONICAL_SITE_URL } from "@/lib/site-url"; + +// Warming data alone leaves the next visitor to regenerate invalidated HTML. +export const warmPublicPages = async ( + paths: readonly ("/" | "/work" | "/tokens")[] +) => { + // A preview must never warm or regenerate production pages. + if (env.VERCEL_ENV !== "production") { + return; + } + await Promise.all( + paths.map(async (path) => { + try { + const response = await fetch(new URL(path, CANONICAL_SITE_URL), { + cache: "no-store", + redirect: "error", + signal: AbortSignal.timeout(10_000), + }); + await response.arrayBuffer(); + if (!response.ok) { + throw new Error("Public page warming failed"); + } + } catch (error) { + reportOperationalError("public_page_warm", error); + } + }) + ); +}; diff --git a/tests/warm-public-pages.test.ts b/tests/warm-public-pages.test.ts new file mode 100644 index 00000000..d4fa185c --- /dev/null +++ b/tests/warm-public-pages.test.ts @@ -0,0 +1,65 @@ +import { expect, spyOn, test } from "bun:test"; + +import { CANONICAL_SITE_URL } from "../src/lib/site-url"; +import { warmPublicPages } from "../src/lib/warm-public-pages"; +import { env, mockFetch } from "./helpers"; + +test("only production warms pages concurrently; failures cannot fail publication", async () => { + const original = env.VERCEL_ENV; + const release = Promise.withResolvers(); + const started = Promise.withResolvers(); + let requests = 0; + const drained: string[] = []; + const request = spyOn(globalThis, "fetch").mockImplementation( + mockFetch(async (url, options) => { + expect(options?.cache).toBe("no-store"); + expect(options?.redirect).toBe("error"); + expect(options?.signal).toBeInstanceOf(AbortSignal); + if (!(url instanceof URL)) { + throw new Error("Expected a canonical URL"); + } + const path = url.pathname; + expect(url.origin).toBe(CANONICAL_SITE_URL); + requests++; + if (requests === 2) { + started.resolve(null); + } + await release.promise; + const response = new Response("Rendered HTML"); + spyOn(response, "arrayBuffer").mockImplementation(async () => { + drained.push(path); + return new ArrayBuffer(0); + }); + return response; + }) + ); + let pending: Promise | undefined; + try { + for (const deployment of ["preview", "development", undefined] as const) { + env.VERCEL_ENV = deployment; + await warmPublicPages(["/", "/work"]); + } + expect(request).not.toHaveBeenCalled(); + env.VERCEL_ENV = "production"; + pending = warmPublicPages(["/", "/work"]); + expect( + await Promise.race([ + started.promise.then(() => true), + Bun.sleep(200).then(() => false), + ]) + ).toBe(true); + release.resolve(null); + await pending; + expect(drained.toSorted()).toEqual(["/", "/work"]); + request.mockRejectedValue(new Error("Network unavailable")); + await warmPublicPages(["/", "/tokens"]); + request.mockResolvedValue(new Response("Unavailable", { status: 503 })); + await warmPublicPages(["/work"]); + expect(request).toHaveBeenCalledTimes(5); + } finally { + release.resolve(null); + await pending; + env.VERCEL_ENV = original; + request.mockRestore(); + } +}); From ca3048e880c9e3ac7ee330bfae707da3712cb145 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Thu, 1 Oct 2026 22:41:17 +0000 Subject: [PATCH 3/3] fix: preserve fresh snapshots and bound page warming --- src/app/api/cron/codex-stats/route.ts | 3 +- src/app/api/cron/github-worker/route.ts | 3 +- src/lib/codex/public-stats.ts | 24 ++++++---- src/lib/github-activity-feed.ts | 12 +++-- src/lib/warm-public-pages.ts | 8 ++-- tests/codex-public-cache.test.ts | 31 +++++++++++++ tests/github-activity-cache.test.ts | 58 ++++++++++++++++++++++--- tests/warm-public-pages.test.ts | 40 +++++++++++++++-- 8 files changed, 152 insertions(+), 27 deletions(-) diff --git a/src/app/api/cron/codex-stats/route.ts b/src/app/api/cron/codex-stats/route.ts index a40ca569..4aeb8656 100644 --- a/src/app/api/cron/codex-stats/route.ts +++ b/src/app/api/cron/codex-stats/route.ts @@ -14,6 +14,7 @@ export async function POST(request: Request) { if (!hasBearerSecret(request.headers.get("authorization"), env.CRON_SECRET)) { return Response.json({ ok: false }, { status: 401 }); } + const warmingDeadlineAt = Date.now() + maxDuration * 1000 - 1000; try { const result = await syncCodexAccounts(); @@ -23,7 +24,7 @@ export async function POST(request: Request) { after(async () => { if ((await getPublicCodexStats()) !== null) { - await warmPublicPages(["/", "/tokens"]); + await warmPublicPages(["/", "/tokens"], warmingDeadlineAt); } }); } diff --git a/src/app/api/cron/github-worker/route.ts b/src/app/api/cron/github-worker/route.ts index 1710475b..603abaca 100644 --- a/src/app/api/cron/github-worker/route.ts +++ b/src/app/api/cron/github-worker/route.ts @@ -26,6 +26,7 @@ export async function POST(request: Request) { if (batchSize === null) { return Response.json({ ok: false }, { status: 400 }); } + const warmingDeadlineAt = Date.now() + maxDuration * 1000 - 1000; try { const activity = await runGitHubActivityWorker({ @@ -48,7 +49,7 @@ export async function POST(request: Request) { after(async () => { if ((await getInitialGitHubActivity()) !== null) { - await warmPublicPages(["/", "/work"]); + await warmPublicPages(["/", "/work"], warmingDeadlineAt); } }); } diff --git a/src/lib/codex/public-stats.ts b/src/lib/codex/public-stats.ts index 968e1986..0945bbb8 100644 --- a/src/lib/codex/public-stats.ts +++ b/src/lib/codex/public-stats.ts @@ -40,6 +40,7 @@ const getViews = cache(async () => { preferencesKey, ]); const fallbackRead = readRuntimeCache(fallbackKey); + const cacheWrites: Promise[] = []; let bodyRead: Promise | undefined; const readBody = async (closed?: ClosedCodexHistory) => await (bodyRead ??= readCodexPublicViews(today, closed)); @@ -61,16 +62,18 @@ const getViews = cache(async () => { ); if (closed === null) { closed = await readClosedCodexHistory(today); - await writeRuntimeCache( - historyKey(today, closed.historyRevision), - closed, - 86_400 + cacheWrites.push( + writeRuntimeCache( + historyKey(today, closed.historyRevision), + closed, + 86_400 + ) ); } value = await readBody(closed); // Both store reads stamp their actual transaction revision. A publication // between reads must never put newer data under an older revision key. - await writeRuntimeCache(viewsKey(today, value), value, 900); + cacheWrites.push(writeRuntimeCache(viewsKey(today, value), value, 900)); } if (value === null) { throw new Error("Missing Codex public views"); @@ -79,12 +82,15 @@ const getViews = cache(async () => { fallback?.viewsRevision !== value.viewsRevision || fallback.historyRevision !== value.historyRevision ) { - await writeRuntimeCache(fallbackKey, value); + cacheWrites.push(writeRuntimeCache(fallbackKey, value)); } return value.views; })(); try { - return await readPublicSnapshot(async () => await healthyRead); + const views = await readPublicSnapshot(async () => await healthyRead); + // Cache writes are optional, and must not trigger an older outage snapshot. + await Promise.all(cacheWrites); + return views; } catch (error) { reportOperationalError("public_codex_stats", error); const fallback = await fallbackRead; @@ -93,7 +99,9 @@ const getViews = cache(async () => { } try { // Keep a cold timeout on the same history/body read already in flight. - return await healthyRead; + const views = await healthyRead; + await Promise.all(cacheWrites); + return views; } catch { try { const value = await readBody(); diff --git a/src/lib/github-activity-feed.ts b/src/lib/github-activity-feed.ts index b1911fae..61a151da 100644 --- a/src/lib/github-activity-feed.ts +++ b/src/lib/github-activity-feed.ts @@ -22,6 +22,7 @@ const contentKey = ( export const getInitialGitHubActivity = async () => { const fallbackRead = readRuntimeCache(FALLBACK_KEY); + let cacheWrites: Promise | undefined; let bodyRead: Promise | undefined; const readBody = async () => await (bodyRead ??= readPublicGitHubActivityPage( @@ -46,7 +47,7 @@ export const getInitialGitHubActivity = async () => { BigInt(live.head.revision) >= BigInt(snapshot.head.revision) ? { ...snapshot, head: live.head } : snapshot; - await Promise.all([ + cacheWrites = Promise.all([ cached === null ? writeRuntimeCache(contentKey(snapshot), snapshot, 3600) : undefined, @@ -59,7 +60,10 @@ export const getInitialGitHubActivity = async () => { return page; })(); try { - return await readPublicSnapshot(async () => await healthyRead); + const page = await readPublicSnapshot(async () => await healthyRead); + // Optional persistence must not turn a fresh, masked page into an old fallback. + await cacheWrites; + return page; } catch (error) { reportOperationalError("github_activity_initial", error); const fallback = await fallbackRead; @@ -69,7 +73,9 @@ export const getInitialGitHubActivity = async () => { // A timeout leaves the original read running. Reuse it instead of downloading // the page again. If metadata failed, one direct page read can still succeed. try { - return await healthyRead; + const page = await healthyRead; + await cacheWrites; + return page; } catch { try { const page = await readBody(); diff --git a/src/lib/warm-public-pages.ts b/src/lib/warm-public-pages.ts index b523e012..7ddca772 100644 --- a/src/lib/warm-public-pages.ts +++ b/src/lib/warm-public-pages.ts @@ -4,10 +4,12 @@ import { CANONICAL_SITE_URL } from "@/lib/site-url"; // Warming data alone leaves the next visitor to regenerate invalidated HTML. export const warmPublicPages = async ( - paths: readonly ("/" | "/work" | "/tokens")[] + paths: readonly ("/" | "/work" | "/tokens")[], + deadlineAt: number ) => { + const timeoutMs = Math.min(10_000, deadlineAt - Date.now()); // A preview must never warm or regenerate production pages. - if (env.VERCEL_ENV !== "production") { + if (env.VERCEL_ENV !== "production" || timeoutMs <= 0) { return; } await Promise.all( @@ -16,7 +18,7 @@ export const warmPublicPages = async ( const response = await fetch(new URL(path, CANONICAL_SITE_URL), { cache: "no-store", redirect: "error", - signal: AbortSignal.timeout(10_000), + signal: AbortSignal.timeout(timeoutMs), }); await response.arrayBuffer(); if (!response.ok) { diff --git a/tests/codex-public-cache.test.ts b/tests/codex-public-cache.test.ts index 7df78bbd..4075d4c2 100644 --- a/tests/codex-public-cache.test.ts +++ b/tests/codex-public-cache.test.ts @@ -222,3 +222,34 @@ test("cache outages keep Codex data available without duplicate history reads", restore(state); } }); + +test("slow optional history and view cache writes cannot trigger older fallback stats", async () => { + const state = setup(); + const release = Promise.withResolvers(); + try { + await getPublicCodexStats(); + const updated = { + ...initial, + viewsRevision: "9007199254740993", + historyRevision: "2", + views: { ...initial.views, stats: stats(101) }, + }; + state.revision.mockResolvedValue({ + viewsRevision: updated.viewsRevision, + historyRevision: "2", + }); + state.history.mockResolvedValue({ ...closed, historyRevision: "2" }); + state.body.mockResolvedValue(updated); + state.cache.set.mockImplementation(async (key, value) => { + await release.promise; + state.cache.values.set(key, value); + }); + expect(await getPublicCodexStats()).toEqual(updated.views.stats); + expect(state.history).toHaveBeenCalledTimes(2); + expect(state.body).toHaveBeenCalledTimes(2); + } finally { + release.resolve(null); + await Bun.sleep(20); + restore(state); + } +}); diff --git a/tests/github-activity-cache.test.ts b/tests/github-activity-cache.test.ts index 905027b6..c46b714b 100644 --- a/tests/github-activity-cache.test.ts +++ b/tests/github-activity-cache.test.ts @@ -44,6 +44,14 @@ const page: PublicGitHubActivityPage = { orderingRevision: "4", }; +const maskedDays = page.days.map((day) => ({ + ...day, + repositories: day.repositories.map((group) => ({ + repository: { ...group.repository, label: "Private", url: null }, + items: group.items.map((item) => ({ ...item, destination: null })), + })), +})); + test("cron and page readers share content while status, ordering and visibility revisions stay fresh", async () => { const cache = installRuntimeCache(); const head = spyOn(store, "readPublicGitHubActivityHead").mockResolvedValue({ @@ -72,13 +80,7 @@ test("cron and page readers share content while status, ordering and visibility expect(body).toHaveBeenCalledTimes(1); const masked = { ...page, - days: page.days.map((day) => ({ - ...day, - repositories: day.repositories.map((group) => ({ - repository: { ...group.repository, label: "Private", url: null }, - items: group.items.map((item) => ({ ...item, destination: null })), - })), - })), + days: maskedDays, head: { ...status, feedRevision: "11", revision: "9007199254740994" }, orderingRevision: "5", }; @@ -270,3 +272,45 @@ test("cache outages do not hide a healthy database page", async () => { cache.restore(); } }); + +test("slow optional cache writes cannot replace a freshly masked page with its old public fallback", async () => { + const cache = installRuntimeCache(); + const head = spyOn(store, "readPublicGitHubActivityHead").mockResolvedValue({ + etag: "head", + head: page.head, + orderingRevision: page.orderingRevision, + }); + const body = spyOn(store, "readPublicGitHubActivityPage").mockResolvedValue( + page + ); + const release = Promise.withResolvers(); + try { + await getInitialGitHubActivity(); + const masked = { + ...page, + days: maskedDays, + head: { ...page.head, feedRevision: "11", revision: "9007199254740993" }, + }; + head.mockImplementation(async () => { + await Bun.sleep(700); + return { + etag: "private", + head: masked.head, + orderingRevision: masked.orderingRevision, + }; + }); + body.mockResolvedValue(masked); + cache.set.mockImplementation(async (key, value) => { + await release.promise; + cache.values.set(key, value); + }); + expect(await getInitialGitHubActivity()).toEqual(masked); + expect(body).toHaveBeenCalledTimes(2); + } finally { + release.resolve(null); + await Bun.sleep(20); + head.mockRestore(); + body.mockRestore(); + cache.restore(); + } +}); diff --git a/tests/warm-public-pages.test.ts b/tests/warm-public-pages.test.ts index d4fa185c..057c283c 100644 --- a/tests/warm-public-pages.test.ts +++ b/tests/warm-public-pages.test.ts @@ -37,11 +37,13 @@ test("only production warms pages concurrently; failures cannot fail publication try { for (const deployment of ["preview", "development", undefined] as const) { env.VERCEL_ENV = deployment; - await warmPublicPages(["/", "/work"]); + await warmPublicPages(["/", "/work"], Date.now() + 60_000); } expect(request).not.toHaveBeenCalled(); env.VERCEL_ENV = "production"; - pending = warmPublicPages(["/", "/work"]); + await warmPublicPages(["/", "/work"], Date.now() - 1); + expect(request).not.toHaveBeenCalled(); + pending = warmPublicPages(["/", "/work"], Date.now() + 60_000); expect( await Promise.race([ started.promise.then(() => true), @@ -52,9 +54,9 @@ test("only production warms pages concurrently; failures cannot fail publication await pending; expect(drained.toSorted()).toEqual(["/", "/work"]); request.mockRejectedValue(new Error("Network unavailable")); - await warmPublicPages(["/", "/tokens"]); + await warmPublicPages(["/", "/tokens"], Date.now() + 60_000); request.mockResolvedValue(new Response("Unavailable", { status: 503 })); - await warmPublicPages(["/work"]); + await warmPublicPages(["/work"], Date.now() + 60_000); expect(request).toHaveBeenCalledTimes(5); } finally { release.resolve(null); @@ -63,3 +65,33 @@ test("only production warms pages concurrently; failures cannot fail publication request.mockRestore(); } }); + +test("page warming aborts within the invocation's remaining time", async () => { + const original = env.VERCEL_ENV; + const signalTimeout = spyOn(AbortSignal, "timeout"); + const request = spyOn(globalThis, "fetch").mockImplementation( + mockFetch(async (_url, options) => { + const aborted = Promise.withResolvers(); + options?.signal?.addEventListener( + "abort", + () => { + aborted.reject(new Error("Invocation deadline")); + }, + { once: true } + ); + return await aborted.promise; + }) + ); + try { + env.VERCEL_ENV = "production"; + await warmPublicPages(["/work"], Date.now() + 50); + expect(request).toHaveBeenCalledTimes(1); + const [[timeoutMs]] = signalTimeout.mock.calls; + expect(timeoutMs).toBeGreaterThan(0); + expect(timeoutMs).toBeLessThanOrEqual(50); + } finally { + env.VERCEL_ENV = original; + request.mockRestore(); + signalTimeout.mockRestore(); + } +});