diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e68c0f7f..1942da0f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index f688ef67..3dce179e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) diff --git a/src/ClientContextInterface.php b/src/ClientContextInterface.php index 29a38d1f..e156f9e3 100644 --- a/src/ClientContextInterface.php +++ b/src/ClientContextInterface.php @@ -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 diff --git a/src/ClientInterface.php b/src/ClientInterface.php index bb3424e2..7320c9ff 100644 --- a/src/ClientInterface.php +++ b/src/ClientInterface.php @@ -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 diff --git a/src/Command/Container/HOTKEYS.php b/src/Command/Container/HOTKEYS.php new file mode 100644 index 00000000..1274ba34 --- /dev/null +++ b/src/Command/Container/HOTKEYS.php @@ -0,0 +1,49 @@ + $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|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()); + } +} diff --git a/src/Command/Redis/HOTKEYS.php b/src/Command/Redis/HOTKEYS.php new file mode 100644 index 00000000..77187d35 --- /dev/null +++ b/src/Command/Redis/HOTKEYS.php @@ -0,0 +1,93 @@ +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); + } +} diff --git a/tests/Predis/Command/Redis/HOTKEYS_Test.php b/tests/Predis/Command/Redis/HOTKEYS_Test.php new file mode 100644 index 00000000..ca0461d8 --- /dev/null +++ b/tests/Predis/Command/Redis/HOTKEYS_Test.php @@ -0,0 +1,324 @@ +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], + ], + ]; + } +}