Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 92 additions & 0 deletions packages/client/lib/commands/HOTKEYS_GET.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import HOTKEYS_GET from './HOTKEYS_GET';
import { parseArgs } from './generic-transformers';

describe('HOTKEYS GET', () => {
it('transformArguments', () => {
assert.deepEqual(
parseArgs(HOTKEYS_GET),
['HOTKEYS', 'GET']
);
});

testUtils.testWithClient('client.hotkeysGet returns null when no tracking', async client => {
// Clean up any existing state first
await client.hotkeysStop();
await client.hotkeysReset();

// GET on empty state should return null
const reply = await client.hotkeysGet();
assert.equal(reply, null);
}, {
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [8, 6]
});

testUtils.testWithClient('client.hotkeysGet returns data during tracking', async client => {
// Clean up any existing state first
await client.hotkeysStop();
await client.hotkeysReset();

// Start tracking
await client.hotkeysStart({
METRICS: { count: 2, CPU: true, NET: true }
});

// Perform some operations to generate hotkey data
await client.set('testKey1', 'value1');
await client.set('testKey2', 'value2');
await client.get('testKey1');
await client.get('testKey2');

// GET should return data
const reply = await client.hotkeysGet();
assert.notEqual(reply, null);
assert.equal(typeof reply.trackingActive, 'number');
assert.equal(typeof reply.sampleRatio, 'number');
assert.ok(Array.isArray(reply.selectedSlots));
assert.equal(typeof reply.collectionStartTimeUnixMs, 'number');
assert.equal(typeof reply.collectionDurationMs, 'number');
assert.ok(Array.isArray(reply.byCpuTime));
assert.ok(Array.isArray(reply.byNetBytes));

// Stop and reset tracking to clean up
await client.hotkeysStop();
await client.hotkeysReset();
}, {
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [8, 6]
});

testUtils.testWithClient('client.hotkeysGet returns data after stopping', async client => {
// Clean up any existing state first
await client.hotkeysStop();
await client.hotkeysReset();

// Start tracking
await client.hotkeysStart({
METRICS: { count: 1, CPU: true }
});

// Perform some operations
await client.set('testKey', 'value');
await client.get('testKey');

// Stop tracking
await client.hotkeysStop();

// GET should still return data in STOPPED state
const reply = await client.hotkeysGet();
assert.notEqual(reply, null);
// Tracking should be inactive after stop
assert.equal(reply.trackingActive, 0);

// Reset to clean up
await client.hotkeysReset();
}, {
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [8, 6]
});
});

144 changes: 144 additions & 0 deletions packages/client/lib/commands/HOTKEYS_GET.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { CommandParser } from '../client/parser';
import { Command, ReplyUnion, UnwrapReply, ArrayReply, BlobStringReply, NumberReply } from '../RESP/types';

/**
* Hotkey entry with key name and metric value
*/
export interface HotkeyEntry {
key: string;
value: number;
}

/**
* HOTKEYS GET response structure
*/
export interface HotkeysGetReply {
trackingActive: number;
sampleRatio: number;
selectedSlots: Array<number>;
sampledCommandSelectedSlotsMs?: number;
allCommandsSelectedSlotsMs?: number;
allCommandsAllSlotsMs: number;
netBytesSampledCommandsSelectedSlots?: number;
netBytesAllCommandsSelectedSlots?: number;
netBytesAllCommandsAllSlots: number;
collectionStartTimeUnixMs: number;
collectionDurationMs: number;
usedCpuSysMs: number;
usedCpuUserMs: number;
totalNetBytes: number;
byCpuTime: Array<HotkeyEntry>;
byNetBytes: Array<HotkeyEntry>;
}

type HotkeysGetRawReply = ArrayReply<BlobStringReply | NumberReply | ArrayReply<BlobStringReply | NumberReply>>;

/**
* Parse the hotkeys array into HotkeyEntry objects
*/
function parseHotkeysList(arr: Array<BlobStringReply | NumberReply>): Array<HotkeyEntry> {
const result: Array<HotkeyEntry> = [];
for (let i = 0; i < arr.length; i += 2) {
result.push({
key: arr[i].toString(),
value: Number(arr[i + 1])
});
}
return result;
}

