Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .devcontainer/rust/devcontainer-feature.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// this should match the `rust-toolchain.toml`
"version": "nightly-2026-08-20",
"profile": "minimal",
"components": "rustfmt,clippy,rust-analyzer"
"components": "rustfmt,clippy,rust-analyzer,miri"
}
}
}
18 changes: 18 additions & 0 deletions .github/workflows/build_and_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,7 @@ jobs:
with:
needsRust: 'yes'
needsNextest: 'yes'
needsRustJsInstall: 'yes'
skipNativeBuild: 'yes'
skipInstallBuild: 'yes'
afterBuild: pnpm dlx turbo@${TURBO_VERSION} run test-cargo-unit ${TURBO_ARGS}
Expand All @@ -313,6 +314,23 @@ jobs:
runs_on_labels: '["ubuntu-latest-16-core-oss"]'
secrets: inherit

# Turbo Tasks' link-section registry is unavailable under Miri, so this runs the explicit
# low-level package allowlist in packages/next-swc/package.json instead of the whole workspace.
test-cargo-unit-miri:
name: test cargo unit (miri)
needs: ['changes', 'build-next']
if: ${{ needs.changes.outputs.docs-only == 'false' }}

uses: ./.github/workflows/build_reusable.yml
with:
needsRust: 'yes'
needsNextest: 'yes'
skipNativeBuild: 'yes'
skipInstallBuild: 'yes'
afterBuild: pnpm dlx turbo@${TURBO_VERSION} run test-cargo-unit-miri ${TURBO_ARGS}
stepName: 'test-cargo-unit-miri'
secrets: inherit

test-bench:
name: test cargo benches
needs: ['optimize-ci', 'changes', 'build-next']
Expand Down
10 changes: 7 additions & 3 deletions .github/workflows/build_reusable.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ on:
required: false
description: 'if nextest rust dep is needed'
type: string
needsRustJsInstall:
required: false
description: 'if Rust JS dependencies should be installed'
type: string
rustBuildProfile:
required: false
description: 'The profile to use for the build, default is `release-with-assertions`, also supports `` for debug and `release` for normal release'
Expand Down Expand Up @@ -360,7 +364,7 @@ jobs:
# risk caching an incomplete store
# If keep conditions in sync breaks, we can split into restore and save
# steps where saving runs based on the outcome of the install step
if: ${{ inputs.skipInstallBuild != 'yes' || inputs.needsNextest == 'yes' }}
if: ${{ inputs.skipInstallBuild != 'yes' || inputs.needsRustJsInstall == 'yes' }}
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
timeout-minutes: 5
id: cache-pnpm-store
Expand All @@ -371,10 +375,10 @@ jobs:

- run: pnpm install
# condititions must be superset of cache step, otherwise we risk running install without a cache and then caching the incomplete store.
if: ${{ inputs.skipInstallBuild != 'yes' || inputs.needsNextest == 'yes' }}
if: ${{ inputs.skipInstallBuild != 'yes' || inputs.needsRustJsInstall == 'yes' }}

- name: Install node-file-trace test dependencies
if: ${{ inputs.needsNextest == 'yes' }}
if: ${{ inputs.needsRustJsInstall == 'yes' }}
working-directory: turbopack/crates/turbopack-tracing/tests/node-file-trace
run: pnpm install --recursive

Expand Down
4 changes: 2 additions & 2 deletions examples/cache-handler-redis/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ The `/[timezone]` page renders a mostly static shell and, inside it, a `'use cac

- **ISR cache (`cache-handler.js`):** stores the prerendered page entries as JSON under a `nextjs:cache:` prefix, and tracks which keys belong to each tag in a Redis set (`nextjs:tag:<tag>`). `revalidateTag` deletes every key associated with a tag.

- **Remote cache (`remote-cache-handler.js`):** stores each `'use cache: remote'` entry under a `nextjs:use-cache:` prefix (the streamed value is base64-encoded). Tag revalidation is timestamp-based: `updateTags` records `nextjs:use-cache-tag:<tag>` = now, and `getExpiration` reports the latest time so Next treats older entries as stale. Clicking **Revalidate** calls [`updateTag('time-data')`](https://nextjs.org/docs/app/api-reference/functions/updateTag), which regenerates the remote entry.
- **Remote cache (`remote-cache-handler.js`):** stores each `'use cache: remote'` entry under a `nextjs:use-cache:` prefix (the streamed value is base64-encoded). Tag revalidation is timestamp-based: `updateTags` records `nextjs:use-cache-tag:<tag>` = now. On a hit, `get` compares the entry's own tags (from `cacheTag`) against those timestamps and reports a miss if any is newer, and `getExpiration` reports the latest time for the route's soft tags so Next discards older entries. Clicking **Revalidate** calls [`updateTag('time-data')`](https://nextjs.org/docs/app/api-reference/functions/updateTag), which regenerates the remote entry.

- **Building without Redis:** both handlers skip connecting during `next build` (they check `NEXT_PHASE`) and degrade gracefully when Redis is unavailable, so the app still builds and runs, just without a shared cache.
- **Building without Redis:** both handlers skip connecting during `next build` (they check `NEXT_PHASE`) and degrade gracefully when Redis is unavailable, so the app still builds and runs, just without a shared cache. Revalidation is the exception: `revalidateTag` and `updateTags` throw while Redis is unavailable, because an invalidation that never reached Redis would be lost, and the old entries would be served again once Redis is back.

- **Redis server setup:** ensure your Redis server is running before starting the app. Configure the connection with `REDIS_URL` (defaults to `redis://localhost:6379`).

