Added support for HOTKEYS container command (#1630)

* Added testing with SSL authentication using CN

* Update CHANGELOG.md

* Codestyle changes

* Fixed broken tests

* Fixed hybdrid tests

* Fixed SSL tests

* Fixed array merging

* Updated README.md

* Updated spelling list

* Added support for HOTKEYS container command

* Added ignoring for PHPUnit package

* Update CHANGELOG.md, codestyle fixes, mark tests as relay-incompatible

* Added RESP3 test, fixed response parsing

* Removed redundant tests

* Added cluster test

* Fix assertion
This commit is contained in:
Vladyslav Vildanov
2026-02-05 10:48:48 +02:00
committed by GitHub
parent 53504469d7
commit 861bde010a
7 changed files with 472 additions and 1 deletions
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
run: |
# Mapping of original redis versions to client test containers
declare -A redis_clients_version_mapping=(
["8.6"]="8.6-rc1-21356658603-debian-amd64"
["8.6"]="custom-21651605017-debian-amd64"
["8.4"]="8.4.0"
["8.2"]="8.2.2-pre"
["8.0"]="8.0.2"
+1
View File
@@ -10,6 +10,7 @@
### Added
- Added retry support (#1616)
- Added support for VRANGE command (#1623)
- Added support for HOTKEYS container command (#1630)
### Maintenance
- Added testing with SSL connection (#1624)
+2
View File
@@ -42,6 +42,7 @@ use Predis\Command\CommandInterface;
use Predis\Command\Container\ACL;
use Predis\Command\Container\CLIENT;
use Predis\Command\Container\FUNCTIONS;
use Predis\Command\Container\HOTKEYS;
use Predis\Command\Container\Json\JSONDEBUG;
use Predis\Command\Container\Search\FTCONFIG;
use Predis\Command\Container\Search\FTCURSOR;
@@ -395,6 +396,7 @@ use Predis\Command\Redis\VADD;
*
* Container commands
* @property CLIENT $client
* @property HOTKEYS $hotkeys
* @property FUNCTIONS $function
* @property FTCONFIG $ftconfig
* @property FTCURSOR $ftcursor
+2
View File
@@ -42,6 +42,7 @@ use Predis\Command\CommandInterface;
use Predis\Command\Container\ACL;
use Predis\Command\Container\CLIENT;
use Predis\Command\Container\FUNCTIONS;
use Predis\Command\Container\HOTKEYS;
use Predis\Command\Container\Json\JSONDEBUG;
use Predis\Command\Container\Search\FTCONFIG;
use Predis\Command\Container\Search\FTCURSOR;
@@ -408,6 +409,7 @@ use Predis\Response\Status;
*
* Container commands
* @property CLIENT $client
* @property HOTKEYS $hotkeys
* @property FUNCTIONS $function
* @property FTCONFIG $ftconfig
* @property FTCURSOR $ftcursor
+49
View File
@@ -0,0 +1,49 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) 2009-2020 Daniele Alessandri
* (c) 2021-2025 Till Krüss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Command\Container;
use Predis\Response\Status;
/**
* @method Status stop()
* @method Status reset()
* @method array get()
*/
class HOTKEYS extends AbstractContainer
{
public const CPU = 'CPU';
public const NET = 'NET';
/**
* @return string
*/
public function getContainerCommandId(): string
{
return 'HOTKEYS';
}
/**
* Starts a hotkeys tracking on server side.
*
* @param array<self::CPU|self::NET> $metrics One of the available metric types. Check class constants.
* @param int|null $count Number of top keys to report. Default: 10, Min: 10, Max: 64
* @param int|null $duration Auto-stop tracking after this many seconds. Default: 0 (no auto-stop)
* @param int|null $sample Sample ratio - track keys with probability 1/sample. Default: 1 (track every key), Min: 1
* @param array<int>|null $slots All specified slots must be hosted by the receiving node! If not specified, all slots are tracked.
* @return string|Status
*/
public function start(array $metrics, ?int $count = null, ?int $duration = null, ?int $sample = null, ?array $slots = null)
{
return $this->__call('START', func_get_args());
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) 2009-2020 Daniele Alessandri
* (c) 2021-2025 Till Krüss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Command\Redis;
use Predis\Command\Command as RedisCommand;
use Predis\Command\Redis\Utils\CommandUtility;
use ValueError;
class HOTKEYS extends RedisCommand
{
/**
* {@inheritDoc}
*/
public function getId()
{
return 'HOTKEYS';
}
/**
* @param array $arguments
* @return void
*/
public function setArguments(array $arguments)
{
switch ($arguments[0]) {
case 'START':
$this->setStartArguments($arguments);
break;
default:
parent::setArguments($arguments);
}
}
public function parseResponse($data)
{
if (is_array($data)) {
foreach ($data as $key => $item) {
$dict = CommandUtility::arrayToDictionary($item, null, false);
$data[$key] = $dict;
}
}
return $data;
}
/**
* @param array $arguments
* @return void
*/
private function setStartArguments(array $arguments)
{
$processedArguments = [$arguments[0]];
array_push($processedArguments, 'METRICS', count($arguments[1]), ...$arguments[1]);
if (isset($arguments[2])) {
if ($arguments[2] > 9 && $arguments[2] < 65) {
array_push($processedArguments, 'COUNT', $arguments[2]);
} else {
throw new ValueError('Count value should be between 10 and 64');
}
}
if (isset($arguments[3])) {
array_push($processedArguments, 'DURATION', $arguments[3]);
}
if (isset($arguments[4])) {
if ($arguments[4] > 0) {
array_push($processedArguments, 'SAMPLE', $arguments[4]);
} else {
throw new ValueError('Sample value should be greater than 0');
}
}
if (isset($arguments[5])) {
array_push($processedArguments, 'SLOTS', count($arguments[5]), ...$arguments[5]);
}
parent::setArguments($processedArguments);
}
}
+324
View File
@@ -0,0 +1,324 @@
<?php
/*
* This file is part of the Predis package.
*
* (c) 2009-2020 Daniele Alessandri
* (c) 2021-2025 Till Krüss
*
* For the full copyright and license information, please view the LICENSE
* file that was distributed with this source code.
*/
namespace Predis\Command\Redis;
use Predis\Command\Container\HOTKEYS as Container;
use Predis\NotSupportedException;
use ValueError;
/**
* @group commands
* @group relay-incompatible
* @group realm-generic
*/
class HOTKEYS_Test extends PredisCommandTestCase
{
/**
* {@inheritdoc}
*/
protected function getExpectedCommand(): string
{
return HOTKEYS::class;
}
/**
* {@inheritdoc}
*/
protected function getExpectedId(): string
{
return 'HOTKEYS';
}
/**
* @group disconnected
* @dataProvider argumentsProvider
*/
public function testFilterArguments(array $actualArguments, array $expectedArguments): void
{
$command = $this->getCommand();
$command->setArguments($actualArguments);
$this->assertSame($expectedArguments, $command->getArguments());
}
/**
* @group disconnected
*/
public function testParseResponse(): void
{
$this->assertSame(1, $this->getCommand()->parseResponse(1));
}
/**
* @group connected
* @requiresRedisVersion >= 8.5.0
* @return void
*/
public function testRetrieveHotKeys()
{
$redis = $this->getClient();
// Starts hotkeys tracking (CPU only)
$this->assertEquals('OK', $redis->hotkeys->start([Container::CPU]));
$this->assertEquals('OK', $redis->set('key', 'value'));
$this->assertEquals('OK', $redis->hotkeys->stop());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertContains('key', $hotkeysInfo['by-cpu-time-us']);
// Starts hotkeys tracking (CPU and NET)
$this->assertEquals('OK', $redis->hotkeys->start([Container::CPU, Container::NET]));
$this->assertEquals('OK', $redis->set('key', 'value'));
$this->assertEquals('OK', $redis->set('key1', 'value1'));
$this->assertEquals('OK', $redis->hotkeys->stop());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertContains('key', $hotkeysInfo['by-cpu-time-us']);
$this->assertContains('key1', $hotkeysInfo['by-cpu-time-us']);
$this->assertContains('key', $hotkeysInfo['by-net-bytes']);
$this->assertContains('key1', $hotkeysInfo['by-net-bytes']);
// Starts hotkeys tracking (limited COUNT)
$this->assertEquals('OK', $redis->hotkeys->start([Container::CPU, Container::NET], 12));
for ($i = 0; $i < 13; $i++) {
$redis->set("key:$i", "value:$i");
}
$this->assertEquals('OK', $redis->hotkeys->stop());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertCount(24, $hotkeysInfo['by-cpu-time-us']);
// Starts hotkeys tracking (with DURATION, SAMPLE)
$this->assertEquals(
'OK',
$redis->hotkeys->start([Container::CPU, Container::NET], null, 1, 10)
);
$this->sleep(1.2);
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertEquals(0, $hotkeysInfo['tracking-active']);
$this->assertEquals(10, $hotkeysInfo['sample-ratio']);
$this->assertEquals('OK', $redis->hotkeys->reset());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertEquals(0, $hotkeysInfo['tracking-active']);
$this->assertNull($hotkeysInfo['sample-ratio']);
$this->assertEmpty($hotkeysInfo['selected-slots']);
$this->assertEmpty($hotkeysInfo['by-cpu-time-us']);
$this->assertEmpty($hotkeysInfo['by-net-bytes']);
}
/**
* @group connected
* @requiresRedisVersion >= 8.5.0
* @return void
*/
public function testRetrieveHotKeysResp3()
{
$redis = $this->getResp3Client();
// Starts hotkeys tracking (CPU only)
$this->assertEquals('OK', $redis->hotkeys->start([Container::CPU]));
$this->assertEquals('OK', $redis->set('key', 'value'));
$this->assertEquals('OK', $redis->hotkeys->stop());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertContains('key', $hotkeysInfo['by-cpu-time-us']);
// Starts hotkeys tracking (CPU and NET)
$this->assertEquals('OK', $redis->hotkeys->start([Container::CPU, Container::NET]));
$this->assertEquals('OK', $redis->set('key', 'value'));
$this->assertEquals('OK', $redis->set('key1', 'value1'));
$this->assertEquals('OK', $redis->hotkeys->stop());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertContains('key', $hotkeysInfo['by-cpu-time-us']);
$this->assertContains('key1', $hotkeysInfo['by-cpu-time-us']);
$this->assertContains('key', $hotkeysInfo['by-net-bytes']);
$this->assertContains('key1', $hotkeysInfo['by-net-bytes']);
// Starts hotkeys tracking (limited COUNT)
$this->assertEquals('OK', $redis->hotkeys->start([Container::CPU, Container::NET], 12));
for ($i = 0; $i < 13; $i++) {
$redis->set("key:$i", "value:$i");
}
$this->assertEquals('OK', $redis->hotkeys->stop());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertCount(24, $hotkeysInfo['by-cpu-time-us']);
// Starts hotkeys tracking (with DURATION, SAMPLE)
$this->assertEquals(
'OK',
$redis->hotkeys->start([Container::CPU, Container::NET], null, 1, 10)
);
$this->sleep(1.2);
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertEquals(0, $hotkeysInfo['tracking-active']);
$this->assertEquals(10, $hotkeysInfo['sample-ratio']);
$this->assertEquals('OK', $redis->hotkeys->reset());
$hotkeysInfo = $redis->hotkeys->get()[0];
$this->assertEquals(0, $hotkeysInfo['tracking-active']);
$this->assertNull($hotkeysInfo['sample-ratio']);
$this->assertEmpty($hotkeysInfo['selected-slots']);
$this->assertEmpty($hotkeysInfo['by-cpu-time-us']);
$this->assertEmpty($hotkeysInfo['by-net-bytes']);
}
/**
* @group connected
* @group cluster
* @requiresRedisVersion >= 8.5.0
* @return void
*/
public function testHotkeysStartDisabledInClusterClient()
{
$redis = $this->getClient();
$this->expectException(NotSupportedException::class);
$this->expectExceptionMessage("Cannot use 'HOTKEYS' with redis-cluster");
$redis->hotkeys->start([Container::CPU]);
}
/**
* @group disconnected
*/
public function testThrowsExceptionOnInvalidSampleValue(): void
{
$command = $this->getCommand();
$this->expectException(ValueError::class);
$this->expectExceptionMessage('Sample value should be greater than 0');
$command->setArguments(['START', ['metric1', 'metric2'], null, null, 0]);
}
/**
* @group disconnected
*/
public function testThrowsExceptionOnNegativeSampleValue(): void
{
$command = $this->getCommand();
$this->expectException(ValueError::class);
$this->expectExceptionMessage('Sample value should be greater than 0');
$command->setArguments(['START', ['metric1', 'metric2'], null, null, -1]);
}
/**
* @group disconnected
*/
public function testThrowsExceptionOnCountValueTooLow(): void
{
$command = $this->getCommand();
$this->expectException(ValueError::class);
$this->expectExceptionMessage('Count value should be between 10 and 64');
$command->setArguments(['START', ['metric1', 'metric2'], 9]);
}
/**
* @group disconnected
*/
public function testThrowsExceptionOnCountValueTooHigh(): void
{
$command = $this->getCommand();
$this->expectException(ValueError::class);
$this->expectExceptionMessage('Count value should be between 10 and 64');
$command->setArguments(['START', ['metric1', 'metric2'], 65]);
}
/**
* @group disconnected
*/
public function testThrowsExceptionOnCountValueZero(): void
{
$command = $this->getCommand();
$this->expectException(ValueError::class);
$this->expectExceptionMessage('Count value should be between 10 and 64');
$command->setArguments(['START', ['metric1', 'metric2'], 0]);
}
public function argumentsProvider(): array
{
return [
'with non-START subcommand' => [
['STOP'],
['STOP'],
],
'with START and metrics only' => [
['START', ['metric1', 'metric2']],
['START', 'METRICS', 2, 'metric1', 'metric2'],
],
'with START, metrics and COUNT' => [
['START', ['metric1', 'metric2'], 50],
['START', 'METRICS', 2, 'metric1', 'metric2', 'COUNT', 50],
],
'with START, metrics, COUNT and DURATION' => [
['START', ['metric1', 'metric2'], 50, 60],
['START', 'METRICS', 2, 'metric1', 'metric2', 'COUNT', 50, 'DURATION', 60],
],
'with START, metrics, COUNT, DURATION and SAMPLE' => [
['START', ['metric1', 'metric2'], 50, 60, 10],
['START', 'METRICS', 2, 'metric1', 'metric2', 'COUNT', 50, 'DURATION', 60, 'SAMPLE', 10],
],
'with START, metrics, COUNT, DURATION, SAMPLE and SLOTS' => [
['START', ['metric1', 'metric2'], 50, 60, 10, [1, 2, 3]],
['START', 'METRICS', 2, 'metric1', 'metric2', 'COUNT', 50, 'DURATION', 60, 'SAMPLE', 10, 'SLOTS', 3, 1, 2, 3],
],
'with START, metrics and SLOTS (no COUNT, DURATION, SAMPLE)' => [
['START', ['metric1'], null, null, null, [5, 10]],
['START', 'METRICS', 1, 'metric1', 'SLOTS', 2, 5, 10],
],
'with START, metrics, COUNT and SLOTS (no DURATION, SAMPLE)' => [
['START', ['metric1'], 50, null, null, [1, 2]],
['START', 'METRICS', 1, 'metric1', 'COUNT', 50, 'SLOTS', 2, 1, 2],
],
'with START, metrics, COUNT, DURATION and SLOTS (no SAMPLE)' => [
['START', ['metric1'], 50, 30, null, [1]],
['START', 'METRICS', 1, 'metric1', 'COUNT', 50, 'DURATION', 30, 'SLOTS', 1, 1],
],
'with START and single metric' => [
['START', ['metric1']],
['START', 'METRICS', 1, 'metric1'],
],
'with START and multiple metrics' => [
['START', ['metric1', 'metric2', 'metric3']],
['START', 'METRICS', 3, 'metric1', 'metric2', 'metric3'],
],
'with START, metrics and minimum valid COUNT (10)' => [
['START', ['metric1'], 10],
['START', 'METRICS', 1, 'metric1', 'COUNT', 10],
],
'with START, metrics and maximum valid COUNT (64)' => [
['START', ['metric1'], 64],
['START', 'METRICS', 1, 'metric1', 'COUNT', 64],
],
];
}
}