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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions src/routes/api/github/webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ export const Route = createFileRoute("/api/github/webhook")({
.map((library) => `docs:${library.id}:branch:${gitRef}`),
];

const affectedLibraries = libraries.filter(
(library) =>
library.repo === repo && library.latestBranch === gitRef,
);

const invalidate = async () => {
const [staleContentCount, staleArtifactCount] = await Promise.all([
markGitHubContentStale({ repo, gitRef }),
Expand All @@ -176,6 +181,27 @@ export const Route = createFileRoute("/api/github/webhook")({
return { purge, staleArtifactCount, staleContentCount };
};

const warmCaches = async () => {
const { warmDocsArtifacts } = await import(
'~/utils/docs-warm.server'
);

await Promise.all(
affectedLibraries.map((library) =>
warmDocsArtifacts({
repo: library.repo,
branch: gitRef,
docsRoot: library.docsRoot ?? 'docs',
}).catch((error) => {
console.warn(
`[GitHub webhook] docs cache warm-up failed for ${library.repo}@${gitRef}`,
error,
);
}),
),
);
};

if (
scheduleHostRuntimeTask(async () => {
try {
Expand All @@ -187,6 +213,10 @@ export const Route = createFileRoute("/api/github/webhook")({
repo,
});
}

// Proactively rebuild manifests so the next user request is
// served from cache rather than triggering an N+1 build.
await warmCaches();
})
) {
return jsonResponse({
Expand All @@ -200,6 +230,9 @@ export const Route = createFileRoute("/api/github/webhook")({
const { purge, staleArtifactCount, staleContentCount } =
await invalidate();

// Warm caches inline when background scheduling is unavailable.
await warmCaches();

return jsonResponse({
ok: true,
gitRef,
Expand Down
56 changes: 56 additions & 0 deletions src/utils/docs-warm.server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Server-only utility for pre-warming the docs artifact cache.
*
* Call warmDocsArtifacts after marking artifacts stale (e.g. from a GitHub
* webhook) so the next user request is served from cache rather than
* triggering an on-request N+1 GitHub API call.
*/
import { getCachedDocsArtifact } from './github-content-cache.server'
import { buildDocsManifest, buildDocsPathManifest } from './docs.functions'

type DocsManifest = {
paths: Array<string>
redirects: Record<string, string>
}

function isDocsManifest(value: unknown): value is DocsManifest {
return (
typeof value === 'object' &&
value !== null &&
'paths' in value &&
'redirects' in value &&
Array.isArray((value as DocsManifest).paths) &&
typeof (value as DocsManifest).redirects === 'object'
)
}

export async function warmDocsArtifacts({
repo,
branch,
docsRoot,
}: {
repo: string
branch: string
docsRoot: string
}) {
await Promise.all([
getCachedDocsArtifact({
repo,
gitRef: branch,
docsRoot,
artifactType: 'docs-manifest',
artifactKey: 'default',
isValue: isDocsManifest,
build: () => buildDocsManifest({ repo, branch, docsRoot }),
}),
getCachedDocsArtifact({
repo,
gitRef: branch,
docsRoot,
artifactType: 'docs-path-manifest',
artifactKey: 'default',
isValue: isDocsManifest,
build: () => buildDocsPathManifest({ repo, branch, docsRoot }),
}),
])
}
78 changes: 50 additions & 28 deletions src/utils/docs.functions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,10 @@ const docsRedirectInput = v.object({
// Matches RAW_FETCH_CONCURRENCY in github-example.server.ts.
const DOCS_MANIFEST_FETCH_CONCURRENCY = 6

// In-flight deduplication for concurrent cold-start manifest builds.
// Keyed by `${repo}@${branch}:${docsRoot}` for each manifest type.
const pendingManifestBuilds = new Map<string, Promise<DocsManifest>>()

export async function mapWithConcurrency<T, TResult>(
values: Array<T>,
concurrency: number,
Expand Down Expand Up @@ -261,7 +265,7 @@ export async function collectRedirectEntriesForFile(
return entries
}

async function buildDocsManifest({
export async function buildDocsManifest({
repo,
branch,
docsRoot,
Expand All @@ -270,40 +274,58 @@ async function buildDocsManifest({
branch: string
docsRoot: string
}): Promise<DocsManifest> {
const { fetchApiContents, fetchRepoFile } = await loadDocumentsServerModule()
const nodes = await fetchApiContents(repo, branch, docsRoot)
const key = `manifest:${repo}@${branch}:${docsRoot}`

if (!nodes) {
return { paths: [], redirects: {} }
const inFlight = pendingManifestBuilds.get(key)
if (inFlight) {
return inFlight
}

const markdownFiles = flattenDocsNodes(nodes).filter((node) =>
node.path.endsWith('.md'),
)
const paths = new Set<string>()

// A recoverable error on one file must not fail the whole manifest build
// (see collectRedirectEntriesForFile).
const redirectsByFile = await mapWithConcurrency(
markdownFiles,
DOCS_MANIFEST_FETCH_CONCURRENCY,
(node) =>
collectRedirectEntriesForFile(node, {
docsRoot,
fetchFile: (filePath) => fetchRepoFile(repo, branch, filePath),
onCanonicalPath: (canonicalPath) => paths.add(canonicalPath),
}),
)
const build = async (): Promise<DocsManifest> => {
try {
const { fetchApiContents, fetchRepoFile } =
await loadDocumentsServerModule()
const nodes = await fetchApiContents(repo, branch, docsRoot)

return {
paths: Array.from(paths),
redirects: buildRedirectManifest(redirectsByFile.flat(), {
label: `docs redirects for ${repo}@${branch}:${docsRoot}`,
}),
if (!nodes) {
return { paths: [], redirects: {} }
}

const markdownFiles = flattenDocsNodes(nodes).filter((node) =>
node.path.endsWith('.md'),
)
const paths = new Set<string>()

// A recoverable error on one file must not fail the whole manifest build
// (see collectRedirectEntriesForFile).
const redirectsByFile = await mapWithConcurrency(
markdownFiles,
DOCS_MANIFEST_FETCH_CONCURRENCY,
(node) =>
collectRedirectEntriesForFile(node, {
docsRoot,
fetchFile: (filePath) => fetchRepoFile(repo, branch, filePath),
onCanonicalPath: (canonicalPath) => paths.add(canonicalPath),
}),
)

return {
paths: Array.from(paths),
redirects: buildRedirectManifest(redirectsByFile.flat(), {
label: `docs redirects for ${repo}@${branch}:${docsRoot}`,
}),
}
} finally {
pendingManifestBuilds.delete(key)
}
}

const promise = build()
pendingManifestBuilds.set(key, promise)
return promise
}

async function buildDocsPathManifest({
export async function buildDocsPathManifest({
repo,
branch,
docsRoot,
Expand Down
Loading