Skip to content

Commit 270d42b

Browse files
authored
fix(cache): batch CDN tag purges per request and log purge failures (#4074)
* fix(cache): batch CDN tag purges per request and log purge failures revalidatePath/revalidateTag/updateTag sent one CDN adapter call per API call, and a failed edge purge rejected the request drain. On Workers Cache, purges share a 5-per-minute account budget, so an autosaving Server Action with several revalidatePath calls hit error 1134 and returned a 500 even though its write had committed. - Inside a Server Action or Route Handler, defer the CDN purge to the request drain and send one adapter call per cacheLife profile, matching Next.js' executeRevalidates. The data cache is still invalidated per call. - Log CDN adapter failures instead of failing the request; the data cache is already invalidated, so the edge just stays stale until it expires. - Split Workers Cache purges into chunks of 100 tags, Cloudflare's per-request cap. Fixes #4071 * fix(route-handlers): flush CDN purges queued by completed bodies and regeneration A completed Route Handler body can call revalidateTag() after the handler's eager drain, so drain again once completion finishes. Stale route regeneration ran the handler in the render phase, so it skipped batching; run it in the route-handler phase a live request uses. * fix(cache): keep deferred CDN purges from being stranded - after() callbacks run once the response has closed, past the request's last drain, so they purge immediately instead of deferring. - Drain what a Route Handler body queued before it failed during completion, keeping the body error as the response error. - Settle every Workers Cache purge chunk before reporting a failure so no started purge is left un-awaited.
1 parent 95ebc98 commit 270d42b

11 files changed

Lines changed: 620 additions & 29 deletions

‎docs/caching.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -230,6 +230,8 @@ Explicit provider headers remain a Cloudflare extension: `Cloudflare-CDN-Cache-C
230230

231231
This setup is fast when an entry is present at the edge, but Workers Cache is distributed rather than one globally shared cache. A response cached in one region may still miss in another. Tiered caching can reduce this duplication, but without a durable response store a regional miss or eviction can require another render. The KV data cache is also eventually consistent.
232232

233+
`revalidatePath()` and `revalidateTag()` purge the edge by tag. [Workers Cache purges](https://developers.cloudflare.com/workers/cache/purge/) use Cloudflare's Free-tier purge limits, which every Worker in the account shares. As in Next.js, vinext sends one batched purge per cacheLife profile at the end of a Server Action or Route Handler, not one per call. If a purge fails, for example because the rate limit is reached, vinext logs the error and the action still succeeds: the data cache is already invalidated, but edge-cached responses for those tags stay stale until they expire. A route that only needs the client to refetch dynamic data can call `refresh()`, which doesn't purge.
234+
233235
Use `createWorkersCacheConfig()` to configure the response entrypoints and version metadata, and add the KV binding for the data adapter:
234236

235237
```ts

‎packages/cloudflare/src/cache/cdn-adapter.runtime.ts‎

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,10 @@ function toEdgeCacheControl(cacheControl: string): string {
212212
const MAX_CACHE_TAG_BYTES = 16 * 1024;
213213
const CACHE_TAG_PREFIX = "vinext-";
214214

215+
// Cloudflare caps a tag purge or invalidation request at 100 tags.
216+
// https://developers.cloudflare.com/cache/how-to/purge-cache/#availability-and-limits
217+
const MAX_TAGS_PER_PURGE = 100;
218+
215219
/** Encode a case-sensitive Next.js tag into a fixed Workers Cache tag. */
216220
export function encodeCloudflareCacheTag(tag: string): string {
217221
// Two domain-separated 64-bit rounds keep accidental collisions negligible
@@ -373,13 +377,32 @@ export class CloudflareCdnCacheAdapter implements CdnCacheAdapter {
373377
);
374378
if (tagList.length === 0) return;
375379

376-
const options = { tags: tagList.map(encodeCloudflareCacheTag) };
380+
const encodedTags = [...new Set(tagList.map(encodeCloudflareCacheTag))];
377381
const operation = isStaleTagInvalidation(durations) ? "invalidate" : "purge";
378-
const result = (await (operation === "invalidate"
379-
? invalidateOrPurge(cache, options)
380-
: cache.purge(options))) as WorkersCachePurgeResult | undefined;
381-
if (result?.success === false) {
382-
const errors = result.errors.map(({ code, message }) => `${code}: ${message}`).join(", ");
382+
const chunks: string[][] = [];
383+
for (let i = 0; i < encodedTags.length; i += MAX_TAGS_PER_PURGE) {
384+
chunks.push(encodedTags.slice(i, i + MAX_TAGS_PER_PURGE));
385+
}
386+
// Settle every chunk before reporting, so no started purge is left
387+
// un-awaited (Workers can cancel it).
388+
const settled = await Promise.allSettled(
389+
chunks.map(
390+
async (chunk) =>
391+
(await (operation === "invalidate"
392+
? invalidateOrPurge(cache, { tags: chunk })
393+
: cache.purge({ tags: chunk }))) as WorkersCachePurgeResult | undefined,
394+
),
395+
);
396+
const rejection = settled.find((result) => result.status === "rejected");
397+
if (rejection) throw rejection.reason;
398+
const failures = settled.flatMap((result) =>
399+
result.status === "fulfilled" && result.value?.success === false ? [result.value] : [],
400+
);
401+
if (failures.length > 0) {
402+
const errors = failures
403+
.flatMap((result) => result.errors)
404+
.map(({ code, message }) => `${code}: ${message}`)
405+
.join(", ");
383406
throw new Error(`[vinext] Workers Cache ${operation} failed${errors ? `: ${errors}` : ""}`);
384407
}
385408
}

‎packages/vinext/src/server/app-route-handler-dispatch.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,9 @@ async function runInRouteHandlerRevalidationContext(
149149
const requestContext = createRequestContext({
150150
headersContext,
151151
executionContext: getRequestExecutionContext(),
152+
// Regeneration runs the handler directly, so it needs the phase that
153+
// executeAppRouteHandler() would have set for a live request.
154+
phase: "route-handler",
152155
unstableCacheRevalidation: "foreground",
153156
});
154157

‎packages/vinext/src/server/app-route-handler-execution.ts‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,10 +423,22 @@ async function executeAppRouteHandlerImpl(
423423
requiresCompletedResponseAdmission: isRouteCacheabilityEvaluation(),
424424
})
425425
) {
426-
const completed = await completeAppRouteHandlerResponse(response);
426+
const completed = await completeAppRouteHandlerResponse(response).catch(
427+
async (error: unknown) => {
428+
// Drain what the body queued before it failed. The body error stays
429+
// the response error, as the eager drain above does for handlers.
430+
await _drainPendingRevalidations().catch(() => {});
431+
throw error;
432+
},
433+
);
427434
response = completed.response;
428435
cleanupDeferredToBody = !completed.completed;
429-
if (completed.completed) markRouteCacheabilityResponseBodyComplete();
436+
if (completed.completed) {
437+
markRouteCacheabilityResponseBodyComplete();
438+
// The body's producer ran after the eager drain above, so it owns any
439+
// next/cache invalidations it queued (incomplete bodies drain at cleanup).
440+
await _drainPendingRevalidations();
441+
}
430442
const dynamicUsedDuringCompletion = options.consumeDynamicUsage();
431443
dynamicUsedInHandler =
432444
handlerResult.didAccessDynamicRequest() ||

‎packages/vinext/src/shims/cache-request-state.ts‎

Lines changed: 96 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { getCdnCacheAdapter } from "./cdn-cache.js";
12
import { getHeadersAccessPhase } from "./internal/headers-state.js";
23
import { getOrCreateAls } from "./internal/als-registry.js";
34
import {
@@ -45,9 +46,19 @@ export type UnstableCacheObservation = Readonly<{
4546
*/
4647
export type CacheLifeSink = { cacheLife: CacheLifeConfig | null };
4748

49+
type TagInvalidationDurations = { expire?: number } | undefined;
50+
51+
/** A CDN purge waiting for the request boundary, after its data-cache invalidation. */
52+
export type PendingCdnTagInvalidation = Readonly<{
53+
tag: string;
54+
durations: TagInvalidationDurations;
55+
dataInvalidation: Promise<void>;
56+
}>;
57+
4858
export type CacheState = {
4959
actionRevalidationKind: ActionRevalidationKind;
5060
cacheLifeSink: CacheLifeSink | null;
61+
pendingCdnTagInvalidations: PendingCdnTagInvalidation[];
5162
pendingRevalidatedTags: Set<string>;
5263
pendingRevalidations: Set<Promise<void>>;
5364
requestScopedCacheLife: CacheLifeConfig | null;
@@ -66,6 +77,7 @@ export const ACTION_DID_REVALIDATE_DYNAMIC_ONLY = 2 satisfies ActionRevalidation
6677
const fallbackState = (globalState[FALLBACK_KEY] ??= {
6778
actionRevalidationKind: ACTION_DID_NOT_REVALIDATE,
6879
cacheLifeSink: null,
80+
pendingCdnTagInvalidations: [],
6981
pendingRevalidatedTags: new Set<string>(),
7082
pendingRevalidations: new Set<Promise<void>>(),
7183
requestScopedCacheLife: null,
@@ -95,6 +107,7 @@ export function _runWithCacheState<T>(fn: () => T | Promise<T>): T | Promise<T>
95107
const state: CacheState = {
96108
actionRevalidationKind: ACTION_DID_NOT_REVALIDATE,
97109
cacheLifeSink: null,
110+
pendingCdnTagInvalidations: [],
98111
pendingRevalidatedTags: new Set<string>(),
99112
pendingRevalidations: new Set<Promise<void>>(),
100113
requestScopedCacheLife: null,
@@ -170,26 +183,105 @@ export function _queuePendingRevalidation(promise: Promise<void>): boolean {
170183
}
171184

172185
/**
173-
* Await and clear every cache invalidation queued in the current request.
174-
* Clearing before awaiting also lets a later drain observe work enqueued by
175-
* an async continuation while this batch is settling.
186+
* Defer the CDN purge for `tag` to the current Server Action or Route Handler
187+
* boundary, so the request sends one adapter call per cacheLife profile like
188+
* Next.js' `executeRevalidates`. Edge purges are rate limited (Workers Cache
189+
* allows 5 requests a minute per account), so per-call purges run out fast.
190+
*
191+
* Only those two phases defer: their executors always drain before the
192+
* request finishes. Once the response has closed, `after()` callbacks run
193+
* past the last drain, so they purge immediately too. Everywhere else returns
194+
* false and the caller purges immediately.
195+
*
196+
* @internal
197+
*/
198+
export function _queuePendingCdnTagInvalidation(
199+
tag: string,
200+
durations: TagInvalidationDurations,
201+
dataInvalidation: Promise<void>,
202+
): boolean {
203+
const phase = getHeadersAccessPhase();
204+
if (phase !== "action" && phase !== "route-handler") return false;
205+
if (isInsideUnifiedScope() && getRequestContext().afterContext.responseClosed) return false;
206+
207+
getCacheState().pendingCdnTagInvalidations.push({ tag, durations, dataInvalidation });
208+
return true;
209+
}
210+
211+
/**
212+
* Ask the CDN adapter to invalidate `tags`, logging rather than rethrowing a
213+
* failure. The data cache is already invalidated by then, so a failed edge
214+
* purge leaves edge-cached responses stale until they expire, and failing the
215+
* request would not change that. It would only report a committed Server
216+
* Action write as a failure.
217+
*
218+
* @internal
219+
*/
220+
export async function _revalidateCdnTags(
221+
tags: string | string[],
222+
durations?: { expire?: number },
223+
): Promise<void> {
224+
try {
225+
await getCdnCacheAdapter().revalidateTag(tags, durations);
226+
} catch (error) {
227+
console.error(
228+
"[vinext] CDN cache invalidation failed; edge-cached responses may stay stale until they expire:",
229+
error,
230+
);
231+
}
232+
}
233+
234+
async function flushCdnTagInvalidations(
235+
invalidations: readonly PendingCdnTagInvalidation[],
236+
): Promise<void> {
237+
if (invalidations.length === 0) return;
238+
239+
const settled = await Promise.allSettled(
240+
invalidations.map(({ dataInvalidation }) => dataInvalidation),
241+
);
242+
const batches = new Map<string, { durations: TagInvalidationDurations; tags: Set<string> }>();
243+
invalidations.forEach(({ tag, durations }, index) => {
244+
// Purging the edge for a tag whose data-cache invalidation failed would
245+
// let the edge refetch and re-cache stale data.
246+
if (settled[index].status === "rejected") return;
247+
const key = durations === undefined ? "" : JSON.stringify(durations);
248+
let batch = batches.get(key);
249+
if (!batch) {
250+
batch = { durations, tags: new Set() };
251+
batches.set(key, batch);
252+
}
253+
batch.tags.add(tag);
254+
});
255+
await Promise.all(
256+
[...batches.values()].map(({ durations, tags }) => _revalidateCdnTags([...tags], durations)),
257+
);
258+
}
259+
260+
/**
261+
* Await and clear every cache invalidation queued in the current request,
262+
* then send the deferred CDN purges in batches. Clearing before awaiting also
263+
* lets a later drain observe work enqueued by an async continuation while
264+
* this batch is settling.
176265
*
177266
* @internal
178267
*/
179268
export async function _drainPendingRevalidations(): Promise<void> {
180269
const state = getCacheState();
181270
let didReject = false;
182271
let firstRejection: unknown;
183-
while (state.pendingRevalidations.size > 0) {
272+
while (state.pendingRevalidations.size > 0 || state.pendingCdnTagInvalidations.length > 0) {
184273
const pending = [...state.pendingRevalidations];
185274
state.pendingRevalidations.clear();
275+
// Mutate in place: shallow-cloned child scopes share this array.
276+
const cdnInvalidations = state.pendingCdnTagInvalidations.splice(0);
186277
const results = await Promise.allSettled(pending);
187278
for (const result of results) {
188279
if (result.status === "rejected" && !didReject) {
189280
didReject = true;
190281
firstRejection = result.reason;
191282
}
192283
}
284+
await flushCdnTagInvalidations(cdnInvalidations);
193285
}
194286
if (didReject) throw firstRejection;
195287
}

‎packages/vinext/src/shims/cache.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ import { workUnitAsyncStorage } from "./internal/work-unit-async-storage.js";
3030
import { makeHangingPromise } from "./internal/make-hanging-promise.js";
3131
import { isInsideWorkStoreScope } from "./internal/work-store-scope.js";
3232
import { encodeCacheTag, encodeCacheTags } from "../utils/encode-cache-tag.js";
33-
import { getCdnCacheAdapter } from "./cdn-cache.js";
3433
import { getDataCacheHandler, type CachedFetchValue } from "./cache-handler.js";
3534
import { getRequestExecutionContext } from "./request-context.js";
3635
import { isStagedCacheabilityProbeActive } from "./cacheability-classification.js";
@@ -44,7 +43,9 @@ import {
4443
ACTION_DID_REVALIDATE_STATIC_AND_DYNAMIC,
4544
_hasPendingRevalidatedTag,
4645
_markPendingRevalidatedTag,
46+
_queuePendingCdnTagInvalidation,
4747
_queuePendingRevalidation,
48+
_revalidateCdnTags,
4849
_setRequestScopedCacheLife,
4950
cacheLifeProfiles,
5051
getRegisteredCacheContext,
@@ -125,7 +126,7 @@ export function revalidateTag(tag: string, profile?: string | { expire?: number
125126
}
126127
const encodedTag = encodeCacheTag(tag);
127128
_markPendingRevalidatedTag(encodedTag);
128-
return scheduleRevalidation(_invalidateEncodedTag(encodedTag, durations));
129+
return scheduleTagInvalidation(encodedTag, durations);
129130
}
130131

131132
/**
@@ -136,13 +137,24 @@ export function revalidateTag(tag: string, profile?: string | { expire?: number
136137
* default CDN adapter), THEN ask the CDN adapter to purge its edge (a no-op for
137138
* the default adapter). Purging the edge before the store is invalidated would
138139
* let the edge re-fetch and re-cache stale data.
140+
*
141+
* The data cache is invalidated right away, so reads later in the same action
142+
* see it. Inside a Server Action or Route Handler the purge waits for the
143+
* request boundary, which sends one batched adapter call per profile.
139144
*/
140-
async function _invalidateEncodedTag(
145+
function scheduleTagInvalidation(encoded: string, durations?: { expire?: number }): undefined {
146+
const dataInvalidation = invalidateDataCacheTag(encoded, durations);
147+
if (_queuePendingCdnTagInvalidation(encoded, durations, dataInvalidation)) {
148+
return scheduleRevalidation(dataInvalidation);
149+
}
150+
return scheduleRevalidation(dataInvalidation.then(() => _revalidateCdnTags(encoded, durations)));
151+
}
152+
153+
async function invalidateDataCacheTag(
141154
encoded: string,
142155
durations?: { expire?: number },
143156
): Promise<void> {
144157
await getDataCacheHandler().revalidateTag(encoded, durations);
145-
await getCdnCacheAdapter().revalidateTag(encoded, durations);
146158
}
147159

148160
/**
@@ -171,7 +183,7 @@ export function revalidatePath(path: string, type?: "page" | "layout"): undefine
171183
const tag = type ? `_N_T_${stem}/${type}` : `_N_T_${stem || "/"}`;
172184
const encodedTag = encodeCacheTag(tag);
173185
_markPendingRevalidatedTag(encodedTag);
174-
return scheduleRevalidation(_invalidateEncodedTag(encodedTag));
186+
return scheduleTagInvalidation(encodedTag);
175187
}
176188

177189
/**
@@ -211,7 +223,7 @@ export function updateTag(tag: string): undefined {
211223
// Expire the tag immediately (same as revalidateTag without SWR)
212224
const encodedTag = encodeCacheTag(tag);
213225
_markPendingRevalidatedTag(encodedTag);
214-
return scheduleRevalidation(_invalidateEncodedTag(encodedTag));
226+
return scheduleTagInvalidation(encodedTag);
215227
}
216228

217229
/**

‎packages/vinext/src/shims/unified-request-context.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export function createRequestContext(opts?: Partial<UnifiedRequestContext>): Uni
113113
headersContext: null,
114114
actionRevalidationKind: 0,
115115
cacheLifeSink: null,
116+
pendingCdnTagInvalidations: [],
116117
pendingRevalidatedTags: new Set<string>(),
117118
pendingRevalidations: new Set<Promise<void>>(),
118119
dynamicUsageDetected: false,
@@ -388,7 +389,8 @@ export function runWithUnifiedStateMutation<T>(
388389
ensureRenderDynamicLatch(parentCtx);
389390
const childCtx = { ...parentCtx };
390391
// NOTE: This is a shallow clone. Object/array fields (afterContext, pendingSetCookies,
391-
// serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren), Set
392+
// serverInsertedHTMLCallbacks, currentRequestTags, ssrHeadChildren,
393+
// pendingCdnTagInvalidations), Set
392394
// fields (renderRequestApiUsage, pendingRevalidatedTags, pendingRevalidations,
393395
// cacheableFetchUrls, dynamicFetchUrls),
394396
// Map fields (unstableCacheObservations, _privateCache),

0 commit comments

Comments
 (0)