Skip to content

Commit 6f961bd

Browse files
authored
fix(client): cache subsequent clients (#2963)
* fix(client): cache subsequent clients we dont need to recreate a client if its config hasnt changed fixes #2954 * handle circular structures * make cache generic
1 parent ebd0303 commit 6f961bd

File tree

5 files changed

+178
-31
lines changed

5 files changed

+178
-31
lines changed

packages/client/lib/client/index.ts

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { RedisLegacyClient, RedisLegacyClientType } from './legacy-mode';
1717
import { RedisPoolOptions, RedisClientPool } from './pool';
1818
import { RedisVariadicArgument, parseArgs, pushVariadicArguments } from '../commands/generic-transformers';
1919
import { BasicCommandParser, CommandParser } from './parser';
20+
import SingleEntryCache from '../single-entry-cache';
2021

2122
export interface RedisClientOptions<
2223
M extends RedisModules = RedisModules,
@@ -206,23 +207,32 @@ export default class RedisClient<
206207
}
207208
}
208209

210+
static #SingleEntryCache = new SingleEntryCache<any, any>()
211+
209212
static factory<
210213
M extends RedisModules = {},
211214
F extends RedisFunctions = {},
212215
S extends RedisScripts = {},
213216
RESP extends RespVersions = 2
214217
>(config?: CommanderConfig<M, F, S, RESP>) {
215-
const Client = attachConfig({
216-
BaseClass: RedisClient,
217-
commands: COMMANDS,
218-
createCommand: RedisClient.#createCommand,
219-
createModuleCommand: RedisClient.#createModuleCommand,
220-
createFunctionCommand: RedisClient.#createFunctionCommand,
221-
createScriptCommand: RedisClient.#createScriptCommand,
222-
config
223-
});
224218

225-
Client.prototype.Multi = RedisClientMultiCommand.extend(config);
219+
220+
let Client = RedisClient.#SingleEntryCache.get(config);
221+
if (!Client) {
222+
Client = attachConfig({
223+
BaseClass: RedisClient,
224+
commands: COMMANDS,
225+
createCommand: RedisClient.#createCommand,
226+
createModuleCommand: RedisClient.#createModuleCommand,
227+
createFunctionCommand: RedisClient.#createFunctionCommand,
228+
createScriptCommand: RedisClient.#createScriptCommand,
229+
config
230+
});
231+
232+
Client.prototype.Multi = RedisClientMultiCommand.extend(config);
233+
234+
RedisClient.#SingleEntryCache.set(config, Client);
235+
}
226236

227237
return <TYPE_MAPPING extends TypeMapping = {}>(
228238
options?: Omit<RedisClientOptions<M, F, S, RESP, TYPE_MAPPING>, keyof Exclude<typeof config, undefined>>

packages/client/lib/client/pool.ts

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumen
88
import { CommandOptions } from './commands-queue';
99
import RedisClientMultiCommand, { RedisClientMultiCommandType } from './multi-command';
1010
import { BasicCommandParser } from './parser';
11+
import SingleEntryCache from '../single-entry-cache';
1112

1213
export interface RedisPoolOptions {
1314
/**
@@ -110,6 +111,8 @@ export class RedisClientPool<
110111
};
111112
}
112113

114+
static #SingleEntryCache = new SingleEntryCache<any, any>();
115+
113116
static create<
114117
M extends RedisModules,
115118
F extends RedisFunctions,
@@ -120,17 +123,21 @@ export class RedisClientPool<
120123
clientOptions?: RedisClientOptions<M, F, S, RESP, TYPE_MAPPING>,
121124
options?: Partial<RedisPoolOptions>
122125
) {
123-
const Pool = attachConfig({
124-
BaseClass: RedisClientPool,
125-
commands: COMMANDS,
126-
createCommand: RedisClientPool.#createCommand,
127-
createModuleCommand: RedisClientPool.#createModuleCommand,
128-
createFunctionCommand: RedisClientPool.#createFunctionCommand,
129-
createScriptCommand: RedisClientPool.#createScriptCommand,
130-
config: clientOptions
131-
});
132126

133-
Pool.prototype.Multi = RedisClientMultiCommand.extend(clientOptions);
127+
let Pool = RedisClientPool.#SingleEntryCache.get(clientOptions);
128+
if(!Pool) {
129+
Pool = attachConfig({
130+
BaseClass: RedisClientPool,
131+
commands: COMMANDS,
132+
createCommand: RedisClientPool.#createCommand,
133+
createModuleCommand: RedisClientPool.#createModuleCommand,
134+
createFunctionCommand: RedisClientPool.#createFunctionCommand,
135+
createScriptCommand: RedisClientPool.#createScriptCommand,
136+
config: clientOptions
137+
});
138+
Pool.prototype.Multi = RedisClientMultiCommand.extend(clientOptions);
139+
RedisClientPool.#SingleEntryCache.set(clientOptions, Pool);
140+
}
134141

135142
// returning a "proxy" to prevent the namespaces._self to leak between "proxies"
136143
return Object.create(

packages/client/lib/cluster/index.ts

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { RedisTcpSocketOptions } from '../client/socket';
1212
import ASKING from '../commands/ASKING';
1313
import { BasicCommandParser } from '../client/parser';
1414
import { parseArgs } from '../commands/generic-transformers';
15+
import SingleEntryCache from '../single-entry-cache';
1516

1617
interface ClusterCommander<
1718
M extends RedisModules,
@@ -213,6 +214,8 @@ export default class RedisCluster<
213214
};
214215
}
215216

217+
static #SingleEntryCache = new SingleEntryCache<any, any>();
218+
216219
static factory<
217220
M extends RedisModules = {},
218221
F extends RedisFunctions = {},
@@ -221,17 +224,22 @@ export default class RedisCluster<
221224
TYPE_MAPPING extends TypeMapping = {},
222225
// POLICIES extends CommandPolicies = {}
223226
>(config?: ClusterCommander<M, F, S, RESP, TYPE_MAPPING/*, POLICIES*/>) {
224-
const Cluster = attachConfig({
225-
BaseClass: RedisCluster,
226-
commands: COMMANDS,
227-
createCommand: RedisCluster.#createCommand,
228-
createModuleCommand: RedisCluster.#createModuleCommand,
229-
createFunctionCommand: RedisCluster.#createFunctionCommand,
230-
createScriptCommand: RedisCluster.#createScriptCommand,
231-
config
232-
});
233-
234-
Cluster.prototype.Multi = RedisClusterMultiCommand.extend(config);
227+
228+
let Cluster = RedisCluster.#SingleEntryCache.get(config);
229+
if (!Cluster) {
230+
Cluster = attachConfig({
231+
BaseClass: RedisCluster,
232+
commands: COMMANDS,
233+
createCommand: RedisCluster.#createCommand,
234+
createModuleCommand: RedisCluster.#createModuleCommand,
235+
createFunctionCommand: RedisCluster.#createFunctionCommand,
236+
createScriptCommand: RedisCluster.#createScriptCommand,
237+
config
238+
});
239+
240+
Cluster.prototype.Multi = RedisClusterMultiCommand.extend(config);
241+
RedisCluster.#SingleEntryCache.set(config, Cluster);
242+
}
235243

236244
return (options?: Omit<RedisClusterOptions, keyof Exclude<typeof config, undefined>>) => {
237245
// returning a "proxy" to prevent the namespaces._self to leak between "proxies"
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import assert from 'node:assert';
2+
import SingleEntryCache from './single-entry-cache';
3+
4+
describe('SingleEntryCache', () => {
5+
let cache: SingleEntryCache;
6+
beforeEach(() => {
7+
cache = new SingleEntryCache();
8+
});
9+
10+
it('should return undefined when getting from empty cache', () => {
11+
assert.strictEqual(cache.get({ key: 'value' }), undefined);
12+
});
13+
14+
it('should return the cached instance when getting with the same key object', () => {
15+
const keyObj = { key: 'value' };
16+
const instance = { data: 'test data' };
17+
18+
cache.set(keyObj, instance);
19+
assert.strictEqual(cache.get(keyObj), instance);
20+
});
21+
22+
it('should return undefined when getting with a different key object', () => {
23+
const keyObj1 = { key: 'value1' };
24+
const keyObj2 = { key: 'value2' };
25+
const instance = { data: 'test data' };
26+
27+
cache.set(keyObj1, instance);
28+
assert.strictEqual(cache.get(keyObj2), undefined);
29+
});
30+
31+
it('should update the cached instance when setting with the same key object', () => {
32+
const keyObj = { key: 'value' };
33+
const instance1 = { data: 'test data 1' };
34+
const instance2 = { data: 'test data 2' };
35+
36+
cache.set(keyObj, instance1);
37+
assert.strictEqual(cache.get(keyObj), instance1);
38+
39+
cache.set(keyObj, instance2);
40+
assert.strictEqual(cache.get(keyObj), instance2);
41+
});
42+
43+
it('should handle undefined key object', () => {
44+
const instance = { data: 'test data' };
45+
46+
cache.set(undefined, instance);
47+
assert.strictEqual(cache.get(undefined), instance);
48+
});
49+
50+
it('should handle complex objects as keys', () => {
51+
const keyObj = {
52+
id: 123,
53+
nested: {
54+
prop: 'value',
55+
array: [1, 2, 3]
56+
}
57+
};
58+
const instance = { data: 'complex test data' };
59+
60+
cache.set(keyObj, instance);
61+
assert.strictEqual(cache.get(keyObj), instance);
62+
});
63+
64+
it('should consider objects with same properties but different order as different keys', () => {
65+
const keyObj1 = { a: 1, b: 2 };
66+
const keyObj2 = { b: 2, a: 1 }; // Same properties but different order
67+
const instance = { data: 'test data' };
68+
69+
cache.set(keyObj1, instance);
70+
71+
assert.strictEqual(cache.get(keyObj2), undefined);
72+
});
73+
74+
it('should handle circular structures', () => {
75+
const keyObj: any = {};
76+
keyObj.self = keyObj;
77+
78+
const instance = { data: 'test data' };
79+
80+
cache.set(keyObj, instance);
81+
82+
assert.strictEqual(cache.get(keyObj), instance);
83+
});
84+
85+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export default class SingleEntryCache<K, V> {
2+
#cached?: V;
3+
#serializedKey?: string;
4+
5+
/**
6+
* Retrieves an instance from the cache based on the provided key object.
7+
*
8+
* @param keyObj - The key object to look up in the cache.
9+
* @returns The cached instance if found, undefined otherwise.
10+
*
11+
* @remarks
12+
* This method uses JSON.stringify for comparison, which may not work correctly
13+
* if the properties in the key object are rearranged or reordered.
14+
*/
15+
get(keyObj?: K): V | undefined {
16+
return JSON.stringify(keyObj, makeCircularReplacer()) === this.#serializedKey ? this.#cached : undefined;
17+
}
18+
19+
set(keyObj: K | undefined, obj: V) {
20+
this.#cached = obj;
21+
this.#serializedKey = JSON.stringify(keyObj, makeCircularReplacer());
22+
}
23+
}
24+
25+
function makeCircularReplacer() {
26+
const seen = new WeakSet();
27+
return function serialize(_: string, value: any) {
28+
if (value && typeof value === 'object') {
29+
if (seen.has(value)) {
30+
return 'circular';
31+
}
32+
seen.add(value);
33+
return value;
34+
}
35+
return value;
36+
}
37+
}

0 commit comments

Comments
 (0)