Skip to content

Commit 883147c

Browse files
committed
feat(client): add HOTKEYS command for hotkey tracking
1 parent 7170157 commit 883147c

9 files changed

Lines changed: 619 additions & 0 deletions

File tree

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils, { GLOBAL } from '../test-utils';
3+
import HOTKEYS_GET from './HOTKEYS_GET';
4+
import { parseArgs } from './generic-transformers';
5+
6+
describe('HOTKEYS GET', () => {
7+
testUtils.isVersionGreaterThanHook([8, 4]);
8+
9+
it('transformArguments', () => {
10+
assert.deepEqual(
11+
parseArgs(HOTKEYS_GET),
12+
['HOTKEYS', 'GET']
13+
);
14+
});
15+
16+
testUtils.testWithClient('client.hotkeysGet returns null when no tracking', async client => {
17+
// Clean up any existing state first
18+
await client.hotkeysStop();
19+
await client.hotkeysReset();
20+
21+
// GET on empty state should return null
22+
const reply = await client.hotkeysGet();
23+
assert.equal(reply, null);
24+
}, {
25+
...GLOBAL.SERVERS.OPEN,
26+
minimumDockerVersion: [8, 6]
27+
});
28+
29+
testUtils.testWithClient('client.hotkeysGet returns data during tracking', async client => {
30+
// Clean up any existing state first
31+
await client.hotkeysStop();
32+
await client.hotkeysReset();
33+
34+
// Start tracking
35+
await client.hotkeysStart({
36+
METRICS: { count: 2, CPU: true, NET: true }
37+
});
38+
39+
// Perform some operations to generate hotkey data
40+
await client.set('testKey1', 'value1');
41+
await client.set('testKey2', 'value2');
42+
await client.get('testKey1');
43+
await client.get('testKey2');
44+
45+
// GET should return data
46+
const reply = await client.hotkeysGet();
47+
assert.notEqual(reply, null);
48+
49+
if (reply !== null) {
50+
assert.equal(typeof reply.trackingActive, 'number');
51+
assert.equal(typeof reply.sampleRatio, 'number');
52+
assert.ok(Array.isArray(reply.selectedSlots));
53+
assert.equal(typeof reply.collectionStartTimeUnixMs, 'number');
54+
assert.equal(typeof reply.collectionDurationMs, 'number');
55+
assert.ok(Array.isArray(reply.byCpuTime));
56+
assert.ok(Array.isArray(reply.byNetBytes));
57+
}
58+
59+
// Stop and reset tracking to clean up
60+
await client.hotkeysStop();
61+
await client.hotkeysReset();
62+
}, {
63+
...GLOBAL.SERVERS.OPEN,
64+
minimumDockerVersion: [8, 6]
65+
});
66+
67+
testUtils.testWithClient('client.hotkeysGet returns data after stopping', async client => {
68+
// Clean up any existing state first
69+
await client.hotkeysStop();
70+
await client.hotkeysReset();
71+
72+
// Start tracking
73+
await client.hotkeysStart({
74+
METRICS: { count: 1, CPU: true }
75+
});
76+
77+
// Perform some operations
78+
await client.set('testKey', 'value');
79+
await client.get('testKey');
80+
81+
// Stop tracking
82+
await client.hotkeysStop();
83+
84+
// GET should still return data in STOPPED state
85+
const reply = await client.hotkeysGet();
86+
assert.notEqual(reply, null);
87+
88+
if (reply !== null) {
89+
// Tracking should be inactive after stop
90+
assert.equal(reply.trackingActive, 0);
91+
}
92+
93+
// Reset to clean up
94+
await client.hotkeysReset();
95+
}, {
96+
...GLOBAL.SERVERS.OPEN,
97+
minimumDockerVersion: [8, 6]
98+
});
99+
});
100+
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { CommandParser } from '../client/parser';
2+
import { Command, ReplyUnion, UnwrapReply, ArrayReply, BlobStringReply, NumberReply } from '../RESP/types';
3+
4+
/**
5+
* Hotkey entry with key name and metric value
6+
*/
7+
export interface HotkeyEntry {
8+
key: string;
9+
value: number;
10+
}
11+
12+
/**
13+
* HOTKEYS GET response structure
14+
*/
15+
export interface HotkeysGetReply {
16+
trackingActive: number;
17+
sampleRatio: number;
18+
selectedSlots: Array<number>;
19+
sampledCommandSelectedSlotsMs?: number;
20+
allCommandsSelectedSlotsMs?: number;
21+
allCommandsAllSlotsMs: number;
22+
netBytesSampledCommandsSelectedSlots?: number;
23+
netBytesAllCommandsSelectedSlots?: number;
24+
netBytesAllCommandsAllSlots: number;
25+
collectionStartTimeUnixMs: number;
26+
collectionDurationMs: number;
27+
usedCpuSysMs: number;
28+
usedCpuUserMs: number;
29+
totalNetBytes: number;
30+
byCpuTime: Array<HotkeyEntry>;
31+
byNetBytes: Array<HotkeyEntry>;
32+
}
33+
34+
type HotkeysGetRawReply = ArrayReply<BlobStringReply | NumberReply | ArrayReply<BlobStringReply | NumberReply>>;
35+
36+
/**
37+
* Parse the hotkeys array into HotkeyEntry objects
38+
*/
39+
function parseHotkeysList(arr: Array<BlobStringReply | NumberReply>): Array<HotkeyEntry> {
40+
const result: Array<HotkeyEntry> = [];
41+
for (let i = 0; i < arr.length; i += 2) {
42+
result.push({
43+
key: arr[i].toString(),
44+
value: Number(arr[i + 1])
45+
});
46+
}
47+
return result;
48+
}
49+
50+
/**
51+
* Transform the raw reply into a structured object
52+
*/
53+
function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): HotkeysGetReply {
54+
const result: Partial<HotkeysGetReply> = {};
55+
56+
for (let i = 0; i < reply.length; i += 2) {
57+
const key = reply[i].toString();
58+
const value = reply[i + 1];
59+
60+
switch (key) {
61+
case 'tracking-active':
62+
result.trackingActive = Number(value);
63+
break;
64+
case 'sample-ratio':
65+
result.sampleRatio = Number(value);
66+
break;
67+
case 'selected-slots':
68+
result.selectedSlots = (value as unknown as Array<NumberReply>).map(Number);
69+
break;
70+
case 'sampled-command-selected-slots-ms':
71+
result.sampledCommandSelectedSlotsMs = Number(value);
72+
break;
73+
case 'all-commands-selected-slots-ms':
74+
result.allCommandsSelectedSlotsMs = Number(value);
75+
break;
76+
case 'all-commands-all-slots-ms':
77+
result.allCommandsAllSlotsMs = Number(value);
78+
break;
79+
case 'net-bytes-sampled-commands-selected-slots':
80+
result.netBytesSampledCommandsSelectedSlots = Number(value);
81+
break;
82+
case 'net-bytes-all-commands-selected-slots':
83+
result.netBytesAllCommandsSelectedSlots = Number(value);
84+
break;
85+
case 'net-bytes-all-commands-all-slots':
86+
result.netBytesAllCommandsAllSlots = Number(value);
87+
break;
88+
case 'collection-start-time-unix-ms':
89+
result.collectionStartTimeUnixMs = Number(value);
90+
break;
91+
case 'collection-duration-ms':
92+
result.collectionDurationMs = Number(value);
93+
break;
94+
case 'used-cpu-sys-ms':
95+
result.usedCpuSysMs = Number(value);
96+
break;
97+
case 'used-cpu-user-ms':
98+
result.usedCpuUserMs = Number(value);
99+
break;
100+
case 'total-net-bytes':
101+
result.totalNetBytes = Number(value);
102+
break;
103+
case 'by-cpu-time':
104+
result.byCpuTime = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
105+
break;
106+
case 'by-net-bytes':
107+
result.byNetBytes = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
108+
break;
109+
}
110+
}
111+
112+
return result as HotkeysGetReply;
113+
}
114+
115+
/**
116+
* HOTKEYS GET command - returns hotkeys tracking data
117+
*
118+
* State transitions:
119+
* - ACTIVE -> returns data (does not stop)
120+
* - STOPPED -> returns data
121+
* - EMPTY -> returns nil
122+
*/
123+
export default {
124+
NOT_KEYED_COMMAND: true,
125+
IS_READ_ONLY: true,
126+
/**
127+
* Returns the top K hotkeys by CPU time and network bytes.
128+
* Returns nil if no tracking has been started or tracking was reset.
129+
* @param parser - The Redis command parser
130+
* @see https://redis.io/commands/hotkeys-get/
131+
*/
132+
parseCommand(parser: CommandParser) {
133+
parser.push('HOTKEYS', 'GET');
134+
},
135+
transformReply: {
136+
2: (reply: UnwrapReply<HotkeysGetRawReply> | null): HotkeysGetReply | null => {
137+
if (reply === null) return null;
138+
return transformHotkeysGetReply(reply);
139+
},
140+
3: undefined as unknown as () => ReplyUnion
141+
},
142+
unstableResp3: true
143+
} as const satisfies Command;
144+
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { strict as assert } from 'node:assert';
2+
import testUtils, { GLOBAL } from '../test-utils';
3+
import HOTKEYS_RESET from './HOTKEYS_RESET';
4+
import { parseArgs } from './generic-transformers';
5+
6+
describe('HOTKEYS RESET', () => {
7+
testUtils.isVersionGreaterThanHook([8, 4]);
8+
9+
it('transformArguments', () => {
10+
assert.deepEqual(
11+
parseArgs(HOTKEYS_RESET),
12+
['HOTKEYS', 'RESET']
13+
);
14+
});
15+
16+
testUtils.testWithClient('client.hotkeysReset', async client => {
17+
// Clean up any existing state first
18+
await client.hotkeysStop();
19+
await client.hotkeysReset();
20+
21+
// Start and stop tracking, then reset
22+
await client.hotkeysStart({ METRICS: { count: 1, CPU: true } });
23+
await client.hotkeysStop();
24+
assert.equal(
25+
await client.hotkeysReset(),
26+
'OK'
27+
);
28+
}, {
29+
...GLOBAL.SERVERS.OPEN,
30+
minimumDockerVersion: [8, 6]
31+
});
32+
});
33+
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { CommandParser } from '../client/parser';
2+
import { SimpleStringReply, Command } from '../RESP/types';
3+
4+
/**
5+
* HOTKEYS RESET command - releases resources used for hotkey tracking
6+
*
7+
* State transitions:
8+
* - STOPPED -> EMPTY
9+
* - EMPTY -> EMPTY
10+
* - ACTIVE -> ERROR (must stop first)
11+
*/
12+
export default {
13+
NOT_KEYED_COMMAND: true,
14+
IS_READ_ONLY: false,
15+
/**
16+
* Releases resources used for hotkey tracking.
17+
* Returns error if a session is active (must be stopped first).
18+
* @param parser - The Redis command parser
19+
* @see https://redis.io/commands/hotkeys-reset/
20+
*/
21+
parseCommand(parser: CommandParser) {
22+
parser.push('HOTKEYS', 'RESET');
23+
},
24+
transformReply: undefined as unknown as () => SimpleStringReply<'OK'>
25+
} as const satisfies Command;
26+

0 commit comments

Comments
 (0)