/**
* Transform the raw reply into a structured object
*/
function transformHotkeysGetReply(reply: UnwrapReply<HotkeysGetRawReply>): HotkeysGetReply {
const result: Partial<HotkeysGetReply> = {};

for (let i = 0; i < reply.length; i += 2) {
const key = reply[i].toString();
const value = reply[i + 1];

switch (key) {
case 'tracking-active':
result.trackingActive = Number(value);
break;
case 'sample-ratio':
result.sampleRatio = Number(value);
break;
case 'selected-slots':
result.selectedSlots = (value as unknown as Array<NumberReply>).map(Number);
break;
case 'sampled-command-selected-slots-ms':
result.sampledCommandSelectedSlotsMs = Number(value);
break;
case 'all-commands-selected-slots-ms':
result.allCommandsSelectedSlotsMs = Number(value);
break;
case 'all-commands-all-slots-ms':
result.allCommandsAllSlotsMs = Number(value);
break;
case 'net-bytes-sampled-commands-selected-slots':
result.netBytesSampledCommandsSelectedSlots = Number(value);
break;
case 'net-bytes-all-commands-selected-slots':
result.netBytesAllCommandsSelectedSlots = Number(value);
break;
case 'net-bytes-all-commands-all-slots':
result.netBytesAllCommandsAllSlots = Number(value);
break;
case 'collection-start-time-unix-ms':
result.collectionStartTimeUnixMs = Number(value);
break;
case 'collection-duration-ms':
result.collectionDurationMs = Number(value);
break;
case 'used-cpu-sys-ms':
result.usedCpuSysMs = Number(value);
break;
case 'used-cpu-user-ms':
result.usedCpuUserMs = Number(value);
break;
case 'total-net-bytes':
result.totalNetBytes = Number(value);
break;
case 'by-cpu-time':
result.byCpuTime = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
break;
case 'by-net-bytes':
result.byNetBytes = parseHotkeysList(value as unknown as Array<BlobStringReply | NumberReply>);
break;
}
}

return result as HotkeysGetReply;
}

/**
* HOTKEYS GET command - returns hotkeys tracking data
*
* State transitions:
* - ACTIVE -> returns data (does not stop)
* - STOPPED -> returns data
* - EMPTY -> returns nil
*/
export default {
NOT_KEYED_COMMAND: true,
IS_READ_ONLY: true,
/**
* Returns the top K hotkeys by CPU time and network bytes.
* Returns nil if no tracking has been started or tracking was reset.
* @param parser - The Redis command parser
* @see https://redis.io/commands/hotkeys-get/
*/
parseCommand(parser: CommandParser) {
parser.push('HOTKEYS', 'GET');
},
transformReply: {
2: (reply: UnwrapReply<HotkeysGetRawReply> | null): HotkeysGetReply | null => {
if (reply === null) return null;
return transformHotkeysGetReply(reply);
},
3: undefined as unknown as () => ReplyUnion
},
unstableResp3: true
} as const satisfies Command;

31 changes: 31 additions & 0 deletions packages/client/lib/commands/HOTKEYS_RESET.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { strict as assert } from 'node:assert';
import testUtils, { GLOBAL } from '../test-utils';
import HOTKEYS_RESET from './HOTKEYS_RESET';
import { parseArgs } from './generic-transformers';

describe('HOTKEYS RESET', () => {
it('transformArguments', () => {
assert.deepEqual(
parseArgs(HOTKEYS_RESET),
['HOTKEYS', 'RESET']
);
});

testUtils.testWithClient('client.hotkeysReset', async client => {
// Clean up any existing state first
await client.hotkeysStop();
await client.hotkeysReset();

// Start and stop tracking, then reset
await client.hotkeysStart({ METRICS: { count: 1, CPU: true } });
await client.hotkeysStop();
assert.equal(
await client.hotkeysReset(),
'OK'
);
}, {
...GLOBAL.SERVERS.OPEN,
minimumDockerVersion: [8, 6]
});
});

26 changes: 26 additions & 0 deletions packages/client/lib/commands/HOTKEYS_RESET.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import { CommandParser } from '../client/parser';
import { SimpleStringReply, Command } from '../RESP/types';

/**
* HOTKEYS RESET command - releases resources used for hotkey tracking
*
* State transitions:
* - STOPPED -> EMPTY
* - EMPTY -> EMPTY
* - ACTIVE -> ERROR (must stop first)
*/
export default {
NOT_KEYED_COMMAND: true,
IS_READ_ONLY: false,
/**
* Releases resources used for hotkey tracking.
* Returns error if a session is active (must be stopped first).
* @param parser - The Redis command parser
* @see https://redis.io/commands/hotkeys-reset/
*/
parseCommand(parser: CommandParser) {
parser.push('HOTKEYS', 'RESET');
},
transformReply: undefined as unknown as () => SimpleStringReply<'OK'>
} as const satisfies Command;

Loading
Loading