Skip to content

Commit 3243823

Browse files
committed
Refactor node-redis handler to use pub/sub
1 parent e835a3f commit 3243823

File tree

11 files changed

+210
-184
lines changed

11 files changed

+210
-184
lines changed

apps/cache-testing/next.config.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ const nextConfig: NextConfig = {
2020
default: './redis.js',
2121
},
2222
},
23+
eslint: {
24+
ignoreDuringBuilds: true,
25+
},
26+
typescript: {
27+
ignoreBuildErrors: true,
28+
},
2329
};
2430

2531
export default nextConfig;

apps/cache-testing/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@
1717
},
1818
"dependencies": {
1919
"@neshca/cache-handler": "workspace:*",
20-
"axios": "1.8.3",
21-
"next": "15.3.0-canary.13",
20+
"axios": "1.8.4",
21+
"next": "15.3.0-canary.14",
2222
"react": "19.0.0",
2323
"react-dom": "19.0.0",
2424
"redis": "4.7.0"

apps/cache-testing/src/utils/create-get-data.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ export function createGetData(path: string, revalidate?: number) {
1515

1616
cacheLife({
1717
revalidate,
18-
expire: revalidate ? revalidate * 2 : undefined,
1918
});
2019

2120
cacheTag(pathAndTag, 'whole-app-route');

docs/cache-handler-docs/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"start:docs": "serve out"
1313
},
1414
"dependencies": {
15-
"next": "15.3.0-canary.13",
15+
"next": "15.3.0-canary.14",
1616
"nextra": "4.2.16",
1717
"nextra-theme-docs": "4.2.16",
1818
"react": "19.0.0",

internal/eslint-config/package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@
88
},
99
"devDependencies": {
1010
"@eslint/js": "9.22.0",
11-
"@next/eslint-plugin-next": "15.3.0-canary.13",
11+
"@next/eslint-plugin-next": "15.3.0-canary.14",
1212
"eslint": "9.22.0",
1313
"eslint-config-prettier": "10.1.1",
1414
"eslint-plugin-react": "7.37.4",
1515
"eslint-plugin-react-hooks": "5.2.0",
1616
"eslint-plugin-turbo": "2.4.4",
1717
"globals": "16.0.0",
1818
"typescript": "5.8.2",
19-
"typescript-eslint": "8.26.1"
19+
"typescript-eslint": "8.27.0"
2020
}
2121
}

packages/cache-handler/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@
4444
"devDependencies": {
4545
"@repo/typescript-config": "workspace:*",
4646
"@types/node": "22.13.10",
47-
"next": "15.3.0-canary.13",
47+
"next": "15.3.0-canary.14",
4848
"tsx": "4.19.3",
4949
"typescript": "5.8.2",
5050
"vitest": "3.0.9"

packages/cache-handler/src/cache-handler.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,8 @@ async function removeEntryFromHandlers(
323323
});
324324
}
325325

