Skip to content

Commit 296e8fe

Browse files
authored
fix: track background work (#407)
* tmp * restore previous dynamic page with fetch-cache and add separate ~isr page * await background tasks * optimistically prepare to use context.waitUntil * remove dev logs * test: allow using different test site id * revert timing changes in test fixture * remove test page as behavior is background revalidation behavior is already tested in both integration and e2e tests * add e2e for time-based revalidation * test: update assertions * chore: format with prettier * update test * tmp: don't await background promise * Revert "tmp: don't await background promise" This reverts commit d91cb976f5330b1cc9bc14902515c2cdf173a0d2. * chore: update todo with reference to upstream pull request * test: revert not strictly related e2e test setup change --------- Co-authored-by: pieh <[email protected]>
1 parent 8b3f65b commit 296e8fe

File tree

8 files changed

+171
-19
lines changed

8 files changed

+171
-19
lines changed

src/run/handlers/request-context.cts

+16
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,29 @@ export type RequestContext = {
1010
didPagesRouterOnDemandRevalidate?: boolean
1111
serverTiming?: string
1212
routeHandlerRevalidate?: NetlifyCachedRouteValue['revalidate']
13+
/**
14+
* Track promise running in the background and need to be waited for
15+
*/
16+
trackBackgroundWork: (promise: Promise<unknown>) => void
17+
/**
18+
* Promise that need to be executed even if response was already sent
19+
*/
20+
backgroundWorkPromise: Promise<unknown>
1321
}
1422

1523
type RequestContextAsyncLocalStorage = AsyncLocalStorage<RequestContext>
1624

1725
export function createRequestContext(debug = false): RequestContext {
26+
const backgroundWorkPromises: Promise<unknown>[] = []
27+
1828
return {
1929
debug,
30+
trackBackgroundWork: (promise) => {
31+
backgroundWorkPromises.push(promise)
32+
},
33+
get backgroundWorkPromise() {
34+
return Promise.allSettled(backgroundWorkPromises)
35+
},
2036
}
2137
}
2238

src/run/handlers/server.ts

+20-5
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { OutgoingHttpHeaders } from 'http'
22

33
import { toComputeResponse, toReqRes, ComputeJsOutgoingMessage } from '@fastly/http-compute-js'
4+
import { Context } from '@netlify/functions'
45
import type { NextConfigComplete } from 'next/dist/server/config-shared.js'
56
import type { WorkerRequestHandler } from 'next/dist/server/lib/types.js'
67

@@ -45,7 +46,13 @@ const disableFaultyTransferEncodingHandling = (res: ComputeJsOutgoingMessage) =>
4546
}
4647
}
4748

