Skip to content

Commit 98ddbc1

Browse files
committed
Normalize unstable RESP3 replies to RESP2-compatible shapes
1 parent f98116b commit 98ddbc1

File tree

19 files changed

+863
-264
lines changed

19 files changed

+863
-264
lines changed

docs/v5.md

Lines changed: 0 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -40,41 +40,6 @@ This replaces the previous approach of using `commandOptions({ returnBuffers: tr
4040

4141
RESP3 uses a different mechanism for handling Pub/Sub messages. Instead of modifying the `onReply` handler as in RESP2, RESP3 provides a dedicated `onPush` handler. When using RESP3, the client automatically uses this more efficient push notification system.
4242

43-
## Known Limitations
44-
45-
### Unstable Commands
46-
47-
Some Redis commands have unstable RESP3 transformations. These commands will throw an error when used with RESP3 unless you explicitly opt in to using them by setting `unstableResp3: true` in your client configuration:
48-
49-
```javascript
50-
const client = createClient({
51-
RESP: 3,
52-
unstableResp3: true
53-
});
54-
```
55-
56-
The following commands have unstable RESP3 implementations:
57-
58-
1. **Stream Commands**:
59-
- `XREAD` and `XREADGROUP` - The response format differs between RESP2 and RESP3
60-
61-
2. **Search Commands (RediSearch)**:
62-
- `FT.AGGREGATE`
63-
- `FT.AGGREGATE_WITHCURSOR`
64-
- `FT.CURSOR_READ`
65-
- `FT.INFO`
66-
- `FT.PROFILE_AGGREGATE`
67-
- `FT.PROFILE_SEARCH`
68-
- `FT.SEARCH`
69-
- `FT.SEARCH_NOCONTENT`
70-
- `FT.SPELLCHECK`
71-
72-
3. **Time Series Commands**:
73-
- `TS.INFO`
74-
- `TS.INFO_DEBUG`
75-
76-
If you need to use these commands with RESP3, be aware that the response format might change in future versions.
77-
7843
# Sentinel Support
7944

8045
[Sentinel](./sentinel.md)

packages/client/lib/RESP/types.ts

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -326,14 +326,6 @@ export interface CommanderConfig<
326326
* additional data types and features introduced in Redis 6.0.
327327
*/
328328
RESP?: RESP;
329-
/**
330-
* When set to true, enables commands that have unstable RESP3 implementations.
331-
* When using RESP3 protocol, commands marked as having unstable RESP3 support
332-
* will throw an error unless this flag is explicitly set to true.
333-
* This primarily affects modules like Redis Search where response formats
334-
* in RESP3 mode may change in future versions.
335-
*/
336-
unstableResp3?: boolean;
337329
}
338330

339331
type Resp2Array<T> = (

packages/client/lib/commander.ts

Lines changed: 2 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,6 @@ interface AttachConfigOptions<
1515
config?: CommanderConfig<M, F, S, RESP>;
1616
}
1717

18-
/* FIXME: better error message / link */
19-
function throwResp3SearchModuleUnstableError() {
20-
throw new Error('Some RESP3 results for Redis Query Engine responses may change. Refer to the readme for guidance');
21-
}
22-
2318
export function attachConfig<
2419
M extends RedisModules,
2520
F extends RedisFunctions,
@@ -38,22 +33,14 @@ export function attachConfig<
3833
Class: any = class extends BaseClass {};
3934

4035
for (const [name, command] of Object.entries(commands)) {
41-
if (RESP == 3 && command.unstableResp3 && !config?.unstableResp3) {
42-
Class.prototype[name] = throwResp3SearchModuleUnstableError;
43-
} else {
44-
Class.prototype[name] = createCommand(command, RESP);
45-
}
36+
Class.prototype[name] = createCommand(command, RESP);
4637
}
4738

4839
if (config?.modules) {
4940
for (const [moduleName, module] of Object.entries(config.modules)) {
5041
const fns: Record<string, (...args: Array<any>) => any> = {};
5142
for (const [name, command] of Object.entries(module)) {
52-
if (RESP == 3 && command.unstableResp3 && !config?.unstableResp3) {
53-
fns[name] = throwResp3SearchModuleUnstableError;
54-
} else {
55-
fns[name] = createModuleCommand(command, RESP);
56-
}
43+
fns[name] = createModuleCommand(command, RESP);
5744
}
5845

5946
attachNamespace(Class.prototype, moduleName, fns);

packages/client/lib/commands/HOTKEYS_GET.ts

Lines changed: 69 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { CommandParser } from '../client/parser';
2-
import { Command, UnwrapReply, ArrayReply, BlobStringReply, NumberReply } from '../RESP/types';
2+
import { Command } from '../RESP/types';
33

44
/**
55
* Hotkey entry with key name and metric value
@@ -43,30 +43,77 @@ export interface HotkeysGetReply {
4343
byNetBytes?: Array<HotkeyEntry>;
4444
}
4545

46-
type HotkeysGetRawReply = ArrayReply<ArrayReply<BlobStringReply | NumberReply | ArrayReply<BlobStringReply | NumberReply>>>;
46+
function mapLikeEntries(value: any): Array<[string, any]> {
47+
if (value instanceof Map) {
48+
return Array.from(value.entries(), ([key, entryValue]) => [key.toString(), entryValue]);
49+
}
50+
51+
if (Array.isArray(value)) {
52+
if (
53+
value.length === 1 &&
54+
(Array.isArray(value[0]) || value[0] instanceof Map || (typeof value[0] === 'object' && value[0] !== null))
55+
) {
56+
return mapLikeEntries(value[0]);
57+
}
58+
59+
if (value.every(item => Array.isArray(item) && item.length >= 2)) {
60+
return value.map(item => [item[0].toString(), item[1]]);
61+
}
62+
63+
const entries: Array<[string, any]> = [];
64+
for (let i = 0; i < value.length - 1; i += 2) {
65+
entries.push([value[i].toString(), value[i + 1]]);
66+
}
67+
return entries;
68+
}
69+
70+
if (value !== null && typeof value === 'object') {
71+
return Object.entries(value);
72+
}
73+
74+
return [];
75+
}
76+
77+
function mapLikeValues(value: any): Array<any> {
78+
if (Array.isArray(value)) return value;
79+
if (value instanceof Map) return [...value.values()];
80+
if (value !== null && typeof value === 'object') return Object.values(value);
81+
return [];
82+
}
4783

4884
/**
4985
* Parse the hotkeys array into HotkeyEntry objects
5086
*/
51-
function parseHotkeysList(arr: Array<BlobStringReply | NumberReply>): Array<HotkeyEntry> {
52-
const result: Array<HotkeyEntry> = [];
53-
for (let i = 0; i < arr.length; i += 2) {
54-
result.push({
55-
key: arr[i].toString(),
56-
value: Number(arr[i + 1])
57-
});
58-
}
59-
return result;
87+
function parseHotkeysList(arr: unknown): Array<HotkeyEntry> {
88+
return mapLikeEntries(arr).map(([key, value]) => ({
89+
key,
90+
value: Number(value)
91+
}));
6092
}
6193

6294
/**
6395
* Parse slot ranges from the server response.
6496
* Single slots are represented as arrays with one element: [slot]
6597
* Slot ranges are represented as arrays with two elements: [start, end]
6698
*/
67-
function parseSlotRanges(arr: Array<ArrayReply<NumberReply>>): Array<SlotRange> {
68-
return arr.map(range => {
69-
const unwrapped = range as unknown as Array<number>;
99+
function parseSlotRanges(arr: unknown): Array<SlotRange> {
100+
return mapLikeValues(arr).map(range => {
101+
let unwrapped: Array<number>;
102+
103+
if (Array.isArray(range)) {
104+
unwrapped = range as Array<number>;
105+
} else if (range instanceof Map) {
106+
unwrapped = [...range.values()].map(value => Number(value));
107+
} else if (range !== null && typeof range === 'object') {
108+
const objectRange = range as Record<string, unknown>;
109+
const start = Number(objectRange.start ?? objectRange[0]);
110+
const end = Number(objectRange.end ?? objectRange[1] ?? start);
111+
unwrapped = [start, end];
112+
} else {
113+
const slot = Number(range);
114+
unwrapped = [slot, slot];
115+
}
116+
70117
if (unwrapped.length === 1) {
71118
// Single slot - start and end are the same
72119
return {
@@ -85,15 +132,11 @@ function parseSlotRanges(arr: Array<ArrayReply<NumberReply>>): Array<SlotRange>
85132
/**
86133
* Transform the raw reply into a structured object
87134
*/
88-
function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): HotkeysGetReply {
89-
const result: Partial<HotkeysGetReply> = {};
90-
91-
// The reply is wrapped in an extra array, so we need to access reply[0]
92-
const data = reply[0] as unknown as Array<BlobStringReply | NumberReply | ArrayReply<BlobStringReply | NumberReply>>;
135+
function transformHotkeysGetReply(reply: unknown | null): HotkeysGetReply | null {
136+
if (reply === null) return null;
93137

94-
for (let i = 0; i < data.length; i += 2) {
95-
const key = data[i].toString();
96-
const value = data[i + 1];
138+
const result: Partial<HotkeysGetReply> = {};
139+
for (const [key, value] of mapLikeEntries(reply)) {
97140

98141
switch (key) {
99142
case 'tracking-active':
@@ -103,7 +146,7 @@ function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): Hotke
103146
result.sampleRatio = Number(value);
104147
break;
105148
case 'selected-slots':
106-
result.selectedSlots = parseSlotRanges(value as unknown as Array<ArrayReply<NumberReply>>);
149+
result.selectedSlots = parseSlotRanges(value);
107150
break;
108151
case 'sampled-commands-selected-slots-us':
109152
result.sampledCommandsSelectedSlotsUs = Number(value);
@@ -139,10 +182,10 @@ function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): Hotke
139182
result.totalNetBytes = Number(value);
140183
break;
141184
case 'by-cpu-time-us':
142-
result.byCpuTimeUs = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
185+
result.byCpuTimeUs = parseHotkeysList(value);
143186
break;
144187
case 'by-net-bytes':
145-
result.byNetBytes = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
188+
result.byNetBytes = parseHotkeysList(value);
146189
break;
147190
}
148191
}
@@ -170,12 +213,6 @@ export default {
170213
parseCommand(parser: CommandParser) {
171214
parser.push('HOTKEYS', 'GET');
172215
},
173-
transformReply: {
174-
2: (reply: UnwrapReply<HotkeysGetRawReply> | null): HotkeysGetReply | null => {
175-
if (reply === null) return null;
176-
return transformHotkeysGetReply(reply);
177-
},
178-
3: undefined as unknown as () => HotkeysGetReply
179-
},
216+
transformReply: transformHotkeysGetReply,
180217
unstableResp3: true
181218
} as const satisfies Command;

packages/client/lib/commands/XREAD.spec.ts

Lines changed: 2 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -132,24 +132,7 @@ describe('XREAD', () => {
132132
cluster: GLOBAL.CLUSTERS.OPEN
133133
});
134134

135-
testUtils.testWithClient('client.xRead should throw with resp3 and unstableResp3: false', async client => {
136-
assert.throws(
137-
() => client.xRead({
138-
key: 'key',
139-
id: '0-0'
140-
}),
141-
{
142-
message: 'Some RESP3 results for Redis Query Engine responses may change. Refer to the readme for guidance'
143-
}
144-
);
145-
}, {
146-
...GLOBAL.SERVERS.OPEN,
147-
clientOptions: {
148-
RESP: 3
149-
}
150-
});
151-
152-
testUtils.testWithClient('client.xRead should not throw with resp3 and unstableResp3: true', async client => {
135+
testUtils.testWithClient('client.xRead should not throw with resp3', async client => {
153136
assert.doesNotThrow(
154137
() => client.xRead({
155138
key: 'key',
@@ -159,8 +142,7 @@ describe('XREAD', () => {
159142
}, {
160143
...GLOBAL.SERVERS.OPEN,
161144
clientOptions: {
162-
RESP: 3,
163-
unstableResp3: true
145+
RESP: 3
164146
}
165147
});
166148

packages/client/lib/commands/XREAD.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { CommandParser } from '../client/parser';
22
import { Command, RedisArgument, ReplyUnion } from '../RESP/types';
3-
import { transformStreamsMessagesReplyResp2 } from './generic-transformers';
3+
import { transformStreamsMessagesReplyResp2, transformStreamsMessagesReplyResp3 } from './generic-transformers';
44

55
/**
66
* Structure representing a stream to read from
@@ -48,6 +48,44 @@ export interface XReadOptions {
4848
BLOCK?: number;
4949
}
5050

51+
function transformStreamsMessagesReplyResp3Compat(reply: ReplyUnion) {
52+
const transformed = transformStreamsMessagesReplyResp3(reply as any);
53+
if (transformed === null) return null;
54+
55+
const compat = [];
56+
57+
if (transformed instanceof Map) {
58+
for (const [name, messages] of transformed.entries()) {
59+
compat.push({
60+
name,
61+
messages
62+
});
63+
}
64+
65+
return compat;
66+
}
67+
68+
if (Array.isArray(transformed)) {
69+
for (let i = 0; i < transformed.length; i += 2) {
70+
compat.push({
71+
name: transformed[i],
72+
messages: transformed[i + 1]
73+
});
74+
}
75+
76+
return compat;
77+
}
78+
79+
for (const [name, messages] of Object.entries(transformed)) {
80+
compat.push({
81+
name,
82+
messages
83+
});
84+
}
85+
86+
return compat;
87+
}
88+
5189
export default {
5290
IS_READ_ONLY: true,
5391
/**
@@ -77,7 +115,7 @@ export default {
77115
*/
78116
transformReply: {
79117
2: transformStreamsMessagesReplyResp2,
80-
3: undefined as unknown as () => ReplyUnion
118+
3: transformStreamsMessagesReplyResp3Compat
81119
},
82120
unstableResp3: true
83121
} as const satisfies Command;

0 commit comments

Comments
 (0)