Skip to content

Commit e30ab2d

Browse files
authored
fix(client): update HOTKEYS commands to match latest server API
* chore(tests): update test container to custom-21651605017-debian-amd64 * fix(client): update HOTKEYS commands to match latest server API - Change time units from ms to us for CPU time fields - Add SlotRange interface for slot range representation - Update field names (byCpuTime → byCpuTimeUs, etc.) - Fix HOTKEYS_STOP to return null in empty state - Update response parsing for new array-wrapped format - Add test for HOTKEYS_STOP empty state behavior - nil -> null * fix(client): exclude HOTKEYS commands from cluster, pool, and sentinel clients HOTKEYS commands require session affinity (sticky connection to a single Redis node) which cluster, pool, and sentinel clients cannot guarantee. Changes: - Created NON_STICKY_COMMANDS export in commands/index.ts that excludes HOTKEYS commands - Updated cluster, pool, and sentinel to use NON_STICKY_COMMANDS instead of COMMANDS - Added tests to verify HOTKEYS commands are not available on these client types * chore(tests): update test container to custom-21860421418-debian-amd64 * fix(hotkeys): update field name to sampled-commands-selected-slots-us - Fix field name from 'sampled-command-selected-slots-us' to 'sampled-commands-selected-slots-us' (with 's') to align with server changes in the new test image - Update TypeScript property name from sampledCommandSelectedSlotsUs to sampledCommandsSelectedSlotsUs for consistency - Add comprehensive tests for all HOTKEYS GET response fields - Add cluster tests for SLOTS and SAMPLE options to verify slot-specific and sampled fields are correctly parsed
1 parent ed55918 commit e30ab2d

File tree

23 files changed

+295
-72
lines changed

23 files changed

+295
-72
lines changed

.github/workflows/tests.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ jobs:
2929
version: "8.2"
3030
- tag: "8.4.0"
3131
version: "8.4"
32-
- tag: "custom-21183968220-debian-amd64"
32+
- tag: "custom-21860421418-debian-amd64"
3333
version: "8.6"
3434
steps:
3535
- uses: actions/checkout@v4

packages/bloom/lib/test-utils.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export default TestUtils.createFromConfig({
55
dockerImageName: 'redislabs/client-libs-test',
66
dockerImageTagArgument: 'redis-tag',
77
dockerImageVersionArgument: 'redis-version',
8-
defaultDockerVersion: { tag: 'custom-21183968220-debian-amd64', version: '8.6' }
8+
defaultDockerVersion: { tag: 'custom-21860421418-debian-amd64', version: '8.6' }
99
});
1010

