@trieb.work/nextjs-turbo-redis-cache 1.16.0 → 1.16.1
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 +7 -0
- package/README.md +8 -0
- package/dist/index.js +16 -1
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +16 -1
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/CacheComponentsHandler.ts +35 -2
- 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 +49 -2
- package/test/vitest/unit/reconnect-socket-already-opened.test.ts +56 -0
|
@@ -530,5 +530,38 @@ export function getRedisCacheComponentsHandler(
|
|
|
530
530
|
return singletonHandler;
|
|
531
531
|
}
|
|
532
532
|
|
|
533
|
-
|
|
534
|
-
|
|
533
|
+
// Lazily resolve the default Cache Components handler.
|
|
534
|
+
//
|
|
535
|
+
// Constructing a RedisCacheComponentsHandler opens a Redis connection in its
|
|
536
|
+
// constructor. Building the singleton at module-eval time therefore means that simply
|
|
537
|
+
// *importing this package* — e.g. only for `RedisStringsHandler` (the legacy
|
|
538
|
+
// `cacheHandler`), with Cache Components never enabled — eagerly connects to Redis,
|
|
539
|
+
// defaulting to `redis://localhost:6379` when neither `REDIS_URL` nor `REDISHOST` is
|
|
540
|
+
// set. In a deployment whose Redis is not on localhost that yields a non-stop
|
|
541
|
+
// `RedisCacheComponentsHandler client error ECONNREFUSED 127.0.0.1:6379` reconnect
|
|
542
|
+
// loop, and it also makes a consumer's later `getRedisCacheComponentsHandler(options)`
|
|
543
|
+
// a no-op, because the singleton was already built with defaults (see #84).
|
|
544
|
+
//
|
|
545
|
+
// Defer construction to first use via a Proxy: importing the package never connects, a
|
|
546
|
+
// consumer that configures the handler via `getRedisCacheComponentsHandler(options)`
|
|
547
|
+
// before it is first used has that configuration honored, and a consumer that never
|
|
548
|
+
// touches Cache Components never opens a Redis connection at all.
|
|
549
|
+
let resolvedHandler: CacheComponentsHandler | undefined;
|
|
550
|
+
|
|
551
|
+
export const redisCacheHandler: CacheComponentsHandler = new Proxy(
|
|
552
|
+
{} as CacheComponentsHandler,
|
|
553
|
+
{
|
|
554
|
+
get(_target, prop) {
|
|
555
|
+
if (!resolvedHandler) {
|
|
556
|
+
resolvedHandler = getRedisCacheComponentsHandler();
|
|
557
|
+
}
|
|
558
|
+
const value = resolvedHandler[prop as keyof CacheComponentsHandler];
|
|
559
|
+
return typeof value === 'function'
|
|
560
|
+
? (value as (...args: unknown[]) => unknown).bind(resolvedHandler)
|
|
561
|
+
: value;
|
|
562
|
+
},
|
|
563
|
+
has(_target, prop) {
|
|
564
|
+
return prop in RedisCacheComponentsHandler.prototype;
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
);
|
|
@@ -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),
|
|
@@ -96,3 +96,50 @@ describe('Public exports', () => {
|
|
|
96
96
|
expect(_typeCheck.keyPrefix).toBe('test');
|
|
97
97
|
});
|
|
98
98
|
});
|
|
99
|
+
|
|
100
|
+
describe('redisCacheHandler lazy proxy', () => {
|
|
101
|
+
it('does not construct a Redis connection on import', async () => {
|
|
102
|
+
vi.resetModules();
|
|
103
|
+
const { createClient } = await import('redis');
|
|
104
|
+
vi.mocked(createClient).mockClear();
|
|
105
|
+
|
|
106
|
+
// Importing the module should NOT trigger a Redis connection
|
|
107
|
+
await import('../../../src');
|
|
108
|
+
|
|
109
|
+
expect(vi.mocked(createClient)).not.toHaveBeenCalled();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('has trap returns true for known methods without constructing the handler', async () => {
|
|
113
|
+
vi.resetModules();
|
|
114
|
+
const { createClient } = await import('redis');
|
|
115
|
+
vi.mocked(createClient).mockClear();
|
|
116
|
+
|
|
117
|
+
const { redisCacheHandler } = await import('../../../src');
|
|
118
|
+
|
|
119
|
+
expect('getExpiration' in redisCacheHandler).toBe(true);
|
|
120
|
+
expect('get' in redisCacheHandler).toBe(true);
|
|
121
|
+
expect('set' in redisCacheHandler).toBe(true);
|
|
122
|
+
expect('refreshTags' in redisCacheHandler).toBe(true);
|
|
123
|
+
expect('updateTags' in redisCacheHandler).toBe(true);
|
|
124
|
+
|
|
125
|
+
expect(vi.mocked(createClient)).not.toHaveBeenCalled();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('method call delegates to the singleton and binds correctly', async () => {
|
|
129
|
+
vi.resetModules();
|
|
130
|
+
const { createClient } = await import('redis');
|
|
131
|
+
vi.mocked(createClient).mockClear();
|
|
132
|
+
|
|
133
|
+
const { redisCacheHandler } = await import('../../../src');
|
|
134
|
+
|
|
135
|
+
// Accessing a method triggers construction and delegates to the real handler
|
|
136
|
+
const getFn = redisCacheHandler.get;
|
|
137
|
+
expect(typeof getFn).toBe('function');
|
|
138
|
+
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1);
|
|
139
|
+
|
|
140
|
+
// Subsequent accesses reuse the same singleton (no extra construction)
|
|
141
|
+
const getFn2 = redisCacheHandler.get;
|
|
142
|
+
expect(vi.mocked(createClient)).toHaveBeenCalledTimes(1);
|
|
143
|
+
expect(typeof getFn2).toBe('function');
|
|
144
|
+
});
|
|
145
|
+
});
|
|
@@ -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 });
|