@trieb.work/nextjs-turbo-redis-cache 1.16.0 → 1.16.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +14 -0
- package/README.md +8 -0
- package/dist/index.js +36 -16
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +36 -16
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/CacheComponentsHandler.ts +35 -2
- package/src/RedisStringsHandler.ts +26 -15
- package/test/vitest/integration/cache-components/redis-kill-reconnect.test.ts +6 -0
- package/test/vitest/integration/cache-components/scripts/redis-kill-reconnect.ts +3 -5
- package/test/vitest/unit/index.test.ts +77 -2
- package/test/vitest/unit/reconnect-socket-already-opened.test.ts +56 -0
|
@@ -444,21 +444,32 @@ export default class RedisStringsHandler {
|
|
|
444
444
|
'assertClientIsReady called more than 10 times without being ready.',
|
|
445
445
|
);
|
|
446
446
|
}
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
447
|
+
let readyTimeout: ReturnType<typeof setTimeout> | undefined;
|
|
448
|
+
try {
|
|
449
|
+
await Promise.race([
|
|
450
|
+
Promise.all([
|
|
451
|
+
this.sharedTagsMap.waitUntilReady(),
|
|
452
|
+
this.revalidatedTagsMap.waitUntilReady(),
|
|
453
|
+
]),
|
|
454
|
+
new Promise((_, reject) => {
|
|
455
|
+
readyTimeout = setTimeout(() => {
|
|
456
|
+
reject(
|
|
457
|
+
new Error(
|
|
458
|
+
'assertClientIsReady: Timeout waiting for Redis maps to be ready',
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
}, 30_000);
|
|
462
|
+
}),
|
|
463
|
+
]);
|
|
464
|
+
} finally {
|
|
465
|
+
// Always clear the timeout: once the race is won (the common case after
|
|
466
|
+
// the initial sync), the timer would otherwise stay pending for the full
|
|
467
|
+
// 30s. Timers capture the ambient async context (AsyncLocalStorage),
|
|
468
|
+
// so in a Next.js server every leaked timer pins the request's store —
|
|
469
|
+
// and with it the whole response object graph — for at least 30s on
|
|
470
|
+
// every single cache operation.
|
|
471
|
+
clearTimeout(readyTimeout);
|
|
472
|
+
}
|
|
462
473
|
this.clientReadyCalls = 0;
|
|
463
474
|
if (!this.client.isReady) {
|
|
464
475
|
throw new Error(
|
|
@@ -50,6 +50,12 @@ describe('redis kill/reconnect end-to-end (both handlers)', () => {
|
|
|
50
50
|
|
|
51
51
|
const res = await runNode(script, 180_000);
|
|
52
52
|
|
|
53
|
+
if (res.code !== 0) {
|
|
54
|
+
console.error('Script exited with code', res.code);
|
|
55
|
+
console.error('stdout:', res.stdout);
|
|
56
|
+
console.error('stderr:', res.stderr);
|
|
57
|
+
}
|
|
58
|
+
|
|
53
59
|
expect(res.code).toBe(0);
|
|
54
60
|
expect(res.stdout).toContain('OK');
|
|
55
61
|
expect(res.stderr).not.toContain('Socket already opened');
|
|
@@ -92,8 +92,9 @@ async function main() {
|
|
|
92
92
|
if (r.code !== 0) throw new Error(`podman run failed: ${r.stderr}`);
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
//
|
|
96
|
-
//
|
|
95
|
+
// Set env BEFORE importing in case any module-level code reads REDIS_URL.
|
|
96
|
+
// (With lazy init the singleton is no longer created at import time, but
|
|
97
|
+
// setting env first is still good practice.)
|
|
97
98
|
process.env.REDIS_URL = `redis://127.0.0.1:${port}`;
|
|
98
99
|
process.env.VERCEL_URL = `e2e-${name}-`;
|
|
99
100
|
|
|
@@ -112,9 +113,6 @@ async function main() {
|
|
|
112
113
|
// allow redis client to reconnect; this scenario is about stability under restart
|
|
113
114
|
reconnectStrategy: (retries) => Math.min(50 + retries * 50, 500),
|
|
114
115
|
},
|
|
115
|
-
clientOptions: {
|
|
116
|
-
disableOfflineQueue: true,
|
|
117
|
-
} as any,
|
|
118
116
|
});
|
|
119
117
|
|
|
120
118
|
const cacheComponentsClient = (cacheComponentsHandler as any).client as {
|
|
@@ -4,7 +4,7 @@ import type { CreateRedisStringsHandlerOptions } from '../../../src/index';
|
|
|
4
4
|
import RedisStringsHandler from '../../../src/RedisStringsHandler';
|
|
5
5
|
|
|
6
6
|
vi.mock('redis', () => {
|
|
7
|
-
const createClient = () => {
|
|
7
|
+
const createClient = vi.fn(() => {
|
|
8
8
|
return {
|
|
9
9
|
isReady: true,
|
|
10
10
|
on: vi.fn(),
|
|
@@ -31,7 +31,7 @@ vi.mock('redis', () => {
|
|
|
31
31
|
unlink: vi.fn(async () => 1),
|
|
32
32
|
set: vi.fn(async () => 'OK'),
|
|
33
33
|
};
|
|
34
|
-
};
|
|
34
|
+
});
|
|
35
35
|
return {
|
|
36
36
|
createClient,
|
|
37
37
|
commandOptions: vi.fn((opts) => opts),
|
|
@@ -85,6 +85,34 @@ describe('RedisStringsHandler', () => {
|
|
|
85
85
|
expect(res).toBeNull();
|
|
86
86
|
expect(consoleErrorSpy).not.toHaveBeenCalled();
|
|
87
87
|
});
|
|
88
|
+
|
|
89
|
+
it('does not leave the readiness timeout timer pending after an operation', async () => {
|
|
90
|
+
vi.useFakeTimers();
|
|
91
|
+
try {
|
|
92
|
+
const handler = new RedisStringsHandler({
|
|
93
|
+
redisUrl: 'redis://localhost:6379',
|
|
94
|
+
keyPrefix: 'test:',
|
|
95
|
+
database: 0,
|
|
96
|
+
getTimeoutMs: 1,
|
|
97
|
+
redisGetDeduplication: false,
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
const timersBefore = vi.getTimerCount();
|
|
101
|
+
await handler.get('missing-key', {
|
|
102
|
+
kind: 'APP_PAGE',
|
|
103
|
+
isRoutePPREnabled: false,
|
|
104
|
+
isFallback: false,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// assertClientIsReady() races waitUntilReady() against a 30s timeout;
|
|
108
|
+
// the timer must not stay pending once the race is settled, because
|
|
109
|
+
// pending timers retain the ambient async context (and with it, in a
|
|
110
|
+
// Next.js server, the whole per-request object graph).
|
|
111
|
+
expect(vi.getTimerCount()).toBe(timersBefore);
|
|
112
|
+
} finally {
|
|
113
|
+
vi.useRealTimers();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
88
116
|
});
|
|
89
117
|
|
|
90
118
|
describe('Public exports', () => {
|
|
@@ -96,3 +124,50 @@ describe('Public exports', () => {
|
|
|
96
124
|
expect(_typeCheck.keyPrefix).toBe('test');
|
|
97
125
|
});
|
|
98
126
|
});
|
|
127
|
+
|
|
128
|
+
describe('redisCacheHandler lazy proxy', () => {
|
|
129
|
+
it('does not construct a Redis connection on import', async () => {
|
|
130
|
+
vi.resetModules();
|
|
131
|
+
const { createClient } = await import('redis');
|
|
132
|
+
vi.mocked(createClient).mockClear();
|
|
133
|
+
|
|
134
|
+
// Importing the module should NOT trigger a Redis connection
|
|
135
|
+
await import('../../../src');
|
|
136
|
+
|
|
137
|
+
expect(vi.mocked(createClient)).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('has trap returns true for known methods without constructing the handler', async () => {
|
|
141
|
+
vi.resetModules();
|
|
142
|
+
const { createClient } = await import('redis');
|
|
143
|
+
vi.mocked(createClient).mockClear();
|
|
144
|
+
|
|
145
|
+
const { redisCacheHandler } = await import('../../../src');
|
|
146
|
+
|
|
147
|
+
expect('getExpiration' in redisCacheHandler).toBe(true);
|
|
148
|
+
expect('get' in redisCacheHandler).toBe(true);
|
|
149
|
+
expect('set' in redisCacheHandler).toBe(true);
|
|
150
|
+
expect('refreshTags' in redisCacheHandler).toBe(true);
|
|
151
|
+
expect('updateTags' in redisCacheHandler).toBe(true);
|
|
152
|
+
|
|
153
|
+
expect(vi.mocked(createClient)).not.toHaveBeenCalled();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('method call delegates to the singleton and binds correctly', async () => {
|
|
157
|
+
vi.resetModules();
|
|
158
|
+
const { createClient } = await import('redis');
|
|
159
|
+
vi.mocked(createClient).mockClear();
|
|
160
|
+
|
|
161
|
+
const { redisCacheHandler } = await import('../../../src');
|
|
162
|
+
|
|
163
|
+
// Accessing a method triggers construction and delegates to the real handler
|
|
164
|
+
const getFn = redisCacheHandler.get;
|
|
165
|
+
expect(typeof getFn).toBe('function');
|
|
166
|
+
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1);
|
|
167
|
+
|
|
168
|
+
// Subsequent accesses reuse the same singleton (no extra construction)
|
|
169
|
+
const getFn2 = redisCacheHandler.get;
|
|
170
|
+
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(typeof getFn2).toBe('function');
|
|
172
|
+
});
|
|
173
|
+
});
|
|
@@ -1,5 +1,58 @@
|
|
|
1
1
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
2
|
|
|
3
|
+
vi.mock('redis', () => ({
|
|
4
|
+
createClient: vi.fn(() => {
|
|
5
|
+
const listeners = new Map<string, Function[]>();
|
|
6
|
+
const client = {
|
|
7
|
+
isOpen: false,
|
|
8
|
+
isReady: false,
|
|
9
|
+
on: vi.fn((event: string, cb: Function) => {
|
|
10
|
+
if (!listeners.has(event)) listeners.set(event, []);
|
|
11
|
+
listeners.get(event)!.push(cb);
|
|
12
|
+
}),
|
|
13
|
+
emit: vi.fn((event: string, ...args: unknown[]) => {
|
|
14
|
+
(listeners.get(event) ?? []).forEach((cb) => cb(...args));
|
|
15
|
+
}),
|
|
16
|
+
connect: vi.fn(async () => undefined),
|
|
17
|
+
disconnect: vi.fn(),
|
|
18
|
+
quit: vi.fn(async () => undefined),
|
|
19
|
+
duplicate: vi.fn(() => ({
|
|
20
|
+
connect: vi.fn(async () => undefined),
|
|
21
|
+
subscribe: vi.fn(async () => undefined),
|
|
22
|
+
on: vi.fn(),
|
|
23
|
+
quit: vi.fn(async () => undefined),
|
|
24
|
+
configGet: vi.fn(async () => ({ 'notify-keyspace-events': 'Exe' })),
|
|
25
|
+
})),
|
|
26
|
+
get: vi.fn(async () => null),
|
|
27
|
+
hScan: vi.fn(async () => ({ cursor: 0, tuples: [] })),
|
|
28
|
+
scan: vi.fn(async () => ({ cursor: 0, keys: [] })),
|
|
29
|
+
hSet: vi.fn(async () => 1),
|
|
30
|
+
hDel: vi.fn(async () => 1),
|
|
31
|
+
publish: vi.fn(async () => 1),
|
|
32
|
+
unlink: vi.fn(async () => 1),
|
|
33
|
+
set: vi.fn(async () => 'OK'),
|
|
34
|
+
};
|
|
35
|
+
return client;
|
|
36
|
+
}),
|
|
37
|
+
commandOptions: vi.fn((opts) => opts),
|
|
38
|
+
}));
|
|
39
|
+
|
|
40
|
+
vi.mock('../../../src/SyncedMap', () => {
|
|
41
|
+
class SyncedMap {
|
|
42
|
+
waitUntilReady = vi.fn(async () => undefined);
|
|
43
|
+
get = vi.fn(() => undefined);
|
|
44
|
+
set = vi.fn(async () => undefined);
|
|
45
|
+
delete = vi.fn(async () => undefined);
|
|
46
|
+
entries = vi.fn(function* () {
|
|
47
|
+
return;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
constructor() {}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return { SyncedMap };
|
|
54
|
+
});
|
|
55
|
+
|
|
3
56
|
/**
|
|
4
57
|
* Deterministic regression test for:
|
|
5
58
|
* "Failed to reconnect RedisCacheComponentsHandler client after connection loss: Error: Socket already opened"
|
|
@@ -34,6 +87,9 @@ describe('RedisCacheComponentsHandler reconnect logic', () => {
|
|
|
34
87
|
|
|
35
88
|
const client = (handler as any).client;
|
|
36
89
|
|
|
90
|
+
// Clear the initial connect() call from the constructor.
|
|
91
|
+
vi.mocked(client.connect).mockClear();
|
|
92
|
+
|
|
37
93
|
// Simulate a connection-loss situation where the socket is still open.
|
|
38
94
|
Object.defineProperty(client, 'isOpen', { value: true });
|
|
39
95
|
Object.defineProperty(client, 'isReady', { value: false });
|