Skip to content

fix(cache): scope use cache keys by deployment id#1092

Open
NathanDrake2406 wants to merge 8 commits intocloudflare:mainfrom
NathanDrake2406:nathan/use-cache-deployment-id
Open

fix(cache): scope use cache keys by deployment id#1092
NathanDrake2406 wants to merge 8 commits intocloudflare:mainfrom
NathanDrake2406:nathan/use-cache-deployment-id

Conversation

@NathanDrake2406
Copy link
Copy Markdown
Contributor

@NathanDrake2406 NathanDrake2406 commented May 6, 2026

What this changes

Shared "use cache" entries now include a deployment-aware seed before falling back to the existing vinext build ID. This matches the upstream cache key precedence model for vinext's supported deployment ID sources while keeping Worker deployment identity request-scoped.

Why

Next.js changed use-cache-wrapper so cache keys use workStore.deploymentId || workStore.buildId. Without this, two deployments can share the same "use cache" entry when the build ID is stable or when a runtime deployment ID changes independently of the build fallback.

References:

Approach

  • Add a deployment-aware cache key seed in cache-runtime, with precedence: request-scoped deployment ID, explicit startup/test fallback, build-time resolved deployment ID, then vinext build ID.
  • Resolve next.config.js deploymentId before NEXT_DEPLOYMENT_ID, validate its string shape, and expose the resolved value through process.env.__VINEXT_DEPLOYMENT_ID.
  • Read Worker deployment identity from env.NEXT_DEPLOYMENT_ID or env.CF_VERSION_METADATA.id in the App Router Worker entry.
  • Use AsyncLocalStorage for the Worker deployment ID so concurrent requests cannot overwrite one another.
  • Generate a version_metadata binding in wrangler.jsonc so deployed Cloudflare Workers have CF_VERSION_METADATA.id available by default.

Validation

  • vp test run tests/shims.test.ts -t 'use cache'
  • vp test run tests/deploy.test.ts -t 'Wrangler Config Generation|generateAppRouterWorkerEntry'
  • vp test run tests/next-config.test.ts -t 'deploymentId|generateBuildId'
  • vp check packages/vinext/src/config/next-config.ts packages/vinext/src/shims/cache-runtime.ts packages/vinext/src/server/app-router-entry.ts packages/vinext/src/deploy.ts packages/vinext/src/index.ts packages/vinext/src/global.d.ts tests/shims.test.ts tests/deploy.test.ts tests/next-config.test.ts
  • Pre-commit hook ran vp check --fix and knip --no-progress

Risks / follow-ups

This PR scopes the App Router Worker entry and generated deploy config. Custom Worker entries that bypass vinext/server/app-router-entry still need to provide their own deployment context if they invoke lower-level internals directly.

★ Insight

  • Next.js seeds "use cache" with deployment identity before build identity because cacheHandlers can outlive a single build assumption.
  • Cloudflare version metadata is request-time Worker env state, so reading it at module registration would miss or race the real deployment value.
  • The fallback chain keeps existing build ID isolation intact for non-Cloudflare and local paths while adding stronger isolation for configured and Workers deployments.

Shared "use cache" entries only used the vinext build ID as their cache-key seed. That diverges from the upstream Next.js change for cacheHandlers and can let a Cloudflare Worker deployment reuse entries from another deployment when the deployment identity changes independently of the build fallback.

The cache runtime now resolves a deployment-aware key seed at execution time, with request-scoped Worker version metadata or NEXT_DEPLOYMENT_ID taking precedence over the build ID. Generated Wrangler config includes the CF_VERSION_METADATA binding, and the App Router Worker entry threads it into the runtime without process-global request races.

Tests cover deployment ID precedence, concurrent deployment scope isolation, build ID fallback, and generated Worker config wiring.
Copilot AI review requested due to automatic review settings May 6, 2026 04:41
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 6, 2026

Open in StackBlitz

npm i https://pkg.pr.new/vinext@1092

commit: 6b80257

Deployment-aware "use cache" keys had runtime Worker and NEXT_DEPLOYMENT_ID support, but vinext did not resolve the Next.js deploymentId config field. That left config-defined deployment identity out of the build-time cache seed.

Resolve deploymentId from next.config.js before NEXT_DEPLOYMENT_ID, validate the same string shape Next.js documents, and feed the resolved value into the __VINEXT_DEPLOYMENT_ID define. The process-wide cache-runtime setter is also documented as a fallback rather than the per-request API.