48-
export default async (request: Request) => {
49+
// TODO: remove once https://github.com/netlify/serverless-functions-api/pull/219
50+
// is released and public types are updated
51+
interface FutureContext extends Context {
52+
waitUntil?: (promise: Promise<unknown>) => void
53+
}
54+
55+
export default async (request: Request, context: FutureContext) => {
4956
const tracer = getTracer()
5057

5158
if (!nextHandler) {
@@ -133,11 +140,19 @@ export default async (request: Request) => {
133140
return new Response(body || null, response)
134141
}
135142

143+
if (context.waitUntil) {
144+
context.waitUntil(requestContext.backgroundWorkPromise)
145+
}
146+
136147
const keepOpenUntilNextFullyRendered = new TransformStream({
137-
flush() {
138-
// it's important to keep the stream open until the next handler has finished,
139-
// or otherwise the cache revalidates might not go through
140-
return nextHandlerPromise
148+
async flush() {
149+
// it's important to keep the stream open until the next handler has finished
150+
await nextHandlerPromise
151+
if (!context.waitUntil) {
152+
// if waitUntil is not available, we have to keep response stream open until background promises are resolved
153+
// to ensure that all background work executes
154+
await requestContext.backgroundWorkPromise
155+
}
141156
},
142157
})
143158

src/run/next.cts

+27
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,33 @@ console.time('import next server')
1212

1313
// eslint-disable-next-line @typescript-eslint/no-var-requires
1414
const { getRequestHandlers } = require('next/dist/server/lib/start-server.js')
15+
// eslint-disable-next-line @typescript-eslint/no-var-requires
16+
const ResponseCache = require('next/dist/server/response-cache/index.js').default
17+
18+
// Next.js standalone doesn't expose background work promises (such as generating fresh response
19+
// while stale one is being served) that we could use so we regrettably have to use hacks to
20+
// gain access to them so that we can explicitly track them to ensure they finish before function
21+
// execution stops
22+
const originalGet = ResponseCache.prototype.get
23+
ResponseCache.prototype.get = function get(...getArgs: unknown[]) {
24+
if (!this.didAddBackgroundWorkTracking) {
25+
const originalBatcherBatch = this.batcher.batch
26+
this.batcher.batch = async (key: string, fn: (...args: unknown[]) => unknown) => {
27+
const trackedFn = async (...workFnArgs: unknown[]) => {
28+
const workPromise = fn(...workFnArgs)
29+
const requestContext = getRequestContext()
30+
if (requestContext && workPromise instanceof Promise) {
31+
requestContext.trackBackgroundWork(workPromise)
32+
}
33+
return await workPromise
34+
}
35+
36+
return originalBatcherBatch.call(this.batcher, key, trackedFn)
37+
}
38+
this.didAddBackgroundWorkTracking = true
39+
}
40+
return originalGet.apply(this, getArgs)
41+
}
1542

1643
console.timeEnd('import next server')
1744

tests/e2e/page-router.test.ts

+69-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,11 @@ export async function check(
4949
}
5050

5151
test.describe('Simple Page Router (no basePath, no i18n)', () => {
52-
test('Static revalidate works correctly', async ({ page, pollUntilHeadersMatch, pageRouter }) => {
52+
test('On-demand revalidate works correctly', async ({
53+
page,
54+
pollUntilHeadersMatch,
55+
pageRouter,
56+
}) => {
5357
// in case there is retry or some other test did hit that path before
5458
// we want to make sure that cdn cache is not warmed up
5559
const purgeCdnCache = await page.goto(
@@ -230,6 +234,70 @@ test.describe('Simple Page Router (no basePath, no i18n)', () => {
230234
expect(data3?.pageProps?.time).toBe(date3)
231235
})
232236

237+
test('Time based revalidate works correctly', async ({
238+
page,
239+
pollUntilHeadersMatch,
240+
pageRouter,
241+
}) => {
242+
// in case there is retry or some other test did hit that path before
243+
// we want to make sure that cdn cache is not warmed up
244+
const purgeCdnCache = await page.goto(
245+
new URL('/api/purge-cdn?path=/static/revalidate-slow-data', pageRouter.url).href,
246+
)
247+
expect(purgeCdnCache?.status()).toBe(200)
248+
249+
// wait a bit until cdn cache purge propagates and make sure page gets stale (revalidate 10)
250+
await page.waitForTimeout(10_000)
251+
252+
const beforeFetch = new Date().toISOString()
253+
254+
const response1 = await pollUntilHeadersMatch(
255+
new URL('static/revalidate-slow-data', pageRouter.url).href,
256+
{
257+
headersToMatch: {
258+
// either first time hitting this route or we invalidated
259+
// just CDN node in earlier step
260+
// we will invoke function and see Next cache hit status \
261+
// in the response because it was prerendered at build time
262+
// or regenerated in previous attempt to run this test
263+
'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit/m],
264+
},
265+
headersNotMatchedMessage:
266+
'First request to tested page (html) should be a miss or stale on the Edge and stale in Next.js',
267+
},
268+
)
269+
expect(response1?.status()).toBe(200)
270+
const date1 = (await page.textContent('[data-testid="date-now"]')) ?? ''
271+
272+
// ensure response was produced before invocation (served from cache)
273+
expect(date1.localeCompare(beforeFetch)).toBeLessThan(0)
274+
275+
// wait a bit to ensure background work has a chance to finish
276+
// (page is fresh for 10 seconds and it should take at least 5 seconds to regenerate, so we should wait at least more than 15 seconds)
277+
await page.waitForTimeout(20_000)
278+
279+
const response2 = await pollUntilHeadersMatch(
280+
new URL('static/revalidate-slow-data', pageRouter.url).href,
281+
{
282+
headersToMatch: {
283+
// either first time hitting this route or we invalidated
284+
// just CDN node in earlier step
285+
// we will invoke function and see Next cache hit status \
286+
// in the response because it was prerendered at build time
287+
// or regenerated in previous attempt to run this test
288+
'cache-status': [/"Netlify Edge"; fwd=(miss|stale)/m, /"Next.js"; hit;/m],
289+
},
290+
headersNotMatchedMessage:
291+
'Second request to tested page (html) should be a miss or stale on the Edge and hit or stale in Next.js',
292+
},
293+
)
294+
expect(response2?.status()).toBe(200)
295+
const date2 = (await page.textContent('[data-testid="date-now"]')) ?? ''
296+
297+
// ensure response was produced after initial invocation
298+
expect(beforeFetch.localeCompare(date2)).toBeLessThan(0)
299+
})
300+
233301
test('should serve 404 page when requesting non existing page (no matching route)', async ({
234302
page,
235303
pageRouter,
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,6 @@
11
{
22
"compilerOptions": {
3-
"lib": [
4-
"dom",
5-
"dom.iterable",
6-
"esnext"
7-
],
3+
"lib": ["dom", "dom.iterable", "esnext"],
84
"allowJs": true,
95
"skipLibCheck": true,
106
"strict": false,
@@ -17,12 +13,6 @@
1713
"isolatedModules": true,
1814
"jsx": "preserve"
1915
},
20-
"include": [
21-
"next-env.d.ts",
22-
"**/*.ts",
23-
"**/*.tsx"
24-
],
25-
"exclude": [
26-
"node_modules"
27-
]
16+
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
17+
"exclude": ["node_modules"]
2818
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
const Show = ({ show, time }) => (
2+
<div>
3+
<p>
4+
This page uses getStaticProps() to pre-fetch a TV show at
5+
<span data-testid="date-now">{time}</span>
6+
</p>
7+
<hr />
8+
<h1>Show #{show.id}</h1>
9+
<p>{show.name}</p>
10+
</div>
11+
)
12+
13+
export async function getStaticProps(context) {
14+
const start = Date.now()
15+
16+
const res = await fetch(`https://tvproxy.netlify.app/shows/71`)
17+
const data = await res.json()
18+
19+
const fetchDuration = Date.now() - start
20+
if (fetchDuration < 5000) {
21+
// simulate slow (~5s) response
22+
await new Promise((resolve) => setTimeout(resolve, 5000 - fetchDuration))
23+
}
24+
25+
return {
26+
props: {
27+
show: data,
28+
time: new Date().toISOString(),
29+
},
30+
revalidate: +process.env.REVALIDATE_SECONDS || 10, // In seconds
31+
}
32+
}
33+
34+
export default Show

tests/integration/cache-handler.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ describe('page router', () => {
4343
'/static/revalidate-automatic',
4444
'/static/revalidate-manual',
4545
'/static/revalidate-slow',
46+
'/static/revalidate-slow-data',
4647
'404.html',
4748
'500.html',
4849
'static/fully-static.html',

tests/integration/static.test.ts

+1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ test<FixtureTestContext>('requesting a non existing page route that needs to be
3838
'/static/revalidate-automatic',
3939
'/static/revalidate-manual',
4040
'/static/revalidate-slow',
41+
'/static/revalidate-slow-data',
4142
'404.html',
4243
'500.html',
4344
'static/fully-static.html',

0 commit comments

Comments
 (0)