326+
export type CacheHandlerType = typeof CacheHandler;
327+
326328
export class CacheHandler implements NextCacheHandler {
327329
/**
328330
* Provides a descriptive name for the CacheHandler class.

packages/cache-handler/src/instrumentation/register-initial-cache.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,9 @@ import { promises as fsPromises } from 'node:fs';
22
import path from 'node:path';
33
import { PRERENDER_MANIFEST, SERVER_DIRECTORY } from 'next/constants.js';
44
import type { PrerenderManifest } from 'next/dist/build/index.js';
5+
import type { CacheHandlerType } from '../cache-handler.js';
56
import { CachedRouteKind, type Revalidate } from '../next-common-types.js';
67

7-
type CacheHandlerType = typeof import('../cache-handler.js').CacheHandler;
8-
98
type Router = 'pages' | 'app';
109

1110
const PRERENDER_MANIFEST_VERSION = 4;

packages/cache-handler/src/use-cache-cache.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type {
22
CacheEntry,
33
CacheHandlerV2,
4+
Timestamp,
45
} from 'next/dist/server/lib/cache-handlers/types.js';
56
import {
67
isStale,
@@ -15,9 +16,16 @@ export type { CacheHandlerV2, CacheEntry };
1516

1617
const pendingSets = new Map<string, Promise<void>>();
1718

19+
type RemoteStoreOptions = {
20+
timestamp: Timestamp;
21+
revalidate: number;
22+
expire: number;
23+
stale: number;
24+
};
25+
1826
export type RemoteStore = {
1927
get(key: string): Promise<string | undefined>;
20-
set(key: string, value: string): Promise<void>;
28+
set(key: string, value: string, options: RemoteStoreOptions): Promise<void>;
2129
refreshTags(tagsManifest: Map<string, number>): Promise<void>;
2230
getExpirationTimestamps(tags: string[]): Promise<number[]>;
2331
expireTags(expiredTags: Map<string, number>): Promise<void>;
@@ -100,7 +108,12 @@ export async function createCacheHandler(
100108

101109
const remoteStore = await remoteStorePromise;
102110

103-
await remoteStore.set(cacheKey, JSON.stringify(storageEntry));
111+
await remoteStore.set(cacheKey, JSON.stringify(storageEntry), {
112+
expire: entry.expire,
113+
revalidate: entry.revalidate,
114+
stale: entry.stale,
115+
timestamp: entry.timestamp,
116+
});
104117
} catch (_error) {
105118
//
106119
} finally {

packages/cache-handler/src/use-cache/node-redis.ts

Lines changed: 67 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -1,111 +1,118 @@
1+
import { randomUUID } from 'node:crypto';
2+
import { tagsManifest } from 'next/dist/server/lib/incremental-cache/tags-manifest.external.js';
13
import type { createClient } from 'redis';
24
import { createRedisTimeoutConfig } from '../helpers/create-redis-timeout-config.js';
35
import { type RemoteStore, createCacheHandler } from '../use-cache-cache.js';
46

57
export type Config<T extends ReturnType<typeof createClient>> = {
68
client: T;
9+
pubClient: T;
10+
subClientId: string;
11+
channel: string;
712
keyPrefix?: string;
8-
sharedTagsKey?: string;
913
timeoutMs?: number;
14+
expireTrigger?: 'stale' | 'expire';
15+
};
16+
17+
type Message = {
18+
expiredTags: Record<string, number>;
19+
subClientId: string;
1020
};
1121

1222
function createRedisStore<T extends ReturnType<typeof createClient>>({
1323
client,
24+
pubClient,
25+
channel,
26+
subClientId,
1427
keyPrefix,
15-
sharedTagsKey,
1628
timeoutMs = 5000,
29+
expireTrigger = 'stale',
1730
}: Config<T>): RemoteStore {
1831
const getKey = (key: string) => `${keyPrefix}${key}`;
19-
const getSharedTagsKey = () => `${keyPrefix}${sharedTagsKey}`;
2032

2133
return {
2234
async get(key) {
2335
if (!client.isReady) {
24-
return Promise.resolve(undefined);
36+
return;
2537
}
2638

2739
const options = createRedisTimeoutConfig(timeoutMs);
2840

2941
return (await client.get(options, getKey(key))) ?? undefined;
3042
},
31-
async set(key, value) {
43+
async set(key, value, { expire, timestamp, stale }) {
3244
if (!client.isReady) {
3345
return;
3446
}
3547

3648
const options = createRedisTimeoutConfig(timeoutMs);
3749

38-
await client.set(options, getKey(key), value);
50+
const expireAt =
51+
expireTrigger === 'stale'
52+
? Math.floor(timestamp / 1000 + Math.min(expire, stale))
53+
: Math.floor(timestamp / 1000 + expire);
54+
55+
await client.set(options, getKey(key), value, {
56+
EXAT: expireAt,
57+
});
3958
},
4059
async expireTags(expiredTags) {
41-
if (!client.isReady) {
60+
if (!pubClient.isReady) {
4261
return;
4362
}
4463

45-
const options = createRedisTimeoutConfig(timeoutMs);
46-
47-
await client.hSet(
48-
options,
49-
getSharedTagsKey(),
50-
Object.fromEntries(expiredTags),
64+
await pubClient.publish(
65+
channel,
66+
JSON.stringify({
67+
expiredTags: Object.fromEntries(expiredTags),
68+
subClientId,
69+
} satisfies Message),
5170
);
5271
},
5372
getExpirationTimestamps() {
54-
return Promise.resolve([]);
73+
return Promise.resolve([0]);
5574
},
56-
async refreshTags(tagsManifest) {
57-
try {
58-
if (!client?.isReady) {
59-
return;
60-
}
61-
62-
let cursor = 0;
63-
64-
const hScanOptions = { COUNT: 10 };
65-
66-
do {
67-
const options = createRedisTimeoutConfig(timeoutMs);
68-
69-
const remoteTagsPortion = await client.hScan(
70-
options,
71-
getSharedTagsKey(),
72-
cursor,
73-
hScanOptions,
74-
);
75-
76-
for (const {
77-
field: tag,
78-
value: timestampStr,
79-
} of remoteTagsPortion.tuples) {
80-
try {
81-
const timestamp = Number.parseInt(timestampStr, 10);
82-
83-
if (!Number.isNaN(timestamp)) {
84-
tagsManifest.set(tag, timestamp);
85-
}
86-
} catch (err) {
87-
console.error(`Failed to parse tag timestamp for ${tag}:`, err);
88-
}
89-
}
90-
91-
cursor = remoteTagsPortion.cursor;
92-
} while (cursor !== 0);
93-
} catch (error) {
94-
console.error('Failed to load tags from Redis:', error);
95-
}
75+
async refreshTags() {
76+
// must be empty when using pub/sub
9677
},
9778
};
9879
}
9980

10081
export function createRedisCacheHandler<
10182
T extends ReturnType<typeof createClient>,
102-
>({ client, keyPrefix, sharedTagsKey, timeoutMs }: Config<T>) {
103-
const remoteStore = client
104-
.connect()
105-
.then((redisClient) => ({
106-
client: redisClient,
83+
>({ client, keyPrefix, timeoutMs }: Config<T>) {
84+
const remoteStore = Promise.all([
85+
client.connect(),
86+
client.duplicate().connect(),
87+
client.duplicate().connect(),
88+
])
89+
.then(async ([mainClient, pubClient, subClient]) => {
90+
const subClientId = randomUUID();
91+
const channel = `${keyPrefix}__revalidate_channel__`;
92+
93+
await subClient.subscribe(channel, (message) => {
94+
const { expiredTags, subClientId: messageSubClientId } = JSON.parse(
95+
message,
96+
) as Message;
97+
98+
if (subClientId === messageSubClientId) {
99+
console.info('ignoring message from self');
100+
return;
101+
}
102+
103+
for (const [tag, timestamp] of Object.entries(expiredTags)) {
104+
tagsManifest.set(tag, timestamp);
105+
}
106+
});
107+
108+
return { mainClient, pubClient, subClientId, channel };
109+
})
110+
.then(({ mainClient, pubClient, subClientId, channel }) => ({
111+
client: mainClient,
112+
pubClient,
113+
subClientId,
114+
channel,
107115
keyPrefix,
108-
sharedTagsKey,
109116
timeoutMs,
110117
}))
111118
.then(createRedisStore);

0 commit comments

Comments
 (0)