Skip to content

Commit 5e8ea88

Browse files
committed
Serve stale docs artifacts while refreshing
1 parent 00d096e commit 5e8ea88

4 files changed

Lines changed: 194 additions & 40 deletions

File tree

src/server.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,10 @@ import { wrapFetchWithSentry } from '@sentry/tanstackstart-react'
44
import handler, { createServerEntry } from '@tanstack/react-start/server-entry'
55
import { runWithDatabaseContext } from '~/db/client'
66
import { runScheduledTasks } from '~/server/scheduled.server'
7-
import { runWithHostRuntimeEnv } from '~/server/runtime/host.server'
7+
import {
8+
runWithHostRuntimeContext,
9+
runWithHostRuntimeEnv,
10+
} from '~/server/runtime/host.server'
811
import {
912
installProductionFetchProbe,
1013
installProductionProcessProbe,
@@ -189,8 +192,10 @@ const server = createServerEntry(
189192
)
190193

191194
export default {
192-
fetch(request: Request, env: unknown) {
193-
return runWithHostRuntimeEnv(env, () => server.fetch(request))
195+
fetch(request: Request, env: unknown, context: unknown) {
196+
return runWithHostRuntimeEnv(env, () =>
197+
runWithHostRuntimeContext(context, () => server.fetch(request)),
198+
)
194199
},
195200
scheduled(
196201
controller: ScheduledController,
@@ -199,8 +204,10 @@ export default {
199204
) {
200205
context.waitUntil(
201206
runWithHostRuntimeEnv(env, () =>
202-
runWithDatabaseContext(() =>
203-
runScheduledTasks(controller.cron, controller.scheduledTime),
207+
runWithHostRuntimeContext(context, () =>
208+
runWithDatabaseContext(() =>
209+
runScheduledTasks(controller.cron, controller.scheduledTime),
210+
),
204211
),
205212
),
206213
)

src/server/runtime/host.server.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,12 @@ type HostRuntimeModule = {
1919
env: HostRuntimeEnv
2020
}
2121

22+
type HostRuntimeContext = {
23+
waitUntil(promise: Promise<unknown>): void
24+
}
25+
2226
const hostRuntimeEnvStorage = new AsyncLocalStorage<HostRuntimeEnv>()
27+
const hostRuntimeContextStorage = new AsyncLocalStorage<HostRuntimeContext>()
2328

2429
function isObject(value: unknown): value is Record<string, unknown> {
2530
return typeof value === 'object' && value !== null
@@ -29,6 +34,14 @@ function isHostRuntimeEnv(value: unknown): value is HostRuntimeEnv {
2934
return isObject(value)
3035
}
3136

37+
function isHostRuntimeContext(value: unknown): value is HostRuntimeContext {
38+
return (
39+
isObject(value) &&
40+
'waitUntil' in value &&
41+
typeof value.waitUntil === 'function'
42+
)
43+
}
44+
3245
function isStaticAssetService(value: unknown): value is StaticAssetService {
3346
return (
3447
isObject(value) && 'fetch' in value && typeof value.fetch === 'function'
@@ -62,10 +75,29 @@ export function runWithHostRuntimeEnv<T>(env: unknown, fn: () => T): T {
6275
return hostRuntimeEnvStorage.run(env, fn)
6376
}
6477

78+
export function runWithHostRuntimeContext<T>(context: unknown, fn: () => T): T {
79+
if (!isHostRuntimeContext(context)) {
80+
return fn()
81+
}
82+
83+
return hostRuntimeContextStorage.run(context, fn)
84+
}
85+
6586
export function getCurrentHostRuntimeEnv() {
6687
return hostRuntimeEnvStorage.getStore()
6788
}
6889

90+
export function scheduleHostRuntimeTask(createTask: () => Promise<unknown>) {
91+
const context = hostRuntimeContextStorage.getStore()
92+
93+
if (!context) {
94+
return false
95+
}
96+
97+
context.waitUntil(createTask())
98+
return true
99+
}
100+
69101
async function getStaticAssetService() {
70102
if (!isIsolateRuntime()) return
71103

src/utils/github-content-cache.server.ts

Lines changed: 57 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
type BlobStorageCache,
66
type BlobStorageListedObject,
77
} from '~/server/runtime/blob-storage.server'
8+
import { scheduleHostRuntimeTask } from '~/server/runtime/host.server'
89
import { isValidRepoPath, MAX_REPO_PATH_LENGTH } from './repo-path'
910

1011
const POSITIVE_STALE_MS = 5 * 60 * 1000
@@ -256,10 +257,10 @@ function isFresh(staleAt: Date) {
256257

257258
// markGitHubContentStale / markDocsArtifactsStale set staleAt to the epoch
258259
// (new Date(0)) as a sentinel for "forcibly invalidated": an admin clicked
259-
// the purge button or a push webhook fired. Natural TTL expiry and forced
260-
// invalidation both refresh synchronously now. The object stays around so the
261-
// bottom of getCachedGitHubContent / getCachedDocsArtifact can still fall back
262-
// to it if GitHub is unreachable.
260+
// the purge button or a push webhook fired. The object stays around so refresh
261+
// paths can still fall back to it if GitHub is unreachable, and docs artifacts
262+
// can serve it while scheduling a background rebuild when the runtime supports
263+
// waitUntil.
263264
function isForciblyStale(staleAt: Date) {
264265
return staleAt.getTime() <= 0
265266
}
@@ -1408,43 +1409,65 @@ export async function getCachedDocsArtifact<T>(opts: {
14081409
cachedRow && opts.isValue(cachedRow.payload) ? cachedRow.payload : undefined
14091410
const forciblyStale = !!cachedRow && isForciblyStale(cachedRow.staleAt)
14101411

1411-
if (storedValue !== undefined && !forciblyStale) {
1412-
if (cachedRow && isFresh(cachedRow.staleAt)) {
1412+
const refreshArtifact = () =>
1413+
withPendingRefresh(cacheKey, async () => {
1414+
const latestRow = await readRow()
1415+
const latestValue =
1416+
latestRow && opts.isValue(latestRow.payload)
1417+
? latestRow.payload
1418+
: undefined
1419+
const latestForciblyStale =
1420+
!!latestRow && isForciblyStale(latestRow.staleAt)
1421+
1422+
if (
1423+
latestValue !== undefined &&
1424+
latestRow &&
1425+
!latestForciblyStale &&
1426+
isFresh(latestRow.staleAt)
1427+
) {
1428+
return latestValue
1429+
}
1430+
1431+
try {
1432+
const payload = await opts.build()
1433+
await upsertDocsArtifact({ ...opts, payload })
1434+
return payload
1435+
} catch (error) {
1436+
if (latestValue !== undefined) {
1437+
console.warn(`[GitHub Cache] Serving stale artifact ${cacheKey}`)
1438+
return latestValue
1439+
}
1440+
1441+
throw error
1442+
}
1443+
})
1444+
1445+
if (storedValue !== undefined) {
1446+
if (!forciblyStale && cachedRow && isFresh(cachedRow.staleAt)) {
14131447
return storedValue
14141448
}
1415-
}
1416-
1417-
return withPendingRefresh(cacheKey, async () => {
1418-
const latestRow = await readRow()
1419-
const latestValue =
1420-
latestRow && opts.isValue(latestRow.payload)
1421-
? latestRow.payload
1422-
: undefined
1423-
const latestForciblyStale =
1424-
!!latestRow && isForciblyStale(latestRow.staleAt)
14251449

14261450
if (
1427-
latestValue !== undefined &&
1428-
latestRow &&
1429-
!latestForciblyStale &&
1430-
isFresh(latestRow.staleAt)
1451+
scheduleHostRuntimeTask(() =>
1452+
refreshArtifact().then(
1453+
() => undefined,
1454+
(error) => {
1455+
console.warn(
1456+
`[GitHub Cache] Background artifact refresh failed ${cacheKey}`,
1457+
error,
1458+
)
1459+
},
1460+
),
1461+
)
14311462
) {
1432-
return latestValue
1463+
console.warn(
1464+
`[GitHub Cache] Serving stale artifact ${cacheKey}; refreshing in background`,
1465+
)
1466+
return storedValue
14331467
}
1468+
}
14341469

1435-
try {
1436-
const payload = await opts.build()
1437-
await upsertDocsArtifact({ ...opts, payload })
1438-
return payload
1439-
} catch (error) {
1440-
if (latestValue !== undefined) {
1441-
console.warn(`[GitHub Cache] Serving stale artifact ${cacheKey}`)
1442-
return latestValue
1443-
}
1444-
1445-
throw error
1446-
}
1447-
})
1470+
return refreshArtifact()
14481471
}
14491472

14501473
export async function listDocsCacheRepoStats() {

tests/github-content-cache.test.ts

Lines changed: 93 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import assert from 'node:assert/strict'
2-
import { runWithHostRuntimeEnv } from '../src/server/runtime/host.server'
2+
import {
3+
runWithHostRuntimeContext,
4+
runWithHostRuntimeEnv,
5+
} from '../src/server/runtime/host.server'
36
import {
47
getCachedDocsArtifact,
58
getCachedGitHubJsonContent,
@@ -22,6 +25,15 @@ function isObject(value: unknown): value is Record<string, unknown> {
2225
return typeof value === 'object' && value !== null
2326
}
2427

28+
async function withTimeout(promise: Promise<void>, message: string) {
29+
await Promise.race([
30+
promise,
31+
new Promise<void>((_, reject) => {
32+
setTimeout(() => reject(new Error(message)), 1000)
33+
}),
34+
])
35+
}
36+
2537
function isDocsManifest(value: unknown): value is { paths: Array<string> } {
2638
if (!isObject(value)) {
2739
return false
@@ -294,6 +306,85 @@ async function testArtifactInvalidationAndPruneDelete() {
294306
assert.equal(originCalls, 1)
295307
}
296308

309+
async function testStaleArtifactRefreshesInWaitUntil() {
310+
resetGitHubContentCacheForTest()
311+
312+
await getCachedDocsArtifact({
313+
repo,
314+
gitRef,
315+
docsRoot: 'docs',
316+
artifactType: 'docs-manifest',
317+
artifactKey: 'default',
318+
isValue: isDocsManifest,
319+
build: async () => ({ paths: ['old'] }),
320+
})
321+
322+
assert.equal(await markDocsArtifactsStale({ repo, gitRef }), 1)
323+
324+
const waitUntilPromises: Array<Promise<unknown>> = []
325+
let buildCalls = 0
326+
let resolveRefresh: ((value: { paths: Array<string> }) => void) | undefined
327+
let markRefreshStarted: (() => void) | undefined
328+
const refreshStarted = new Promise<void>((resolve) => {
329+
markRefreshStarted = resolve
330+
})
331+
const refreshResult = new Promise<{ paths: Array<string> }>((resolve) => {
332+
resolveRefresh = resolve
333+
})
334+
335+
const staleResult = await runWithHostRuntimeContext(
336+
{
337+
waitUntil(promise: Promise<unknown>) {
338+
waitUntilPromises.push(promise)
339+
},
340+
},
341+
() =>
342+
getCachedDocsArtifact({
343+
repo,
344+
gitRef,
345+
docsRoot: 'docs',
346+
artifactType: 'docs-manifest',
347+
artifactKey: 'default',
348+
isValue: isDocsManifest,
349+
build: () => {
350+
buildCalls += 1
351+
markRefreshStarted?.()
352+
return refreshResult
353+
},
354+
}),
355+
)
356+
357+
assert.deepEqual(staleResult, { paths: ['old'] })
358+
assert.equal(waitUntilPromises.length, 1)
359+
360+
await withTimeout(
361+
refreshStarted,
362+
'stale artifact refresh did not start in waitUntil',
363+
)
364+
assert.equal(buildCalls, 1)
365+
366+
if (!resolveRefresh) {
367+
throw new Error('stale artifact refresh resolver was not created')
368+
}
369+
370+
resolveRefresh({ paths: ['new'] })
371+
await Promise.all(waitUntilPromises)
372+
373+
const freshResult = await getCachedDocsArtifact({
374+
repo,
375+
gitRef,
376+
docsRoot: 'docs',
377+
artifactType: 'docs-manifest',
378+
artifactKey: 'default',
379+
isValue: isDocsManifest,
380+
build: async () => {
381+
throw new Error('fresh artifact should not rebuild')
382+
},
383+
})
384+
385+
assert.deepEqual(freshResult, { paths: ['new'] })
386+
}
387+
297388
await testMissStoresContent()
298389
await testFreshHitSkipsOrigin()
299390
await testWorkerEnvUsesBlobStorage()
@@ -304,5 +395,6 @@ await testJsonNegativeEntryRefreshes()
304395
await testRefreshFailureFallsBackToStaleContent()
305396
await testJsonContentUsesTypeGuard()
306397
await testArtifactInvalidationAndPruneDelete()
398+
await testStaleArtifactRefreshesInWaitUntil()
307399

308400
console.log('github-content-cache tests passed')

0 commit comments

Comments
 (0)