1111
export const GLOBAL = {

packages/client/lib/client/pool.spec.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,21 @@
11
import { strict as assert } from 'node:assert';
22
import testUtils, { GLOBAL } from '../test-utils';
3+
import { RedisClientPool } from './pool';
34

45
describe('RedisClientPool', () => {
6+
it('should not have HOTKEYS commands (requires session affinity)', () => {
7+
// HOTKEYS commands require session affinity and are only available on standalone clients
8+
const pool = RedisClientPool.create({});
9+
assert.equal((pool as any).hotkeysStart, undefined);
10+
assert.equal((pool as any).hotkeysStop, undefined);
11+
assert.equal((pool as any).hotkeysGet, undefined);
12+
assert.equal((pool as any).hotkeysReset, undefined);
13+
assert.equal((pool as any).HOTKEYS_START, undefined);
14+
assert.equal((pool as any).HOTKEYS_STOP, undefined);
15+
assert.equal((pool as any).HOTKEYS_GET, undefined);
16+
assert.equal((pool as any).HOTKEYS_RESET, undefined);
17+
});
18+
519
testUtils.testWithClientPool('sendCommand', async pool => {
620
assert.equal(
721
await pool.sendCommand(['PING']),

packages/client/lib/client/pool.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import COMMANDS from '../commands';
2-
import { Command, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, RespVersions, TypeMapping } from '../RESP/types';
3-
import RedisClient, { RedisClientType, RedisClientOptions, RedisClientExtensions } from '.';
1+
import { NON_STICKY_COMMANDS } from '../commands';
2+
import { Command, CommandSignature, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, RespVersions, TypeMapping } from '../RESP/types';
3+
import RedisClient, { RedisClientType, RedisClientOptions, WithModules, WithFunctions, WithScripts } from '.';
44
import { EventEmitter } from 'node:events';
55
import { DoublyLinkedNode, DoublyLinkedList, SinglyLinkedList } from './linked-list';
66
import { TimeoutError } from '../errors';
@@ -88,6 +88,14 @@ export type PoolTask<
8888
T = unknown
8989
> = (client: RedisClientType<M, F, S, RESP, TYPE_MAPPING>) => T;
9090

91+
// Pool uses NON_STICKY_COMMANDS to exclude commands that require session affinity (like HOTKEYS)
92+
type PoolWithCommands<
93+
RESP extends RespVersions,
94+
TYPE_MAPPING extends TypeMapping
95+
> = {
96+
[P in keyof typeof NON_STICKY_COMMANDS]: CommandSignature<(typeof NON_STICKY_COMMANDS)[P], RESP, TYPE_MAPPING>;
97+
};
98+
9199
export type RedisClientPoolType<
92100
M extends RedisModules = {},
93101
F extends RedisFunctions = {},
@@ -96,7 +104,10 @@ export type RedisClientPoolType<
96104
TYPE_MAPPING extends TypeMapping = {}
97105
> = (
98106
RedisClientPool<M, F, S, RESP, TYPE_MAPPING> &
99-
RedisClientExtensions<M, F, S, RESP, TYPE_MAPPING>
107+
PoolWithCommands<RESP, TYPE_MAPPING> &
108+
WithModules<M, RESP, TYPE_MAPPING> &
109+
WithFunctions<F, RESP, TYPE_MAPPING> &
110+
WithScripts<S, RESP, TYPE_MAPPING>
100111
);
101112

102113
type ProxyPool = RedisClientPoolType<any, any, any, any, any>;
@@ -174,7 +185,7 @@ export class RedisClientPool<
174185
if(!Pool) {
175186
Pool = attachConfig({
176187
BaseClass: RedisClientPool,
177-
commands: COMMANDS,
188+
commands: NON_STICKY_COMMANDS,
178189
createCommand: RedisClientPool.#createCommand,
179190
createModuleCommand: RedisClientPool.#createModuleCommand,
180191
createFunctionCommand: RedisClientPool.#createFunctionCommand,

packages/client/lib/cluster/index.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,19 @@ import { spy } from 'sinon';
77
import RedisClient from '../client';
88

99
describe('Cluster', () => {
10+
it('should not have HOTKEYS commands (requires session affinity)', () => {
11+
// HOTKEYS commands require session affinity and are only available on standalone clients
12+
const cluster = RedisCluster.create({ rootNodes: [] });
13+
assert.equal((cluster as any).hotkeysStart, undefined);
14+
assert.equal((cluster as any).hotkeysStop, undefined);
15+
assert.equal((cluster as any).hotkeysGet, undefined);
16+
assert.equal((cluster as any).hotkeysReset, undefined);
17+
assert.equal((cluster as any).HOTKEYS_START, undefined);
18+
assert.equal((cluster as any).HOTKEYS_STOP, undefined);
19+
assert.equal((cluster as any).HOTKEYS_GET, undefined);
20+
assert.equal((cluster as any).HOTKEYS_RESET, undefined);
21+
});
22+
1023
testUtils.testWithCluster('sendCommand', async cluster => {
1124
assert.equal(
1225
await cluster.sendCommand(undefined, true, ['PING']),

packages/client/lib/cluster/index.ts

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
import { RedisClientOptions, RedisClientType } from '../client';
1+
import { RedisClientOptions, RedisClientType, WithFunctions, WithModules, WithScripts } from '../client';
22
import { CommandOptions } from '../client/commands-queue';
3-
import { Command, CommandArguments, CommanderConfig, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions } from '../RESP/types';
4-
import COMMANDS from '../commands';
3+
import { Command, CommandArguments, CommanderConfig, CommandSignature, TypeMapping, RedisArgument, RedisFunction, RedisFunctions, RedisModules, RedisScript, RedisScripts, ReplyUnion, RespVersions } from '../RESP/types';
4+
import { NON_STICKY_COMMANDS } from '../commands';
55
import { EventEmitter } from 'node:events';
66
import { attachConfig, functionArgumentsPrefix, getTransformReply, scriptArgumentsPrefix } from '../commander';
77
import RedisClusterSlots, { NodeAddressMap, ShardNode } from './cluster-slots';
@@ -13,7 +13,13 @@ import { ClientSideCacheConfig, PooledClientSideCacheProvider } from '../client/
1313
import { BasicCommandParser } from '../client/parser';
1414
import { ASKING_CMD } from '../commands/ASKING';
1515
import SingleEntryCache from '../single-entry-cache'
16-
import { WithCommands, WithFunctions, WithModules, WithScripts } from '../client';
16+
17+
type WithCommands<
18+
RESP extends RespVersions,
19+
TYPE_MAPPING extends TypeMapping
20+
> = {
21+
[P in keyof typeof NON_STICKY_COMMANDS]: CommandSignature<(typeof NON_STICKY_COMMANDS)[P], RESP, TYPE_MAPPING>;
22+
};
1723

1824
interface ClusterCommander<
1925
M extends RedisModules,
@@ -222,7 +228,7 @@ export default class RedisCluster<
222228
if (!Cluster) {
223229
Cluster = attachConfig({
224230
BaseClass: RedisCluster,
225-
commands: COMMANDS,
231+
commands: NON_STICKY_COMMANDS,
226232
createCommand: RedisCluster.#createCommand,
227233
createModuleCommand: RedisCluster.#createModuleCommand,
228234
createFunctionCommand: RedisCluster.#createFunctionCommand,

packages/client/lib/cluster/multi-command.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import COMMANDS from '../commands';
1+
import { NON_STICKY_COMMANDS } from '../commands';
22
import RedisMultiCommand, { MULTI_REPLY, MultiReply, MultiReplyType, RedisMultiQueuedCommand } from '../multi-command';
33
import { ReplyWithTypeMapping, CommandReply, Command, CommandArguments, CommanderConfig, RedisFunctions, RedisModules, RedisScripts, RespVersions, TransformReply, RedisScript, RedisFunction, TypeMapping, RedisArgument } from '../RESP/types';
44
import { attachConfig, functionArgumentsPrefix, getTransformReply } from '../commander';
@@ -30,7 +30,7 @@ type WithCommands<
3030
RESP extends RespVersions,
3131
TYPE_MAPPING extends TypeMapping
3232
> = {
33-
[P in keyof typeof COMMANDS]: CommandSignature<REPLIES, (typeof COMMANDS)[P], M, F, S, RESP, TYPE_MAPPING>;
33+
[P in keyof typeof NON_STICKY_COMMANDS]: CommandSignature<REPLIES, (typeof NON_STICKY_COMMANDS)[P], M, F, S, RESP, TYPE_MAPPING>;
3434
};
3535

3636
type WithModules<
@@ -183,7 +183,7 @@ export default class RedisClusterMultiCommand<REPLIES = []> {
183183
>(config?: CommanderConfig<M, F, S, RESP>) {
184184
return attachConfig({
185185
BaseClass: RedisClusterMultiCommand,
186-
commands: COMMANDS,
186+
commands: NON_STICKY_COMMANDS,
187187
createCommand: RedisClusterMultiCommand.#createCommand,
188188
createModuleCommand: RedisClusterMultiCommand.#createModuleCommand,
189189
createFunctionCommand: RedisClusterMultiCommand.#createFunctionCommand,

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

Lines changed: 96 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,12 @@ describe('HOTKEYS GET', () => {
2424
minimumDockerVersion: [8, 6]
2525
});
2626

27-
testUtils.testWithClient('client.hotkeysGet returns data during tracking', async client => {
27+
testUtils.testWithClient('client.hotkeysGet returns all required fields during tracking', async client => {
2828
// Clean up any existing state first
2929
await client.hotkeysStop();
3030
await client.hotkeysReset();
3131

32-
// Start tracking
32+
// Start tracking with both CPU and NET metrics
3333
await client.hotkeysStart({
3434
METRICS: { count: 2, CPU: true, NET: true }
3535
});
@@ -40,15 +40,24 @@ describe('HOTKEYS GET', () => {
4040
await client.get('testKey1');
4141
await client.get('testKey2');
4242

43-
// GET should return data
43+
// GET should return data with all required fields
4444
const reply = await client.hotkeysGet();
45-
assert.notEqual(reply, null);
45+
assert.ok(reply, 'Expected reply to not be null');
46+
47+
// Required fields - must be present
4648
assert.equal(typeof reply.trackingActive, 'number');
4749
assert.equal(typeof reply.sampleRatio, 'number');
4850
assert.ok(Array.isArray(reply.selectedSlots));
51+
assert.equal(typeof reply.allCommandsAllSlotsUs, 'number');
52+
assert.equal(typeof reply.netBytesAllCommandsAllSlots, 'number');
4953
assert.equal(typeof reply.collectionStartTimeUnixMs, 'number');
5054
assert.equal(typeof reply.collectionDurationMs, 'number');
51-
assert.ok(Array.isArray(reply.byCpuTime));
55+
assert.equal(typeof reply.totalCpuTimeSysMs, 'number');
56+
assert.equal(typeof reply.totalCpuTimeUserMs, 'number');
57+
assert.equal(typeof reply.totalNetBytes, 'number');
58+
59+
// Metric arrays - present when requested
60+
assert.ok(Array.isArray(reply.byCpuTimeUs));
5261
assert.ok(Array.isArray(reply.byNetBytes));
5362

5463
// Stop and reset tracking to clean up
@@ -59,6 +68,87 @@ describe('HOTKEYS GET', () => {
5968
minimumDockerVersion: [8, 6]
6069
});
6170

71+
testUtils.testWithCluster('cluster.hotkeysGet returns slot-specific fields with SLOTS option', async cluster => {
72+
const client = await cluster.nodeClient(cluster.masters[0]);
73+
74+
// Get the slots owned by this master node
75+
const clusterSlots = await client.clusterSlots();
76+
const masterSlots = clusterSlots[0]; // First slot range
77+
const slotStart = masterSlots.from as number;
78+
const slotEnd = Math.min(slotStart + 1, masterSlots.to as number);
79+
80+
// Clean up any existing state first
81+
await client.hotkeysStop();
82+
await client.hotkeysReset();
83+
84+
// Start tracking with SLOTS option using slots owned by this master
85+
await client.hotkeysStart({
86+
METRICS: { count: 2, CPU: true, NET: true },
87+
SLOTS: { count: 2, slots: [slotStart, slotEnd] }
88+
});
89+
90+
// Perform some operations using the cluster (which routes correctly)
91+
await cluster.set('testKey1', 'value1');
92+
await cluster.get('testKey1');
93+
94+
const reply = await client.hotkeysGet();
95+
assert.ok(reply, 'Expected reply to not be null');
96+
97+
// When SLOTS is specified, these fields should be present
98+
assert.equal(typeof reply.allCommandsSelectedSlotsUs, 'number');
99+
assert.equal(typeof reply.netBytesAllCommandsSelectedSlots, 'number');
100+
101+
// selectedSlots should contain the specified slots
102+
assert.ok(Array.isArray(reply.selectedSlots));
103+
assert.ok(reply.selectedSlots.length > 0);
104+
105+
// Stop and reset tracking to clean up
106+
await client.hotkeysStop();
107+
await client.hotkeysReset();
108+
}, {
109+
...GLOBAL.CLUSTERS.OPEN,
110+
minimumDockerVersion: [8, 6]
111+
});
112+
113+
testUtils.testWithCluster('cluster.hotkeysGet returns sampled fields with SAMPLE and SLOTS options', async cluster => {
114+
const client = await cluster.nodeClient(cluster.masters[0]);
115+
116+
// Get the slots owned by this master node
117+
const clusterSlots = await client.clusterSlots();
118+
const masterSlots = clusterSlots[0]; // First slot range
119+
const slotStart = masterSlots.from as number;
120+
const slotEnd = Math.min(slotStart + 1, masterSlots.to as number);
121+
122+
// Clean up any existing state first
123+
await client.hotkeysStop();
124+
await client.hotkeysReset();
125+
126+
// Start tracking with SAMPLE > 1 and SLOTS using slots owned by this master
127+
await client.hotkeysStart({
128+
METRICS: { count: 2, CPU: true, NET: true },
129+
SAMPLE: 2,
130+
SLOTS: { count: 2, slots: [slotStart, slotEnd] }
131+
});
132+
133+
// Perform some operations using the cluster (which routes correctly)
134+
await cluster.set('testKey1', 'value1');
135+
await cluster.get('testKey1');
136+
137+
const reply = await client.hotkeysGet();
138+
assert.ok(reply, 'Expected reply to not be null');
139+
140+
// When SAMPLE > 1 AND SLOTS is specified, these fields should be present
141+
assert.equal(typeof reply.sampledCommandsSelectedSlotsUs, 'number');
142+
assert.equal(typeof reply.netBytesSampledCommandsSelectedSlots, 'number');
143+
144+
// Stop and reset tracking to clean up
145+
await client.hotkeysStop();
146+
await client.hotkeysReset();
147+
}, {
148+
...GLOBAL.CLUSTERS.OPEN,
149+
minimumDockerVersion: [8, 6]
150+
});
151+
62152
testUtils.testWithClient('client.hotkeysGet returns data after stopping', async client => {
63153
// Clean up any existing state first
64154
await client.hotkeysStop();
@@ -78,7 +168,7 @@ describe('HOTKEYS GET', () => {
78168

79169
// GET should still return data in STOPPED state
80170
const reply = await client.hotkeysGet();
81-
assert.notEqual(reply, null);
171+
assert.ok(reply, 'Expected reply to not be null');
82172
// Tracking should be inactive after stop
83173
assert.equal(reply.trackingActive, 0);
84174

0 commit comments

Comments
 (0)