Expand Down
118 changes: 78 additions & 40 deletions examples/cache-handler-redis/cache-handler.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,49 +43,77 @@ function deserialize(text) {
});
}

// Next.js constructs the `cacheHandler` class once per request, so the Redis
// client must live at module scope: creating it in the constructor would open
// a new connection on every request and never close it.
const client = createClient({
url: process.env.REDIS_URL ?? "redis://localhost:6379",
// Fail commands immediately while the connection is down instead of queueing
// them until Redis is back.
disableOfflineQueue: true,
});

// Redis won't work without error handling. Do not throw here, otherwise the
// client won't reconnect after a connection drop.
client.on("error", (error) => {
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.warn("Redis client error:", error);
}
});

// Connecting to Redis during `next build` can cause issues, so we only connect
// at runtime.
const connection =
process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD
? Promise.resolve()
: client.connect().catch((error) => {
console.warn("Failed to connect to Redis:", error);
});

// `connect()` stays pending for as long as Redis is unreachable, so cap the
// wait: requests arriving during startup wait for the connection at most
// once, and while Redis is down every request is served uncached instead of
// blocking. The client keeps retrying in the background, so `isReady` flips
// back on its own once Redis is reachable again.
const CONNECT_TIMEOUT_MS = 1000;
const ready = Promise.race([
connection,
// `unref()` so this timer never keeps the process alive.
new Promise((resolve) => setTimeout(resolve, CONNECT_TIMEOUT_MS).unref()),
]);

// Resolve a connected client, or `null` when Redis is unavailable so the app
// keeps working (without a shared cache) instead of hanging or crashing.
async function getClient() {
await ready;
return client.isReady ? client : null;
}

module.exports = class CacheHandler {
constructor(options) {
this.options = options;

this.client = createClient({
url: process.env.REDIS_URL ?? "redis://localhost:6379",
});

// Redis won't work without error handling. Do not throw here, otherwise
// the client won't reconnect after a connection drop.
this.client.on("error", (error) => {
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.warn("Redis client error:", error);
}
});

// Connecting to Redis during `next build` can cause issues, so we only
// connect at runtime. `this.connection` resolves once the client is ready.
this.connection =
process.env.NEXT_PHASE === PHASE_PRODUCTION_BUILD
? Promise.resolve()
: this.client.connect().catch((error) => {
console.warn("Failed to connect to Redis:", error);
});
}

// Resolve a connected client, or `null` when Redis is unavailable so the
// app keeps working (without a shared cache) instead of crashing.
async getClient() {
await this.connection;
return this.client.isReady ? this.client : null;
}

async get(key) {
const client = await this.getClient();
const client = await getClient();
if (!client) return null;

const entry = await client.get(CACHE_PREFIX + key);
let entry;
try {
entry = await client.get(CACHE_PREFIX + key);
} catch (error) {
// A connection dropping mid-request degrades to a cache miss.
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.warn("Redis get failed:", error);
}
return null;
}

return entry ? deserialize(entry) : null;
}

async set(key, data, ctx) {
const client = await this.getClient();
const client = await getClient();
if (!client || !data) return;

// Collect tags from both sources: `ctx.tags` (fetch entries) and the
Expand All @@ -105,19 +133,29 @@ module.exports = class CacheHandler {
? { expiration: { type: "EX", value: Math.max(1, Math.ceil(expire)) } }
: {};

await client.set(
CACHE_PREFIX + key,
serialize({ value: data, lastModified: Date.now(), tags }),
options,
);
const value = serialize({ value: data, lastModified: Date.now(), tags });

try {
await client.set(CACHE_PREFIX + key, value, options);

// Index this key under each of its tags so `revalidateTag` can find it.
await Promise.all(tags.map((tag) => client.sAdd(TAG_PREFIX + tag, key)));
// Index this key under each of its tags so `revalidateTag` can find it.
await Promise.all(tags.map((tag) => client.sAdd(TAG_PREFIX + tag, key)));
} catch (error) {
if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
console.warn("Redis set failed:", error);
}
}
}

async revalidateTag(tags) {
const client = await this.getClient();
if (!client) return;
const client = await getClient();
// Don't report success for a revalidation that never reached Redis: once
// Redis is back, every instance would serve the old entries again.
if (!client) {
throw new Error(
"Redis is unavailable, so the tag revalidation was not recorded",
);
}

// `tags` is either a single tag or an array of tags.
for (const tag of [tags].flat()) {
Expand Down
Loading
Loading