Tests cover config precedence, env fallback, empty config values, and invalid deploymentId shapes.
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot encountered an error and was unable to review this pull request. You can try again by re-requesting a review.

@james-elicx
Copy link
Copy Markdown
Collaborator

/bigbonk review

Copy link
Copy Markdown
Contributor

@ask-bonk ask-bonk Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Solid work. The deployment-aware cache key seeding matches the upstream Next.js model, the ALS-based concurrency isolation is correct, and the test coverage is good. The refactor of app-router-entry.ts to extract handleRequest preserves behavior while cleanly wrapping the deployment ID scope.

A few observations below — nothing blocking, mostly edge-case clarifications and a minor nit.

function resolveDeploymentId(configDeploymentId: unknown): string | undefined {
const deploymentId =
configDeploymentId !== undefined ? configDeploymentId : process.env.NEXT_DEPLOYMENT_ID;
if (deploymentId === undefined || deploymentId === "") return undefined;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling out: when a user sets deploymentId: "" in next.config.js, this silently swallows NEXT_DEPLOYMENT_ID from the environment. The test covers this behavior ("treats an empty next.config.js deploymentId as unset"), so it's intentional — but it functions as a way to opt out of the env var, not just "leave unset." Worth a brief inline comment for the next person who reads this, e.g.:

Suggested change
if (deploymentId === undefined || deploymentId === "") return undefined;
// Setting deploymentId to "" in next.config.js explicitly opts out of
// any deployment ID (including NEXT_DEPLOYMENT_ID from the environment).
if (deploymentId === undefined || deploymentId === "") return undefined;

function getUseCacheKeySeed(): string | undefined {
const runtimeDeploymentId = Reflect.get(globalThis, _DEPLOYMENT_ID_KEY);
return (
_deploymentIdStorage.getStore() ||
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: _deploymentIdStorage.getStore() returns undefined both when no ALS scope is active AND when the store was explicitly set to undefined (via runWithUseCacheDeploymentId(undefined, fn)). In both cases you want to fall through, so the || works correctly here. Just noting this is intentional — undefined from an active scope deliberately falls through to the define/build-ID layers rather than short-circuiting the chain.

return notFoundResponse();
if (result instanceof Response) {
if (env?.ASSETS) {
const assetFetcher = env.ASSETS;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice cleanup: extracting env.ASSETS into assetFetcher avoids the non-null assertion that was previously needed inside the closure (env.ASSETS!). This is a strict improvement over the original.

// Deployment ID — mirrors Next.js' NEXT_DEPLOYMENT_ID seed for shared
// "use cache" entries, falling back to build ID when absent.
defines["process.env.__VINEXT_DEPLOYMENT_ID"] = JSON.stringify(
nextConfig.deploymentId ?? "",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When deploymentId is undefined, this serializes to "". Downstream in getUseCacheKeySeed(), "" is falsy so it correctly falls through to getUseCacheBuildIdDefine(). This is the right approach — it avoids needing Vite to define-replace with undefined (which wouldn't inline cleanly). Just wanted to confirm: have you verified this works when __VINEXT_DEPLOYMENT_ID is never defined at all (non-Vite test runner, no define transform)? In that case process.env.__VINEXT_DEPLOYMENT_ID would be literally undefined, which also falls through correctly. Looks good.

Comment thread packages/vinext/src/deploy.ts Outdated
${isrImports}
interface Env {
ASSETS: Fetcher;${isrEnvField}
NEXT_DEPLOYMENT_ID?: string;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The generated worker entry declares NEXT_DEPLOYMENT_ID and CF_VERSION_METADATA in the Env interface, which is good. But the generated fetch handler at line 510 just does handler.fetch(request, env, ctx) — meaning app-router-entry.ts receives the full env and extracts the deployment ID itself. This works cleanly because deploymentIdFromEnv in app-router-entry.ts handles the env?.NEXT_DEPLOYMENT_ID || env?.CF_VERSION_METADATA?.id chain.

The only gap (noted in the PR description) is custom worker entries that don't delegate to app-router-entry — they'd need to call runWithUseCacheDeploymentId themselves. Could be worth adding a one-liner comment in the generated entry template pointing users to runWithUseCacheDeploymentId if they customize the handler.

}

function buildUseCacheKey(id: string, keySeed: string | undefined, argsKey?: string): string {
const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: The key prefix is still build: even when the seed is a deployment ID rather than a build ID. This doesn't cause correctness issues (it's just a string prefix for namespacing), but it could be confusing when debugging cache keys. Consider whether a more generic prefix like scope: would be clearer, or add a comment explaining the naming is historical.

Suggested change
const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id;
const scopedId = keySeed ? `build:${encodeURIComponent(keySeed)}:${id}` : id; // "build:" prefix is historical — seed may be deployment ID or build ID

@ask-bonk
Copy link
Copy Markdown
Contributor

ask-bonk Bot commented May 6, 2026

Review submitted with APPROVE. Here's a summary of what I found:

Overall: Clean, well-structured PR. The deployment-aware cache key seeding correctly matches the upstream Next.js model (deploymentId || buildId), and the ALS-based concurrency isolation in runWithUseCacheDeploymentId is the right pattern for Workers where concurrent requests share the same isolate.

Key observations posted as inline comments:

  1. resolveDeploymentId empty-string behavior — Setting deploymentId: "" in next.config.js silently opts out of NEXT_DEPLOYMENT_ID from the environment. Behavior is tested but could use an inline comment for clarity.

  2. ALS undefined fall-through_deploymentIdStorage.getStore() returns undefined both when no scope is active and when explicitly set to undefined. The || chain handles both correctly, letting the define/build-ID layers take over.

  3. app-router-entry.ts refactor — The extraction of handleRequest and assetFetcher variable is a clean improvement over the original (eliminates the env.ASSETS! non-null assertion).

  4. Generated worker entry gap — Custom worker entries that bypass app-router-entry won't get deployment ID scoping. Suggested adding a comment pointing users to runWithUseCacheDeploymentId.

  5. build: prefix naming — The cache key prefix is build: even when the seed is a deployment ID. Not a correctness issue but could confuse during debugging. Suggested a clarifying comment.

github run

Reflect.set(globalThis, _DEPLOYMENT_ID_KEY, deploymentId || undefined);
}

export function runWithUseCacheDeploymentId<T>(
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead of using ALS, we should be able to inline this value with the same pattern we are using for build ids

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i see we are inlining it - in that case, can we rip out the ALS?

The Worker version metadata path still needs request-scoped state, but cache-runtime should not own a cache-specific deployment ALS or public-looking setter.

Move runtime deployment ID storage into the unified request-context layer. The Worker boundary now writes deploymentId into request runtime context, cache-runtime reads that generic request metadata before falling back to inlined deployment/build IDs, and the tests cover direct runtime scopes plus inheritance into unified request contexts.
@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector
Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🎉

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

};

function deploymentIdFromEnv(env: WorkerAssetEnv | undefined): string | undefined {
return env?.NEXT_DEPLOYMENT_ID || env?.CF_VERSION_METADATA?.id || undefined;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is a binding and not guaranteed to follow that var name

Comment on lines +36 to +38
type AppRouterRuntimeContext = {
deploymentId?: string;
passThroughOnException?(): void;
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why does deployment id need to live on the request context?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

for simplicities sake, all we need for deployment ids at the moment if a function that return the value of process.env.__VINEXT_DEPLOYMENT_ID which is inlined at build time, and falls back to process.env. NEXT_DEPLOYMENT_ID, and then probably falls back to build id potentially.

deploymentid doesnt need to touch ALS, unless there's a reason I'm not seeing?

in the future when we support experimental.runtimeServerDeploymentId as well, i believe we would always default to the NEXT_DEPLOYMENT_ID var and error if there's a deploymentId in the next config.

Comment thread packages/vinext/src/deploy.ts Outdated
// Exposes the Cloudflare Worker version ID at runtime. vinext uses it as
// the deployment seed for shared "use cache" entries when available.
version_metadata: {
binding: "CF_VERSION_METADATA",
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

please can we exclude these changes for now, there's probably an open question around what's the right way to consume this at runtime, so would prefer to leave it out for now.

@NathanDrake2406
Copy link
Copy Markdown
Contributor Author

Updated to match this direction:

  • removed the CF_VERSION_METADATA / version_metadata changes for now
  • removed deployment ID from the app router runtime/request context
  • removed the request-context cache seed path
  • cache-runtime now uses the simple seed chain: process.env.__VINEXT_DEPLOYMENT_ID || process.env.NEXT_DEPLOYMENT_ID || process.env.__VINEXT_BUILD_ID

Focused checks passed locally: vp check ..., vp test run tests/shims.test.ts -t "use cache", vp test run tests/deploy.test.ts, and vp test run tests/next-config.test.ts -t "deploymentId".

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants