Skip to content

Commit 70171d7

Browse files
Add support for redis v5
1 parent 1aebe64 commit 70171d7

15 files changed

Lines changed: 123 additions & 89 deletions

CHANGES.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
1+
2.12.0 (February 20, 2026)
2+
- Add support for ioredis v5
3+
14
2.11.0 (January 28, 2026)
25
- Added functionality to provide metadata alongside SDK update and READY events. Read more in our docs.
36

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@splitsoftware/splitio-commons",
3-
"version": "2.11.0",
3+
"version": "2.12.0",
44
"description": "Split JavaScript SDK common components",
55
"main": "cjs/index.js",
66
"module": "esm/index.js",

src/storages/inRedis/EventsCacheInRedis.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class EventsCacheInRedis implements IEventsCacheAsync {
3131
)
3232
// We use boolean values to signal successful queueing
3333
.then(() => true)
34-
.catch(err => {
34+
.catch((err: unknown) => {
3535
this.log.error(`${LOG_PREFIX}Error adding event to queue: ${err}.`);
3636
return false;
3737
});
@@ -65,9 +65,9 @@ export class EventsCacheInRedis implements IEventsCacheAsync {
6565
* It is the submitter responsability to handle that.
6666
*/
6767
popNWithMetadata(count: number): Promise<StoredEventWithMetadata[]> {
68-
return this.redis.lrange(this.key, 0, count - 1).then(items => {
68+
return this.redis.lrange(this.key, 0, count - 1).then((items: string[]) => {
6969
return this.redis.ltrim(this.key, items.length, -1).then(() => {
70-
return items.map(item => JSON.parse(item) as StoredEventWithMetadata);
70+
return items.map((item: string) => JSON.parse(item) as StoredEventWithMetadata);
7171
});
7272
});
7373
}

src/storages/inRedis/ImpressionCountsCacheInRedis.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,13 +32,13 @@ export class ImpressionCountsCacheInRedis extends ImpressionCountsCacheInMemory
3232
pipeline.hincrby(this.key, key, counts[key]);
3333
});
3434
return pipeline.exec()
35-
.then(data => {
35+
.then((data: [Error | null, unknown][] | null) => {
3636
// If this is the creation of the key on Redis, set the expiration for it in 3600 seconds.
3737
if (data && data.length && data.length === keys.length) {
3838
return this.redis.expire(this.key, TTL_REFRESH);
3939
}
4040
})
41-
.catch(err => {
41+
.catch((err: unknown) => {
4242
this.log.error(`${LOG_PREFIX}Error in impression counts pipeline: ${err}.`);
4343
return false;
4444
});
@@ -56,14 +56,14 @@ export class ImpressionCountsCacheInRedis extends ImpressionCountsCacheInMemory
5656
// Async consumer API, used by synchronizer
5757
getImpressionsCount(): Promise<ImpressionCountsPayload | undefined> {
5858
return this.redis.hgetall(this.key)
59-
.then(counts => {
59+
.then((counts: Record<string, string>) => {
6060
if (!Object.keys(counts).length) return undefined;
6161

6262
this.redis.del(this.key).catch(() => { /* no-op */ });
6363

6464
const pf: ImpressionCountsPayload['pf'] = [];
6565

66-
forOwn(counts, (count, key) => {
66+
forOwn(counts, (count: string, key) => {
6767
const nameAndTime = key.split('::');
6868
if (nameAndTime.length !== 2) {
6969
this.log.error(`${LOG_PREFIX}Error spliting key ${key}`);

src/storages/inRedis/ImpressionsCacheInRedis.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export class ImpressionsCacheInRedis implements IImpressionsCacheAsync {
2626
return this.redis.rpush(
2727
this.key,
2828
...impressionsToJSON(impressions, this.metadata),
29-
).then(queuedCount => {
29+
).then((queuedCount: number) => {
3030
// If this is the creation of the key on Redis, set the expiration for it in 1hr.
3131
if (queuedCount === impressions.length) {
3232
return this.redis.expire(this.key, IMPRESSIONS_TTL_REFRESH);
@@ -45,15 +45,15 @@ export class ImpressionsCacheInRedis implements IImpressionsCacheAsync {
4545
}
4646

4747
popNWithMetadata(count: number): Promise<StoredImpressionWithMetadata[]> {
48-
return this.redis.lrange(this.key, 0, count - 1).then(items => {
48+
return this.redis.lrange(this.key, 0, count - 1).then((items: string[]) => {
4949
return this.redis.ltrim(this.key, items.length, -1).then(() => {
5050
// This operation will simply do nothing if the key no longer exists (queue is empty)
5151
// It's only done in the "successful" exit path so that the TTL is not overriden if impressons weren't
5252
// popped correctly. This will result in impressions getting lost but will prevent the queue from taking
5353
// a huge amount of memory.
5454
this.redis.expire(this.key, IMPRESSIONS_TTL_REFRESH).catch(() => { }); // noop catch handler
5555

56-
return items.map(item => JSON.parse(item) as StoredImpressionWithMetadata);
56+
return items.map((item: string) => JSON.parse(item) as StoredImpressionWithMetadata);
5757
});
5858
});
5959
}

src/storages/inRedis/RBSegmentsCacheInRedis.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,12 @@ export class RBSegmentsCacheInRedis implements IRBSegmentsCacheAsync {
2121

2222
get(name: string): Promise<IRBSegment | null> {
2323
return this.redis.get(this.keys.buildRBSegmentKey(name))
24-
.then(maybeRBSegment => maybeRBSegment && JSON.parse(maybeRBSegment));
24+
.then((maybeRBSegment: string | null) => maybeRBSegment && JSON.parse(maybeRBSegment));
2525
}
2626

2727
private getNames(): Promise<string[]> {
2828
return this.redis.keys(this.keys.searchPatternForRBSegmentKeys()).then(
29-
(listOfKeys) => listOfKeys.map(this.keys.extractKey)
29+
(listOfKeys: string[]) => listOfKeys.map(this.keys.extractKey)
3030
);
3131
}
3232

@@ -47,7 +47,7 @@ export class RBSegmentsCacheInRedis implements IRBSegmentsCacheAsync {
4747
})),
4848
Promise.all(toRemove.map(toRemove => {
4949
const key = this.keys.buildRBSegmentKey(toRemove.name);
50-
return this.redis.del(key).then(status => status === 1);
50+
return this.redis.del(key).then((status: number) => status === 1);
5151
}))
5252
]).then(([, added, removed]) => {
5353
return added.some(result => result) || removed.some(result => result);
@@ -56,7 +56,7 @@ export class RBSegmentsCacheInRedis implements IRBSegmentsCacheAsync {
5656

5757
setChangeNumber(changeNumber: number) {
5858
return this.redis.set(this.keys.buildRBSegmentsTillKey(), changeNumber + '').then(
59-
status => status === 'OK'
59+
(status: string | null) => status === 'OK'
6060
);
6161
}
6262

@@ -65,7 +65,7 @@ export class RBSegmentsCacheInRedis implements IRBSegmentsCacheAsync {
6565
const i = parseInt(value as string, 10);
6666

6767
return isNaNNumber(i) ? -1 : i;
68-
}).catch((e) => {
68+
}).catch((e: unknown) => {
6969
this.log.error(LOG_PREFIX + 'Could not retrieve changeNumber from storage. Error: ' + e);
7070
return -1;
7171
});

src/storages/inRedis/RedisAdapter.ts

Lines changed: 68 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,14 @@
1-
import ioredis, { Pipeline } from 'ioredis';
1+
// Dynamically require ioredis to prevent strict TypeScript binding
2+
// and handle module export differences between v4 and v5.
3+
let RedisConstructor: any;
4+
try {
5+
const ioredisLib = require('ioredis');
6+
RedisConstructor = ioredisLib.default || ioredisLib;
7+
} catch (e) {
8+
// If we reach here, the peer dependency is missing
9+
throw new Error('ioredis is missing. Please install ioredis v4 or v5.');
10+
}
11+
212
import { ILogger } from '../../logger/types';
313
import { merge, isString } from '../../utils/lang';
414
import { thenable } from '../../utils/promise/thenable';
@@ -20,42 +30,70 @@ const DEFAULT_OPTIONS = {
2030
const DEFAULT_LIBRARY_OPTIONS = {
2131
enableOfflineQueue: false,
2232
connectTimeout: DEFAULT_OPTIONS.connectionTimeout,
23-
lazyConnect: false
33+
lazyConnect: false,
34+
// CRITICAL: v5 defaults this to 0 (disabled), which breaks dynamic clusters.
35+
// v4 defaulted to 5000. We explicitly set it here to ensure v5 works like v4.
36+
slotsRefreshInterval: 5000,
2437
};
2538

2639
interface IRedisCommand {
27-
resolve: () => void,
40+
resolve: (value?: any) => void,
2841
reject: (err?: any) => void,
29-
command: () => Promise<void>,
30-
name: string
42+
command: () => Promise<any>,
43+
name: string,
3144
}
3245

3346
/**
3447
* Redis adapter on top of the library of choice (written with ioredis) for some extra control.
48+
* Refactored to use Composition and Proxy instead of Inheritance to support both v4 and v5.
3549
*/
36-
export class RedisAdapter extends ioredis {
50+
export class RedisAdapter {
51+
// eslint-disable-next-line no-undef -- Index signature to allow proxying dynamic ioredis methods without TS errors
52+
[key: string]: any;
3753
private readonly log: ILogger;
38-
private _options: object;
54+
private _options: Record<string, any>;
3955
private _notReadyCommandsQueue?: IRedisCommand[];
4056
private _runningCommands: Set<Promise<any>>;
4157

58+
// The actual ioredis instance
59+
public client: any;
60+
4261
constructor(log: ILogger, storageSettings: Record<string, any> = {}) {
4362
const options = RedisAdapter._defineOptions(storageSettings);
44-
// Call the ioredis constructor
45-
// @ts-ignore
46-
super(...RedisAdapter._defineLibrarySettings(options));
4763

4864
this.log = log;
4965
this._options = options;
5066
this._notReadyCommandsQueue = [];
5167
this._runningCommands = new Set();
68+
69+
// Instantiate the client using the dynamic constructor
70+
const librarySettings = RedisAdapter._defineLibrarySettings(options);
71+
this.client = new RedisConstructor(...librarySettings);
72+
5273
this._listenToEvents();
5374
this._setTimeoutWrappers();
5475
this._setDisconnectWrapper();
76+
77+
// Return a Proxy. This allows the adapter to act exactly like an extended class.
78+
// If a method/property is accessed that we didn't explicitly wrap, it forwards it to `this.client`.
79+
return new Proxy(this, {
80+
get(target: RedisAdapter, prop: string | symbol) {
81+
// If the property exists on our wrapper (like wrapped 'get', 'set', or internal methods)
82+
if (prop in target) {
83+
return target[prop as keyof RedisAdapter];
84+
}
85+
// If it doesn't exist on our wrapper but exists on the real client (like 'on', 'quit')
86+
if (target.client && prop in target.client) {
87+
const val = target.client[prop];
88+
return typeof val === 'function' ? val.bind(target.client) : val;
89+
}
90+
return undefined;
91+
}
92+
});
5593
}
5694

5795
_listenToEvents() {
58-
this.once('ready', () => {
96+
this.client.once('ready', () => {
5997
const commandsCount = this._notReadyCommandsQueue ? this._notReadyCommandsQueue.length : 0;
6098
this.log.info(LOG_PREFIX + `Redis connection established. Queued commands: ${commandsCount}.`);
6199

@@ -66,24 +104,21 @@ export class RedisAdapter extends ioredis {
66104
// After the SDK is ready for the first time we'll stop queueing commands. This is just so we can keep handling BUR for them.
67105
this._notReadyCommandsQueue = undefined;
68106
});
69-
this.once('close', () => {
107+
this.client.once('close', () => {
70108
this.log.info(LOG_PREFIX + 'Redis connection closed.');
71109
});
72110
}
73111

74112
_setTimeoutWrappers() {
75-
const instance: Record<string, any> = this;
76-
77-
const wrapCommand = (originalMethod: Function, methodName: string) => {
78-
// The value of "this" in this function should be the instance actually executing the method. It might be the instance referred (the base one)
79-
// or it can be the instance of a Pipeline object.
80-
return function (this: RedisAdapter | Pipeline) {
81-
const params = arguments;
82-
const caller = this;
113+
const instance = this;
83114

115+
// We pass `bindTarget` so pipeline execution is bound to the pipeline object,
116+
// while standard commands are bound to the client.
117+
const wrapCommand = (originalMethod: Function, methodName: string, bindTarget: any) => {
118+
return function (...params: any[]) {
84119
function commandWrapper() {
85120
instance.log.debug(`${LOG_PREFIX}Executing ${methodName}.`);
86-
const result = originalMethod.apply(caller, params);
121+
const result = originalMethod.apply(bindTarget, params);
87122

88123
if (thenable(result)) {
89124
// For handling pending commands on disconnect, add to the set and remove once finished.
@@ -107,7 +142,7 @@ export class RedisAdapter extends ioredis {
107142

108143
if (instance._notReadyCommandsQueue) {
109144
return new Promise((resolve, reject) => {
110-
instance._notReadyCommandsQueue.unshift({
145+
instance._notReadyCommandsQueue!.unshift({
111146
resolve,
112147
reject,
113148
command: commandWrapper,
@@ -122,19 +157,19 @@ export class RedisAdapter extends ioredis {
122157

123158
// Wrap regular async methods to track timeouts and queue when Redis is not yet executing commands.
124159
METHODS_TO_PROMISE_WRAP.forEach(methodName => {
125-
const originalFn = instance[methodName];
126-
instance[methodName] = wrapCommand(originalFn, methodName);
160+
const originalFn = this.client[methodName];
161+
this[methodName] = wrapCommand(originalFn, methodName, this.client);
127162
});
128163

129164
// Special handling for pipeline~like methods. We need to wrap the async trigger, which is exec, but return the Pipeline right away.
130165
METHODS_TO_PROMISE_WRAP_EXEC.forEach(methodName => {
131-
const originalFn = instance[methodName];
166+
const originalFn = this.client[methodName];
132167
// "First level wrapper" to handle the sync execution and wrap async, queueing later if applicable.
133-
instance[methodName] = function () {
134-
const res = originalFn.apply(instance, arguments);
168+
this[methodName] = function (...args: any[]) {
169+
const res = originalFn.apply(instance.client, args);
135170
const originalExec = res.exec;
136171

137-
res.exec = wrapCommand(originalExec, methodName + '.exec').bind(res);
172+
res.exec = wrapCommand(originalExec, `${methodName}.exec`, res);
138173

139174
return res;
140175
};
@@ -143,27 +178,26 @@ export class RedisAdapter extends ioredis {
143178

144179
_setDisconnectWrapper() {
145180
const instance = this;
146-
const originalMethod = instance.disconnect;
147-
148-
instance.disconnect = function disconnect(...params: []) {
181+
const originalMethod = this.client.disconnect;
149182

183+
this.disconnect = function disconnect(...params: any[]) {
150184
setTimeout(function deferredDisconnect() {
151185
if (instance._runningCommands.size > 0) {
152186
instance.log.info(LOG_PREFIX + `Attempting to disconnect but there are ${instance._runningCommands.size} commands still waiting for resolution. Defering disconnection until those finish.`);
153187

154188
Promise.all(setToArray(instance._runningCommands))
155189
.then(() => {
156190
instance.log.debug(LOG_PREFIX + 'Pending commands finished successfully, disconnecting.');
157-
originalMethod.apply(instance, params);
191+
originalMethod.apply(instance.client, params);
158192
})
159193
.catch(e => {
160194
instance.log.warn(LOG_PREFIX + `Pending commands finished with error: ${e}. Proceeding with disconnection.`);
161-
originalMethod.apply(instance, params);
195+
originalMethod.apply(instance.client, params);
162196
});
163197
} else {
164198
instance.log.debug(LOG_PREFIX + 'No commands pending execution, disconnect.');
165199
// Nothing pending, just proceed.
166-
originalMethod.apply(instance, params);
200+
originalMethod.apply(instance.client, params);
167201
}
168202
}, 10);
169203
};

src/storages/inRedis/SegmentsCacheInRedis.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,15 +37,15 @@ export class SegmentsCacheInRedis implements ISegmentsCacheAsync {
3737
isInSegment(name: string, key: string) {
3838
return this.redis.sismember(
3939
this.keys.buildSegmentNameKey(name), key
40-
).then(matches => matches !== 0);
40+
).then((matches: number) => matches !== 0);
4141
}
4242

4343
getChangeNumber(name: string) {
4444
return this.redis.get(this.keys.buildSegmentTillKey(name)).then((value: string | null) => {
4545
const i = parseInt(value as string, 10);
4646

4747
return isNaNNumber(i) ? undefined : i;
48-
}).catch((e) => {
48+
}).catch((e: unknown) => {
4949
this.log.error(LOG_PREFIX + 'Could not retrieve changeNumber from segments storage. Error: ' + e);
5050
return undefined;
5151
});

0 commit comments

Comments
 (0)