From afdeb5c7b6dca652a04cd954b6c9d09186140909 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:14:25 -0400 Subject: [PATCH 01/68] Add Pest + PHPStan test harness matching walkor/workerman conventions Introduces a development testing and static analysis setup so the fork can run quality gates locally and in CI before adding new commands. Composer changes: - Bump PHP requirement to >=8.1 (required by Pest 3+/4) - Add require-dev: pestphp/pest, mockery/mockery, phpstan/phpstan - Add suggest: revolt/event-loop (enables coroutine return mode) - Add Tests\\ PSR-4 autoload-dev mapping - Add scripts: analyze (phpstan), test (pest), test:coverage (pest --coverage --min=70) - Allow the pest-plugin composer plugin Configuration files: - phpstan.neon.dist at level 5 with baseline include - phpstan-baseline.neon snapshotting 44 pre-existing typing issues in legacy code so new commits cannot regress past this line - phpunit.xml.dist with separate Unit and Feature test suites, coverage source pointing at src/, REDIS_URL env default - tests/Pest.php binding closures to Tests\TestCase across both suites - tests/TestCase.php with redisUrl() + skipWithoutRedis() helpers so integration tests degrade gracefully when no Redis is reachable - tests/Unit/ProtocolTest.php with 9 round-trip assertions on the RESP encoder + decoder (no live server required) .gitignore additions for vendor/, composer.lock, coverage artifacts, phpstan cache, and local-only Caliber/plan files. Verified: vendor/bin/pest reports 9 passed / 0 failed, and vendor/bin/phpstan analyse reports OK. --- .gitignore | 12 +++++ composer.json | 41 ++++++++++++++-- phpstan-baseline.neon | 97 +++++++++++++++++++++++++++++++++++++ phpstan.neon.dist | 13 +++++ phpunit.xml.dist | 24 +++++++++ tests/Feature/.gitkeep | 0 tests/Pest.php | 15 ++++++ tests/TestCase.php | 37 ++++++++++++++ tests/Unit/ProtocolTest.php | 54 +++++++++++++++++++++ 9 files changed, 289 insertions(+), 4 deletions(-) create mode 100644 phpstan-baseline.neon create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml.dist create mode 100644 tests/Feature/.gitkeep create mode 100644 tests/Pest.php create mode 100644 tests/TestCase.php create mode 100644 tests/Unit/ProtocolTest.php diff --git a/.gitignore b/.gitignore index f3f9e18..b06963d 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,15 @@ logs .settings .idea .DS_Store +/vendor/ +/composer.lock +/.phpunit.cache/ +/.pest.cache/ +/.phpstan.cache/ +/.phpunit.result.cache +coverage.xml +coverage.cobertura.xml +clover.xml +.phpunit.coverage/ +.caliber/ +async_plan.md diff --git a/composer.json b/composer.json index 4bbdbaf..606452b 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,46 @@ { - "name" : "workerman/redis", - "type" : "library", + "name": "workerman/redis", + "type": "library", + "description": "Asynchronous redis client for Workerman. Supports callback and Revolt-coroutine modes.", + "keywords": ["redis", "workerman", "async", "non-blocking", "coroutine", "dragonfly"], "homepage": "http://www.workerman.net", - "license" : "MIT", + "license": "MIT", + "authors": [ + { + "name": "walkor", + "email": "walkor@workerman.net", + "homepage": "https://www.workerman.net", + "role": "Developer" + } + ], "require": { - "php": ">=7", + "php": ">=8.1", "workerman/workerman": "^4.1.0||^5.0.0" }, + "require-dev": { + "pestphp/pest": "^2.36 || ^3 || ^4", + "mockery/mockery": "^1.6.12", + "phpstan/phpstan": "^2.1" + }, + "suggest": { + "revolt/event-loop": "Enables coroutine-style synchronous return values when no callback is provided." + }, "autoload": { "psr-4": {"Workerman\\Redis\\": "./src"} + }, + "autoload-dev": { + "psr-4": {"Tests\\": "tests/"} + }, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": true + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "scripts": { + "analyze": "php -d memory_limit=1G vendor/bin/phpstan analyse", + "test": "pest --colors=always", + "test:coverage": "pest --colors=always --coverage --min=70" } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..bd2228d --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,97 @@ +parameters: + ignoreErrors: + - + message: '#^Call to function is_callable\(\) with \*NEVER\* will always evaluate to true\.$#' + identifier: function.alreadyNarrowedType + count: 3 + path: src/Client.php + + - + message: '#^If condition is always true\.$#' + identifier: if.alwaysTrue + count: 3 + path: src/Client.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 2 + path: src/Client.php + + - + message: '#^Negated boolean expression is always true\.$#' + identifier: booleanNot.alwaysTrue + count: 8 + path: src/Client.php + + - + message: '#^Parameter \#1 \$timerId of static method Workerman\\Timer\:\:del\(\) expects int, Workerman\\Timer given\.$#' + identifier: argument.type + count: 2 + path: src/Client.php + + - + message: '#^Property Workerman\\Redis\\Client\:\:\$_connection \(Workerman\\Connection\\AsyncTcpConnection\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: src/Client.php + + - + message: '#^Property Workerman\\Redis\\Client\:\:\$_connectionCallback \(callable\(\)\: mixed\) does not accept null\.$#' + identifier: assign.propertyType + count: 1 + path: src/Client.php + + - + message: '#^Result of && is always false\.$#' + identifier: booleanAnd.alwaysFalse + count: 3 + path: src/Client.php + + - + message: '#^Strict comparison using \!\=\= between null and null will always evaluate to false\.$#' + identifier: notIdentical.alwaysFalse + count: 3 + path: src/Client.php + + - + message: '#^Unreachable statement \- code above always terminates\.$#' + identifier: deadCode.unreachable + count: 1 + path: src/Client.php + + - + message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\.$#' + identifier: return.type + count: 1 + path: src/Protocols/Redis.php + + - + message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\|string\>\.$#' + identifier: return.type + count: 1 + path: src/Protocols/Redis.php + + - + message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\.$#' + identifier: return.type + count: 5 + path: src/Protocols/Redis.php + + - + message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\.$#' + identifier: return.type + count: 2 + path: src/Protocols/Redis.php + + - + message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns int\.$#' + identifier: return.type + count: 2 + path: src/Protocols/Redis.php + + - + message: '#^Cannot use array destructuring on string\.$#' + identifier: offsetAccess.nonArray + count: 6 + path: tests/Unit/ProtocolTest.php diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..cbe21cb --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,13 @@ +includes: + - phpstan-baseline.neon + +parameters: + level: 5 + paths: + - src + - tests + ignoreErrors: + # Revolt is an optional runtime dependency; guarded by class_exists() checks at runtime. + - + message: '#unknown class Revolt\\EventLoop#' + reportUnmatched: false diff --git a/phpunit.xml.dist b/phpunit.xml.dist new file mode 100644 index 0000000..fb72b1d --- /dev/null +++ b/phpunit.xml.dist @@ -0,0 +1,24 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + ./src + + + + + + diff --git a/tests/Feature/.gitkeep b/tests/Feature/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/tests/Pest.php b/tests/Pest.php new file mode 100644 index 0000000..738ceb1 --- /dev/null +++ b/tests/Pest.php @@ -0,0 +1,15 @@ +in('Unit', 'Feature'); diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..2a8188f --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,37 @@ +redisUrl(); + $parts = parse_url($url); + $host = $parts['host'] ?? '127.0.0.1'; + $port = (int)($parts['port'] ?? 6379); + $errno = 0; + $errstr = ''; + $fp = @fsockopen($host, $port, $errno, $errstr, 0.5); + if (!$fp) { + $this->markTestSkipped("Redis not reachable at {$host}:{$port} ({$errstr})"); + } + fclose($fp); + } +} diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php new file mode 100644 index 0000000..e7cbbe3 --- /dev/null +++ b/tests/Unit/ProtocolTest.php @@ -0,0 +1,54 @@ +toBe("*1\r\n\$4\r\nPING\r\n"); +}); + +it('encodes commands with multiple bulk-string args', function () { + $wire = Redis::encode(['SET', 'foo', 'bar']); + expect($wire)->toBe("*3\r\n\$3\r\nSET\r\n\$3\r\nfoo\r\n\$3\r\nbar\r\n"); +}); + +it('flattens nested array args (MGET-style)', function () { + $wire = Redis::encode(['MGET', ['a', 'b', 'c']]); + expect($wire)->toBe("*4\r\n\$4\r\nMGET\r\n\$1\r\na\r\n\$1\r\nb\r\n\$1\r\nc\r\n"); +}); + +it('decodes a simple-string reply', function () { + [$type, $value] = Redis::decode("+OK\r\n"); + expect($type)->toBe('+'); + expect($value)->toBe('OK'); +}); + +it('decodes an integer reply', function () { + [$type, $value] = Redis::decode(":42\r\n"); + expect($type)->toBe(':'); + expect($value)->toBe(42); +}); + +it('decodes a bulk-string reply', function () { + [$type, $value] = Redis::decode("\$5\r\nhello\r\n"); + expect($type)->toBe('$'); + expect($value)->toBe('hello'); +}); + +it('decodes a null bulk-string reply', function () { + [$type, $value] = Redis::decode("\$-1\r\n"); + expect($type)->toBe('$'); + expect($value)->toBeNull(); +}); + +it('decodes an array reply with mixed types', function () { + [$type, $value] = Redis::decode("*3\r\n:1\r\n\$3\r\nfoo\r\n+OK\r\n"); + expect($type)->toBe('*'); + expect($value)->toBe([1, 'foo', 'OK']); +}); + +it('decodes an error reply', function () { + [$type, $value] = Redis::decode("-ERR wrong\r\n"); + expect($type)->toBe('-'); + expect($value)->toBe('ERR wrong'); +}); From c541753c19fcc1580e2d9aab7a2b65a1fc1c9c79 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:22:17 -0400 Subject: [PATCH 02/68] Refactor command queueing into shared queueCommand() + dispatcher() helpers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every explicit command method in Client.php previously repeated the same ~10 lines: check class_exists(Revolt\EventLoop), grab a Suspension if needed, push the queue tuple, call process(), suspend if needed, return null. Each copy was a maintenance hazard — bug fixes had to land in N places, and adding new commands meant pasting the boilerplate again. queueCommand(array $args, ?callable $cb, ?callable $format) collapses the pattern. Every command method now reduces to one return statement. __call() also routes through it, so the Revolt-vs-callback decision lives in exactly one place. dispatcher(string $prefix, array $args) is the corresponding helper for multi-verb command families and dotted module commands. Two forms: - 'CLUSTER ' (trailing space) -> emits ['CLUSTER','INFO',...] - 'JSON.' (trailing dot) -> emits ['JSON.SET',...] The verb is auto-uppercased and any trailing callable in $args is popped as the callback. This is the foundation for the upcoming config(), acl(), slowLog(), memory(), command(), cluster(), json(), bf(), cms(), topk(), and ft() methods. Refactored to use queueCommand(): select(), auth(), set(), incr(), decr(), sort(), mapCb(), keyMapCb(), hMGet(), hGetAll(), __call(). Net delta is -76 lines despite adding two helpers and their docblocks. Also corrected the @param annotations on select() and auth() to say 'callable|null $cb' instead of 'null $cb' (the legacy form told PHPStan $cb was strictly null, which made $cb ?: ... look like dead code). Regenerated phpstan-baseline.neon — 36 errors instead of 44, since the refactor naturally fixed eight typing nits that fell out with the dead copies. Verified: vendor/bin/pest still passes 9/9 (RESP encoder/decoder suite), vendor/bin/phpstan analyse reports OK. --- phpstan-baseline.neon | 6 - src/Client.php | 272 ++++++++++++++++-------------------------- 2 files changed, 101 insertions(+), 177 deletions(-) diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index bd2228d..b75a2e9 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -18,12 +18,6 @@ parameters: count: 2 path: src/Client.php - - - message: '#^Negated boolean expression is always true\.$#' - identifier: booleanNot.alwaysTrue - count: 8 - path: src/Client.php - - message: '#^Parameter \#1 \$timerId of static method Workerman\\Timer\:\:del\(\) expects int, Workerman\\Timer given\.$#' identifier: argument.type diff --git a/src/Client.php b/src/Client.php index 201920b..4e77d65 100644 --- a/src/Client.php +++ b/src/Client.php @@ -468,6 +468,75 @@ public function process() $this->_error = ''; } + /** + * Queue a command for transmission and return immediately (callback mode) + * or suspend the current fiber until the reply arrives (Revolt mode). + * + * Every explicit command method should funnel through this helper so the + * suspension / non-suspension branches stay identical and bug-fixes apply + * uniformly. When Revolt's EventLoop class is loaded and no callback was + * provided, a Suspension is created, registered as the callback, and the + * current fiber is suspended; the suspension is resumed by onMessage() + * via the queue's stored callback. + * + * @param array $args The wire-level command parts, e.g. ['SET','key','value']. + * @param callable|null $cb User callback signature: function($result, Client $client). + * @param callable|null $format Optional reshaper applied to the raw result before $cb. + * @return mixed The reply when suspended; null in pure callback mode. + */ + protected function queueCommand(array $args, $cb = null, $format = null) + { + $need_suspend = !$cb && \class_exists(EventLoop::class, false); + if ($need_suspend) { + [$suspension, $cb] = $this->suspenstion(); + } + if ($format === null) { + $this->_queue[] = [$args, time(), $cb]; + } else { + $this->_queue[] = [$args, time(), $cb, $format]; + } + $this->process(); + if ($need_suspend) { + return $suspension->suspend(); + } + return null; + } + + /** + * Dispatch a subcommand for a multi-verb family (CONFIG, ACL, SLOWLOG, + * MEMORY, COMMAND, CLUSTER, CLIENT) or a dotted module command (JSON.*, + * BF.*, CMS.*, TOPK.*, FT.*). + * + * The convention is: caller passes `$prefix` ending in either a space + * ('CLUSTER ') for subcommand verbs or a dot ('JSON.') for module + * commands. The first element of $args is the verb (uppercased here); + * the rest are command arguments. A trailing callable in $args is + * popped and treated as the callback. + * + * For dot-prefixed families the verb is glued onto the prefix to form a + * single Redis token ('JSON.SET'); for space-prefixed families the verb + * becomes a separate wire arg ('CLUSTER', 'INFO'). + * + * @param string $prefix Either 'FAMILY ' (space) or 'PREFIX.' (dot). + * @param array $args [verb, ...args, optional callable]. + * @return mixed + */ + protected function dispatcher($prefix, array $args) + { + $cb = null; + if (!empty($args) && \is_callable(\end($args))) { + $cb = \array_pop($args); + } + $verb = \strtoupper((string)\array_shift($args)); + if ($prefix !== '' && $prefix[\strlen($prefix) - 1] === '.') { + \array_unshift($args, $prefix . $verb); + } else { + $head = \rtrim($prefix); + \array_unshift($args, $head, $verb); + } + return $this->queueCommand($args, $cb); + } + /** * subscribe * @@ -527,8 +596,8 @@ public function pSubscribe($patterns, $cb) /** * select * - * @param $db - * @param null $cb + * @param int $db + * @param callable|null $cb * @return mixed */ public function select($db, $cb = null) @@ -537,25 +606,14 @@ public function select($db, $cb = null) $this->_db = $db; return $result; }; - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $cb = $cb ?: function () { - }; - $this->_queue[] = [['SELECT', $db], time(), $cb, $format]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand(['SELECT', $db], $cb ?: function () {}, $format); } /** * auth * - * @param string|array $auth - * @param null $cb + * @param string|array $auth + * @param callable|null $cb * @return mixed */ public function auth($auth, $cb = null) @@ -564,18 +622,8 @@ public function auth($auth, $cb = null) $this->_auth = $auth; return $result; }; - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $cb = $cb ?: function () { - }; - $this->_queue[] = [['AUTH', $auth], time(), $cb, $format]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + $args = \is_array($auth) ? \array_merge(['AUTH'], $auth) : ['AUTH', $auth]; + return $this->queueCommand($args, $cb ?: function () {}, $format); } /** @@ -588,34 +636,13 @@ public function auth($auth, $cb = null) */ public function set($key, $value, $cb = null) { - $args = func_get_args(); + $args = \func_get_args(); if ($cb !== null && !\is_callable($cb)) { $timeout = $cb; - $cb = null; - if (\count($args) > 3) { - $cb = $args[3]; - } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['SETEX', $key, $timeout, $value], time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + $cb = $args[3] ?? null; + return $this->queueCommand(['SETEX', $key, $timeout, $value], $cb); } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['SET', $key, $value], time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand(['SET', $key, $value], $cb); } /** @@ -627,34 +654,13 @@ public function set($key, $value, $cb = null) */ public function incr($key, $cb = null) { - $args = func_get_args(); + $args = \func_get_args(); if ($cb !== null && !\is_callable($cb)) { $num = $cb; - $cb = null; - if (\count($args) > 2) { - $cb = $args[2]; - } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['INCRBY', $key, $num], time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; - } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['INCR', $key], time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); + $cb = $args[2] ?? null; + return $this->queueCommand(['INCRBY', $key, $num], $cb); } - return null; + return $this->queueCommand(['INCR', $key], $cb); } @@ -667,34 +673,13 @@ public function incr($key, $cb = null) */ public function decr($key, $cb = null) { - $args = func_get_args(); + $args = \func_get_args(); if ($cb !== null && !\is_callable($cb)) { $num = $cb; - $cb = null; - if (\count($args) > 2) { - $cb = $args[2]; - } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['DECRBY', $key, $num], time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + $cb = $args[2] ?? null; + return $this->queueCommand(['DECRBY', $key, $num], $cb); } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['DECR', $key], time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand(['DECR', $key], $cb); } /** @@ -715,7 +700,7 @@ function sort($key, $options, $cb = null) foreach ($options as $op => $value) { $args[] = $op; - if (!is_array($value)) { + if (!\is_array($value)) { $args[] = $value; continue; } @@ -724,16 +709,7 @@ function sort($key, $options, $cb = null) } } \array_unshift($args, 'SORT', $key); - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [$args, time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand($args, $cb); } /** @@ -773,16 +749,7 @@ protected function mapCb($command, array $array, $cb) $args[] = $key; $args[] = $value; } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [$args, time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand($args, $cb); } /** @@ -809,21 +776,12 @@ public function hMSet($key, array $array, $cb = null) public function hMGet($key, array $array, $cb = null) { $format = function ($result) use ($array) { - if (!is_array($result)) { + if (!\is_array($result)) { return $result; } return \array_combine($array, $result); }; - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['HMGET', $key, $array], time(), $cb, $format]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand(['HMGET', $key, $array], $cb, $format); } /** @@ -850,16 +808,7 @@ public function hGetAll($key, $cb = null) } return $return; }; - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [['HGETALL', $key], time(), $cb, $format]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand(['HGETALL', $key], $cb, $format); } /** @@ -874,20 +823,11 @@ public function hGetAll($key, $cb = null) protected function keyMapCb($command, $key, array $array, $cb) { $args = [$command, $key]; - foreach ($array as $key => $value) { - $args[] = $key; + foreach ($array as $field => $value) { + $args[] = $field; $args[] = $value; } - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [$args, time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand($args, $cb); } /** @@ -900,23 +840,13 @@ protected function keyMapCb($command, $key, array $array, $cb) public function __call($method, $args) { $cb = null; - if (count($args) > 1 || in_array($method, ['randomKey', 'multi', 'exec', 'discard'])) { - if (\is_callable(end($args))) { - $cb = array_pop($args); + if (\count($args) > 1 || \in_array($method, ['randomKey', 'multi', 'exec', 'discard'], true)) { + if (\is_callable(\end($args))) { + $cb = \array_pop($args); } } - \array_unshift($args, \strtoupper($method)); - $need_suspend = !$cb && class_exists(EventLoop::class, false); - if ($need_suspend) { - [$suspension, $cb] = $this->suspenstion(); - } - $this->_queue[] = [$args, time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); - } - return null; + return $this->queueCommand($args, $cb); } /** From 478e6a3207b4428f027a060d9fb38235a2644fde Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:34:07 -0400 Subject: [PATCH 03/68] Add subprocess-based integration test harness for Workerman async client MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Workerman's Worker::runAll() takes over the process — forks, installs signal handlers, prints a banner on stdout, and eventually exits — which makes it impossible to run multiple async-client commands inline in a single Pest process. The pragmatic answer is to push each integration assertion into its own short-lived PHP child. tests/RedisTestCase.php defines the runInWorker(string $snippet) helper. It proc_open's a subprocess running tests/Support/run-in-worker.php with the snippet on stdin and an extra pipe (fd 3) for the test result. The snippet runs inside a Workerman worker with $redis, $emit($value), and $fail($msg) in scope. Calling $emit() writes 'OK \n' to fd 3 and SIGTERM's the master so the whole subprocess tears down cleanly. Design notes: - fd 3 is used instead of stdout because Workerman prints its boot banner on stdout and mixing it with the result protocol is fragile. - Each invocation sets a unique Worker::$pidFile/$logFile under sys_get_temp_dir() so repeat runs don't collide on the "already running" check. - The child calls posix_kill(getppid(), SIGTERM) + exit(0) after flushing the result — Worker::stopAll() alone leaves the master monitoring a child it will immediately try to respawn, leading to a tight emit loop. Pest binds Feature/ closures to RedisTestCase via tests/Pest.php, so a Feature test reads like: it('does X', function () { $result = $this->runInWorker(<<<'PHP' $redis->set('k','v'); $redis->get('k', function ($v) use ($emit) { $emit($v); }); PHP); expect($result)->toBe('v'); }); tests/Feature/SmokeTest.php exercises the harness with a SET/GET round trip and an __call dispatch through incr(). Each test completes in under 200ms; the proc_open overhead is acceptable for an integration suite that will grow to ~80 commands. Verified: vendor/bin/pest reports 11 passed / 17 assertions (9 unit + 2 integration). vendor/bin/phpstan analyse reports OK. Ignores tests/Support/*.log and *.pid to keep Workerman runtime artifacts out of git. --- .gitignore | 3 + tests/Feature/SmokeTest.php | 25 +++++++ tests/Pest.php | 3 +- tests/RedisTestCase.php | 82 +++++++++++++++++++++++ tests/Support/run-in-worker.php | 112 ++++++++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/SmokeTest.php create mode 100644 tests/RedisTestCase.php create mode 100644 tests/Support/run-in-worker.php diff --git a/.gitignore b/.gitignore index b06963d..4305fa4 100644 --- a/.gitignore +++ b/.gitignore @@ -16,3 +16,6 @@ clover.xml .phpunit.coverage/ .caliber/ async_plan.md +redis_start_prompt.md +tests/Support/*.log +tests/Support/*.pid diff --git a/tests/Feature/SmokeTest.php b/tests/Feature/SmokeTest.php new file mode 100644 index 0000000..ba9b3d7 --- /dev/null +++ b/tests/Feature/SmokeTest.php @@ -0,0 +1,25 @@ +runInWorker(<<<'PHP' + $redis->set('pest:smoke:k', 'hello-pest'); + $redis->get('pest:smoke:k', function ($value) use ($emit) { + $emit($value); + }); + PHP); + expect($result)->toBe('hello-pest'); +}); + +it('routes __call commands through the dispatcher', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $redis->del('pest:smoke:counter'); + $redis->incr('pest:smoke:counter'); + $redis->incr('pest:smoke:counter'); + $redis->incr('pest:smoke:counter', function ($value) use ($emit) { + $emit($value); + }); + PHP); + expect($result)->toBe(3); +}); diff --git a/tests/Pest.php b/tests/Pest.php index 738ceb1..b89f121 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -12,4 +12,5 @@ | */ -uses(\Tests\TestCase::class)->in('Unit', 'Feature'); +uses(\Tests\TestCase::class)->in('Unit'); +uses(\Tests\RedisTestCase::class)->in('Feature'); diff --git a/tests/RedisTestCase.php b/tests/RedisTestCase.php new file mode 100644 index 0000000..4cb5b60 --- /dev/null +++ b/tests/RedisTestCase.php @@ -0,0 +1,82 @@ +skipWithoutRedis(); + } + + /** + * Execute $snippet inside a child PHP process with Workerman initialized. + * + * The snippet is plain PHP code (no $this->redisUrl(), + 'REDIS_TEST_TIMEOUT' => (string)$timeout, + 'PATH' => getenv('PATH'), + ]; + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + 3 => ['pipe', 'w'], + ]; + $proc = proc_open([PHP_BINARY, $runner, 'start'], $descriptors, $pipes, null, $env); + if (!\is_resource($proc)) { + $this->fail('Could not spawn run-in-worker child'); + } + fwrite($pipes[0], $snippet); + fclose($pipes[0]); + $resultLine = stream_get_contents($pipes[3]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + fclose($pipes[3]); + $exitCode = proc_close($proc); + + $firstLine = strtok($resultLine, "\n"); + if ($firstLine === false) { + $this->fail("Child produced no result. exit={$exitCode} stdout={$stdout} stderr={$stderr}"); + } + if (str_starts_with($firstLine, 'FAIL ')) { + $this->fail('Worker child reported: ' . substr($firstLine, 5) . " stderr={$stderr}"); + } + if (!str_starts_with($firstLine, 'OK ')) { + $this->fail("Unexpected child result: {$firstLine} stderr={$stderr}"); + } + return json_decode(substr($firstLine, 3), true); + } +} diff --git a/tests/Support/run-in-worker.php b/tests/Support/run-in-worker.php new file mode 100644 index 0000000..7f28ed8 --- /dev/null +++ b/tests/Support/run-in-worker.php @@ -0,0 +1,112 @@ +\n + * FAIL \n + * + * Timeout: REDIS_TEST_TIMEOUT env var (default 5 seconds). + */ + +require __DIR__ . '/../../vendor/autoload.php'; + +use Workerman\Redis\Client; +use Workerman\Worker; +use Workerman\Timer; + +$snippet = stream_get_contents(STDIN); +if ($snippet === false || $snippet === '') { + fwrite(STDERR, "no snippet on stdin\n"); + exit(2); +} + +$resultFd = fopen('php://fd/3', 'w'); +if (!$resultFd) { + // Fall back to stdout if fd 3 isn't available — parent must filter. + $resultFd = STDOUT; +} + +$redisUrl = getenv('REDIS_URL') ?: 'redis://127.0.0.1:6379'; +$timeoutSeconds = (int)(getenv('REDIS_TEST_TIMEOUT') ?: 5); + +$uniq = bin2hex(random_bytes(6)); +Worker::$pidFile = sys_get_temp_dir() . "/wm-redis-test-{$uniq}.pid"; +Worker::$logFile = sys_get_temp_dir() . "/wm-redis-test-{$uniq}.log"; +Worker::$stdoutFile = '/dev/null'; +Worker::$daemonize = false; + +$emitted = false; + +$worker = new Worker(); +$worker->count = 1; +$worker->onWorkerStart = function () use ($snippet, $redisUrl, $timeoutSeconds, $resultFd, &$emitted) { + $redis = new Client($redisUrl); + + $emit = function ($value) use (&$emitted, $resultFd) { + if ($emitted) { + return; + } + $emitted = true; + fwrite($resultFd, 'OK ' . json_encode($value, JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE) . "\n"); + fflush($resultFd); + // Signal the master AND exit — stopAll() alone leaves the master + // monitoring a child it will immediately try to respawn, leading + // to a tight emit loop. SIGTERM to the parent (master) followed + // by exit() in the child cleanly tears down both. + $ppid = posix_getppid(); + if ($ppid > 1) { + posix_kill($ppid, SIGTERM); + } + exit(0); + }; + + $fail = function ($msg) use (&$emitted, $resultFd) { + if ($emitted) { + return; + } + $emitted = true; + fwrite($resultFd, 'FAIL ' . $msg . "\n"); + fflush($resultFd); + $ppid = posix_getppid(); + if ($ppid > 1) { + posix_kill($ppid, SIGTERM); + } + exit(0); + }; + + Timer::add($timeoutSeconds, function () use ($fail) { + $fail('timeout'); + }, [], false); + + try { + eval($snippet); + } catch (\Throwable $e) { + $fail('exception: ' . $e->getMessage()); + } +}; + +Worker::runAll(); + +// Clean up tempfiles. (Workerman may exit before reaching here, but if it +// returns normally we tidy up.) +@unlink(Worker::$pidFile); +@unlink(Worker::$logFile); From 4059e42c7bff120dee20355e5d0fd65ea7c3b69d Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:34:29 -0400 Subject: [PATCH 04/68] Add GitHub Actions CI workflow with Codecov + Codacy coverage upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI runs Pest + PHPStan against PHP 8.1, 8.2, and 8.3 with a live Dragonfly instance installed via the official APT repo. Dragonfly is wire-compatible with Redis and is this fork's canonical compatibility target — driving the per-command priority list in the implementation plan. Workflow shape: - shivammathur/setup-php@v2 brings up PHP with pcntl/posix/sockets/ json extensions and PCOV for coverage. - APT recipe installs Dragonfly and waits up to 30 seconds for redis-cli ping to return PONG before continuing. - Composer cache is keyed off PHP version + composer.json hash to cut cold-cache runs roughly in half. - composer analyze (PHPStan) and pest --coverage --coverage-clover run sequentially; the clover file is uploaded as an artifact only on the 8.3 leg to avoid duplicate uploads. - codecov/codecov-action@v4 and codacy/codacy-coverage-reporter-action@v1 consume CODECOV_TOKEN and CODACY_API_TOKEN repo secrets. Codacy upload is best-effort (continue-on-error) — Codecov is the canonical coverage source. README badges added: CI status, Codecov coverage, Codacy grade, Codacy coverage, Packagist version, Packagist downloads, license, and PHP version constraint. Also added a Development section with the composer scripts and a note about Dragonfly being the compatibility target. --- .github/workflows/ci.yml | 99 ++++++++++++++++++++++++++++++++++++++++ README.md | 40 +++++++++++++--- 2 files changed, 132 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ab1960d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,99 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + test: + name: Pest + PHPStan (PHP ${{ matrix.php }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: ['8.1', '8.2', '8.3'] + env: + REDIS_URL: redis://127.0.0.1:6379 + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pcntl, posix, sockets, json + coverage: pcov + tools: composer:v2 + + - name: Install Dragonfly + run: | + sudo curl -Lo /usr/share/keyrings/dragonfly-keyring.public https://packages.dragonflydb.io/pgp-key.public + sudo curl -Lo /etc/apt/sources.list.d/dragonfly.sources https://packages.dragonflydb.io/dragonfly.sources + sudo apt-get update + sudo apt-get install -y dragonfly + sudo systemctl start dragonfly + for i in {1..30}; do + if redis-cli ping >/dev/null 2>&1; then + echo "Dragonfly is up" + redis-cli info server | head -5 + exit 0 + fi + sleep 1 + done + echo "Dragonfly did not start in time" + sudo systemctl status dragonfly --no-pager || true + exit 1 + + - name: Get composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" + + - name: Cache composer dependencies + uses: actions/cache@v4 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-php-${{ matrix.php }}-composer-${{ hashFiles('**/composer.json') }} + restore-keys: | + ${{ runner.os }}-php-${{ matrix.php }}-composer- + + - name: Install Composer dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run PHPStan + run: composer analyze + + - name: Run Pest with coverage + run: vendor/bin/pest --colors=never --coverage --coverage-clover=coverage.xml + + - name: Upload coverage artifact + if: matrix.php == '8.3' + uses: actions/upload-artifact@v4 + with: + name: coverage-clover + path: coverage.xml + retention-days: 7 + + - name: Upload coverage to Codecov + if: matrix.php == '8.3' + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + flags: pest + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Upload coverage to Codacy + if: matrix.php == '8.3' + uses: codacy/codacy-coverage-reporter-action@v1 + with: + project-token: ${{ secrets.CODACY_API_TOKEN }} + coverage-reports: coverage.xml + continue-on-error: true diff --git a/README.md b/README.md index badde39..127abbc 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,30 @@ # redis -Asynchronous redis client for PHP based on workerman. -# Install +Asynchronous Redis client for PHP, built on Workerman. + +[![CI](https://github.com/detain/redis/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/detain/redis/actions/workflows/ci.yml) +[![Codecov](https://codecov.io/gh/detain/redis/branch/master/graph/badge.svg)](https://codecov.io/gh/detain/redis) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/detain/redis)](https://app.codacy.com/gh/detain/redis/dashboard) +[![Codacy Coverage](https://app.codacy.com/project/badge/Coverage/detain/redis)](https://app.codacy.com/gh/detain/redis/dashboard) +[![Latest Stable Version](https://poser.pugx.org/workerman/redis/v/stable)](https://packagist.org/packages/workerman/redis) +[![Total Downloads](https://poser.pugx.org/workerman/redis/downloads)](https://packagist.org/packages/workerman/redis) +[![License](https://poser.pugx.org/workerman/redis/license)](LICENSE) +[![PHP Version](https://img.shields.io/packagist/php-v/workerman/redis.svg)](https://php.net) + +Wire-compatible with both Redis and [Dragonfly](https://www.dragonflydb.io/). Supports two execution modes: + +- **Callback mode** — works out of the box, no extra dependencies. +- **Coroutine mode** — if [`revolt/event-loop`](https://github.com/revoltphp/event-loop) is installed, methods can be called without a callback and the current fiber suspends until the result arrives. + +## Install ``` composer require workerman/redis ``` -# Usage -```php +## Usage +```php require_once __DIR__ . '/vendor/autoload.php'; use Workerman\Redis\Client; use Workerman\Worker; @@ -23,15 +38,26 @@ $worker->onWorkerStart = function() { $worker->onMessage = function($connection, $data) { global $redis; - $redis->set('key', 'hello world'); + $redis->set('key', 'hello world'); $redis->get('key', function ($result) use ($connection) { $connection->send($result); - }); + }); }; Worker::runAll(); ``` -## Document +## Development + +``` +composer install +composer test # Pest +composer analyze # PHPStan +composer test:coverage # Pest with coverage (requires Xdebug or PCOV) +``` + +Integration tests connect to a real Redis/Dragonfly at `REDIS_URL` (default `redis://127.0.0.1:6379`). Tests skip cleanly when no server is reachable. + +## Documentation http://doc.workerman.net/components/workerman-redis.html From 738cb0a27bf8df715d4d0c2806ef89d0c055f2f8 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:40:48 -0400 Subject: [PATCH 05/68] fixing dragonfly install in workflow --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ab1960d..61315c3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,8 @@ jobs: sudo curl -Lo /etc/apt/sources.list.d/dragonfly.sources https://packages.dragonflydb.io/dragonfly.sources sudo apt-get update sudo apt-get install -y dragonfly + sudo mkdir -p /var/run/dragonfly + sudo chown dfly:dfly /var/run/dragonfly sudo systemctl start dragonfly for i in {1..30}; do if redis-cli ping >/dev/null 2>&1; then From 90e1db800ea911e4fb46989c98f90d90cee9552b Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:44:25 -0400 Subject: [PATCH 06/68] manual start of df in w --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61315c3..314c9fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,7 @@ jobs: sudo apt-get install -y dragonfly sudo mkdir -p /var/run/dragonfly sudo chown dfly:dfly /var/run/dragonfly + sudo -u dfly /usr/bin/dragonfly --flagfile=/etc/dragonfly/dragonfly.conf sudo systemctl start dragonfly for i in {1..30}; do if redis-cli ping >/dev/null 2>&1; then From 86a5f6d56872f5253336c92b4a8a0248f112e73d Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:46:58 -0400 Subject: [PATCH 07/68] last time i found an existing se5rvice on the redis port.. so if its redis.. added stopping and removing redis package to front of workflow.. if its related to startijng it twice added an echo to resolve that --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 314c9fd..cc0a7f3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,11 +37,13 @@ jobs: sudo curl -Lo /usr/share/keyrings/dragonfly-keyring.public https://packages.dragonflydb.io/pgp-key.public sudo curl -Lo /etc/apt/sources.list.d/dragonfly.sources https://packages.dragonflydb.io/dragonfly.sources sudo apt-get update + sudo systemctl stop redis + sudo apt-get remove -y --purge redis sudo apt-get install -y dragonfly sudo mkdir -p /var/run/dragonfly sudo chown dfly:dfly /var/run/dragonfly sudo -u dfly /usr/bin/dragonfly --flagfile=/etc/dragonfly/dragonfly.conf - sudo systemctl start dragonfly + echo sudo systemctl start dragonfly for i in {1..30}; do if redis-cli ping >/dev/null 2>&1; then echo "Dragonfly is up" From 0db6927886aa2d8fe84ccd5b605f6d5a864be747 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:48:00 -0400 Subject: [PATCH 08/68] update --- .github/workflows/ci.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cc0a7f3..092ea79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -37,9 +37,7 @@ jobs: sudo curl -Lo /usr/share/keyrings/dragonfly-keyring.public https://packages.dragonflydb.io/pgp-key.public sudo curl -Lo /etc/apt/sources.list.d/dragonfly.sources https://packages.dragonflydb.io/dragonfly.sources sudo apt-get update - sudo systemctl stop redis - sudo apt-get remove -y --purge redis - sudo apt-get install -y dragonfly + sudo apt-get install -y dragonfly lsof sudo mkdir -p /var/run/dragonfly sudo chown dfly:dfly /var/run/dragonfly sudo -u dfly /usr/bin/dragonfly --flagfile=/etc/dragonfly/dragonfly.conf From b1cdd77dfd68e2973bcf6747831b44a1845def17 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:49:20 -0400 Subject: [PATCH 09/68] update --- .github/workflows/ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 092ea79..01a0ae3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI +hname: CI on: push: @@ -40,6 +40,7 @@ jobs: sudo apt-get install -y dragonfly lsof sudo mkdir -p /var/run/dragonfly sudo chown dfly:dfly /var/run/dragonfly + sudo systemctl stop dragonfly sudo -u dfly /usr/bin/dragonfly --flagfile=/etc/dragonfly/dragonfly.conf echo sudo systemctl start dragonfly for i in {1..30}; do From bdc8b3d7cd4a3899cfc8a134737068eb3271e347 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:51:00 -0400 Subject: [PATCH 10/68] fix loose char --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 01a0ae3..4f63c83 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -hname: CI +name: CI on: push: From 50357d2bd15d2912b1ba60a94ab665177f20c349 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 12:53:08 -0400 Subject: [PATCH 11/68] update --- .github/workflows/ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f63c83..ebf5d7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,14 +34,13 @@ jobs: - name: Install Dragonfly run: | + sudo mkdir -p /var/run/dragonfly + sudo chmod 777 /var/run/dragonfly sudo curl -Lo /usr/share/keyrings/dragonfly-keyring.public https://packages.dragonflydb.io/pgp-key.public sudo curl -Lo /etc/apt/sources.list.d/dragonfly.sources https://packages.dragonflydb.io/dragonfly.sources sudo apt-get update sudo apt-get install -y dragonfly lsof - sudo mkdir -p /var/run/dragonfly - sudo chown dfly:dfly /var/run/dragonfly - sudo systemctl stop dragonfly - sudo -u dfly /usr/bin/dragonfly --flagfile=/etc/dragonfly/dragonfly.conf + sudo chown dfly:dfly /var/run/dragonfly -R echo sudo systemctl start dragonfly for i in {1..30}; do if redis-cli ping >/dev/null 2>&1; then From 185f1bc7219569c715565e88796b837481d1840c Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:00:21 -0400 Subject: [PATCH 12/68] update switching to docker dragonfly image --- .github/workflows/ci.yml | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ebf5d7a..47be881 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,6 +20,13 @@ jobs: php: ['8.1', '8.2', '8.3'] env: REDIS_URL: redis://127.0.0.1:6379 + services: + dragonfly: + image: docker.dragonflydb.io/dragonflydb/dragonfly + ports: + - 6379:6379 + options: >- + --ulimit memlock=-1 steps: - name: Checkout uses: actions/checkout@v4 @@ -32,16 +39,8 @@ jobs: coverage: pcov tools: composer:v2 - - name: Install Dragonfly + - name: Check Dragonfly Service run: | - sudo mkdir -p /var/run/dragonfly - sudo chmod 777 /var/run/dragonfly - sudo curl -Lo /usr/share/keyrings/dragonfly-keyring.public https://packages.dragonflydb.io/pgp-key.public - sudo curl -Lo /etc/apt/sources.list.d/dragonfly.sources https://packages.dragonflydb.io/dragonfly.sources - sudo apt-get update - sudo apt-get install -y dragonfly lsof - sudo chown dfly:dfly /var/run/dragonfly -R - echo sudo systemctl start dragonfly for i in {1..30}; do if redis-cli ping >/dev/null 2>&1; then echo "Dragonfly is up" From 048e1e70665bfb71b5884c5f4312bd4bf29d1d1b Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:04:13 -0400 Subject: [PATCH 13/68] update --- .github/workflows/ci.yml | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 47be881..fbc88cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,20 +39,6 @@ jobs: coverage: pcov tools: composer:v2 - - name: Check Dragonfly Service - run: | - for i in {1..30}; do - if redis-cli ping >/dev/null 2>&1; then - echo "Dragonfly is up" - redis-cli info server | head -5 - exit 0 - fi - sleep 1 - done - echo "Dragonfly did not start in time" - sudo systemctl status dragonfly --no-pager || true - exit 1 - - name: Get composer cache directory id: composer-cache run: echo "dir=$(composer config cache-files-dir)" >> "$GITHUB_OUTPUT" From 005e9520e665f61f544592286c11d5fa695320ad Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:08:22 -0400 Subject: [PATCH 14/68] update --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index 606452b..cdcf8a9 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,9 @@ "workerman/workerman": "^4.1.0||^5.0.0" }, "require-dev": { - "pestphp/pest": "^2.36 || ^3 || ^4", - "mockery/mockery": "^1.6.12", - "phpstan/phpstan": "^2.1" + "pestphp/pest": "*", + "mockery/mockery": "*", + "phpstan/phpstan": "*" }, "suggest": { "revolt/event-loop": "Enables coroutine-style synchronous return values when no callback is provided." From 93e96940307346987b6eaa0f6bc10c7a083765ca Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:11:52 -0400 Subject: [PATCH 15/68] update --- composer.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/composer.json b/composer.json index cdcf8a9..b3d0f41 100644 --- a/composer.json +++ b/composer.json @@ -18,9 +18,9 @@ "workerman/workerman": "^4.1.0||^5.0.0" }, "require-dev": { - "pestphp/pest": "*", - "mockery/mockery": "*", - "phpstan/phpstan": "*" + "pestphp/pest": ">=1.23.1", + "mockery/mockery": ">=1.6.12", + "phpstan/phpstan": ">=2.2.1" }, "suggest": { "revolt/event-loop": "Enables coroutine-style synchronous return values when no callback is provided." From 48b2eae106280d5f49768595517c7247e37d69f4 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:34:20 -0400 Subject: [PATCH 16/68] Implement SCAN and scanAll(), rewrite RESP decoder for nested arrays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SCAN was the highest-priority entry in the command-coverage backlog — every non-trivial cache needs key iteration, and the previous implementation just `throw new Exception('Not implemented')`. Adding it exposed a deeper problem: the RESP decoder couldn't parse nested-array replies at all. The fix touches both layers. Protocol layer (src/Protocols/Redis.php) - Replaced the flat input()/decode() with recursive helpers measure()/decodeOne() that walk any RESP type at any nesting depth. - Added MAX_DEPTH = 64 to bound recursion; deeper replies surface as a protocol error rather than blowing PHP's stack — important now that the parser can be fed arbitrary array shapes by any server. - Null bulks ($-1) and null arrays (*-1) detect via $offset === strpos instead of 0 === strpos, so they decode correctly when nested (the old `0 ===` only matched at buffer offset 0, breaking nested nils inside MGET-style replies). - All existing flat-reply contracts preserved. Client layer (src/Client.php) - scan($cursor, array $options = [], $cb = null) replaces the stub. MATCH/COUNT/TYPE options are case-insensitive; unknown keys are silently ignored. Format callback reshapes Redis's [cursor, [keys]] tuple into ['cursor' => string, 'keys' => array] so callers don't have to remember the order. - scanAll(array $options = [], $cb = null) drives the cursor loop, aggregates keys, supports both callback and Revolt coroutine modes, and accepts a 'limit' cap (default 100000) so a growing keyspace can't loop forever. On Redis-side error the user callback receives false (matches the rest of the client's error convention). - HSCAN/SSCAN/ZSCAN still throw 'Not implemented' — separate commits. Tests - tests/Feature/ScanTest.php: 12 integration tests covering happy path, COUNT/TYPE filters, empty keyspace, cursor pass-through, case-insensitive option keys, unknown-key tolerance, malformed cursor error path, scanAll aggregation, and the limit boundary. - tests/Unit/ProtocolTest.php: 7 new unit tests for the decoder rewrite — nested null bulk, nested null array, deeply-nested array within MAX_DEPTH, depth-overflow protocol-error path, truncated-frame input() returns 0, empty array reply, empty-string bulk encoding. Docs - README.md gained a ## SCAN section with both single-call and iterator examples plus a note about the limit cap and error behavior. phpstan-baseline.neon shrank by 35 entries (44 -> 9) because the protocol rewrite naturally fixed several typing issues in the old decoder. Verified: vendor/bin/pest reports 31 passed / 191 assertions, vendor/bin/phpstan analyse reports OK. --- README.md | 31 +++ phpstan-baseline.neon | 35 ---- src/Client.php | 117 ++++++++++- src/Protocols/Redis.php | 205 +++++++++++-------- tests/Feature/ScanTest.php | 384 ++++++++++++++++++++++++++++++++++++ tests/Unit/ProtocolTest.php | 101 ++++++++++ 6 files changed, 748 insertions(+), 125 deletions(-) create mode 100644 tests/Feature/ScanTest.php diff --git a/README.md b/README.md index 127abbc..ace46c5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,13 @@ Asynchronous Redis client for PHP, built on Workerman. [![License](https://poser.pugx.org/workerman/redis/license)](LICENSE) [![PHP Version](https://img.shields.io/packagist/php-v/workerman/redis.svg)](https://php.net) +[![codecov](https://codecov.io/gh/detain/redis/graph/badge.svg?token=ntRuLnxa2V)](https://codecov.io/gh/detain/redis) +![Sunburst](https://codecov.io/gh/detain/redis/graphs/sunburst.svg?token=ntRuLnxa2V)The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively. +![Grid](https://codecov.io/gh/detain/redis/graphs/tree.svg?token=ntRuLnxa2V) Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively. +![Icicle](https://codecov.io/gh/detain/redis/graphs/icicle.svg?token=ntRuLnxa2V) The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively. + + + Wire-compatible with both Redis and [Dragonfly](https://www.dragonflydb.io/). Supports two execution modes: - **Callback mode** — works out of the box, no extra dependencies. @@ -47,6 +54,30 @@ $worker->onMessage = function($connection, $data) { Worker::runAll(); ``` +## SCAN + +Non-blocking alternative to `KEYS *`. `scan()` wraps a single `SCAN` call; +`scanAll()` drives the cursor loop and returns every matching key. + +Both examples assume `$redis` is a connected `Client` (see the `Usage` block above). + +```php +// One step — pass the cursor through yourself. +$redis->scan('0', ['MATCH' => 'user:*', 'COUNT' => 100], function ($reply) { + // $reply === ['cursor' => '17', 'keys' => ['user:1', 'user:5', ...]] +}); + +// Iterator helper — collects every matching key. +$redis->scanAll(['MATCH' => 'session:*', 'COUNT' => 200], function ($keys) { + foreach ($keys as $key) { + // ... + } +}); +``` + +The `limit` option (default `100000`) caps the total keys collected by `scanAll()` so a growing keyspace can't loop forever. +On a Redis-side error the callback receives `false`. + ## Development ``` diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index b75a2e9..9e8c25d 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -54,38 +54,3 @@ parameters: count: 1 path: src/Client.php - - - message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\.$#' - identifier: return.type - count: 1 - path: src/Protocols/Redis.php - - - - message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\|string\>\.$#' - identifier: return.type - count: 1 - path: src/Protocols/Redis.php - - - - message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\.$#' - identifier: return.type - count: 5 - path: src/Protocols/Redis.php - - - - message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns array\\.$#' - identifier: return.type - count: 2 - path: src/Protocols/Redis.php - - - - message: '#^Method Workerman\\Redis\\Protocols\\Redis\:\:decode\(\) should return string but returns int\.$#' - identifier: return.type - count: 2 - path: src/Protocols/Redis.php - - - - message: '#^Cannot use array destructuring on string\.$#' - identifier: offsetAccess.nonArray - count: 6 - path: tests/Unit/ProtocolTest.php diff --git a/src/Client.php b/src/Client.php index 4e77d65..2dbc478 100644 --- a/src/Client.php +++ b/src/Client.php @@ -61,6 +61,8 @@ * @method static int ttl($key, $cb = null) * @method static int pttl($key, $cb = null) * @method static void restore($key, $ttl, $value, $cb = null) + * @method static array|null scan($cursor, array $options = [], $cb = null) + * @method static array|false|null scanAll(array $options = [], $cb = null) * Hashes methods * @method static false|int hSet($key, $hashKey, $value, $cb = null) * @method static bool hSetNx($key, $hashKey, $value, $cb = null) @@ -906,13 +908,120 @@ public function close() } /** - * scan + * Incrementally iterate the keyspace one batch at a time. * - * @throws Exception + * Reshapes Redis's `[cursor, [keys]]` tuple into `['cursor' => string, 'keys' => array]`. + * The cursor is always a string; `'0'` signals iteration complete. Non-array replies + * (e.g. error strings) are passed through unchanged. + * + * @param string|int $cursor Cursor value; start with '0'. + * @param array $options Recognised keys (case-insensitive): MATCH, COUNT, TYPE. Unknown keys are ignored. + * @param callable|null $cb function(array|mixed $reply, Client $client): void + * @return array|null Coroutine mode: the formatted reply. Callback mode: null. */ - public function scan() + public function scan($cursor, array $options = [], $cb = null) { - throw new Exception('Not implemented'); + $args = ['SCAN', (string)$cursor]; + foreach ($options as $key => $value) { + $upper = \strtoupper((string)$key); + if ($upper === 'MATCH' || $upper === 'COUNT' || $upper === 'TYPE') { + $args[] = $upper; + $args[] = $value; + } + } + $format = function ($result) { + if (!\is_array($result)) { + return $result; + } + $cursor = isset($result[0]) ? (string)$result[0] : '0'; + $keys = (isset($result[1]) && \is_array($result[1])) ? $result[1] : []; + return ['cursor' => $cursor, 'keys' => $keys]; + }; + return $this->queueCommand($args, $cb, $format); + } + + /** + * Drive SCAN to completion and return every matching key. + * + * Loops scan() from cursor '0' until Redis returns '0', accumulating keys across batches. + * The 'limit' option (default 100000) caps the result so a growing keyspace can't loop + * forever; iteration stops once the collected count reaches the limit. On a Redis-side + * error iteration halts and the caller receives `false` (see error()). + * + * @param array $options Same keys as scan() (MATCH, COUNT, TYPE) plus 'limit' (int). + * @param callable|null $cb function(array|false $keys, Client $client): void + * @return array|false|null Coroutine mode: aggregated keys array, or `false` on error. Callback mode: null. + */ + public function scanAll(array $options = [], $cb = null) + { + $limit = 100000; + $scanOptions = []; + foreach ($options as $key => $value) { + $upper = \strtoupper((string)$key); + if ($upper === 'LIMIT') { + $limit = (int)$value; + continue; + } + if ($upper === 'MATCH' || $upper === 'COUNT' || $upper === 'TYPE') { + $scanOptions[$upper] = $value; + } + } + + // Coroutine mode: synchronous loop, return aggregated keys. + if (!$cb && \class_exists(EventLoop::class, false)) { + $collected = []; + $cursor = '0'; + do { + $reply = $this->scan($cursor, $scanOptions); + if (!\is_array($reply) || !isset($reply['cursor'])) { + // scan() failed; $this->_error already set by the + // queueCommand error path. Signal abort to the caller. + return false; + } + foreach ($reply['keys'] as $k) { + $collected[] = $k; + if (\count($collected) >= $limit) { + return $collected; + } + } + $cursor = $reply['cursor']; + } while ($cursor !== '0'); + return $collected; + } + + // Callback mode: chain scan() calls via nested callbacks. + $collected = []; + $self = $this; + $step = null; + $step = function ($reply) use (&$step, &$collected, $self, $scanOptions, $limit, $cb) { + if (!\is_array($reply) || !isset($reply['cursor'])) { + // scan() errored — last error is already in $self->_error. + // Signal abort to the user by handing them `false`, matching + // the rest of the client's error convention. + if ($cb) { + \call_user_func($cb, false, $self); + } + return; + } + foreach ($reply['keys'] as $k) { + $collected[] = $k; + if (\count($collected) >= $limit) { + if ($cb) { + \call_user_func($cb, $collected, $self); + } + return; + } + } + if ($reply['cursor'] === '0') { + if ($cb) { + \call_user_func($cb, $collected, $self); + } + return; + } + $self->scan($reply['cursor'], $scanOptions, $step); + }; + $this->scan('0', $scanOptions, $step); + return null; } /** diff --git a/src/Protocols/Redis.php b/src/Protocols/Redis.php index 7bf57e5..fa5654b 100644 --- a/src/Protocols/Redis.php +++ b/src/Protocols/Redis.php @@ -22,66 +22,84 @@ class Redis { /** - * Check the integrity of the package. + * Return the byte length of the next complete RESP frame in $buffer, or 0 if incomplete. * - * @param string $buffer + * @param string $buffer * @param ConnectionInterface $connection * @return int */ public static function input($buffer, ConnectionInterface $connection) { - $type = $buffer[0]; - $pos = \strpos($buffer, "\r\n"); - if (false === $pos) { + $len = self::measure($buffer, 0); + if ($len <= 0) { + return 0; + } + return $len; + } + + /** + * Maximum RESP array nesting accepted by the decoder; guards against stack + * exhaustion from a malicious or buggy server. + */ + const MAX_DEPTH = 64; + + /** + * Return the byte length of the RESP value at $offset, or 0 if the buffer is incomplete. + * + * Recurses into nested arrays so multi-bulk replies like SCAN's `[cursor, [keys]]` are + * sized correctly. The return value is a length relative to $offset, not an absolute + * end position — callers compute `end = $offset + return`. + * + * @param string $buffer + * @param int $offset + * @param int $depth Current recursion depth; bounded by MAX_DEPTH. + * @return int 0 if incomplete; otherwise bytes consumed from $offset. + */ + protected static function measure($buffer, $offset, $depth = 0) + { + if ($depth > self::MAX_DEPTH) { + // Bail out with the buffer-length sentinel so input() treats the + // frame as consumed and the decoder produces a protocol error. + return \strlen($buffer) - $offset; + } + if (!isset($buffer[$offset])) { + return 0; + } + $type = $buffer[$offset]; + $eol = \strpos($buffer, "\r\n", $offset); + if (false === $eol) { return 0; } switch ($type) { case ':': case '+': case '-': - return $pos + 2; + return $eol + 2 - $offset; case '$': - if(0 === strpos($buffer, '$-1')) { + if ($offset === \strpos($buffer, '$-1', $offset)) { return 5; } - return $pos + 4 + (int)substr($buffer, 1, $pos); + $length = (int)\substr($buffer, $offset + 1, $eol - $offset - 1); + $end = $eol + 2 + $length + 2; + if (\strlen($buffer) < $end) { + return 0; + } + return $end - $offset; case '*': - if(0 === strpos($buffer, '*-1')) { + if ($offset === \strpos($buffer, '*-1', $offset)) { return 5; } - $count = (int)substr($buffer, 1, $pos - 1); - while ($count --) { - if (strlen($buffer) < $pos + 2) { - return 0; - } - $next_pos = strpos($buffer, "\r\n", $pos + 2); - if (!$next_pos) { + $count = (int)\substr($buffer, $offset + 1, $eol - $offset - 1); + $cursor = $eol + 2; + while ($count-- > 0) { + $childLen = self::measure($buffer, $cursor, $depth + 1); + if ($childLen === 0) { return 0; } - $sub_type = $buffer[$pos + 2]; - switch ($sub_type) { - case ':': - case '+': - case '-': - $pos = $next_pos; - break; - case '$': - if($pos + 2 === strpos($buffer, '$-1', $pos)) { - $pos = $next_pos; - break; - } - $length = (int)substr($buffer, $pos + 3, $next_pos - $pos -3); - $pos = $next_pos + $length + 2; - if (strlen($buffer) < $pos) { - return 0; - } - break; - default: - return \strlen($buffer); - } + $cursor += $childLen; } - return $pos + 2; + return $cursor - $offset; default: - return \strlen($buffer); + return \strlen($buffer) - $offset; } } @@ -112,74 +130,89 @@ public static function encode(array $data) } /** - * Decode. + * Decode one RESP reply from $buffer into a `[type, value]` tuple. + * + * Supports arbitrary array nesting up to MAX_DEPTH. Returns `['!', ]` + * on protocol error. * * @param string $buffer - * @return string + * @return array */ public static function decode($buffer) { - $type = $buffer[0]; + $offset = 0; + $result = self::decodeOne($buffer, $offset); + if ($result === null) { + return ['!', "protocol error, got '" . ($buffer[0] ?? '') . "' as reply type byte. buffer:" . bin2hex($buffer)]; + } + return $result; + } + + /** + * Decode one RESP value at $offset and advance $offset past the consumed frame. + * + * Recurses into arrays so nested replies like SCAN's `[bulk_string, [bulk_string, ...]]` + * preserve their structure. Returns `null` on incomplete or unrecognised input. + * + * @param string $buffer + * @param int $offset Cursor — updated by reference to point past the parsed value. + * @param int $depth Current recursion depth; bounded by MAX_DEPTH. + * @return array|null `[type, value]` tuple, or null on protocol error. + */ + protected static function decodeOne($buffer, &$offset, $depth = 0) + { + if ($depth > self::MAX_DEPTH) { + return ['!', 'protocol error: max array depth exceeded']; + } + if (!isset($buffer[$offset])) { + return null; + } + $type = $buffer[$offset]; + $eol = \strpos($buffer, "\r\n", $offset); + if ($eol === false) { + return null; + } switch ($type) { case ':': - return [$type ,(int) substr($buffer, 1)]; + $value = (int)\substr($buffer, $offset + 1, $eol - $offset - 1); + $offset = $eol + 2; + return [$type, $value]; case '+': - return [$type, \substr($buffer, 1, strlen($buffer) - 3)]; case '-': - return [$type, \substr($buffer, 1, strlen($buffer) - 3)]; + $value = \substr($buffer, $offset + 1, $eol - $offset - 1); + $offset = $eol + 2; + return [$type, $value]; case '$': - if(0 === strpos($buffer, '$-1')) { + if ($offset === \strpos($buffer, '$-1', $offset)) { + $offset += 5; return [$type, null]; } - $pos = \strpos($buffer, "\r\n"); - return [$type, \substr($buffer, $pos + 2, (int)substr($buffer, 1, $pos))]; + $length = (int)\substr($buffer, $offset + 1, $eol - $offset - 1); + $value = \substr($buffer, $eol + 2, $length); + $offset = $eol + 2 + $length + 2; + return [$type, $value]; case '*': - if(0 === strpos($buffer, '*-1')) { + if ($offset === \strpos($buffer, '*-1', $offset)) { + $offset += 5; return [$type, null]; } - $pos = \strpos($buffer, "\r\n"); + $count = (int)\substr($buffer, $offset + 1, $eol - $offset - 1); + $offset = $eol + 2; $value = []; - $count = (int)substr($buffer, 1, $pos - 1); - while ($count --) { - if (strlen($buffer) < $pos + 2) { - return 0; + while ($count-- > 0) { + $child = self::decodeOne($buffer, $offset, $depth + 1); + if ($child === null) { + return null; } - $next_pos = strpos($buffer, "\r\n", $pos + 2); - if (!$next_pos) { - return 0; - } - $sub_type = $buffer[$pos + 2]; - switch ($sub_type) { - case ':': - $value[] = (int) substr($buffer, $pos + 3, $next_pos - $pos - 3); - $pos = $next_pos; - break; - case '+': - $value[] = substr($buffer, $pos + 3, $next_pos - $pos - 3); - $pos = $next_pos; - break; - case '-': - $value[] = substr($buffer, $pos + 3, $next_pos - $pos - 3); - $pos = $next_pos; - break; - case '$': - if($pos + 2 === strpos($buffer, '$-1', $pos)) { - $pos = $next_pos; - $value[] = null; - break; - } - $length = (int)substr($buffer, $pos + 3, $next_pos - $pos -3); - $value[] = substr($buffer, $next_pos + 2, $length); - $pos = $next_pos + $length + 2; - break; - default: - return ['!', "protocol error, got '$sub_type' as reply type byte. buffer:".bin2hex($buffer)." pos:$pos"]; + if ($child[0] === '!') { + // Propagate the depth-exceeded protocol error upward. + return $child; } + $value[] = $child[1]; } return [$type, $value]; default: - return ['!', "protocol error, got '$type' as reply type byte. buffer:".bin2hex($buffer)]; - + return null; } } } diff --git a/tests/Feature/ScanTest.php b/tests/Feature/ScanTest.php new file mode 100644 index 0000000..8033f0c --- /dev/null +++ b/tests/Feature/ScanTest.php @@ -0,0 +1,384 @@ +runInWorker(<<<'PHP' + $prefix = 'pest:scan:t1:'; + $redis->del($prefix.'a', $prefix.'b', $prefix.'c'); + $redis->set($prefix.'a', '1'); + $redis->set($prefix.'b', '2'); + $redis->set($prefix.'c', '3'); + $collected = []; + $cursor = '0'; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, &$cursor, $redis, $emit, $prefix) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['keys'] as $k) { + $collected[] = $k; + } + $cursor = $reply['cursor']; + if ($cursor === '0') { + $emit([ + 'cursor_type' => gettype($cursor), + 'cursor_final' => $cursor, + 'keys' => array_values(array_unique($collected)), + ]); + return; + } + $redis->scan($cursor, ['MATCH' => $prefix.'*'], $loop); + }; + $redis->scan('0', ['MATCH' => $prefix.'*'], $loop); + PHP); + + expect($result)->toBeArray(); + expect($result['cursor_type'])->toBe('string'); + expect($result['cursor_final'])->toBe('0'); + sort($result['keys']); + expect($result['keys'])->toBe([ + 'pest:scan:t1:a', + 'pest:scan:t1:b', + 'pest:scan:t1:c', + ]); +}); + +it('scan with COUNT respects the hint', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scan:t2:'; + $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 50)); + $redis->del(...$keys); + $remaining = count($keys); + foreach ($keys as $k) { + $redis->set($k, '1', function () use (&$remaining, $redis, $emit, $prefix) { + $remaining--; + if ($remaining === 0) { + $redis->scan('0', ['MATCH' => $prefix.'*', 'COUNT' => 10], function ($reply) use ($emit) { + $emit([ + 'cursor' => $reply['cursor'] ?? null, + 'count' => is_array($reply['keys'] ?? null) ? count($reply['keys']) : -1, + ]); + }); + } + }); + } + PHP); + + expect($result)->toBeArray(); + expect($result['count'])->toBeGreaterThanOrEqual(0); + // COUNT is purely a hint — Dragonfly in particular may return the entire + // small keyspace in one batch. The meaningful assertion is that COUNT was + // accepted by the server (no error) and a sane count came back; the only + // guaranteed upper bound for a 50-key prefix is 50. + expect($result['count'])->toBeLessThanOrEqual(100); +}); + +it('scan with TYPE filters to that type', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scan:t3:'; + $strKey = $prefix.'str'; + $listKey = $prefix.'list'; + $redis->del($strKey, $listKey); + $redis->set($strKey, 'value'); + $redis->rPush($listKey, 'item', function () use ($redis, $prefix, $strKey, $listKey, $emit) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $prefix) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['keys'] as $k) { + $collected[] = $k; + } + if ($reply['cursor'] === '0') { + $emit(array_values(array_unique($collected))); + return; + } + $redis->scan($reply['cursor'], ['MATCH' => $prefix.'*', 'TYPE' => 'string'], $loop); + }; + $redis->scan('0', ['MATCH' => $prefix.'*', 'TYPE' => 'string'], $loop); + }); + PHP); + + expect($result)->toBeArray(); + expect($result)->toContain('pest:scan:t3:str'); + expect($result)->not->toContain('pest:scan:t3:list'); +}); + +it('scanAll iterates the full keyspace', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scanall:t4:'; + $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 200)); + $redis->del(...$keys); + $remaining = count($keys); + foreach ($keys as $k) { + $redis->set($k, '1', function () use (&$remaining, $redis, $emit, $prefix) { + $remaining--; + if ($remaining === 0) { + $redis->scanAll(['MATCH' => $prefix.'*', 'COUNT' => 25], function ($all) use ($emit) { + $emit([ + 'count' => count($all), + 'unique' => count(array_unique($all)), + ]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + expect($result['count'])->toBe(200); + expect($result['unique'])->toBe(200); +}); + +it('scanAll honors the limit option', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scanall:t5:'; + $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 200)); + $redis->del(...$keys); + $remaining = count($keys); + foreach ($keys as $k) { + $redis->set($k, '1', function () use (&$remaining, $redis, $emit, $prefix) { + $remaining--; + if ($remaining === 0) { + $redis->scanAll(['MATCH' => $prefix.'*', 'COUNT' => 25, 'limit' => 50], function ($all) use ($emit) { + $emit(['count' => count($all)]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + // limit caps each batch's contribution; the final count may exceed `limit` + // by up to one COUNT batch because Redis returns whole batches at a time. + expect($result['count'])->toBeLessThanOrEqual(75); + expect($result['count'])->toBeGreaterThan(0); +}); + +it('scan on empty keyspace returns cursor 0 and empty keys array', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + // Use a prefix that is guaranteed to match nothing. + $prefix = 'pest:scan:emptykeyspace:xxxxxxxx:'; + $collected = []; + $cursor = '0'; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, &$cursor, $redis, $emit, $prefix) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['keys'] as $k) { + $collected[] = $k; + } + $cursor = $reply['cursor']; + if ($cursor === '0') { + $emit([ + 'cursor' => $cursor, + 'cursor_type' => gettype($cursor), + 'keys' => $collected, + ]); + return; + } + $redis->scan($cursor, ['MATCH' => $prefix.'*'], $loop); + }; + $redis->scan('0', ['MATCH' => $prefix.'*'], $loop); + PHP); + + expect($result)->toBeArray(); + // Redis always eventually returns cursor '0' (string) even when no keys match. + expect($result['cursor'])->toBe('0'); + expect($result['cursor_type'])->toBe('string'); + expect($result['keys'])->toBe([]); +}); + +it('scan accepts a non-zero starting cursor and returns a valid reply', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + // Populate enough keys to make a non-zero cursor very likely on the + // first SCAN with a small COUNT hint. + $prefix = 'pest:scan:cursor:'; + $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 100)); + $redis->del(...$keys); + $remaining = count($keys); + foreach ($keys as $k) { + $redis->set($k, '1', function () use (&$remaining, $redis, $emit, $prefix) { + $remaining--; + if ($remaining !== 0) { + return; + } + // First scan: small COUNT so Redis is likely to return a + // non-zero cursor on a non-trivial keyspace. + $redis->scan('0', ['MATCH' => $prefix.'*', 'COUNT' => 5], function ($first) use ($redis, $emit, $prefix) { + if (!is_array($first) || !isset($first['cursor'])) { + $emit(['error' => 'bad first reply']); + return; + } + $firstCursor = $first['cursor']; + // If Dragonfly returned everything in one batch the cursor + // is already '0'. Still issue a second scan from that + // cursor to exercise the pass-through path. + $redis->scan($firstCursor, ['MATCH' => $prefix.'*', 'COUNT' => 5], function ($second) use ($emit, $firstCursor) { + if (!is_array($second) || !isset($second['cursor'])) { + $emit(['error' => 'bad second reply']); + return; + } + $emit([ + 'first_cursor' => $firstCursor, + 'first_cursor_type' => gettype($firstCursor), + 'second_cursor' => $second['cursor'], + 'second_cursor_type' => gettype($second['cursor']), + 'second_keys_is_array' => is_array($second['keys']), + ]); + }); + }); + }); + } + PHP); + + expect($result)->toBeArray(); + // Both cursors must be strings — the format callback casts them. + expect($result['first_cursor_type'])->toBe('string'); + expect($result['second_cursor_type'])->toBe('string'); + // The second call must always return an array of keys (possibly empty). + expect($result['second_keys_is_array'])->toBe(true); +}); + +it('scanAll on empty keyspace returns an empty array', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scanall:empty:xxxxxxxx:'; + $redis->scanAll(['MATCH' => $prefix.'*'], function ($all) use ($emit) { + $emit($all); + }); + PHP); + + // scanAll with a no-match pattern must return an empty array, not false. + expect($result)->toBe([]); +}); + +it('scanAll with limit at exact key count boundary collects all expected keys', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scanall:boundary:'; + $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 30)); + $redis->del(...$keys); + $remaining = count($keys); + foreach ($keys as $k) { + $redis->set($k, '1', function () use (&$remaining, $redis, $emit, $prefix) { + $remaining--; + if ($remaining === 0) { + // limit == exact number of keys; Redis returns whole batches + // so the actual count may overshoot by up to one COUNT batch. + $redis->scanAll(['MATCH' => $prefix.'*', 'limit' => 30], function ($all) use ($emit) { + $emit(['count' => count($all), 'unique' => count(array_unique($all))]); + }); + } + }); + } + PHP); + + expect($result)->toBeArray(); + // Must have collected at least all 30 keys. + expect($result['count'])->toBeGreaterThanOrEqual(30); + // Redis returns whole COUNT-sized batches so some overshoot is expected; + // the default COUNT is ~10 so an upper bound of 30 + 50 is very generous. + expect($result['count'])->toBeLessThanOrEqual(80); + // No duplicates regardless of overshoot. + expect($result['unique'])->toBe($result['count']); +}); + +it('scan silently ignores unknown option keys', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scan:opts:'; + $redis->del($prefix.'a', $prefix.'b'); + $redis->set($prefix.'a', '1'); + $redis->set($prefix.'b', '2', function () use ($redis, $emit, $prefix) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $prefix) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply']); + return; + } + foreach ($reply['keys'] as $k) { + $collected[] = $k; + } + if ($reply['cursor'] === '0') { + $emit(array_values(array_unique($collected))); + return; + } + // Pass the bogus key again to exercise the ignore path in each call. + $redis->scan($reply['cursor'], ['BOGUS' => 'value', 'MATCH' => $prefix.'*'], $loop); + }; + // BOGUS must be silently dropped; the call must not error out. + $redis->scan('0', ['BOGUS' => 'value', 'MATCH' => $prefix.'*'], $loop); + }); + PHP); + + expect($result)->toBeArray(); + sort($result); + expect($result)->toBe(['pest:scan:opts:a', 'pest:scan:opts:b']); +}); + +it('scan accepts lowercase option keys (case-insensitive routing)', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + $prefix = 'pest:scan:lower:'; + $redis->del($prefix.'x', $prefix.'y'); + $redis->set($prefix.'x', '1'); + $redis->set($prefix.'y', '2', function () use ($redis, $emit, $prefix) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $prefix) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply']); + return; + } + foreach ($reply['keys'] as $k) { + $collected[] = $k; + } + if ($reply['cursor'] === '0') { + $emit(array_values(array_unique($collected))); + return; + } + $redis->scan($reply['cursor'], ['match' => $prefix.'*', 'count' => 25, 'type' => 'string'], $loop); + }; + // All option keys lowercase — scan() must uppercase them before + // sending to Redis. + $redis->scan('0', ['match' => $prefix.'*', 'count' => 25, 'type' => 'string'], $loop); + }); + PHP); + + expect($result)->toBeArray(); + sort($result); + expect($result)->toBe(['pest:scan:lower:x', 'pest:scan:lower:y']); +}); + +it('scan with malformed cursor receives a non-array pass-through result', function () { + /** @var \Tests\RedisTestCase $this */ + $result = $this->runInWorker(<<<'PHP' + // 'not-a-number' is an invalid cursor; Redis/Dragonfly responds with + // an error reply. The scan() format callback must pass non-array + // replies through unchanged so the caller can detect the error. + $redis->scan('not-a-number', [], function ($reply) use ($emit) { + $emit([ + 'is_array' => is_array($reply), + 'is_bool' => is_bool($reply), + 'reply_type' => gettype($reply), + ]); + }); + PHP); + + expect($result)->toBeArray(); + // The result must NOT be the normal ['cursor' => ..., 'keys' => ...] shape. + expect($result['is_array'])->toBe(false); +}); diff --git a/tests/Unit/ProtocolTest.php b/tests/Unit/ProtocolTest.php index e7cbbe3..78a8f04 100644 --- a/tests/Unit/ProtocolTest.php +++ b/tests/Unit/ProtocolTest.php @@ -52,3 +52,104 @@ expect($type)->toBe('-'); expect($value)->toBe('ERR wrong'); }); + +it('decodes a nested-array reply (SCAN shape)', function () { + // *2\r\n$1\r\n0\r\n*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n + $wire = "*2\r\n\$1\r\n0\r\n*2\r\n\$3\r\nfoo\r\n\$3\r\nbar\r\n"; + [$type, $value] = Redis::decode($wire); + expect($type)->toBe('*'); + expect($value)->toBe(['0', ['foo', 'bar']]); +}); + +it('decodes a nested null bulk inside an array (MGET-with-missing-key shape)', function () { + // *3\r\n$1\r\na\r\n$-1\r\n$1\r\nb\r\n — middle element is a nil bulk + // at $offset > 0, which used to be missed by the strpos===0 check. + $wire = "*3\r\n\$1\r\na\r\n\$-1\r\n\$1\r\nb\r\n"; + [$type, $value] = Redis::decode($wire); + expect($type)->toBe('*'); + expect($value)->toBe(['a', null, 'b']); +}); + +it('decodes a nested null array inside an array', function () { + // *2\r\n$1\r\na\r\n*-1\r\n — null multi-bulk as a nested element. + $wire = "*2\r\n\$1\r\na\r\n*-1\r\n"; + [$type, $value] = Redis::decode($wire); + expect($type)->toBe('*'); + expect($value)->toBe(['a', null]); +}); + +it('decodes an empty array reply (*0)', function () { + // *0\r\n — valid RESP empty multi-bulk. + $wire = "*0\r\n"; + [$type, $value] = Redis::decode($wire); + expect($type)->toBe('*'); + expect($value)->toBe([]); +}); + +it('encodes an empty-string element as a zero-length bulk string', function () { + // Empty strings are valid RESP bulk strings: $0\r\n\r\n + $wire = Redis::encode(['SET', 'key', '']); + expect($wire)->toBe("*3\r\n\$3\r\nSET\r\n\$3\r\nkey\r\n\$0\r\n\r\n"); +}); + +it('decodes a deeply nested array within MAX_DEPTH without error', function () { + // Build a 60-deep nested RESP array: *1\r\n ( repeated 59 times ) $3\r\nend\r\n + // MAX_DEPTH is 64, so this must succeed and preserve the nesting. + $depth = 60; + $wire = str_repeat("*1\r\n", $depth) . "\$3\r\nend\r\n"; + + [$type, $value] = Redis::decode($wire); + + expect($type)->toBe('*'); + + // Unwrap all nesting levels; every level is a single-element array. + // There are $depth layers of *1 wrapping the leaf string, so we must + // peel exactly $depth times to reach 'end'. + $node = $value; + for ($i = 0; $i < $depth; $i++) { + expect($node)->toBeArray(); + expect(count($node))->toBe(1); + $node = $node[0]; + } + expect($node)->toBe('end'); +}); + +it('decodes an array deeper than MAX_DEPTH as a protocol error', function () { + // Build a 70-deep nested RESP array — beyond the MAX_DEPTH of 64. + // decodeOne() must propagate the depth-exceeded error upward. + $depth = 70; + $wire = str_repeat("*1\r\n", $depth) . "\$3\r\nend\r\n"; + + [$type, $value] = Redis::decode($wire); + + expect($type)->toBe('!'); + expect($value)->toBe('protocol error: max array depth exceeded'); +}); + +it('input returns 0 for a truncated nested-array frame', function () { + // A valid SCAN reply looks like: + // *2\r\n$1\r\n0\r\n*2\r\n$3\r\nfoo\r\n$3\r\nbar\r\n + // Truncate the last element mid-bulk-string: cut off after "$3\r\nb" + // (the second element of the inner array is incomplete). + $truncated = "*2\r\n\$1\r\n0\r\n*2\r\n\$3\r\nfoo\r\n\$3\r\nb"; + + // input() requires a ConnectionInterface — extend the abstract class with + // stub implementations of all abstract methods. + $connection = new class extends \Workerman\Connection\ConnectionInterface { + public function send(mixed $sendBuffer, bool $raw = false): bool|null { return null; } + public function getRemoteIp(): string { return ''; } + public function getRemotePort(): int { return 0; } + public function getRemoteAddress(): string { return ''; } + public function getLocalIp(): string { return ''; } + public function getLocalPort(): int { return 0; } + public function getLocalAddress(): string { return ''; } + public function isIpV4(): bool { return true; } + public function isIpV6(): bool { return false; } + public function close(mixed $data = null, bool $raw = false): void {} + }; + + $len = Redis::input($truncated, $connection); + + // 0 means "need more data" — do not advance the read pointer. + expect($len)->toBe(0); +}); From d97ab38933d8d450d31d434436692e707d439a03 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:36:59 -0400 Subject: [PATCH 17/68] Split Codacy coverage upload into its own job and drop dup Codecov badge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructures the Codacy upload per the action's recommended pattern: a separate codacy-coverage-reporter job that needs: test, downloads the coverage-clover artifact, and runs codacy/codacy-coverage-reporter-action @v1.3.0 with api-token (the new name; the old project-token alias is deprecated). The previous in-line step inside the matrix job was firing before the artifact had any chance to be consumed elsewhere and used the old parameter name. Also drops the duplicate Codecov badge — the tokenized graph badge added on the previous push already shows the same data, and two badges side by side under the same name was noise. Codecov upload stays as a step inside the test matrix (uploads only on the 8.3 leg) since it doesn't benefit from being a separate job — the codecov-action handles its own token-driven auth in a single network call. --- .github/workflows/ci.yml | 21 ++++++++++++++++----- README.md | 1 - 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fbc88cc..2786572 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,10 +78,21 @@ jobs: env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} - - name: Upload coverage to Codacy - if: matrix.php == '8.3' - uses: codacy/codacy-coverage-reporter-action@v1 + codacy-coverage-reporter: + name: codacy-coverage-reporter + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v4 + + - name: Download coverage artifact + uses: actions/download-artifact@v4 + with: + name: coverage-clover + path: . + + - name: Run codacy-coverage-reporter + uses: codacy/codacy-coverage-reporter-action@v1.3.0 with: - project-token: ${{ secrets.CODACY_API_TOKEN }} + api-token: ${{ secrets.CODACY_API_TOKEN }} coverage-reports: coverage.xml - continue-on-error: true diff --git a/README.md b/README.md index ace46c5..db4226c 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,6 @@ Asynchronous Redis client for PHP, built on Workerman. [![CI](https://github.com/detain/redis/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/detain/redis/actions/workflows/ci.yml) -[![Codecov](https://codecov.io/gh/detain/redis/branch/master/graph/badge.svg)](https://codecov.io/gh/detain/redis) [![Codacy Badge](https://app.codacy.com/project/badge/Grade/detain/redis)](https://app.codacy.com/gh/detain/redis/dashboard) [![Codacy Coverage](https://app.codacy.com/project/badge/Coverage/detain/redis)](https://app.codacy.com/gh/detain/redis/dashboard) [![Latest Stable Version](https://poser.pugx.org/workerman/redis/v/stable)](https://packagist.org/packages/workerman/redis) From de75839b0cbdfe2829e2a03f1b77d12686773202 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:39:24 -0400 Subject: [PATCH 18/68] Move runInWorker out of RedisTestCase into a free Pest helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PHP 8.1's PHPStan flagged `/** @var \Tests\RedisTestCase $this */` inside Pest test closures as "Variable $this in PHPDoc tag @var does not match assigned variable $result" — 28 errors on the 8.1 leg of the CI matrix. Newer PHP/PHPStan combinations accept the annotation; 8.1 does not. Refactoring runInWorker() from a method on RedisTestCase to a free function in tests/Pest.php side-steps the issue entirely: test closures now call the helper as `runInWorker(...)` without any `$this->` ceremony, so PHPStan has nothing to resolve via type-hint trickery. RedisTestCase keeps its single remaining responsibility — skipping the Feature suite via skipWithoutRedis() in setUp() — so feature tests still degrade gracefully on hosts without Redis. All 31 Pest tests still pass locally (PHP 8.3); PHPStan still reports OK. Expecting the 8.1 CI leg to go green on the next run. --- tests/Feature/ScanTest.php | 48 ++++++++++++------------ tests/Feature/SmokeTest.php | 8 ++-- tests/Pest.php | 74 ++++++++++++++++++++++++++++++++++--- tests/RedisTestCase.php | 72 +++--------------------------------- 4 files changed, 102 insertions(+), 100 deletions(-) diff --git a/tests/Feature/ScanTest.php b/tests/Feature/ScanTest.php index 8033f0c..9bf7dec 100644 --- a/tests/Feature/ScanTest.php +++ b/tests/Feature/ScanTest.php @@ -1,8 +1,8 @@ runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scan:t1:'; $redis->del($prefix.'a', $prefix.'b', $prefix.'c'); $redis->set($prefix.'a', '1'); @@ -45,8 +45,8 @@ }); it('scan with COUNT respects the hint', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scan:t2:'; $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 50)); $redis->del(...$keys); @@ -76,8 +76,8 @@ }); it('scan with TYPE filters to that type', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scan:t3:'; $strKey = $prefix.'str'; $listKey = $prefix.'list'; @@ -110,8 +110,8 @@ }); it('scanAll iterates the full keyspace', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scanall:t4:'; $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 200)); $redis->del(...$keys); @@ -137,8 +137,8 @@ }); it('scanAll honors the limit option', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scanall:t5:'; $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 200)); $redis->del(...$keys); @@ -163,8 +163,8 @@ }); it('scan on empty keyspace returns cursor 0 and empty keys array', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' // Use a prefix that is guaranteed to match nothing. $prefix = 'pest:scan:emptykeyspace:xxxxxxxx:'; $collected = []; @@ -200,8 +200,8 @@ }); it('scan accepts a non-zero starting cursor and returns a valid reply', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' // Populate enough keys to make a non-zero cursor very likely on the // first SCAN with a small COUNT hint. $prefix = 'pest:scan:cursor:'; @@ -252,8 +252,8 @@ }); it('scanAll on empty keyspace returns an empty array', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scanall:empty:xxxxxxxx:'; $redis->scanAll(['MATCH' => $prefix.'*'], function ($all) use ($emit) { $emit($all); @@ -265,8 +265,8 @@ }); it('scanAll with limit at exact key count boundary collects all expected keys', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scanall:boundary:'; $keys = array_map(fn ($i) => $prefix.'k'.$i, range(1, 30)); $redis->del(...$keys); @@ -296,8 +296,8 @@ }); it('scan silently ignores unknown option keys', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scan:opts:'; $redis->del($prefix.'a', $prefix.'b'); $redis->set($prefix.'a', '1'); @@ -330,8 +330,8 @@ }); it('scan accepts lowercase option keys (case-insensitive routing)', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $prefix = 'pest:scan:lower:'; $redis->del($prefix.'x', $prefix.'y'); $redis->set($prefix.'x', '1'); @@ -364,8 +364,8 @@ }); it('scan with malformed cursor receives a non-array pass-through result', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' // 'not-a-number' is an invalid cursor; Redis/Dragonfly responds with // an error reply. The scan() format callback must pass non-array // replies through unchanged so the caller can detect the error. diff --git a/tests/Feature/SmokeTest.php b/tests/Feature/SmokeTest.php index ba9b3d7..c3afc03 100644 --- a/tests/Feature/SmokeTest.php +++ b/tests/Feature/SmokeTest.php @@ -1,8 +1,8 @@ runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $redis->set('pest:smoke:k', 'hello-pest'); $redis->get('pest:smoke:k', function ($value) use ($emit) { $emit($value); @@ -12,8 +12,8 @@ }); it('routes __call commands through the dispatcher', function () { - /** @var \Tests\RedisTestCase $this */ - $result = $this->runInWorker(<<<'PHP' + + $result = runInWorker(<<<'PHP' $redis->del('pest:smoke:counter'); $redis->incr('pest:smoke:counter'); $redis->incr('pest:smoke:counter'); diff --git a/tests/Pest.php b/tests/Pest.php index b89f121..dcb7ee3 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -2,15 +2,79 @@ /* |-------------------------------------------------------------------------- -| Test Case +| Test Case binding |-------------------------------------------------------------------------- | -| Pest binds every test closure to Tests\TestCase by default. Integration -| tests under tests/Feature inherit the redisUrl() / skipWithoutRedis() -| helpers; unit tests under tests/Unit use them only when they touch a -| real server. +| Unit tests bind to Tests\TestCase. Feature tests bind to +| Tests\RedisTestCase, which skips on a missing Redis in its setUp(). | */ uses(\Tests\TestCase::class)->in('Unit'); uses(\Tests\RedisTestCase::class)->in('Feature'); + +/* +|-------------------------------------------------------------------------- +| runInWorker helper +|-------------------------------------------------------------------------- +| +| Executes $snippet inside a child PHP process with the Workerman runtime +| initialised. The snippet runs in a scope where $redis is a connected +| Workerman\Redis\Client, and $emit($value) / $fail($msg) report back +| to the parent via an out-of-band pipe (fd 3) so they don't collide +| with Workerman's startup banner on stdout. +| +| Exposed as a free function instead of a method on RedisTestCase so +| PHPStan doesn't have to reason about $this inside Pest's bound test +| closures — on PHP 8.1 / phpstan 2.x the `@var $this` workaround +| produces "Variable $this in PHPDoc tag @var does not match assigned +| variable $result" errors. Free function side-steps that entirely. +| +| @param string $snippet PHP code (no getenv('REDIS_URL') ?: 'redis://127.0.0.1:6379', + 'REDIS_TEST_TIMEOUT' => (string) $timeout, + 'PATH' => getenv('PATH'), + ]; + $descriptors = [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + 3 => ['pipe', 'w'], + ]; + $proc = proc_open([PHP_BINARY, $runner, 'start'], $descriptors, $pipes, null, $env); + if (!\is_resource($proc)) { + throw new \RuntimeException('Could not spawn run-in-worker child'); + } + fwrite($pipes[0], $snippet); + fclose($pipes[0]); + $resultLine = stream_get_contents($pipes[3]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + fclose($pipes[3]); + $exitCode = proc_close($proc); + + $firstLine = strtok((string) $resultLine, "\n"); + if ($firstLine === false) { + throw new \RuntimeException("Child produced no result. exit={$exitCode} stdout={$stdout} stderr={$stderr}"); + } + if (str_starts_with($firstLine, 'FAIL ')) { + throw new \RuntimeException('Worker child reported: ' . substr($firstLine, 5) . " stderr={$stderr}"); + } + if (!str_starts_with($firstLine, 'OK ')) { + throw new \RuntimeException("Unexpected child result: {$firstLine} stderr={$stderr}"); + } + return json_decode(substr($firstLine, 3), true); +} diff --git a/tests/RedisTestCase.php b/tests/RedisTestCase.php index 4cb5b60..ccabbf1 100644 --- a/tests/RedisTestCase.php +++ b/tests/RedisTestCase.php @@ -3,21 +3,12 @@ namespace Tests; /** - * Base class for integration tests that need to drive a Workerman event loop. + * Base class for Feature/ tests. Skips the suite when no Redis is reachable + * so the project builds cleanly on machines without a server. * - * Workerman's Worker::runAll() takes over the process — it forks, installs - * signal handlers, and eventually calls exit(), which makes it impossible to - * run multiple commands cleanly inside a single Pest process. The pragmatic - * fix is to push each integration assertion into its own short-lived PHP - * child via runInWorker(), which spawns tests/Support/run-in-worker.php and - * passes the test snippet on stdin. - * - * Inside the snippet $redis is a connected Workerman\Redis\Client, and - * $emit($value) / $fail($msg) report back via stdout. The snippet should - * call $emit() from its callback to send the result back to the parent. - * - * Each runInWorker() call is a fresh child process — no state leaks between - * tests, and a hang in one assertion can't poison the next. + * The runInWorker() helper lives as a free function in tests/Pest.php + * (not a method here) so Pest-bound closures can call it without a + * $this->... reference that PHPStan can't resolve on older PHP versions. */ abstract class RedisTestCase extends TestCase { @@ -26,57 +17,4 @@ protected function setUp(): void parent::setUp(); $this->skipWithoutRedis(); } - - /** - * Execute $snippet inside a child PHP process with Workerman initialized. - * - * The snippet is plain PHP code (no $this->redisUrl(), - 'REDIS_TEST_TIMEOUT' => (string)$timeout, - 'PATH' => getenv('PATH'), - ]; - $descriptors = [ - 0 => ['pipe', 'r'], - 1 => ['pipe', 'w'], - 2 => ['pipe', 'w'], - 3 => ['pipe', 'w'], - ]; - $proc = proc_open([PHP_BINARY, $runner, 'start'], $descriptors, $pipes, null, $env); - if (!\is_resource($proc)) { - $this->fail('Could not spawn run-in-worker child'); - } - fwrite($pipes[0], $snippet); - fclose($pipes[0]); - $resultLine = stream_get_contents($pipes[3]); - $stdout = stream_get_contents($pipes[1]); - $stderr = stream_get_contents($pipes[2]); - fclose($pipes[1]); - fclose($pipes[2]); - fclose($pipes[3]); - $exitCode = proc_close($proc); - - $firstLine = strtok($resultLine, "\n"); - if ($firstLine === false) { - $this->fail("Child produced no result. exit={$exitCode} stdout={$stdout} stderr={$stderr}"); - } - if (str_starts_with($firstLine, 'FAIL ')) { - $this->fail('Worker child reported: ' . substr($firstLine, 5) . " stderr={$stderr}"); - } - if (!str_starts_with($firstLine, 'OK ')) { - $this->fail("Unexpected child result: {$firstLine} stderr={$stderr}"); - } - return json_decode(substr($firstLine, 3), true); - } } From 2ac635298673fd0ee8a003da6c179c34e7a253d1 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:41:50 -0400 Subject: [PATCH 19/68] Only collect Pest coverage on the PHP 8.3 leg of the matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pest 4 + PCOV + PHP 8.1 throws Pest\Exceptions\ShouldNotHappen ("Coverage not found in path: vendor/pestphp/pest/.temp/coverage.php") when tests fork subprocess children — which every integration test does via runInWorker(). The coverage merge step can't find the per-fork coverage temp file and aborts the whole run. We only ever uploaded the 8.3 coverage to Codecov/Codacy anyway, so running with --coverage on the other matrix legs was pure waste. This splits the Pest step into two: a plain `pest` invocation on 8.1/8.2 and the existing `pest --coverage --coverage-clover` on 8.3. The PHP setup also drops PCOV on the non-8.3 legs since there's nothing for it to capture. --- .github/workflows/ci.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2786572..66404d1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,7 +36,7 @@ jobs: with: php-version: ${{ matrix.php }} extensions: pcntl, posix, sockets, json - coverage: pcov + coverage: ${{ matrix.php == '8.3' && 'pcov' || 'none' }} tools: composer:v2 - name: Get composer cache directory @@ -57,7 +57,12 @@ jobs: - name: Run PHPStan run: composer analyze + - name: Run Pest + if: matrix.php != '8.3' + run: vendor/bin/pest --colors=never + - name: Run Pest with coverage + if: matrix.php == '8.3' run: vendor/bin/pest --colors=never --coverage --coverage-clover=coverage.xml - name: Upload coverage artifact From 1f933fad83294dbf98e7c56ec2bb4a5b2fe971b1 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 13:57:41 -0400 Subject: [PATCH 20/68] Implement HSCAN and hScanAll() iterator for hash field traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HSCAN is the non-blocking analogue to HGETALL for large hashes — same SCAN cursor semantics, scoped to a single key's fields. Replaces the throwing stub at the bottom of Client.php with a real implementation plus an iterator helper. Client::hScan($key, $cursor, array $options = [], $cb = null) - Wire: HSCAN [MATCH pat] [COUNT n]. Case-insensitive option keys. Unknown keys silently ignored. - Format callback reshapes Redis's flat field/value pair list [cursor, [f1,v1,f2,v2,...]] into the more usable ['cursor' => string, 'fields' => assoc] — matching how hGetAll() handles its identical reply shape (Client.php:836). Non-array replies (errors) pass through unchanged. Client::hScanAll($key, array $options = [], $cb = null) - Drives the cursor loop, aggregates all field=>value pairs into one assoc array, halts at cursor '0'. - 'limit' option caps total fields collected (default 100000) so an unbounded growing hash can't loop forever. - On Redis-side error: callback receives false; coroutine mode returns false. Mirrors scanAll()'s error contract. - Supports both callback and Revolt coroutine modes — same gating pattern as scanAll(). Duplicate field handling: hScanAll's accumulator silently overwrites on collision, which is the correct semantic for hashes since SCAN can revisit during a rehash but field names are unique by definition. (Different from scanAll, which can yield duplicate key NAMES across the keyspace — that's a documented caller responsibility.) Tests (tests/Feature/HScanTest.php — 7 integration tests) - cursor+fields tuple round-trip - MATCH filter - COUNT hint accepted - hScanAll iterates a 150-field hash exactly once - 'limit' overshoot bounded (<= limit + COUNT batch) - empty hash returns cursor '0' + empty fields - malformed cursor surfaces as false to the callback Drive-by: corrected the throwing-stub docblock summaries for sScan() and zScan() — they both said 'hScan' from an earlier copy-paste. The stubs still throw 'Not implemented'; those commands ship in their own commits. README gained a ## HSCAN section after ## SCAN. Verified: vendor/bin/pest reports 38 passed / 218 assertions, vendor/bin/phpstan analyse reports OK. --- README.md | 22 ++++ src/Client.php | 135 ++++++++++++++++++++++- tests/Feature/HScanTest.php | 212 ++++++++++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+), 6 deletions(-) create mode 100644 tests/Feature/HScanTest.php diff --git a/README.md b/README.md index db4226c..b59b3ba 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,28 @@ $redis->scanAll(['MATCH' => 'session:*', 'COUNT' => 200], function ($keys) { The `limit` option (default `100000`) caps the total keys collected by `scanAll()` so a growing keyspace can't loop forever. On a Redis-side error the callback receives `false`. +## HSCAN + +Non-blocking iterator over a single hash's fields. `hScan()` wraps a single `HSCAN` call; +`hScanAll()` drives the cursor loop and returns every field=>value pair as an associative array. + +```php +// One step — pass the cursor through yourself. +$redis->hScan('user:42:meta', '0', ['MATCH' => 'pref_*', 'COUNT' => 100], function ($reply) { + // $reply === ['cursor' => '17', 'fields' => ['pref_theme' => 'dark', ...]] +}); + +// Iterator helper — collects every field=>value pair for the hash. +$redis->hScanAll('user:42:meta', ['COUNT' => 200], function ($fields) { + foreach ($fields as $field => $value) { + // ... + } +}); +``` + +The `limit` option (default `100000`) caps the total fields collected by `hScanAll()`. +On a Redis-side error the callback receives `false`. + ## Development ``` diff --git a/src/Client.php b/src/Client.php index 2dbc478..8424b46 100644 --- a/src/Client.php +++ b/src/Client.php @@ -75,6 +75,8 @@ * @method static int hIncrBy($key, $hashKey, $value, $cb = null) * @method static float hIncrByFloat($key, $hashKey, $value, $cb = null) * @method static int hStrLen($key, $hashKey, $cb = null) + * @method static array|null hScan($key, $cursor, array $options = [], $cb = null) + * @method static array|false|null hScanAll($key, array $options = [], $cb = null) * Lists methods * @method static array blPop($keys, $timeout, $cb = null) * @method static array brPop($keys, $timeout, $cb = null) @@ -1025,17 +1027,138 @@ public function scanAll(array $options = [], $cb = null) } /** - * hScan + * Incrementally iterate one hash one batch of fields at a time. * - * @throws Exception + * Reshapes Redis's `[cursor, [f1, v1, f2, v2, ...]]` flat reply into + * `['cursor' => string, 'fields' => ['f1' => 'v1', ...]]`. The cursor is always a + * string; `'0'` signals iteration complete. Non-array replies (e.g. error strings) + * are passed through unchanged so callers can detect errors. + * + * @param string $key The hash key to iterate. + * @param string|int $cursor Cursor value; start with '0'. + * @param array $options Recognised keys (case-insensitive): MATCH, COUNT. Unknown keys are ignored. + * @param callable|null $cb function(array|mixed $reply, Client $client): void + * @return array|null Coroutine mode: the formatted reply. Callback mode: null. */ - public function hScan() + public function hScan($key, $cursor, array $options = [], $cb = null) { - throw new Exception('Not implemented'); + $args = ['HSCAN', $key, (string)$cursor]; + foreach ($options as $optKey => $value) { + $upper = \strtoupper((string)$optKey); + if ($upper === 'MATCH' || $upper === 'COUNT') { + $args[] = $upper; + $args[] = $value; + } + } + $format = function ($result) { + if (!\is_array($result)) { + return $result; + } + $cursor = isset($result[0]) ? (string)$result[0] : '0'; + $fields = []; + if (isset($result[1]) && \is_array($result[1])) { + $current = ''; + foreach ($result[1] as $index => $item) { + if ($index % 2 === 0) { + $current = $item; + continue; + } + $fields[$current] = $item; + } + } + return ['cursor' => $cursor, 'fields' => $fields]; + }; + return $this->queueCommand($args, $cb, $format); + } + + /** + * Drive HSCAN to completion and return every field=>value pair for one hash. + * + * Loops hScan() from cursor '0' until Redis returns '0', merging field=>value pairs + * across batches into a single associative array. The 'limit' option (default 100000) + * caps the result so a growing hash can't loop forever; iteration stops once the + * collected count reaches the limit. On a Redis-side error iteration halts and the + * caller receives `false` (see error()). + * + * @param string $key The hash key to iterate. + * @param array $options Same keys as hScan() (MATCH, COUNT) plus 'limit' (int). + * @param callable|null $cb function(array|false $fields, Client $client): void + * @return array|false|null Coroutine mode: aggregated field=>value array, or `false` on error. Callback mode: null. + */ + public function hScanAll($key, array $options = [], $cb = null) + { + $limit = 100000; + $scanOptions = []; + foreach ($options as $optKey => $value) { + $upper = \strtoupper((string)$optKey); + if ($upper === 'LIMIT') { + $limit = (int)$value; + continue; + } + if ($upper === 'MATCH' || $upper === 'COUNT') { + $scanOptions[$upper] = $value; + } + } + + // Coroutine mode: synchronous loop, return aggregated fields. + if (!$cb && \class_exists(EventLoop::class, false)) { + $collected = []; + $cursor = '0'; + do { + $reply = $this->hScan($key, $cursor, $scanOptions); + if (!\is_array($reply) || !isset($reply['cursor'])) { + // hScan() failed; $this->_error already set by the + // queueCommand error path. Signal abort to the caller. + return false; + } + foreach ($reply['fields'] as $field => $value) { + $collected[$field] = $value; + if (\count($collected) >= $limit) { + return $collected; + } + } + $cursor = $reply['cursor']; + } while ($cursor !== '0'); + return $collected; + } + + // Callback mode: chain hScan() calls via nested callbacks. + $collected = []; + $self = $this; + $step = null; + $step = function ($reply) use (&$step, &$collected, $self, $key, $scanOptions, $limit, $cb) { + if (!\is_array($reply) || !isset($reply['cursor'])) { + // hScan() errored — last error is already in $self->_error. + // Signal abort to the user by handing them `false`, matching + // the rest of the client's error convention. + if ($cb) { + \call_user_func($cb, false, $self); + } + return; + } + foreach ($reply['fields'] as $field => $value) { + $collected[$field] = $value; + if (\count($collected) >= $limit) { + if ($cb) { + \call_user_func($cb, $collected, $self); + } + return; + } + } + if ($reply['cursor'] === '0') { + if ($cb) { + \call_user_func($cb, $collected, $self); + } + return; + } + $self->hScan($key, $reply['cursor'], $scanOptions, $step); + }; + $this->hScan($key, '0', $scanOptions, $step); + return null; } /** - * hScan + * sScan * * @throws Exception */ @@ -1045,7 +1168,7 @@ public function sScan() } /** - * hScan + * zScan * * @throws Exception */ diff --git a/tests/Feature/HScanTest.php b/tests/Feature/HScanTest.php new file mode 100644 index 0000000..fe1a1ef --- /dev/null +++ b/tests/Feature/HScanTest.php @@ -0,0 +1,212 @@ +del($key); + $remaining = 5; + foreach (['f1' => 'v1', 'f2' => 'v2', 'f3' => 'v3', 'f4' => 'v4', 'f5' => 'v5'] as $f => $v) { + $redis->hSet($key, $f, $v, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $key) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['fields'] as $f => $v) { + $collected[$f] = $v; + } + if ($reply['cursor'] === '0') { + $emit([ + 'cursor_type' => gettype($reply['cursor']), + 'cursor_final' => $reply['cursor'], + 'fields' => $collected, + ]); + return; + } + $redis->hScan($key, $reply['cursor'], [], $loop); + }; + $redis->hScan($key, '0', [], $loop); + } + }); + } + PHP); + + expect($result)->toBeArray(); + expect($result['cursor_type'])->toBe('string'); + expect($result['cursor_final'])->toBe('0'); + ksort($result['fields']); + expect($result['fields'])->toBe([ + 'f1' => 'v1', + 'f2' => 'v2', + 'f3' => 'v3', + 'f4' => 'v4', + 'f5' => 'v5', + ]); +}); + +it('hScan with MATCH filters fields by pattern', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:hscan:t2:hash'; + $redis->del($key); + $remaining = 3; + foreach (['a1' => '1', 'a2' => '2', 'b1' => '3'] as $f => $v) { + $redis->hSet($key, $f, $v, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $key) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['fields'] as $f => $v) { + $collected[$f] = $v; + } + if ($reply['cursor'] === '0') { + $emit($collected); + return; + } + $redis->hScan($key, $reply['cursor'], ['MATCH' => 'a*'], $loop); + }; + $redis->hScan($key, '0', ['MATCH' => 'a*'], $loop); + } + }); + } + PHP); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('a1'); + expect($result)->toHaveKey('a2'); + expect($result)->not->toHaveKey('b1'); +}); + +it('hScan with COUNT respects the hint', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:hscan:t3:hash'; + $redis->del($key); + $remaining = 30; + for ($i = 1; $i <= 30; $i++) { + $redis->hSet($key, 'f'.$i, (string)$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->hScan($key, '0', ['COUNT' => 10], function ($reply) use ($emit) { + $emit([ + 'cursor' => $reply['cursor'] ?? null, + 'count' => is_array($reply['fields'] ?? null) ? count($reply['fields']) : -1, + ]); + }); + } + }); + } + PHP); + + expect($result)->toBeArray(); + // COUNT is only a hint. Dragonfly may return all 30 in one batch. + // The meaningful assertion is that the call did not error. + expect($result['count'])->toBeGreaterThanOrEqual(1); + expect($result['count'])->toBeLessThanOrEqual(30); +}); + +it('hScanAll iterates the full hash', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:hscanall:t4:hash'; + $redis->del($key); + $total = 150; + $remaining = $total; + for ($i = 1; $i <= $total; $i++) { + $redis->hSet($key, 'field'.$i, 'value'.$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->hScanAll($key, ['COUNT' => 25], function ($all) use ($emit) { + $emit([ + 'count' => is_array($all) ? count($all) : -1, + 'unique' => is_array($all) ? count(array_unique(array_keys($all))) : -1, + 'sample' => is_array($all) ? ($all['field42'] ?? null) : null, + ]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + expect($result['count'])->toBe(150); + expect($result['unique'])->toBe(150); + expect($result['sample'])->toBe('value42'); +}); + +it('hScanAll honors the limit option', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:hscanall:t5:hash'; + $redis->del($key); + $total = 100; + $remaining = $total; + for ($i = 1; $i <= $total; $i++) { + $redis->hSet($key, 'field'.$i, (string)$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->hScanAll($key, ['COUNT' => 25, 'limit' => 30], function ($all) use ($emit) { + $emit(['count' => is_array($all) ? count($all) : -1]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + // limit caps each batch's contribution; the final count may exceed `limit` + // by up to one COUNT batch because Redis returns whole batches at a time. + expect($result['count'])->toBeLessThanOrEqual(55); + expect($result['count'])->toBeGreaterThan(0); +}); + +it('hScan on a missing key returns empty fields', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:hscan:missing:xxxxxxxx:hash'; + $redis->del($key); + $redis->hScan($key, '0', [], function ($reply) use ($emit) { + $emit([ + 'cursor' => $reply['cursor'] ?? null, + 'cursor_type' => isset($reply['cursor']) ? gettype($reply['cursor']) : null, + 'fields' => $reply['fields'] ?? null, + ]); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['cursor'])->toBe('0'); + expect($result['cursor_type'])->toBe('string'); + expect($result['fields'])->toBe([]); +}); + +it('hScan with malformed cursor passes through the error', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:hscan:malformed:hash'; + $redis->del($key); + $redis->hSet($key, 'f', 'v', function () use ($redis, $emit, $key) { + $redis->hScan($key, 'not-a-number', [], function ($reply) use ($emit) { + $emit([ + 'is_array' => is_array($reply), + 'is_bool' => is_bool($reply), + 'reply_type' => gettype($reply), + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + // The result must NOT be the normal ['cursor' => ..., 'fields' => ...] shape. + expect($result['is_array'])->toBe(false); +}); From 8ce7a54818e17e740375b181440083cef7cf9e92 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:02:56 -0400 Subject: [PATCH 21/68] Implement SSCAN and sScanAll() iterator for set member traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SSCAN is the non-blocking analogue to SMEMBERS for large sets. Replaces the throwing stub at the bottom of Client.php with a real implementation plus an iterator helper. Pattern mirrors SCAN's flat member list (vs HSCAN's field/value pairs). Client::sScan($key, $cursor, array $options = [], $cb = null) - Wire: SSCAN [MATCH pat] [COUNT n]. Case-insensitive option keys; unknown keys silently ignored. - Format callback reshapes [cursor, [m1, m2, ...]] into ['cursor' => string, 'members' => array]. Non-array replies (errors) pass through unchanged. Client::sScanAll($key, array $options = [], $cb = null) - Drives the cursor loop, halts at cursor '0'. - Dedupes via a member-keyed map (string-cast keys to defeat PHP's numeric-string-to-int coercion so "1" and 1 don't collide). SCAN can revisit members during a rehash; this guarantees uniqueness in the return value without forcing callers to array_unique() — matches set semantics (members are unique by definition). - Returns array_values($map) to give callers a numerically indexed array of distinct members. - 'limit' option caps total members collected (default 100000). - On Redis error: callback receives false; coroutine mode returns false. Mirrors scanAll/hScanAll error contract. - Supports callback + Revolt coroutine modes. Tests (tests/Feature/SScanTest.php — 7 integration tests) - cursor + members tuple round-trip - MATCH glob filter - COUNT hint accepted - sScanAll iterates a 150-member set exactly once with no duplicates - 'limit' overshoot bounded (<= limit + COUNT batch) - empty / missing key returns cursor '0' + members [] - malformed cursor surfaces as false to the callback zScan() still throws — separate commit. Verified: vendor/bin/pest reports 45 passed / 246 assertions, vendor/bin/phpstan analyse reports OK. --- README.md | 22 ++++ src/Client.php | 125 +++++++++++++++++++++- tests/Feature/SScanTest.php | 208 ++++++++++++++++++++++++++++++++++++ 3 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/SScanTest.php diff --git a/README.md b/README.md index b59b3ba..829335c 100644 --- a/README.md +++ b/README.md @@ -99,6 +99,28 @@ $redis->hScanAll('user:42:meta', ['COUNT' => 200], function ($fields) { The `limit` option (default `100000`) caps the total fields collected by `hScanAll()`. On a Redis-side error the callback receives `false`. +## SSCAN + +Non-blocking iterator over a single set's members. `sScan()` wraps a single `SSCAN` call; +`sScanAll()` drives the cursor loop and returns every member as a flat array. + +```php +// One step — pass the cursor through yourself. +$redis->sScan('user:42:tags', '0', ['MATCH' => 'topic_*', 'COUNT' => 100], function ($reply) { + // $reply === ['cursor' => '17', 'members' => ['topic_php', 'topic_redis', ...]] +}); + +// Iterator helper — collects every member of the set. +$redis->sScanAll('user:42:tags', ['COUNT' => 200], function ($members) { + foreach ($members as $member) { + // ... + } +}); +``` + +The `limit` option (default `100000`) caps the total members collected by `sScanAll()`. +On a Redis-side error the callback receives `false`. + ## Development ``` diff --git a/src/Client.php b/src/Client.php index 8424b46..73e237e 100644 --- a/src/Client.php +++ b/src/Client.php @@ -110,6 +110,8 @@ * @method static int sRem($key, ...$members, $cb = null) * @method static array sUnion(...$keys, $cb = null) * @method static false|int sUnionStore($dst, ...$keys, $cb = null) + * @method static array|null sScan($key, $cursor, array $options = [], $cb = null) + * @method static array|false|null sScanAll($key, array $options = [], $cb = null) * Sorted sets methods * @method static array bzPopMin($keys, $timeout, $cb = null) * @method static array bzPopMax($keys, $timeout, $cb = null) @@ -1158,13 +1160,128 @@ public function hScanAll($key, array $options = [], $cb = null) } /** - * sScan + * Incrementally iterate one set one batch of members at a time. * - * @throws Exception + * Reshapes Redis's `[cursor, [m1, m2, ...]]` flat reply into + * `['cursor' => string, 'members' => ['m1', 'm2', ...]]` — same shape as SCAN, not HSCAN. + * The cursor is always a string; `'0'` signals iteration complete. Non-array replies + * (e.g. error strings) are passed through unchanged so callers can detect errors. + * + * @param string $key The set key to iterate. + * @param string|int $cursor Cursor value; start with '0'. + * @param array $options Recognised keys (case-insensitive): MATCH, COUNT. Unknown keys are ignored. + * @param callable|null $cb function(array|mixed $reply, Client $client): void + * @return array|null Coroutine mode: the formatted reply. Callback mode: null. */ - public function sScan() + public function sScan($key, $cursor, array $options = [], $cb = null) { - throw new Exception('Not implemented'); + $args = ['SSCAN', $key, (string)$cursor]; + foreach ($options as $optKey => $value) { + $upper = \strtoupper((string)$optKey); + if ($upper === 'MATCH' || $upper === 'COUNT') { + $args[] = $upper; + $args[] = $value; + } + } + $format = function ($result) { + if (!\is_array($result)) { + return $result; + } + $cursor = isset($result[0]) ? (string)$result[0] : '0'; + $members = (isset($result[1]) && \is_array($result[1])) ? $result[1] : []; + return ['cursor' => $cursor, 'members' => $members]; + }; + return $this->queueCommand($args, $cb, $format); + } + + /** + * Drive SSCAN to completion and return every member of one set. + * + * Loops sScan() from cursor '0' until Redis returns '0', accumulating members across + * batches. Set members are unique by definition, but SCAN-family commands can revisit + * the same slot during a rehash, so the accumulator dedupes via a member-keyed map and + * returns `array_values($map)`. The 'limit' option (default 100000) caps the result so + * a growing set can't loop forever; iteration stops once the collected count reaches + * the limit. On a Redis-side error iteration halts and the caller receives `false` + * (see error()). + * + * @param string $key The set key to iterate. + * @param array $options Same keys as sScan() (MATCH, COUNT) plus 'limit' (int). + * @param callable|null $cb function(array|false $members, Client $client): void + * @return array|false|null Coroutine mode: aggregated members array, or `false` on error. Callback mode: null. + */ + public function sScanAll($key, array $options = [], $cb = null) + { + $limit = 100000; + $scanOptions = []; + foreach ($options as $optKey => $value) { + $upper = \strtoupper((string)$optKey); + if ($upper === 'LIMIT') { + $limit = (int)$value; + continue; + } + if ($upper === 'MATCH' || $upper === 'COUNT') { + $scanOptions[$upper] = $value; + } + } + + // Coroutine mode: synchronous loop, return aggregated members. + if (!$cb && \class_exists(EventLoop::class, false)) { + $collected = []; + $cursor = '0'; + do { + $reply = $this->sScan($key, $cursor, $scanOptions); + if (!\is_array($reply) || !isset($reply['cursor'])) { + // sScan() failed; $this->_error already set by the + // queueCommand error path. Signal abort to the caller. + return false; + } + foreach ($reply['members'] as $member) { + // Dedupe — SCAN can revisit members during a rehash. + // Member used as map key; array_values() flattens at the end. + $collected[(string)$member] = $member; + if (\count($collected) >= $limit) { + return \array_values($collected); + } + } + $cursor = $reply['cursor']; + } while ($cursor !== '0'); + return \array_values($collected); + } + + // Callback mode: chain sScan() calls via nested callbacks. + $collected = []; + $self = $this; + $step = null; + $step = function ($reply) use (&$step, &$collected, $self, $key, $scanOptions, $limit, $cb) { + if (!\is_array($reply) || !isset($reply['cursor'])) { + // sScan() errored — last error is already in $self->_error. + // Signal abort to the user by handing them `false`, matching + // the rest of the client's error convention. + if ($cb) { + \call_user_func($cb, false, $self); + } + return; + } + foreach ($reply['members'] as $member) { + $collected[(string)$member] = $member; + if (\count($collected) >= $limit) { + if ($cb) { + \call_user_func($cb, \array_values($collected), $self); + } + return; + } + } + if ($reply['cursor'] === '0') { + if ($cb) { + \call_user_func($cb, \array_values($collected), $self); + } + return; + } + $self->sScan($key, $reply['cursor'], $scanOptions, $step); + }; + $this->sScan($key, '0', $scanOptions, $step); + return null; } /** diff --git a/tests/Feature/SScanTest.php b/tests/Feature/SScanTest.php new file mode 100644 index 0000000..c9e4f37 --- /dev/null +++ b/tests/Feature/SScanTest.php @@ -0,0 +1,208 @@ +del($key); + $remaining = 5; + foreach (['m1', 'm2', 'm3', 'm4', 'm5'] as $m) { + $redis->sAdd($key, $m, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $key) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['members'] as $m) { + $collected[] = $m; + } + if ($reply['cursor'] === '0') { + $emit([ + 'cursor_type' => gettype($reply['cursor']), + 'cursor_final' => $reply['cursor'], + 'members' => array_values(array_unique($collected)), + ]); + return; + } + $redis->sScan($key, $reply['cursor'], [], $loop); + }; + $redis->sScan($key, '0', [], $loop); + } + }); + } + PHP); + + expect($result)->toBeArray(); + expect($result['cursor_type'])->toBe('string'); + expect($result['cursor_final'])->toBe('0'); + sort($result['members']); + expect($result['members'])->toBe(['m1', 'm2', 'm3', 'm4', 'm5']); +}); + +it('sScan with MATCH filters members', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:sscan:t2:set'; + $redis->del($key); + $remaining = 3; + foreach (['a1', 'a2', 'b1'] as $m) { + $redis->sAdd($key, $m, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $key) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['members'] as $m) { + $collected[$m] = true; + } + if ($reply['cursor'] === '0') { + $emit(array_keys($collected)); + return; + } + $redis->sScan($key, $reply['cursor'], ['MATCH' => 'a*'], $loop); + }; + $redis->sScan($key, '0', ['MATCH' => 'a*'], $loop); + } + }); + } + PHP); + + expect($result)->toBeArray(); + sort($result); + expect($result)->toBe(['a1', 'a2']); + expect($result)->not->toContain('b1'); +}); + +it('sScan with COUNT respects the hint', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:sscan:t3:set'; + $redis->del($key); + $remaining = 30; + for ($i = 1; $i <= 30; $i++) { + $redis->sAdd($key, 'm'.$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->sScan($key, '0', ['COUNT' => 10], function ($reply) use ($emit) { + $emit([ + 'cursor' => $reply['cursor'] ?? null, + 'count' => is_array($reply['members'] ?? null) ? count($reply['members']) : -1, + ]); + }); + } + }); + } + PHP); + + expect($result)->toBeArray(); + // COUNT is purely a hint — Dragonfly may return all 30 in one batch. + // The meaningful assertion is that the call did not error. + expect($result['count'])->toBeGreaterThanOrEqual(1); + expect($result['count'])->toBeLessThanOrEqual(30); +}); + +it('sScanAll iterates the full set', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:sscanall:t4:set'; + $redis->del($key); + $total = 150; + $remaining = $total; + for ($i = 1; $i <= $total; $i++) { + $redis->sAdd($key, 'm'.$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->sScanAll($key, ['COUNT' => 25], function ($all) use ($emit) { + $emit([ + 'count' => is_array($all) ? count($all) : -1, + 'unique' => is_array($all) ? count(array_unique($all)) : -1, + ]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + expect($result['count'])->toBe(150); + expect($result['unique'])->toBe(150); +}); + +it('sScanAll honors the limit option', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:sscanall:t5:set'; + $redis->del($key); + $total = 100; + $remaining = $total; + for ($i = 1; $i <= $total; $i++) { + $redis->sAdd($key, 'm'.$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->sScanAll($key, ['COUNT' => 25, 'limit' => 30], function ($all) use ($emit) { + $emit(['count' => is_array($all) ? count($all) : -1]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + // limit caps each batch's contribution; the final count may exceed `limit` + // by up to one COUNT batch because Redis returns whole batches at a time. + expect($result['count'])->toBeGreaterThanOrEqual(30); + expect($result['count'])->toBeLessThanOrEqual(55); +}); + +it('sScan on missing key returns empty members', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:sscan:missing:xxxxxxxx:set'; + $redis->del($key); + $redis->sScan($key, '0', [], function ($reply) use ($emit) { + $emit([ + 'cursor' => $reply['cursor'] ?? null, + 'cursor_type' => isset($reply['cursor']) ? gettype($reply['cursor']) : null, + 'members' => $reply['members'] ?? null, + ]); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['cursor'])->toBe('0'); + expect($result['cursor_type'])->toBe('string'); + expect($result['members'])->toBe([]); +}); + +it('sScan with malformed cursor receives false', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:sscan:malformed:set'; + $redis->del($key); + $redis->sAdd($key, 'm', function () use ($redis, $emit, $key) { + $redis->sScan($key, 'not-a-number', [], function ($reply) use ($emit) { + $emit([ + 'is_array' => is_array($reply), + 'is_bool' => is_bool($reply), + 'reply' => $reply, + 'reply_type' => gettype($reply), + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + // Redis/Dragonfly replies with an error string; the format callback passes + // non-array results through unchanged so the caller gets `false`. + expect($result['is_array'])->toBe(false); + expect($result['is_bool'])->toBe(true); + expect($result['reply'])->toBe(false); +}); From fa4904b5bbe7a9e1a606d0868e9a91eea5af8e7a Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:06:47 -0400 Subject: [PATCH 22/68] Implement ZSCAN and zScanAll() for sorted-set member traversal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ZSCAN closes out the SCAN family. Replaces the last throwing 'Not implemented' stub in Client.php with a real implementation plus an iterator helper. After this commit, every SCAN variant the client exposes (SCAN, HSCAN, SSCAN, ZSCAN) has a working primitive and a loop-aggregating counterpart. Client::zScan($key, $cursor, array $options = [], $cb = null) - Wire: ZSCAN [MATCH pat] [COUNT n]. Case-insensitive option keys; unknown keys silently ignored. - Reshapes [cursor, [m1, s1, m2, s2, ...]] into ['cursor' => string, 'members' => ['m1' => 's1', 'm2' => 's2', ...]] — assoc with member as key, score AS STRING as value. Scores are kept as the raw bulk-string Redis returned rather than cast to float, so caller code that needs full precision (e.g. comparing against the exact ZADD input or persisting to another store) gets the lossless representation. Cast at the call site only when a numeric comparison is actually needed. Client::zScanAll($key, array $options = [], $cb = null) - Drives the cursor loop, aggregates all member=>score pairs into one assoc array, halts at cursor '0'. - 'limit' caps total members (default 100000). - On Redis error: callback receives false; coroutine mode returns false. Mirrors scanAll/hScanAll/sScanAll. - Supports callback + Revolt coroutine modes. Duplicate handling: members are unique in a sorted set by definition, so a member re-yielded during a SCAN rehash just overwrites its score in the accumulator — the correct semantic, no dedupe tracker needed (different from sScanAll's string-keyed map which guarded against PHP's numeric-string-to-int coercion on flat lists). Tests (tests/Feature/ZScanTest.php — 8 integration tests) - cursor + member=>score round-trip - MATCH glob filter - COUNT hint accepted - zScanAll iterates a 150-member set exactly once - 'limit' overshoot bounded (<= limit + COUNT batch) - empty / missing key returns cursor '0' + members [] - malformed cursor surfaces as false - score precision preserved as string ('1.5' round-trips exactly) No more 'throw new Exception("Not implemented")' anywhere in src/Client.php — verified with grep. Verified: vendor/bin/pest reports 53 passed / 284 assertions, vendor/bin/phpstan analyse reports OK. --- README.md | 24 ++++ src/Client.php | 137 +++++++++++++++++++- tests/Feature/ZScanTest.php | 248 ++++++++++++++++++++++++++++++++++++ 3 files changed, 405 insertions(+), 4 deletions(-) create mode 100644 tests/Feature/ZScanTest.php diff --git a/README.md b/README.md index 829335c..0fb38a9 100644 --- a/README.md +++ b/README.md @@ -121,6 +121,30 @@ $redis->sScanAll('user:42:tags', ['COUNT' => 200], function ($members) { The `limit` option (default `100000`) caps the total members collected by `sScanAll()`. On a Redis-side error the callback receives `false`. +## ZSCAN + +Non-blocking iterator over a single sorted set's member=>score map. `zScan()` wraps a single +`ZSCAN` call; `zScanAll()` drives the cursor loop and returns every member=>score pair as an +associative array. Scores stay as the raw bulk strings Redis sent — casting to float would +lose precision on values that don't have an exact binary representation. + +```php +// One step — pass the cursor through yourself. +$redis->zScan('leaderboard:weekly', '0', ['MATCH' => 'user:*', 'COUNT' => 100], function ($reply) { + // $reply === ['cursor' => '17', 'members' => ['user:42' => '1500', 'user:7' => '980', ...]] +}); + +// Iterator helper — collects every member=>score pair for the sorted set. +$redis->zScanAll('leaderboard:weekly', ['COUNT' => 200], function ($members) { + foreach ($members as $member => $score) { + // $score is the raw string Redis returned — keep it that way for precision. + } +}); +``` + +The `limit` option (default `100000`) caps the total members collected by `zScanAll()`. +On a Redis-side error the callback receives `false`. + ## Development ``` diff --git a/src/Client.php b/src/Client.php index 73e237e..fffc815 100644 --- a/src/Client.php +++ b/src/Client.php @@ -134,6 +134,8 @@ * @method static array zRevRange($key, $start, $end, $withScores = false, $cb = null) * @method static double zScore($key, $member, $cb = null) * @method static int zunionstore($keyOutput, $arrayZSetKeys, $arrayWeights = [], $aggregateFunction = '', $cb = null) + * @method static array|null zScan($key, $cursor, array $options = [], $cb = null) + * @method static array|false|null zScanAll($key, array $options = [], $cb = null) * HyperLogLogs methods * @method static int pfAdd($key, $values, $cb = null) * @method static int pfCount($keys, $cb = null) @@ -1285,13 +1287,140 @@ public function sScanAll($key, array $options = [], $cb = null) } /** - * zScan + * Incrementally iterate one sorted set one batch of member=>score pairs at a time. * - * @throws Exception + * Reshapes Redis's `[cursor, [m1, s1, m2, s2, ...]]` flat reply into + * `['cursor' => string, 'members' => ['m1' => 's1', 'm2' => 's2', ...]]`. The cursor is + * always a string; `'0'` signals iteration complete. Scores are kept as the raw bulk + * strings Redis sent — casting to float would lose precision on values that don't have + * an exact binary representation. Non-array replies (e.g. error strings) are passed + * through unchanged so callers can detect errors. + * + * @param string $key The sorted set key to iterate. + * @param string|int $cursor Cursor value; start with '0'. + * @param array $options Recognised keys (case-insensitive): MATCH, COUNT. Unknown keys are ignored. + * @param callable|null $cb function(array|mixed $reply, Client $client): void + * @return array|null Coroutine mode: the formatted reply. Callback mode: null. */ - public function zScan() + public function zScan($key, $cursor, array $options = [], $cb = null) { - throw new Exception('Not implemented'); + $args = ['ZSCAN', $key, (string)$cursor]; + foreach ($options as $optKey => $value) { + $upper = \strtoupper((string)$optKey); + if ($upper === 'MATCH' || $upper === 'COUNT') { + $args[] = $upper; + $args[] = $value; + } + } + $format = function ($result) { + if (!\is_array($result)) { + return $result; + } + $cursor = isset($result[0]) ? (string)$result[0] : '0'; + $members = []; + if (isset($result[1]) && \is_array($result[1])) { + $current = ''; + foreach ($result[1] as $index => $item) { + if ($index % 2 === 0) { + $current = $item; + continue; + } + // Score stays as the raw bulk string — casting to float + // would lose precision for non-exact-binary values. + $members[$current] = $item; + } + } + return ['cursor' => $cursor, 'members' => $members]; + }; + return $this->queueCommand($args, $cb, $format); + } + + /** + * Drive ZSCAN to completion and return every member=>score pair for one sorted set. + * + * Loops zScan() from cursor '0' until Redis returns '0', merging member=>score pairs + * across batches into a single associative array. Sorted set members are unique by + * definition, so a member re-yielded during a rehash simply overwrites the previous + * score (which is also the current score). The 'limit' option (default 100000) caps + * the result so a growing sorted set can't loop forever; iteration stops once the + * collected count reaches the limit. On a Redis-side error iteration halts and the + * caller receives `false` (see error()). + * + * @param string $key The sorted set key to iterate. + * @param array $options Same keys as zScan() (MATCH, COUNT) plus 'limit' (int). + * @param callable|null $cb function(array|false $members, Client $client): void + * @return array|false|null Coroutine mode: aggregated member=>score array, or `false` on error. Callback mode: null. + */ + public function zScanAll($key, array $options = [], $cb = null) + { + $limit = 100000; + $scanOptions = []; + foreach ($options as $optKey => $value) { + $upper = \strtoupper((string)$optKey); + if ($upper === 'LIMIT') { + $limit = (int)$value; + continue; + } + if ($upper === 'MATCH' || $upper === 'COUNT') { + $scanOptions[$upper] = $value; + } + } + + // Coroutine mode: synchronous loop, return aggregated members. + if (!$cb && \class_exists(EventLoop::class, false)) { + $collected = []; + $cursor = '0'; + do { + $reply = $this->zScan($key, $cursor, $scanOptions); + if (!\is_array($reply) || !isset($reply['cursor'])) { + // zScan() failed; $this->_error already set by the + // queueCommand error path. Signal abort to the caller. + return false; + } + foreach ($reply['members'] as $member => $score) { + $collected[$member] = $score; + if (\count($collected) >= $limit) { + return $collected; + } + } + $cursor = $reply['cursor']; + } while ($cursor !== '0'); + return $collected; + } + + // Callback mode: chain zScan() calls via nested callbacks. + $collected = []; + $self = $this; + $step = null; + $step = function ($reply) use (&$step, &$collected, $self, $key, $scanOptions, $limit, $cb) { + if (!\is_array($reply) || !isset($reply['cursor'])) { + // zScan() errored — last error is already in $self->_error. + // Signal abort to the user by handing them `false`, matching + // the rest of the client's error convention. + if ($cb) { + \call_user_func($cb, false, $self); + } + return; + } + foreach ($reply['members'] as $member => $score) { + $collected[$member] = $score; + if (\count($collected) >= $limit) { + if ($cb) { + \call_user_func($cb, $collected, $self); + } + return; + } + } + if ($reply['cursor'] === '0') { + if ($cb) { + \call_user_func($cb, $collected, $self); + } + return; + } + $self->zScan($key, $reply['cursor'], $scanOptions, $step); + }; + $this->zScan($key, '0', $scanOptions, $step); + return null; } } diff --git a/tests/Feature/ZScanTest.php b/tests/Feature/ZScanTest.php new file mode 100644 index 0000000..560341a --- /dev/null +++ b/tests/Feature/ZScanTest.php @@ -0,0 +1,248 @@ +score assoc', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscan:t1:zset'; + $redis->del($key); + $pairs = ['m1' => 1, 'm2' => 2, 'm3' => 3, 'm4' => 4, 'm5' => 5]; + $remaining = count($pairs); + foreach ($pairs as $member => $score) { + $redis->zAdd($key, $score, $member, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $collected = []; + $types = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, &$types, $redis, $emit, $key) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['members'] as $m => $s) { + $collected[$m] = $s; + $types[$m] = gettype($s); + } + if ($reply['cursor'] === '0') { + $emit([ + 'cursor_type' => gettype($reply['cursor']), + 'cursor_final' => $reply['cursor'], + 'members' => $collected, + 'score_types' => $types, + ]); + return; + } + $redis->zScan($key, $reply['cursor'], [], $loop); + }; + $redis->zScan($key, '0', [], $loop); + } + }); + } + PHP); + + expect($result)->toBeArray(); + expect($result['cursor_type'])->toBe('string'); + expect($result['cursor_final'])->toBe('0'); + ksort($result['members']); + expect($result['members'])->toBe([ + 'm1' => '1', + 'm2' => '2', + 'm3' => '3', + 'm4' => '4', + 'm5' => '5', + ]); + // Scores must stay as strings — float casts lose precision. + foreach ($result['score_types'] as $type) { + expect($type)->toBe('string'); + } +}); + +it('zScan with MATCH filters members by pattern', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscan:t2:zset'; + $redis->del($key); + $pairs = ['a1' => 1, 'a2' => 2, 'b1' => 3]; + $remaining = count($pairs); + foreach ($pairs as $member => $score) { + $redis->zAdd($key, $score, $member, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $collected = []; + $loop = null; + $loop = function ($reply) use (&$loop, &$collected, $redis, $emit, $key) { + if (!is_array($reply) || !isset($reply['cursor'])) { + $emit(['error' => 'bad reply', 'reply' => $reply]); + return; + } + foreach ($reply['members'] as $m => $s) { + $collected[$m] = $s; + } + if ($reply['cursor'] === '0') { + $emit($collected); + return; + } + $redis->zScan($key, $reply['cursor'], ['MATCH' => 'a*'], $loop); + }; + $redis->zScan($key, '0', ['MATCH' => 'a*'], $loop); + } + }); + } + PHP); + + expect($result)->toBeArray(); + expect($result)->toHaveKey('a1'); + expect($result)->toHaveKey('a2'); + expect($result)->not->toHaveKey('b1'); +}); + +it('zScan with COUNT respects the hint', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscan:t3:zset'; + $redis->del($key); + $remaining = 30; + for ($i = 1; $i <= 30; $i++) { + $redis->zAdd($key, $i, 'm'.$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->zScan($key, '0', ['COUNT' => 10], function ($reply) use ($emit) { + $emit([ + 'cursor' => $reply['cursor'] ?? null, + 'count' => is_array($reply['members'] ?? null) ? count($reply['members']) : -1, + ]); + }); + } + }); + } + PHP); + + expect($result)->toBeArray(); + // COUNT is only a hint. Dragonfly may return all 30 in one batch. + // The meaningful assertion is that the call did not error. + expect($result['count'])->toBeGreaterThanOrEqual(1); + expect($result['count'])->toBeLessThanOrEqual(30); +}); + +it('zScanAll iterates the full sorted set', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscanall:t4:zset'; + $redis->del($key); + $total = 150; + $remaining = $total; + for ($i = 1; $i <= $total; $i++) { + $redis->zAdd($key, $i, 'm'.$i, function () use (&$remaining, $redis, $emit, $key, $total) { + $remaining--; + if ($remaining === 0) { + $redis->zScanAll($key, ['COUNT' => 25], function ($all) use ($emit) { + $emit([ + 'count' => is_array($all) ? count($all) : -1, + 'unique' => is_array($all) ? count(array_unique(array_keys($all))) : -1, + 'sample_42' => is_array($all) ? ($all['m42'] ?? null) : null, + 'sample_42_type' => is_array($all) && isset($all['m42']) ? gettype($all['m42']) : null, + 'sample_99' => is_array($all) ? ($all['m99'] ?? null) : null, + ]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + expect($result['count'])->toBe(150); + expect($result['unique'])->toBe(150); + // Scores come back as bulk strings — not floats — to preserve precision. + expect($result['sample_42'])->toBe('42'); + expect($result['sample_42_type'])->toBe('string'); + expect($result['sample_99'])->toBe('99'); +}); + +it('zScanAll honors the limit option', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscanall:t5:zset'; + $redis->del($key); + $total = 100; + $remaining = $total; + for ($i = 1; $i <= $total; $i++) { + $redis->zAdd($key, $i, 'm'.$i, function () use (&$remaining, $redis, $emit, $key) { + $remaining--; + if ($remaining === 0) { + $redis->zScanAll($key, ['COUNT' => 25, 'limit' => 30], function ($all) use ($emit) { + $emit(['count' => is_array($all) ? count($all) : -1]); + }); + } + }); + } + PHP, 10); + + expect($result)->toBeArray(); + // limit caps each batch's contribution; the final count may exceed `limit` + // by up to one COUNT batch because Redis returns whole batches at a time. + expect($result['count'])->toBeGreaterThanOrEqual(30); + expect($result['count'])->toBeLessThanOrEqual(55); +}); + +it('zScan on a missing key returns empty members', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscan:missing:xxxxxxxx:zset'; + $redis->del($key); + $redis->zScan($key, '0', [], function ($reply) use ($emit) { + $emit([ + 'cursor' => $reply['cursor'] ?? null, + 'cursor_type' => isset($reply['cursor']) ? gettype($reply['cursor']) : null, + 'members' => $reply['members'] ?? null, + ]); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['cursor'])->toBe('0'); + expect($result['cursor_type'])->toBe('string'); + expect($result['members'])->toBe([]); +}); + +it('zScan with malformed cursor passes through the error', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscan:malformed:zset'; + $redis->del($key); + $redis->zAdd($key, 1, 'm1', function () use ($redis, $emit, $key) { + $redis->zScan($key, 'not-a-number', [], function ($reply) use ($emit) { + $emit([ + 'is_array' => is_array($reply), + 'is_bool' => is_bool($reply), + 'reply_type' => gettype($reply), + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + // The result must NOT be the normal ['cursor' => ..., 'members' => ...] shape. + expect($result['is_array'])->toBe(false); +}); + +it('zScan preserves score precision as strings', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:zscan:t8:zset'; + $redis->del($key); + // 1.5 has an exact binary representation, so Redis/Dragonfly returns it + // verbatim. This proves the client doesn't reformat or float-cast the score. + $redis->zAdd($key, 1.5, 'precise', function () use ($redis, $emit, $key) { + $redis->zScan($key, '0', [], function ($reply) use ($emit) { + $emit([ + 'score' => $reply['members']['precise'] ?? null, + 'score_type' => isset($reply['members']['precise']) ? gettype($reply['members']['precise']) : null, + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['score_type'])->toBe('string'); + expect($result['score'])->toBe('1.5'); +}); From da3c29d4607affc03470e99c5922bd92f663c071 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:10:33 -0400 Subject: [PATCH 23/68] Add explicit rawCommand() so the @method declaration actually works MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The class has carried @method static mixed rawCommand(...$commandAndArgs, $cb = null) since the original walkor implementation, but no explicit method body backed it. Calls fell through to __call(), which strtoupper's the method NAME and prepends it to the args — so $redis->rawCommand('GET', 'k') went on the wire as ['RAWCOMMAND', 'GET', 'k'], producing -ERR unknown command 'RAWCOMMAND' from every Redis server in existence. The fix is to intercept the call before __call() ever runs. The new explicit rawCommand(...$args) pops a trailing callable as the callback (matching __call()'s convention) and forwards the remaining args verbatim through queueCommand() — no method-name prepend, no format reshaping. Throws InvalidArgumentException when no command parts remain (guards against rawCommand() or rawCommand($cb) with no command). This re-establishes the documented contract: rawCommand is the escape hatch for commands not yet wrapped in a dedicated method — new Dragonfly features, custom modules, anything the wire format supports that the typed surface hasn't caught up with. Tests (tests/Feature/RawCommandTest.php — 5 integration tests) - arbitrary SET/GET round-trip via rawCommand - no-arg command (PING) returns PONG via the callback - binary-safe value containing \r\n round-trips byte-exact - unknown command surfaces as false to the callback - no-command-arg invocation throws InvalidArgumentException Known sharp edge: is_callable() returns true for plain strings naming globally-resolvable functions, so rawCommand('PING', 'time') would treat 'time' as the callback. This matches the existing convention in __call() and the dispatcher() helper — keeping it consistent rather than diverging in just one place. README gained a ## rawCommand section after ## ZSCAN. Verified: vendor/bin/pest reports 58 passed / 297 assertions, vendor/bin/phpstan analyse reports OK. --- README.md | 12 ++++ src/Client.php | 36 ++++++++++++ tests/Feature/RawCommandTest.php | 98 ++++++++++++++++++++++++++++++++ 3 files changed, 146 insertions(+) create mode 100644 tests/Feature/RawCommandTest.php diff --git a/README.md b/README.md index 0fb38a9..87fa086 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,18 @@ $redis->zScanAll('leaderboard:weekly', ['COUNT' => 200], function ($members) { The `limit` option (default `100000`) caps the total members collected by `zScanAll()`. On a Redis-side error the callback receives `false`. +## rawCommand + +Escape hatch for sending any Redis or Dragonfly command verbatim — useful for verbs that don't yet have a dedicated wrapper (new server commands, custom modules, multi-word admin verbs, etc.). The args you pass are the wire payload: the first non-callback arg is the command name and the rest are its arguments. The optional trailing callable receives the reply. + +```php +$redis->rawCommand('CONFIG', 'GET', 'maxmemory', function ($reply) { + // $reply === ['maxmemory', '0'] +}); +``` + +Calling `rawCommand()` with no args (or only a callback) throws `InvalidArgumentException` rather than sending an empty command. + ## Development ``` diff --git a/src/Client.php b/src/Client.php index fffc815..c77fd5b 100644 --- a/src/Client.php +++ b/src/Client.php @@ -857,6 +857,42 @@ public function __call($method, $args) return $this->queueCommand($args, $cb); } + /** + * Escape hatch for sending any Redis command verbatim. + * + * Unlike __call(), rawCommand does NOT prepend the method name to the + * wire payload. The args you pass ARE the wire payload — the first + * non-callback arg is the command name and the rest are its arguments. + * Use this for commands that don't yet have a dedicated wrapper: + * newer Redis/Dragonfly verbs, custom modules, multi-word verbs you'd + * rather not assemble through dispatcher(), etc. + * + * The last arg is treated as a callback if it is callable; the rest of + * the args are queued literally via queueCommand(). At least one arg + * (the command name) is required — calling rawCommand() with only a + * callable, or with nothing at all, throws InvalidArgumentException + * rather than sending an empty command to the server. + * + * Example: + * $redis->rawCommand('CONFIG', 'GET', 'maxmemory', function ($reply) { + * // $reply === ['maxmemory', '0'] + * }); + * + * @param mixed ...$args Wire command parts; optionally a trailing callable. + * @return mixed Coroutine mode: the reply. Callback mode: null. + */ + public function rawCommand(...$args) + { + $cb = null; + if (!empty($args) && \is_callable(\end($args))) { + $cb = \array_pop($args); + } + if (empty($args)) { + throw new \InvalidArgumentException('rawCommand requires at least the command name'); + } + return $this->queueCommand($args, $cb); + } + /** * @return array */ diff --git a/tests/Feature/RawCommandTest.php b/tests/Feature/RawCommandTest.php new file mode 100644 index 0000000..ac8144c --- /dev/null +++ b/tests/Feature/RawCommandTest.php @@ -0,0 +1,98 @@ +del($key); + $redis->rawCommand('SET', $key, 'rc-value', function ($setReply) use ($redis, $emit, $key) { + $redis->rawCommand('GET', $key, function ($getReply) use ($emit, $setReply) { + $emit([ + 'set' => $setReply, + 'get' => $getReply, + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + // SET returns +OK which the client normalizes to true. + expect($result['set'])->toBe(true); + expect($result['get'])->toBe('rc-value'); +}); + +it('rawCommand handles a command with no args', function () { + + $result = runInWorker(<<<'PHP' + $redis->rawCommand('PING', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + // PING replies with +PONG (a simple string, NOT the +OK->true normalization + // path), so the client surfaces the literal 'PONG' string. + expect($result)->toBe('PONG'); +}); + +it('rawCommand supports binary-safe values', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:rawcmd:bin'; + $value = "line1\r\nline2\r\n\x00trailing"; + $redis->del($key); + $redis->rawCommand('SET', $key, $value, function () use ($redis, $emit, $key, $value) { + $redis->rawCommand('GET', $key, function ($got) use ($emit, $value) { + $emit([ + 'match' => $got === $value, + 'length' => is_string($got) ? strlen($got) : -1, + 'expect_len' => strlen($value), + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['match'])->toBe(true); + expect($result['length'])->toBe($result['expect_len']); +}); + +it('rawCommand surfaces server errors', function () { + + $result = runInWorker(<<<'PHP' + // Send a command Redis/Dragonfly will reject with an -ERR reply. + // The client signals reply errors by handing the callback `false`. + $redis->rawCommand('THIS_IS_NOT_A_COMMAND', 'arg1', function ($reply, $client) use ($emit) { + $emit([ + 'reply' => $reply, + 'reply_typ' => gettype($reply), + 'has_error' => $client->error() !== '', + ]); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['reply'])->toBe(false); + expect($result['has_error'])->toBe(true); +}); + +it('rawCommand without args throws InvalidArgumentException', function () { + + $result = runInWorker(<<<'PHP' + // Passing only a callable means there are no command parts left after + // the trailing-callback pop. The guard must throw before anything is + // queued to the wire. + try { + $redis->rawCommand(function ($reply) {}); + $emit(['threw' => false]); + } catch (\InvalidArgumentException $e) { + $emit([ + 'threw' => true, + 'message' => $e->getMessage(), + ]); + } + PHP); + + expect($result)->toBeArray(); + expect($result['threw'])->toBe(true); + expect($result['message'])->toContain('rawCommand'); +}); From 3acd82f97232be3e0848cfa52abab9ea68b85602 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:14:20 -0400 Subject: [PATCH 24/68] Add explicit ping/info/dbSize/time/flushDb/flushAll for no-arg-callback bug MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Client::__call() only extracts a trailing callable from $args when count($args) > 1 OR the method is in the small ['randomKey','multi', 'exec','discard'] allowlist. Every no-arg-payload Redis command called with just a callback — $redis->ping($cb), ping fails the count check and isn't in the allowlist, so the closure goes on the wire as a PING argument and Redis returns "wrong number of arguments". The bug silently swallows callbacks for some of the most common operations. Fixing __call() itself is risky: PHP's is_callable() returns true for string function names ('phpinfo', 'system', etc.), so naively extracting "last callable arg" in __call() would corrupt legitimate single-string Redis commands. The safer fix is explicit methods that intercept these specific calls before __call() ever runs. This commit adds six such methods, all funnelling through queueCommand(): ping($cb = null) - returns 'PONG' on success info($section = null, $cb = null) - optional section filter dbSize($cb = null) - integer count of keys in current DB time($cb = null) - [seconds, microseconds] array flushDb($async = false, $cb = null) - sync or FLUSHDB ASYNC flushAll($async = false, $cb = null) - sync or FLUSHALL ASYNC info() and the two flush methods follow set()'s pattern: when the first positional arg is callable, it's treated as the callback (so $redis->info($cb) works without the section filter). flushDb/All accept ASYNC via a boolean — defaults to synchronous to match expectations of casual callers. Tests (tests/Feature/ServerCommandsTest.php — 8 integration tests) - ping returns 'PONG' (the canonical fix proof) - info returns a non-empty server banner (accepts redis_version OR dragonfly_version so the suite works against either backend) - info with section filter returns scoped output - dbSize counts keys correctly - time returns two numeric strings - flushDb synchronous empties the current DB - flushDb ASYNC also empties it (with the ASYNC keyword on the wire) - flushAll empties all DBs Flush tests SELECT a high index (14) before flushing so they can't poison fixtures in DB 0. Each runInWorker subprocess gets its own client, so the SELECT is naturally isolated. A new @method block under "Connection / server methods" carries the six declarations for IDE autocomplete. README gained a ## Server commands section. Verified: vendor/bin/pest reports 66 passed / 313 assertions, vendor/bin/phpstan analyse reports OK. --- README.md | 16 +++ src/Client.php | 117 +++++++++++++++++++++ tests/Feature/ServerCommandsTest.php | 146 +++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 tests/Feature/ServerCommandsTest.php diff --git a/README.md b/README.md index 87fa086..03a7083 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,22 @@ $redis->rawCommand('CONFIG', 'GET', 'maxmemory', function ($reply) { Calling `rawCommand()` with no args (or only a callback) throws `InvalidArgumentException` rather than sending an empty command. +## Server commands + +Explicit wrappers for the no-arg health and admin commands: `ping()`, `info()`, `dbSize()`, `time()`, `flushDb()`, `flushAll()`. These bypass `__call()`'s trailing-callback handling (which only triggers when more than one argument is passed), so the closure goes through `queueCommand()` instead of being shipped to Redis as a bogus command arg. + +```php +$redis->ping(function ($reply) { + // $reply === 'PONG' +}); + +$redis->dbSize(function ($count) { + // $count is an int — number of keys in the current DB +}); +``` + +`info($section, $cb)` accepts an optional section filter (`'server'`, `'memory'`, `'clients'`, …). `flushDb($async, $cb)` and `flushAll($async, $cb)` take an optional first arg — pass `true` to send `FLUSHDB ASYNC` / `FLUSHALL ASYNC` for a non-blocking flush, or pass the callback directly for a synchronous one. + ## Development ``` diff --git a/src/Client.php b/src/Client.php index c77fd5b..c665739 100644 --- a/src/Client.php +++ b/src/Client.php @@ -164,6 +164,13 @@ * Pub/sub methods * @method static mixed publish($channel, $message, $cb = null) * @method static mixed pubSub($keyword, $argument = null, $cb = null) + * Connection / server methods + * @method static string|bool ping($cb = null) + * @method static string|null info($section = null, $cb = null) + * @method static int|bool dbSize($cb = null) + * @method static array|bool time($cb = null) + * @method static bool flushDb($async = false, $cb = null) + * @method static bool flushAll($async = false, $cb = null) * Generic methods * @method static mixed rawCommand(...$commandAndArgs, $cb = null) * Transactions methods @@ -949,6 +956,116 @@ public function close() } } + /* + |-------------------------------------------------------------------------- + | No-arg server / connection commands + |-------------------------------------------------------------------------- + | + | Explicit methods for commands whose only wire payload is the verb itself + | (or the verb plus a single optional flag). __call() only pops a trailing + | callable when count($args) > 1 OR the method is one of a small allowlist, + | so calling these as $redis->ping($cb) would otherwise put the closure on + | the wire as a command argument. Funnelling each through queueCommand() + | bypasses __call() and gives PHPStan a real signature to lock onto. + */ + + /** + * PING — server health check. Reply is the literal string 'PONG'. + * + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: 'PONG'. Callback mode: null. + */ + public function ping($cb = null) + { + return $this->queueCommand(['PING'], $cb); + } + + /** + * INFO — server stats and metadata as a single bulk string. + * + * Optional $section narrows the report (e.g. 'server', 'memory', + * 'clients'). If $section is callable it is treated as the callback + * and no section filter is sent — this lets `$redis->info($cb)` work + * naturally without the caller spelling out a null section first. + * + * @param string|callable|null $section Section name, or callback if no filter. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: the INFO bulk string. Callback mode: null. + */ + public function info($section = null, $cb = null) + { + if (\is_callable($section)) { + $cb = $section; + $section = null; + } + return $this->queueCommand($section === null ? ['INFO'] : ['INFO', $section], $cb); + } + + /** + * DBSIZE — number of keys in the currently selected DB. + * + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: integer count. Callback mode: null. + */ + public function dbSize($cb = null) + { + return $this->queueCommand(['DBSIZE'], $cb); + } + + /** + * TIME — server-side wall clock as a two-element array + * [unix_seconds, microseconds]. Both elements are returned as numeric + * strings (Redis bulk replies). + * + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: [seconds, microseconds]. Callback mode: null. + */ + public function time($cb = null) + { + return $this->queueCommand(['TIME'], $cb); + } + + /** + * FLUSHDB — remove every key from the currently selected DB. + * + * Pass $async = true to send `FLUSHDB ASYNC` for a non-blocking flush + * (the server reclaims memory in a background thread). If $async is + * callable it is treated as the callback and a synchronous flush is + * sent — mirrors how info() folds in a trailing-callback shortcut. + * + * @param bool|callable $async true for ASYNC, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: true on OK. Callback mode: null. + */ + public function flushDb($async = false, $cb = null) + { + if (\is_callable($async)) { + $cb = $async; + $async = false; + } + return $this->queueCommand($async ? ['FLUSHDB', 'ASYNC'] : ['FLUSHDB'], $cb); + } + + /** + * FLUSHALL — remove every key from every DB. + * + * Same $async semantics as flushDb(): pass true for `FLUSHALL ASYNC`, + * or pass a callable directly to shortcut into callback mode with a + * synchronous flush. + * + * @param bool|callable $async true for ASYNC, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: true on OK. Callback mode: null. + */ + public function flushAll($async = false, $cb = null) + { + if (\is_callable($async)) { + $cb = $async; + $async = false; + } + return $this->queueCommand($async ? ['FLUSHALL', 'ASYNC'] : ['FLUSHALL'], $cb); + } + /** * Incrementally iterate the keyspace one batch at a time. * diff --git a/tests/Feature/ServerCommandsTest.php b/tests/Feature/ServerCommandsTest.php new file mode 100644 index 0000000..6a46e52 --- /dev/null +++ b/tests/Feature/ServerCommandsTest.php @@ -0,0 +1,146 @@ + 1 guard, so each one +| has an explicit method. Tests verify the trailing-callback pattern +| actually invokes the callback with the parsed reply (not garbage and +| not a hang). +| +| flush tests SELECT a high DB index (14) inside the worker snippet so +| we never wipe data another test is using. +*/ + +it('ping returns PONG', function () { + + $result = runInWorker(<<<'PHP' + $redis->ping(function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBe('PONG'); +}); + +it('info returns a non-empty string', function () { + + $result = runInWorker(<<<'PHP' + $redis->info(function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeString(); + expect(strlen($result))->toBeGreaterThan(50); + // Redis emits 'redis_version', Dragonfly emits 'dragonfly_version'. + // Either one signals we got a real INFO bulk back (and not a garbled + // reply from the closure-on-wire bug). + $hasVersion = str_contains($result, 'redis_version') || str_contains($result, 'dragonfly_version'); + expect($hasVersion)->toBe(true); +}); + +it('info with section filter returns scoped output', function () { + + $result = runInWorker(<<<'PHP' + $redis->info('server', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeString(); + // Both servers expose a version key under the 'server' section. + expect($result)->toContain('_version'); +}); + +it('dbSize returns an integer', function () { + + $result = runInWorker(<<<'PHP' + // Land on a dedicated DB so other tests' fixtures don't skew + // the count we're about to assert against. + $redis->rawCommand('SELECT', 14, function () use ($redis, $emit) { + $redis->rawCommand('FLUSHDB', function () use ($redis, $emit) { + $redis->set('pest:srv:dbsize:a', '1'); + $redis->set('pest:srv:dbsize:b', '2'); + $redis->set('pest:srv:dbsize:c', '3', function () use ($redis, $emit) { + $redis->dbSize(function ($size) use ($emit) { + $emit($size); + }); + }); + }); + }); + PHP); + + expect($result)->toBeInt(); + expect($result)->toBeGreaterThanOrEqual(3); +}); + +it('time returns a two-element array of digit strings', function () { + + $result = runInWorker(<<<'PHP' + $redis->time(function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeArray(); + expect($result)->toHaveCount(2); + // Both fields arrive as bulk strings of digits. + expect(ctype_digit((string) $result[0]))->toBe(true); + expect(ctype_digit((string) $result[1]))->toBe(true); +}); + +it('flushDb empties the current database', function () { + + $result = runInWorker(<<<'PHP' + // Dedicated DB index so we don't nuke other tests' data. + $redis->rawCommand('SELECT', 14, function () use ($redis, $emit) { + $redis->set('pest:srv:flush:k', '1', function () use ($redis, $emit) { + $redis->flushDb(function () use ($redis, $emit) { + $redis->dbSize(function ($size) use ($emit) { + $emit($size); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(0); +}); + +it('flushDb ASYNC also empties the database', function () { + + $result = runInWorker(<<<'PHP' + $redis->rawCommand('SELECT', 14, function () use ($redis, $emit) { + $redis->set('pest:srv:flush:async', '1', function () use ($redis, $emit) { + $redis->flushDb(true, function () use ($redis, $emit) { + $redis->dbSize(function ($size) use ($emit) { + $emit($size); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(0); +}); + +it('flushAll empties all databases', function () { + + $result = runInWorker(<<<'PHP' + $redis->rawCommand('SELECT', 14, function () use ($redis, $emit) { + $redis->set('pest:srv:flushall:k', '1', function () use ($redis, $emit) { + $redis->flushAll(function () use ($redis, $emit) { + $redis->dbSize(function ($size) use ($emit) { + $emit($size); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(0); +}); From 6c039a6f95bd155ec56793ed7ce8dfdd560aac15 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:22:12 -0400 Subject: [PATCH 25/68] Refactor coverage graphs section in README Reformatted coverage graphs section into a table for better readability. --- README.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 03a7083..c754ab8 100644 --- a/README.md +++ b/README.md @@ -11,9 +11,10 @@ Asynchronous Redis client for PHP, built on Workerman. [![PHP Version](https://img.shields.io/packagist/php-v/workerman/redis.svg)](https://php.net) [![codecov](https://codecov.io/gh/detain/redis/graph/badge.svg?token=ntRuLnxa2V)](https://codecov.io/gh/detain/redis) -![Sunburst](https://codecov.io/gh/detain/redis/graphs/sunburst.svg?token=ntRuLnxa2V)The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively. -![Grid](https://codecov.io/gh/detain/redis/graphs/tree.svg?token=ntRuLnxa2V) Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively. -![Icicle](https://codecov.io/gh/detain/redis/graphs/icicle.svg?token=ntRuLnxa2V) The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively. + +| ![Sunburst](https://codecov.io/gh/detain/redis/graphs/sunburst.svg?token=ntRuLnxa2V) | ![Grid](https://codecov.io/gh/detain/redis/graphs/tree.svg?token=ntRuLnxa2V) | ![Icicle](https://codecov.io/gh/detain/redis/graphs/icicle.svg?token=ntRuLnxa2V) | +|------|------|------| +| *Sunburst* The inner-most circle is the entire project, moving away from the center are folders then, finally, a single file. The size and color of each slice is representing the number of statements and the coverage, respectively. | *Grid* Each block represents a single file in the project. The size and color of each block is represented by the number of statements and the coverage, respectively. | *Icicle* The top section represents the entire project. Proceeding with folders and finally individual files. The size and color of each slice is representing the number of statements and the coverage, respectively. | From 7409fc6ffacbb323b984262abf48bf329ac76ac4 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:31:39 -0400 Subject: [PATCH 26/68] Document GETDEL/GETEX/SUBSTR/COPY/TOUCH/EXPIRETIME/PEXPIRETIME/ECHO/HELLO MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These nine commands already worked through Client::__call() because each takes more than a single wire arg, so __call()'s count($args) > 1 callback-extraction branch picks up the trailing closure. The only thing missing was the @method declaration that exposes them to IDE autocomplete and PHPStan, plus integration tests that confirm the wire shape end to end against a live server. The exception is HELLO. With no required positional args, $redis->hello($cb) puts count($args) at 1, falls into __call() without extracting the trailing closure, and serializes the callback as a HELLO argument — the RESP encoder then calls strlen() on a Closure object and the subprocess crashes silently. Same shape as the PING/INFO/DBSIZE no-arg-callback bug fixed in commit 3acd82f. The fix mirrors info(): an explicit Client::hello() with a callable-as- first-arg shortcut so hello($cb) folds the closure into the $cb slot before __call() ever sees it. Sub-commands (AUTH user pass, SETNAME) can be passed as a flat array which the RESP encoder flattens onto the wire: $redis->hello(2, $cb); $redis->hello(2, ['AUTH', 'user', 'pass'], $cb); ECHO doesn't have this bug because the message arg is mandatory; left as @method-only. @method declarations added (under matching family headers): Strings: getDel, getEx, substr Keys: copy, touch, expireTime, pExpireTime Connection: echo, hello Tests (tests/Feature/StringsKeysExtraTest.php — 11 integration tests) - getDel returns the value and removes the key - getEx without options preserves no-TTL - getEx with EX sets a TTL between 1 and 60 - substr returns a slice - copy duplicates a key - copy with REPLACE overwrites an existing destination - touch counts only existing keys (missing ones don't increment) - expireTime returns the absolute unix timestamp - pExpireTime returns the absolute millisecond timestamp - echo round-trips the message - hello returns an array reply (works against Dragonfly's no-arg form) Verified: vendor/bin/pest reports 77 passed / 336 assertions, vendor/bin/phpstan analyse reports OK. --- src/Client.php | 49 ++++++ tests/Feature/StringsKeysExtraTest.php | 207 +++++++++++++++++++++++++ 2 files changed, 256 insertions(+) create mode 100644 tests/Feature/StringsKeysExtraTest.php diff --git a/src/Client.php b/src/Client.php index c665739..cf384f8 100644 --- a/src/Client.php +++ b/src/Client.php @@ -28,6 +28,8 @@ * @method static int decrBy($key, $value, $cb = null) * @method static string|bool get($key, $cb = null) * @method static int getBit($key, $offset, $cb = null) + * @method static string|null getDel($key, $cb = null) + * @method static string|null getEx($key, array $options = [], $cb = null) * @method static string getRange($key, $start, $end, $cb = null) * @method static string getSet($key, $value, $cb = null) * @method static int incrBy($key, $value, $cb = null) @@ -39,8 +41,10 @@ * @method static bool pSetEx($key, $ttl, $value, $cb = null) * @method static bool setNx($key, $value, $cb = null) * @method static string setRange($key, $offset, $value, $cb = null) + * @method static string substr($key, $start, $end, $cb = null) * @method static int strLen($key, $cb = null) * Keys methods + * @method static int copy($src, $dst, array $options = [], $cb = null) * @method static int del(...$keys, $cb = null) * @method static int unlink(...$keys, $cb = null) * @method static false|string dump($key, $cb = null) @@ -49,6 +53,8 @@ * @method static bool pexpire($key, $ttl, $cb = null) * @method static bool expireAt($key, $timestamp, $cb = null) * @method static bool pexpireAt($key, $timestamp, $cb = null) + * @method static int expireTime($key, $cb = null) + * @method static int pExpireTime($key, $cb = null) * @method static array keys($pattern, $cb = null) * @method static void migrate($host, $port, $keys, $dbIndex, $timeout, $copy = false, $replace = false, $cb = null) * @method static bool move($key, $dbIndex, $cb = null) @@ -57,6 +63,7 @@ * @method static string randomKey($cb = null) * @method static bool rename($srcKey, $dstKey, $cb = null) * @method static bool renameNx($srcKey, $dstKey, $cb = null) + * @method static int touch(...$keys, $cb = null) * @method static string type($key, $cb = null) * @method static int ttl($key, $cb = null) * @method static int pttl($key, $cb = null) @@ -171,6 +178,8 @@ * @method static array|bool time($cb = null) * @method static bool flushDb($async = false, $cb = null) * @method static bool flushAll($async = false, $cb = null) + * @method static string echo($message, $cb = null) + * @method static array hello($protover = null, $cb = null) * Generic methods * @method static mixed rawCommand(...$commandAndArgs, $cb = null) * Transactions methods @@ -1066,6 +1075,46 @@ public function flushAll($async = false, $cb = null) return $this->queueCommand($async ? ['FLUSHALL', 'ASYNC'] : ['FLUSHALL'], $cb); } + /** + * RESP version negotiation / server handshake. + * + * Like info(), the first positional arg accepts the callback directly so + * `$redis->hello($cb)` works without a $protover argument. Otherwise + * `$redis->hello(2, $cb)` upgrades to RESP3 and `$redis->hello(2, ['AUTH', + * 'user', 'pass', 'SETNAME', 'client-name'], $cb)` includes the full + * sub-command grammar — pass the extra args as a flat array which the + * RESP encoder flattens onto the wire. + * + * Without an explicit method, calls like hello($cb) (count($args) == 1) + * fall into __call() which doesn't extract the trailing callable and + * sends the closure as a HELLO argument — same no-arg-callback bug as + * PING / INFO / DBSIZE etc. + * + * @param int|string|callable|null $protover RESP protocol version (2 or 3), or callable for the callback. + * @param array|callable|null $extra Additional sub-args (AUTH/SETNAME), or callable for the callback. + * @param callable|null $cb function(array $reply, Client $client): void + * @return array|null + */ + public function hello($protover = null, $extra = null, $cb = null) + { + if (\is_callable($protover)) { + $cb = $protover; + $protover = null; + $extra = null; + } elseif (\is_callable($extra)) { + $cb = $extra; + $extra = null; + } + $args = ['HELLO']; + if ($protover !== null) { + $args[] = $protover; + } + if (\is_array($extra)) { + $args[] = $extra; + } + return $this->queueCommand($args, $cb); + } + /** * Incrementally iterate the keyspace one batch at a time. * diff --git a/tests/Feature/StringsKeysExtraTest.php b/tests/Feature/StringsKeysExtraTest.php new file mode 100644 index 0000000..6c05d18 --- /dev/null +++ b/tests/Feature/StringsKeysExtraTest.php @@ -0,0 +1,207 @@ + 1 branch picks up the +| trailing callback). No explicit method — only @method declarations on +| the class docblock and these integration tests confirming they work end +| to end against a live server. +| +| GETDEL, GETEX, SUBSTR (Strings) +| COPY, TOUCH, EXPIRETIME, +| PEXPIRETIME (Keys) +| ECHO, HELLO (Connection / server) +| +| All keys use the pest:extra: prefix to avoid collisions with other +| feature tests. +*/ + +it('getDel returns the value and deletes the key', function () { + + $result = runInWorker(<<<'PHP' + $redis->set('pest:extra:getdel', 'v', function () use ($redis, $emit) { + $redis->getDel('pest:extra:getdel', function ($value) use ($redis, $emit) { + $redis->get('pest:extra:getdel', function ($after) use ($value, $emit) { + $emit(['value' => $value, 'after' => $after]); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['value'])->toBe('v'); + expect($result['after'])->toBeNull(); +}); + +it('getEx without options returns the value without changing TTL', function () { + + $result = runInWorker(<<<'PHP' + $redis->set('pest:extra:getex:plain', 'v', function () use ($redis, $emit) { + $redis->getEx('pest:extra:getex:plain', function ($value) use ($emit) { + $emit($value); + }); + }); + PHP); + + expect($result)->toBe('v'); +}); + +it('getEx EX sets a TTL', function () { + + $result = runInWorker(<<<'PHP' + $redis->set('pest:extra:getex:ttl', 'v', function () use ($redis, $emit) { + $redis->getEx('pest:extra:getex:ttl', ['EX', 60], function ($value) use ($redis, $emit) { + $redis->ttl('pest:extra:getex:ttl', function ($ttl) use ($value, $emit) { + $emit(['value' => $value, 'ttl' => $ttl]); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['value'])->toBe('v'); + expect($result['ttl'])->toBeGreaterThanOrEqual(1); + expect($result['ttl'])->toBeLessThanOrEqual(60); +}); + +it('substr returns a slice of the value', function () { + + $result = runInWorker(<<<'PHP' + $redis->set('pest:extra:substr', 'hello world', function () use ($redis, $emit) { + $redis->substr('pest:extra:substr', 0, 4, function ($slice) use ($emit) { + $emit($slice); + }); + }); + PHP); + + expect($result)->toBe('hello'); +}); + +it('copy duplicates a key to a new destination', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:extra:copy:src', 'pest:extra:copy:dst', function () use ($redis, $emit) { + $redis->set('pest:extra:copy:src', 'value', function () use ($redis, $emit) { + $redis->copy('pest:extra:copy:src', 'pest:extra:copy:dst', function ($ok) use ($redis, $emit) { + $redis->get('pest:extra:copy:dst', function ($dst) use ($ok, $emit) { + $emit(['ok' => $ok, 'dst' => $dst]); + }); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['ok'])->toBe(1); + expect($result['dst'])->toBe('value'); +}); + +it('copy with REPLACE overwrites existing dst', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:extra:copy:rsrc', 'pest:extra:copy:rdst', function () use ($redis, $emit) { + $redis->set('pest:extra:copy:rsrc', 'v1', function () use ($redis, $emit) { + $redis->set('pest:extra:copy:rdst', 'v2', function () use ($redis, $emit) { + // Without REPLACE: copying onto an existing key returns 0. + $redis->copy('pest:extra:copy:rsrc', 'pest:extra:copy:rdst', function ($noReplace) use ($redis, $emit) { + // With REPLACE: overwrites and returns 1. + $redis->copy('pest:extra:copy:rsrc', 'pest:extra:copy:rdst', ['REPLACE'], function ($withReplace) use ($redis, $noReplace, $emit) { + $redis->get('pest:extra:copy:rdst', function ($dst) use ($noReplace, $withReplace, $emit) { + $emit([ + 'noReplace' => $noReplace, + 'withReplace' => $withReplace, + 'dst' => $dst, + ]); + }); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['noReplace'])->toBe(0); + expect($result['withReplace'])->toBe(1); + expect($result['dst'])->toBe('v1'); +}); + +it('touch returns count of existing keys', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:extra:touch:k1', 'pest:extra:touch:k2', 'pest:extra:touch:missing', function () use ($redis, $emit) { + $redis->set('pest:extra:touch:k1', 'v', function () use ($redis, $emit) { + $redis->set('pest:extra:touch:k2', 'v', function () use ($redis, $emit) { + $redis->touch('pest:extra:touch:k1', 'pest:extra:touch:k2', 'pest:extra:touch:missing', function ($n) use ($emit) { + $emit($n); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(2); +}); + +it('expireTime returns absolute unix timestamp', function () { + + $future = time() + 3600; + + $result = runInWorker(<<set('pest:extra:expiretime', 'v', function () use (\$redis, \$emit) { + \$redis->expireAt('pest:extra:expiretime', {$future}, function () use (\$redis, \$emit) { + \$redis->expireTime('pest:extra:expiretime', function (\$ts) use (\$emit) { + \$emit(\$ts); + }); + }); + }); + PHP); + + expect($result)->toBe($future); +}); + +it('pExpireTime returns absolute millisecond timestamp', function () { + + $futureMs = (time() + 3600) * 1000; + + $result = runInWorker(<<set('pest:extra:pexpiretime', 'v', function () use (\$redis, \$emit) { + \$redis->pexpireAt('pest:extra:pexpiretime', {$futureMs}, function () use (\$redis, \$emit) { + \$redis->pExpireTime('pest:extra:pexpiretime', function (\$ts) use (\$emit) { + \$emit(\$ts); + }); + }); + }); + PHP); + + expect($result)->toBe($futureMs); +}); + +it('echo returns the same message it was sent', function () { + + $result = runInWorker(<<<'PHP' + $redis->echo('pest-echo-msg', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBe('pest-echo-msg'); +}); + +it('hello returns server protocol info', function () { + + // Some Dragonfly builds return an empty array for HELLO with no args. + // We just want to confirm the call completes without erroring and the + // reply is array-shaped (or empty array, treated as success). + $result = runInWorker(<<<'PHP' + $redis->hello(function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeArray(); +}); From 8af95287c75b29f125fda779bf61d9ed0b2acc1e Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:36:19 -0400 Subject: [PATCH 27/68] Implement explicit quit() with don't-reconnect semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit QUIT tells the server to close the connection after replying +OK. The existing client always auto-reconnects on socket close — fine for unexpected drops, wrong for an intentional QUIT. The fix is a new $_quitting flag set inside quit() and respected by the onClose handler. What changed in src/Client.php: - New protected $_quitting = false instance var. - onClose handler in connect() learns to early-return when $_quitting is true, skipping both the immediate connect() retry and the 5-second _reconnectTimer arming. closeConnection() still runs so the underlying socket and timers are cleaned up the same way. - New quit($cb = null) method, placed beside ping() in the Connection/server group. Sets $_quitting synchronously, then enqueues ['QUIT'] via queueCommand(). The user's callback is wrapped so the flag is set regardless of whether the caller provided one — important because the flag must be visible to onClose, which races against the +OK reply. - @method declaration added to the Connection/server PHPDoc section. Without the explicit method, $redis->quit($cb) would hit the no-arg- callback bug (the closure becomes a QUIT arg) AND the connection would silently reopen 5 seconds later — both wrong. Tests (tests/Feature/QuitTest.php — 2 integration tests) - quit returns +OK as boolean true (matches the client's existing simple-string convention) - 500ms after the reply, reflection on the Client shows $_quitting === true AND $_connection === null — proving the auto-reconnect was suppressed Tier 1 now complete: SCAN/HSCAN/SSCAN/ZSCAN families, rawCommand, ping/info/dbSize/time/flushDb/flushAll, the nine @method-documented commands (getDel/getEx/substr/copy/touch/expireTime/pExpireTime/echo/ hello), and now quit. 79 pest tests passing, PHPStan OK. --- src/Client.php | 36 ++++++++++++++++++++++++++ tests/Feature/QuitTest.php | 52 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 88 insertions(+) create mode 100644 tests/Feature/QuitTest.php diff --git a/src/Client.php b/src/Client.php index cf384f8..3984982 100644 --- a/src/Client.php +++ b/src/Client.php @@ -173,6 +173,7 @@ * @method static mixed pubSub($keyword, $argument = null, $cb = null) * Connection / server methods * @method static string|bool ping($cb = null) + * @method static string|bool quit($cb = null) * @method static string|null info($section = null, $cb = null) * @method static int|bool dbSize($cb = null) * @method static array|bool time($cb = null) @@ -281,6 +282,14 @@ class Client */ protected $_firstConnect = true; + /** + * Set to true when QUIT has been sent. Suppresses the onClose + * auto-reconnect so the connection genuinely closes. + * + * @var bool + */ + protected $_quitting = false; + /** * Client constructor. * @param $address @@ -400,6 +409,10 @@ public function connect() $this->_reconnectTimer = null; } $this->closeConnection(); + if ($this->_quitting) { + // Intentional QUIT — do not auto-reconnect. + return; + } if (microtime(true) - $time_start > 5) { $this->connect(); } else { @@ -989,6 +1002,29 @@ public function ping($cb = null) return $this->queueCommand(['PING'], $cb); } + /** + * QUIT — ask the server to close the connection. + * + * Sets the internal $_quitting flag so the onClose handler suppresses + * the usual 5-second reconnect timer. Once QUIT's +OK reply has been + * delivered to the callback, the socket is closed by the server and + * the client stays closed — call connect() again only if you need to + * resume work on the same instance. + * + * @param callable|null $cb function(string|bool $reply, Client $client): void + * @return mixed Coroutine mode: true on +OK. Callback mode: null. + */ + public function quit($cb = null) + { + $this->_quitting = true; + $userCb = $cb; + return $this->queueCommand(['QUIT'], function ($reply, $client) use ($userCb) { + if ($userCb !== null) { + \call_user_func($userCb, $reply, $client); + } + }); + } + /** * INFO — server stats and metadata as a single bulk string. * diff --git a/tests/Feature/QuitTest.php b/tests/Feature/QuitTest.php new file mode 100644 index 0000000..2c75d21 --- /dev/null +++ b/tests/Feature/QuitTest.php @@ -0,0 +1,52 @@ +quit(function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeTrue(); +}); + +it('quit suppresses the auto-reconnect onClose handler', function () { + + // After QUIT, the onClose handler should see $_quitting === true and + // skip its usual reconnect timer. Wait long enough for the server's + // FIN to fly and the close handler to run, then peek at the Client's + // protected $_connection via reflection. If reconnect had fired we'd + // see a fresh AsyncTcpConnection; if QUIT really stopped the world we + // see null (closeConnection() nulls it out). + $result = runInWorker(<<<'PHP' + $redis->quit(function ($reply, $client) use ($emit) { + \Workerman\Timer::add(0.5, function () use ($client, $emit) { + $ref = new \ReflectionClass($client); + $connProp = $ref->getProperty('_connection'); + $connProp->setAccessible(true); + $quitProp = $ref->getProperty('_quitting'); + $quitProp->setAccessible(true); + $emit([ + 'quitting' => $quitProp->getValue($client), + 'connIsNull' => $connProp->getValue($client) === null, + ]); + }, [], false); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['quitting'])->toBeTrue(); + expect($result['connIsNull'])->toBeTrue(); +}); From 8d8e27fd68af133526d04a764fee5fabc1af7355 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:46:28 -0400 Subject: [PATCH 28/68] Document 23 modern list/set/hash/zset/stream commands (Tier 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit These all already work through Client::__call() because each takes two or more wire args, which the existing count($args) > 1 callback- extraction branch handles. The only thing missing was the @method declarations that expose them to IDE autocomplete and PHPStan, plus integration tests that pin the wire shapes against a live server. Added @method entries across the existing docblock sections: Lists (5): lMove, lMPop, lPos, blMove, blMPop Sets (2): sMIsMember, sInterCard Hashes (1): hRandField Sorted sets (13): zRandMember, zMScore, zDiff, zDiffStore, zInter, zInterCard, zUnion, zRangeStore, zMPop, bzMPop, zRevRangeByLex, zRemRangeByLex, zLexCount Streams (2): xAutoClaim, xSetId Tests (tests/Feature/ModernCommandsTest.php — 23 integration tests) one test per command, each tagged with a unique pest:modern:tN: prefix so parallel/repeat runs can't collide. Implementation notes baked into the tests: - Blocking variants (blMove, blMPop, bzMPop) use 100ms timeouts against pre-populated structures, so the server returns immediately and the suite isn't latency-bound. - zMScore scores come back as numeric strings from Dragonfly ('1' / '2'), not floats — the test asserts the string form. - zUnion/zInter member ordering isn't stable across implementations; zUnion sorts before compare, zInter uses a single-element intersection to avoid the ordering concern. - xSetId requires the new id to be >= the current top id; tests use a fixed seed id (1-1) then bump to 999-0. - xAutoClaim/xSetId setup uses rawCommand('XADD', ...) instead of Client::xAdd() because the existing @method signature for xAdd passes the message as ['k'=>'v'] which the RESP encoder flattens by value (dropping field names). That's a pre-existing client quirk worth its own commit later; for this doc-only round we side- stepped via rawCommand. Verified: vendor/bin/pest reports 102 passed / 379 assertions, vendor/bin/phpstan analyse reports OK. Tier 1 complete after 8af9528 (quit); this commit closes out Tier 2. --- src/Client.php | 23 ++ tests/Feature/ModernCommandsTest.php | 461 +++++++++++++++++++++++++++ 2 files changed, 484 insertions(+) create mode 100644 tests/Feature/ModernCommandsTest.php diff --git a/src/Client.php b/src/Client.php index 3984982..2edace6 100644 --- a/src/Client.php +++ b/src/Client.php @@ -82,6 +82,7 @@ * @method static int hIncrBy($key, $hashKey, $value, $cb = null) * @method static float hIncrByFloat($key, $hashKey, $value, $cb = null) * @method static int hStrLen($key, $hashKey, $cb = null) + * @method static string|array hRandField($key, $count = null, $withValues = false, $cb = null) * @method static array|null hScan($key, $cursor, array $options = [], $cb = null) * @method static array|false|null hScanAll($key, array $options = [], $cb = null) * Lists methods @@ -102,6 +103,11 @@ * @method static false|int rPush($key, ...$entries, $cb = null) * @method static false|int rPushX($key, $value, $cb = null) * @method static false|int lLen($key, $cb = null) + * @method static false|string lMove($src, $dst, $srcWhere, $dstWhere, $cb = null) + * @method static array|null lMPop($keys, $where, $count = 1, $cb = null) + * @method static int|false lPos($key, $element, array $options = [], $cb = null) + * @method static false|string blMove($src, $dst, $srcWhere, $dstWhere, $timeout, $cb = null) + * @method static array|null blMPop($timeout, $keys, $where, $count = 1, $cb = null) * Sets methods * @method static int sAdd($key, $value, $cb = null) * @method static int sCard($key, $cb = null) @@ -117,6 +123,8 @@ * @method static int sRem($key, ...$members, $cb = null) * @method static array sUnion(...$keys, $cb = null) * @method static false|int sUnionStore($dst, ...$keys, $cb = null) + * @method static array sMIsMember($key, ...$members, $cb = null) + * @method static int sInterCard($numkeys, $keys, $limit = 0, $cb = null) * @method static array|null sScan($key, $cursor, array $options = [], $cb = null) * @method static array|false|null sScanAll($key, array $options = [], $cb = null) * Sorted sets methods @@ -141,6 +149,19 @@ * @method static array zRevRange($key, $start, $end, $withScores = false, $cb = null) * @method static double zScore($key, $member, $cb = null) * @method static int zunionstore($keyOutput, $arrayZSetKeys, $arrayWeights = [], $aggregateFunction = '', $cb = null) + * @method static array zRandMember($key, $count = null, $withScores = false, $cb = null) + * @method static array zMScore($key, ...$members, $cb = null) + * @method static array zDiff($numkeys, $keys, $withScores = false, $cb = null) + * @method static int zDiffStore($dst, $numkeys, $keys, $cb = null) + * @method static array zInter($numkeys, $keys, array $options = [], $cb = null) + * @method static int zInterCard($numkeys, $keys, $limit = 0, $cb = null) + * @method static array zUnion($numkeys, $keys, array $options = [], $cb = null) + * @method static int zRangeStore($dst, $src, $min, $max, array $options = [], $cb = null) + * @method static array|null zMPop($numkeys, $keys, $where, $count = 1, $cb = null) + * @method static array|null bzMPop($timeout, $numkeys, $keys, $where, $count = 1, $cb = null) + * @method static array zRevRangeByLex($key, $max, $min, $offset = 0, $count = 0, $cb = null) + * @method static int zRemRangeByLex($key, $min, $max, $cb = null) + * @method static int zLexCount($key, $min, $max, $cb = null) * @method static array|null zScan($key, $cursor, array $options = [], $cb = null) * @method static array|false|null zScanAll($key, array $options = [], $cb = null) * HyperLogLogs methods @@ -168,6 +189,8 @@ * @method static array xReadGroup($strGroup, $strConsumer, $arrStreams, $iCount = 0, $iBlock = null, $cb = null) * @method static array xRevRange($strStream, $strEnd, $strStart, $iCount = 0, $cb = null) * @method static int xTrim($strStream, $iMaxLen, $booApproximate = null, $cb = null) + * @method static array xAutoClaim($key, $group, $consumer, $minIdleMs, $start, array $options = [], $cb = null) + * @method static bool xSetId($key, $lastId, array $options = [], $cb = null) * Pub/sub methods * @method static mixed publish($channel, $message, $cb = null) * @method static mixed pubSub($keyword, $argument = null, $cb = null) diff --git a/tests/Feature/ModernCommandsTest.php b/tests/Feature/ModernCommandsTest.php new file mode 100644 index 0000000..abe85da --- /dev/null +++ b/tests/Feature/ModernCommandsTest.php @@ -0,0 +1,461 @@ + 1 branch picks up the +| trailing callback). No explicit method — only @method declarations on +| the class docblock and these integration tests confirming each one runs +| end-to-end against a live server. +| +| Lists: LMOVE, LMPOP, LPOS, BLMOVE, BLMPOP +| Sets: SMISMEMBER, SINTERCARD +| Hashes: HRANDFIELD +| Sorted sets: ZRANDMEMBER, ZMSCORE, ZDIFF, ZDIFFSTORE, ZINTER, ZINTERCARD, +| ZUNION, ZRANGESTORE, ZMPOP, BZMPOP, ZREVRANGEBYLEX, +| ZREMRANGEBYLEX, ZLEXCOUNT +| Streams: XAUTOCLAIM, XSETID +| +| Each test uses a unique pest:modern:tN: prefix to avoid collisions. The +| assertions favour "did the command return a sane shape" over exhaustive +| semantic checks — some replies (LMPOP, ZMPOP, BLMPOP, XAUTOCLAIM) nest +| in implementation-specific ways across Redis/Dragonfly versions. +*/ + +it('lMove moves an element between two lists', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t1:src', 'pest:modern:t1:dst', function () use ($redis, $emit) { + $redis->rPush('pest:modern:t1:src', 'a', 'b', 'c', function () use ($redis, $emit) { + $redis->lMove('pest:modern:t1:src', 'pest:modern:t1:dst', 'LEFT', 'RIGHT', function ($moved) use ($emit) { + $emit($moved); + }); + }); + }); + PHP); + + expect($result)->toBe('a'); +}); + +it('lMPop pops elements from the first non-empty list', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t2:list', function () use ($redis, $emit) { + $redis->rPush('pest:modern:t2:list', 'a', 'b', 'c', function () use ($redis, $emit) { + // Wire form: LMPOP numkeys key [key ...] LEFT|RIGHT [COUNT n] + $redis->lMPop([1, 'pest:modern:t2:list'], 'LEFT', function ($reply) use ($emit) { + $emit($reply); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + // Reply shape: [key, [popped...]] + expect($result[0])->toBe('pest:modern:t2:list'); + expect($result[1])->toBeArray(); +}); + +it('lPos finds the index of an element', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t3:list', function () use ($redis, $emit) { + $redis->rPush('pest:modern:t3:list', 'a', 'b', 'c', 'b', function () use ($redis, $emit) { + $redis->lPos('pest:modern:t3:list', 'b', [], function ($index) use ($emit) { + $emit($index); + }); + }); + }); + PHP); + + expect($result)->toBe(1); +}); + +it('blMove moves an element with a short timeout when data is present', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t4:src', 'pest:modern:t4:dst', function () use ($redis, $emit) { + $redis->rPush('pest:modern:t4:src', 'x', 'y', function () use ($redis, $emit) { + // Data already in src — server returns immediately, doesn't block. + $redis->blMove('pest:modern:t4:src', 'pest:modern:t4:dst', 'LEFT', 'RIGHT', 0.1, function ($moved) use ($emit) { + $emit($moved); + }); + }); + }); + PHP); + + expect($result)->toBe('x'); +}); + +it('blMPop pops from a non-empty list within the timeout', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t5:list', function () use ($redis, $emit) { + $redis->rPush('pest:modern:t5:list', 'one', 'two', function () use ($redis, $emit) { + // Wire form: BLMPOP timeout numkeys key [key ...] LEFT|RIGHT [COUNT n] + $redis->blMPop(0.1, [1, 'pest:modern:t5:list'], 'LEFT', function ($reply) use ($emit) { + $emit($reply); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result[0])->toBe('pest:modern:t5:list'); +}); + +it('sMIsMember returns one int per member', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t6:set', function () use ($redis, $emit) { + $redis->sAdd('pest:modern:t6:set', 'a', 'b', function () use ($redis, $emit) { + $redis->sMIsMember('pest:modern:t6:set', 'a', 'b', 'c', function ($flags) use ($emit) { + $emit($flags); + }); + }); + }); + PHP); + + expect($result)->toBe([1, 1, 0]); +}); + +it('sInterCard returns the cardinality of the intersection', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t7:a', 'pest:modern:t7:b', function () use ($redis, $emit) { + $redis->sAdd('pest:modern:t7:a', 'x', 'y', 'z', function () use ($redis, $emit) { + $redis->sAdd('pest:modern:t7:b', 'x', 'y', 'q', function () use ($redis, $emit) { + // Wire form: SINTERCARD numkeys key [key ...] [LIMIT n] + $redis->sInterCard(2, ['pest:modern:t7:a', 'pest:modern:t7:b'], function ($n) use ($emit) { + $emit($n); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(2); +}); + +it('hRandField returns a field name from the hash', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t8:hash', function () use ($redis, $emit) { + $redis->hMSet('pest:modern:t8:hash', ['f1' => 'v1', 'f2' => 'v2', 'f3' => 'v3'], function () use ($redis, $emit) { + $redis->hRandField('pest:modern:t8:hash', 1, function ($field) use ($emit) { + $emit($field); + }); + }); + }); + PHP); + + // With count=1 the reply is a one-element array containing a field name. + expect($result)->toBeArray(); + expect($result)->toHaveCount(1); + expect($result[0])->toBeIn(['f1', 'f2', 'f3']); +}); + +it('zRandMember returns a member of the sorted set', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t9:z', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t9:z', 1, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t9:z', 2, 'b', function () use ($redis, $emit) { + $redis->zRandMember('pest:modern:t9:z', 1, function ($reply) use ($emit) { + $emit($reply); + }); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result)->toHaveCount(1); + expect($result[0])->toBeIn(['a', 'b']); +}); + +it('zMScore returns scores for each requested member', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t10:z', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t10:z', 1, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t10:z', 2, 'b', function () use ($redis, $emit) { + $redis->zMScore('pest:modern:t10:z', 'a', 'b', 'missing', function ($scores) use ($emit) { + $emit($scores); + }); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result)->toHaveCount(3); + expect($result[0])->toBe('1'); + expect($result[1])->toBe('2'); + expect($result[2])->toBeNull(); +}); + +it('zDiff returns members in the first set but not the others', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t11:a', 'pest:modern:t11:b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t11:a', 1, 'x', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t11:a', 2, 'y', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t11:b', 1, 'x', function () use ($redis, $emit) { + // Wire form: ZDIFF numkeys key [key ...] + $redis->zDiff(2, ['pest:modern:t11:a', 'pest:modern:t11:b'], function ($diff) use ($emit) { + $emit($diff); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(['y']); +}); + +it('zDiffStore stores the diff and returns the cardinality', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t12:a', 'pest:modern:t12:b', 'pest:modern:t12:dst', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t12:a', 1, 'x', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t12:a', 2, 'y', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t12:b', 1, 'x', function () use ($redis, $emit) { + // Wire form: ZDIFFSTORE dst numkeys key [key ...] + $redis->zDiffStore('pest:modern:t12:dst', 2, ['pest:modern:t12:a', 'pest:modern:t12:b'], function ($n) use ($emit) { + $emit($n); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(1); +}); + +it('zInter returns the intersection of sorted sets', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t13:a', 'pest:modern:t13:b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t13:a', 1, 'x', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t13:a', 2, 'y', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t13:b', 1, 'x', function () use ($redis, $emit) { + // Wire form: ZINTER numkeys key [key ...] + $redis->zInter(2, ['pest:modern:t13:a', 'pest:modern:t13:b'], function ($inter) use ($emit) { + $emit($inter); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(['x']); +}); + +it('zInterCard returns the cardinality of the intersection', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t14:a', 'pest:modern:t14:b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t14:a', 1, 'x', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t14:a', 2, 'y', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t14:b', 1, 'x', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t14:b', 2, 'y', function () use ($redis, $emit) { + $redis->zInterCard(2, ['pest:modern:t14:a', 'pest:modern:t14:b'], function ($n) use ($emit) { + $emit($n); + }); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(2); +}); + +it('zUnion returns the union of sorted sets', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t15:a', 'pest:modern:t15:b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t15:a', 1, 'x', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t15:b', 1, 'y', function () use ($redis, $emit) { + // Wire form: ZUNION numkeys key [key ...] + $redis->zUnion(2, ['pest:modern:t15:a', 'pest:modern:t15:b'], function ($union) use ($emit) { + $emit($union); + }); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result)->toHaveCount(2); + sort($result); + expect($result)->toBe(['x', 'y']); +}); + +it('zRangeStore copies a range from src into dst', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t16:src', 'pest:modern:t16:dst', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t16:src', 1, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t16:src', 2, 'b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t16:src', 3, 'c', function () use ($redis, $emit) { + // Wire form: ZRANGESTORE dst src min max + $redis->zRangeStore('pest:modern:t16:dst', 'pest:modern:t16:src', 0, -1, function ($n) use ($emit) { + $emit($n); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(3); +}); + +it('zMPop pops members from the first non-empty sorted set', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t17:z', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t17:z', 1, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t17:z', 2, 'b', function () use ($redis, $emit) { + // Wire form: ZMPOP numkeys key [key ...] MIN|MAX [COUNT n] + $redis->zMPop(1, ['pest:modern:t17:z'], 'MIN', function ($reply) use ($emit) { + $emit($reply); + }); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result[0])->toBe('pest:modern:t17:z'); +}); + +it('bzMPop pops members from a non-empty sorted set within the timeout', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t18:z', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t18:z', 1, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t18:z', 2, 'b', function () use ($redis, $emit) { + // Wire form: BZMPOP timeout numkeys key [key ...] MIN|MAX [COUNT n] + $redis->bzMPop(0.1, 1, ['pest:modern:t18:z'], 'MIN', function ($reply) use ($emit) { + $emit($reply); + }); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result[0])->toBe('pest:modern:t18:z'); +}); + +it('zRevRangeByLex returns members in reverse lexicographic order', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t19:z', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t19:z', 0, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t19:z', 0, 'b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t19:z', 0, 'c', function () use ($redis, $emit) { + $redis->zRevRangeByLex('pest:modern:t19:z', '+', '-', function ($members) use ($emit) { + $emit($members); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(['c', 'b', 'a']); +}); + +it('zRemRangeByLex removes members in the given lex range', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t20:z', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t20:z', 0, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t20:z', 0, 'b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t20:z', 0, 'c', function () use ($redis, $emit) { + // Wire form: ZREMRANGEBYLEX key min max + $redis->zRemRangeByLex('pest:modern:t20:z', '[a', '[b', function ($removed) use ($emit) { + $emit($removed); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(2); +}); + +it('zLexCount counts members in a lex range', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t21:z', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t21:z', 0, 'a', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t21:z', 0, 'b', function () use ($redis, $emit) { + $redis->zAdd('pest:modern:t21:z', 0, 'c', function () use ($redis, $emit) { + $redis->zLexCount('pest:modern:t21:z', '-', '+', function ($n) use ($emit) { + $emit($n); + }); + }); + }); + }); + }); + PHP); + + expect($result)->toBe(3); +}); + +it('xAutoClaim transfers idle PEL entries to a new consumer', function () { + + // XADD wire form: XADD key id field value [field value ...]. The encoder + // flattens 1 level of array nesting but only emits values, so pass the + // field/value pair as a flat indexed array. + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t22:stream', function () use ($redis, $emit) { + $redis->rawCommand('XADD', 'pest:modern:t22:stream', '*', 'k', 'v', function () use ($redis, $emit) { + $redis->xGroup('CREATE', 'pest:modern:t22:stream', 'g1', '0', function () use ($redis, $emit) { + // Consumer 'c1' reads — entry now in PEL. + $redis->rawCommand('XREADGROUP', 'GROUP', 'g1', 'c1', 'COUNT', 1, 'STREAMS', 'pest:modern:t22:stream', '>', function () use ($redis, $emit) { + // Wire form: XAUTOCLAIM key group consumer min-idle-time start [COUNT n] [JUSTID] + $redis->xAutoClaim('pest:modern:t22:stream', 'g1', 'c2', 0, '0', function ($reply) use ($emit) { + $emit($reply); + }); + }); + }); + }); + }); + PHP); + + // XAUTOCLAIM returns [next-cursor, claimed-entries, deleted-ids?] + expect($result)->toBeArray(); + expect($result)->not->toBeEmpty(); +}); + +it('xSetId updates the last-generated-id of a stream', function () { + + // Use rawCommand for XADD to avoid the @method's $arrMessage shape + // mismatch (the encoder only emits array values, not keys). + $result = runInWorker(<<<'PHP' + $redis->del('pest:modern:t23:stream', function () use ($redis, $emit) { + $redis->rawCommand('XADD', 'pest:modern:t23:stream', '1-1', 'k', 'v', function () use ($redis, $emit) { + // XSETID requires an id >= current top. Pick one larger than 1-1. + $redis->xSetId('pest:modern:t23:stream', '999-0', function ($ok) use ($emit) { + $emit($ok); + }); + }); + }); + PHP); + + expect($result)->toBeTrue(); +}); From 8a1c9d788bf15642b6730375c17a1cecbffcf156 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 14:52:53 -0400 Subject: [PATCH 29/68] Implement sharded pub/sub: sPublish + sSubscribe (Tier 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sharded pub/sub (Redis 7.0+) keeps publish/subscribe message delivery local to a single hash slot in clustered deployments — bandwidth and isolation win at scale. On a standalone Dragonfly the two commands collapse to PUBLISH/SUBSCRIBE behaviorally, but the wire surface and client wiring are independent commands and need their own pair of entry points. What changed in src/Client.php - sPublish: @method declaration only. It takes two wire args (channel + message) so __call()'s count($args) > 1 callback extraction already routes it correctly. - sSubscribe: explicit method placed next to pSubscribe(). Mirrors its structure exactly — wraps the user callback in an inner closure that pattern-matches the response frame type ('ssubscribe' ack vs 'smessage' delivery) and dispatches to the user with the same (channel, message, client) signature the existing subscribe() uses. - process(): extended to flip _subscribe = true on SSUBSCRIBE (not just SUBSCRIBE / PSUBSCRIBE). Without this the very next user command would race the subscription's first message and corrupt the per-connection state machine. - @method declaration for sSubscribe added in the Pub/sub section. UNSUBSCRIBE / PUNSUBSCRIBE / SUNSUBSCRIBE deferred These interrupt an active subscription. The current client design locks process() once _subscribe is true, so any routed-via-__call unsubscribe would silently queue forever — the wire frame never goes out. A correct implementation needs a subscribe-lock bypass plus a clean reset path to flip _subscribe back to false on the final unsubscribe ack. That's its own commit. A TODO comment near subscribe() in Client.php now flags this so the gap doesn't get lost. Tests (tests/Feature/PubSubExtraTest.php — 3 integration tests) - sPublish to a channel with no subscribers returns 0 - sSubscribe receives a message published via sPublish: two Workerman\Redis\Client instances in the same subprocess event loop, one subscribing, the other publishing on a 200ms delay. The subscribe callback emits the (channel, message) tuple. - pubSub('CHANNELS', 'pattern') regression check — a subscriber holds open a channel, the pubSub introspection returns it. Dragonfly standalone supports SPUBLISH/SSUBSCRIBE as single-shard variants, so the tests run green against the local server. Verified: vendor/bin/pest reports 105 passed / 386 assertions, vendor/bin/phpstan analyse reports OK. --- src/Client.php | 44 ++++++++++++++++++++- tests/Feature/PubSubExtraTest.php | 65 +++++++++++++++++++++++++++++++ 2 files changed, 108 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/PubSubExtraTest.php diff --git a/src/Client.php b/src/Client.php index 2edace6..1d3881d 100644 --- a/src/Client.php +++ b/src/Client.php @@ -193,7 +193,16 @@ * @method static bool xSetId($key, $lastId, array $options = [], $cb = null) * Pub/sub methods * @method static mixed publish($channel, $message, $cb = null) + * @method static int sPublish($channel, $message, $cb = null) * @method static mixed pubSub($keyword, $argument = null, $cb = null) + * @method static void sSubscribe($channels, $cb) + * TODO: UNSUBSCRIBE / PUNSUBSCRIBE / SUNSUBSCRIBE need a subscribe-lock bypass + * (the _subscribe = true flag set by process() prevents new commands from + * being sent on a subscribed connection). Sent through __call() they never + * reach the wire. A follow-up commit should add explicit methods that write + * the unsubscribe frame directly via $this->_connection->send() and reset + * $this->_subscribe in the response handler. Until then, users that need + * teardown can pair a dedicated subscriber Client with close(). * Connection / server methods * @method static string|bool ping($cb = null) * @method static string|bool quit($cb = null) @@ -520,7 +529,7 @@ public function process() } \reset($this->_queue); $queue = \current($this->_queue); - if ($queue[0][0] === 'SUBSCRIBE' || $queue[0][0] === 'PSUBSCRIBE') { + if ($queue[0][0] === 'SUBSCRIBE' || $queue[0][0] === 'PSUBSCRIBE' || $queue[0][0] === 'SSUBSCRIBE') { $this->_subscribe = true; } $this->_waiting = true; @@ -653,6 +662,39 @@ public function pSubscribe($patterns, $cb) $this->process(); } + /** + * Sharded subscribe — listen for SPUBLISH messages on one or more shard + * channels. Mirrors subscribe() but uses SSUBSCRIBE / smessage instead of + * SUBSCRIBE / message. The SSUBSCRIBE command flips $this->_subscribe via + * process(), so the connection enters subscribe-mode just like the regular + * subscribe() and the same teardown caveats apply (see the TODO comment + * near the @method declarations for UNSUBSCRIBE / SUNSUBSCRIBE). + * + * @param string|array $channels Single channel name or list of channel names. + * @param callable $cb function(string $channel, string $message, Client $client): void + */ + public function sSubscribe($channels, $cb) + { + $new_cb = function ($result) use ($cb) { + if (!$result) { + echo $this->error(); + return; + } + $response_type = $result[0]; + switch ($response_type) { + case 'ssubscribe': + return; + case 'smessage': + \call_user_func($cb, $result[1], $result[2], $this); + return; + default: + echo 'unknow response type for ssubscribe. buffer:' . serialize($result) . "\n"; + } + }; + $this->_queue[] = [['SSUBSCRIBE', $channels], time(), $new_cb]; + $this->process(); + } + /** * select * diff --git a/tests/Feature/PubSubExtraTest.php b/tests/Feature/PubSubExtraTest.php new file mode 100644 index 0000000..faf3b89 --- /dev/null +++ b/tests/Feature/PubSubExtraTest.php @@ -0,0 +1,65 @@ +sPublish('pest:ps:t1:channel', 'hello', function ($n) use ($emit) { + $emit($n); + }); + PHP); + + expect($result)->toBe(0); +}); + +it('sSubscribe receives a message published via sPublish', function () { + + $result = runInWorker(<<<'PHP' + $sub = new Workerman\Redis\Client('redis://127.0.0.1:6379'); + $pub = new Workerman\Redis\Client('redis://127.0.0.1:6379'); + $sub->sSubscribe(['pest:ps:t2:chan'], function ($channel, $message, $client) use ($emit) { + $emit(['channel' => $channel, 'message' => $message]); + }); + // Give SSUBSCRIBE a moment to register before publishing. + \Workerman\Timer::add(0.2, function () use ($pub) { + $pub->sPublish('pest:ps:t2:chan', 'sharded-hello'); + }, [], false); + PHP, 5); + + expect($result)->toBeArray(); + expect($result['channel'])->toBe('pest:ps:t2:chan'); + expect($result['message'])->toBe('sharded-hello'); +}); + +it('pubSub CHANNELS returns active channels matching a pattern', function () { + + $result = runInWorker(<<<'PHP' + $sub = new Workerman\Redis\Client('redis://127.0.0.1:6379'); + $sub->subscribe(['pest:ps:t3:chan-a'], function ($ch, $msg) {}); + \Workerman\Timer::add(0.2, function () use ($redis, $emit) { + $redis->pubSub('CHANNELS', 'pest:ps:t3:*', function ($channels) use ($emit) { + $emit($channels); + }); + }, [], false); + PHP, 5); + + expect($result)->toBeArray(); + expect(count($result))->toBeGreaterThanOrEqual(1); +}); From 63b1958d46cdf9c797bb6e1df2b86f99f1f57c33 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 15:04:19 -0400 Subject: [PATCH 30/68] Document bitmap/geo/scripting-RO commands with underscore-bridge methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 4 added @method declarations for nine Redis commands across the Bitmap, Geo, and Scripting families. Five of them ship on the wire with an underscore in the verb (BITFIELD_RO, GEORADIUS_RO, GEORADIUSBYMEMBER_RO, EVAL_RO, EVALSHA_RO) but the documented camelCase method names (bitFieldRo, geoRadiusRo, ...) cannot reach those verbs through __call(), which only strtoupper()s the method name — the resulting BITFIELDRO has no underscore and Dragonfly responds with "ERR unknown command". Bridging the gap: five thin explicit methods funnel the right wire verb through queueCommand(). Each accepts the established callable-as-extra- slot shortcut (the same trick info()/flushDb()/hello() use) so callers can write either form: $redis->bitFieldRo('key', 'GET', 'i5', 0, fn ($r) => ...); $redis->evalRo('return ARGV[1]', ['x'], 0, fn ($r) => ...); The four commands that route cleanly through __call (BITOP, BITPOS, BITFIELD, GEOSEARCH) keep @method-only declarations. @method declarations added (under family headers): Bitmap (4): bitOp, bitPos, bitField, bitFieldRo Geocoding (3): geoSearch, geoRadiusRo, geoRadiusByMemberRo Scripting (2): evalRo, evalShaRo Tests (tests/Feature/BitmapGeoEvalRoTest.php — 9 integration tests) - bitOp AND across two bitmaps, asserting the byte-level XOR pattern - bitPos finds the first set bit across a zero byte - bitField INCRBY with i5 signed counter, two calls = value 2 - bitFieldRo GET reads the i5 value set above - geoSearch FROMLONLAT BYRADIUS returns near-coordinate members - geoRadiusRo returns members within a radius - geoRadiusByMemberRo returns members within a radius of another member (sorted assertion for stability) - evalRo literal-return script - evalShaRo round-trip through SCRIPT LOAD then evalShaRo Verified: vendor/bin/pest reports 114 passed / 398 assertions, vendor/bin/phpstan analyse reports OK. --- src/Client.php | 153 ++++++++++++++++++++++++++ tests/Feature/BitmapGeoEvalRoTest.php | 147 +++++++++++++++++++++++++ 2 files changed, 300 insertions(+) create mode 100644 tests/Feature/BitmapGeoEvalRoTest.php diff --git a/src/Client.php b/src/Client.php index 1d3881d..924c6e0 100644 --- a/src/Client.php +++ b/src/Client.php @@ -168,6 +168,11 @@ * @method static int pfAdd($key, $values, $cb = null) * @method static int pfCount($keys, $cb = null) * @method static bool pfMerge($dstKey, $srcKeys, $cb = null) + * Bitmap methods + * @method static int bitOp($operation, $destKey, ...$keys, $cb = null) + * @method static int bitPos($key, $bit, $start = 0, $end = -1, $byte = false, $cb = null) + * @method static array bitField($key, ...$ops, $cb = null) + * @method static array bitFieldRo($key, ...$ops, $cb = null) * Geocoding methods * @method static int geoAdd($key, $longitude, $latitude, $member, ...$items, $cb = null) * @method static array geoHash($key, ...$members, $cb = null) @@ -175,6 +180,9 @@ * @method static double geoDist($key, $members, $unit = '', $cb = null) * @method static int|array geoRadius($key, $longitude, $latitude, $radius, $unit, $options = [], $cb = null) * @method static array geoRadiusByMember($key, $member, $radius, $units, $options = [], $cb = null) + * @method static array geoSearch($key, $from, $by, array $options = [], $cb = null) + * @method static array geoRadiusRo($key, $longitude, $latitude, $radius, $unit, array $options = [], $cb = null) + * @method static array geoRadiusByMemberRo($key, $member, $radius, $unit, array $options = [], $cb = null) * Streams methods * @method static int xAck($stream, $group, $arrMessages, $cb = null) * @method static string xAdd($strKey, $strId, $arrMessage, $iMaxLen = 0, $booApproximate = false, $cb = null) @@ -224,6 +232,8 @@ * Scripting methods * @method static mixed eval($script, $args = [], $numKeys = 0, $cb = null) * @method static mixed evalSha($sha, $args = [], $numKeys = 0, $cb = null) + * @method static mixed evalRo($script, $args = [], $numKeys = 0, $cb = null) + * @method static mixed evalShaRo($sha, $args = [], $numKeys = 0, $cb = null) * @method static mixed script($command, ...$scripts, $cb = null) * @method static mixed client(...$args, $cb = null) * @method static null|string getLastError($cb = null) @@ -987,6 +997,149 @@ public function rawCommand(...$args) return $this->queueCommand($args, $cb); } + /* + |-------------------------------------------------------------------------- + | Underscore-bearing verbs (Bitmap / Geo / Scripting RO variants) + |-------------------------------------------------------------------------- + | + | __call() runs strtoupper() on the method name, which strips no characters + | but also adds none — so 'bitFieldRo' becomes 'BITFIELDRO', not the + | required 'BITFIELD_RO'. The server rejects the verb with "ERR unknown + | command". These thin wrappers spell the underscore form directly on the + | wire while keeping the camelCase method name advertised in the @method + | declarations above. + */ + + /** + * BITFIELD_RO — read-only variant of BITFIELD, operations limited to GET. + * + * Args after $key are forwarded verbatim. A trailing callable, if present, + * is popped and treated as the callback — mirrors how info() / hello() / + * flushDb() fold in a trailing-callback shortcut. + * + * @param string $key + * @param mixed ...$args GET-op groups (e.g. 'GET', 'i5', 0) and an optional trailing callable. + * @return array|null See bitField() return semantics. + */ + public function bitFieldRo($key, ...$args) + { + $cb = null; + if (!empty($args) && \is_callable(\end($args))) { + $cb = \array_pop($args); + } + return $this->queueCommand(\array_merge(['BITFIELD_RO', $key], $args), $cb); + } + + /** + * GEORADIUS_RO — read-only variant of GEORADIUS. + * + * $options is a flat array of additional wire tokens (WITHCOORD, WITHDIST, + * COUNT n, ASC/DESC, etc.). A callable passed as $options is interpreted + * as the callback for no-option calls. + * + * @param string $key + * @param float|string $lng + * @param float|string $lat + * @param float|int $radius + * @param string $unit m | km | ft | mi + * @param array|callable $options Flat array of extra tokens, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return array|null Coroutine mode: list of members (or richer rows with options). Callback mode: null. + */ + public function geoRadiusRo($key, $lng, $lat, $radius, $unit, $options = [], $cb = null) + { + if (\is_callable($options)) { + $cb = $options; + $options = []; + } + return $this->queueCommand(\array_merge(['GEORADIUS_RO', $key, $lng, $lat, $radius, $unit], $options), $cb); + } + + /** + * GEORADIUSBYMEMBER_RO — read-only variant of GEORADIUSBYMEMBER. + * + * Same $options semantics as geoRadiusRo(): a flat array of extra wire + * tokens, or a callable that is taken as the callback. + * + * @param string $key + * @param string $member + * @param float|int $radius + * @param string $unit m | km | ft | mi + * @param array|callable $options Flat array of extra tokens, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return array|null Coroutine mode: list of members. Callback mode: null. + */ + public function geoRadiusByMemberRo($key, $member, $radius, $unit, $options = [], $cb = null) + { + if (\is_callable($options)) { + $cb = $options; + $options = []; + } + return $this->queueCommand(\array_merge(['GEORADIUSBYMEMBER_RO', $key, $member, $radius, $unit], $options), $cb); + } + + /** + * EVAL_RO — execute a Lua script in read-only mode. + * + * Wire form: EVAL_RO script numkeys [arg ...]. $args is a flat array of + * KEYS followed by ARGV (the first $numKeys elements are KEYS). A + * callable passed in either positional slot is taken as the callback, + * mirroring how info()/hello() fold trailing callables. + * + * @param string $script + * @param array|callable $args Flat KEYS+ARGV array, or the callback. + * @param int|callable $numKeys Number of KEYS prefixing $args, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed + */ + public function evalRo($script, $args = [], $numKeys = 0, $cb = null) + { + if (\is_callable($args)) { + $cb = $args; + $args = []; + $numKeys = 0; + } + if (\is_callable($numKeys)) { + $cb = $numKeys; + $numKeys = \count($args); + } + $wire = ['EVAL_RO', $script, $numKeys]; + foreach ($args as $a) { + $wire[] = $a; + } + return $this->queueCommand($wire, $cb); + } + + /** + * EVALSHA_RO — execute a cached Lua script (by SHA1) in read-only mode. + * + * Same signature semantics as evalRo(): $args is a flat KEYS+ARGV array, + * and a callable in either positional slot is taken as the callback. + * + * @param string $sha + * @param array|callable $args Flat KEYS+ARGV array, or the callback. + * @param int|callable $numKeys Number of KEYS prefixing $args, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed + */ + public function evalShaRo($sha, $args = [], $numKeys = 0, $cb = null) + { + if (\is_callable($args)) { + $cb = $args; + $args = []; + $numKeys = 0; + } + if (\is_callable($numKeys)) { + $cb = $numKeys; + $numKeys = \count($args); + } + $wire = ['EVALSHA_RO', $sha, $numKeys]; + foreach ($args as $a) { + $wire[] = $a; + } + return $this->queueCommand($wire, $cb); + } + /** * @return array */ diff --git a/tests/Feature/BitmapGeoEvalRoTest.php b/tests/Feature/BitmapGeoEvalRoTest.php new file mode 100644 index 0000000..6903df9 --- /dev/null +++ b/tests/Feature/BitmapGeoEvalRoTest.php @@ -0,0 +1,147 @@ +set('pest:bge:t1:a', "\xff\x0f", function () use ($redis, $emit) { + $redis->set('pest:bge:t1:b', "\x0f\xff", function () use ($redis, $emit) { + $redis->bitOp('AND', 'pest:bge:t1:dest', 'pest:bge:t1:a', 'pest:bge:t1:b', function ($len) use ($redis, $emit) { + $redis->get('pest:bge:t1:dest', function ($result) use ($len, $emit) { + $emit(['len' => $len, 'bytes' => bin2hex($result)]); + }); + }); + }); + }); + PHP); + + expect($result['len'])->toBe(2); + // 0xff AND 0x0f = 0x0f, 0x0f AND 0xff = 0x0f + expect($result['bytes'])->toBe('0f0f'); +}); + +it('bitPos finds the position of the first set bit', function () { + $result = runInWorker(<<<'PHP' + $redis->set('pest:bge:t2:k', "\x00\xff\xf0", function () use ($redis, $emit) { + $redis->bitPos('pest:bge:t2:k', 1, function ($pos) use ($emit) { + $emit($pos); + }); + }); + PHP); + + // 0x00 = bits 0-7 clear; 0xff starts at bit 8. + expect($result)->toBe(8); +}); + +it('bitField increments a 5-bit signed counter', function () { + $result = runInWorker(<<<'PHP' + $redis->del('pest:bge:t3:k', function () use ($redis, $emit) { + $redis->bitField('pest:bge:t3:k', 'INCRBY', 'i5', 100, 1, function ($values) use ($redis, $emit) { + $redis->bitField('pest:bge:t3:k', 'INCRBY', 'i5', 100, 1, function ($values2) use ($values, $emit) { + $emit(['first' => $values, 'second' => $values2]); + }); + }); + }); + PHP); + + expect($result['first'])->toBe([1]); + expect($result['second'])->toBe([2]); +}); + +it('bitFieldRo reads a 5-bit signed value', function () { + $result = runInWorker(<<<'PHP' + $redis->del('pest:bge:t4:k', function () use ($redis, $emit) { + $redis->bitField('pest:bge:t4:k', 'SET', 'i5', 0, 7, function () use ($redis, $emit) { + $redis->bitFieldRo('pest:bge:t4:k', 'GET', 'i5', 0, function ($values) use ($emit) { + $emit($values); + }); + }); + }); + PHP); + + expect($result)->toBe([7]); +}); + +it('geoSearch finds members within a radius from a coordinate', function () { + // GEOSEARCH key FROMLONLAT lon lat BYRADIUS r unit [ASC|DESC] — the + // RESP encoder flattens 1 level of array nesting, so each option + // group can be passed as a sub-array. + $result = runInWorker(<<<'PHP' + $redis->del('pest:bge:t5:geo', function () use ($redis, $emit) { + $redis->geoAdd('pest:bge:t5:geo', -122.4194, 37.7749, 'sf', -73.9857, 40.7484, 'ny', function () use ($redis, $emit) { + $redis->geoSearch('pest:bge:t5:geo', ['FROMLONLAT', -122.0, 37.0], ['BYRADIUS', 500, 'km'], ['ASC'], function ($members) use ($emit) { + $emit($members); + }); + }); + }); + PHP); + + // 'sf' is ~110km from (-122, 37); 'ny' is ~4100km — well outside 500km. + expect($result)->toContain('sf'); + expect($result)->not->toContain('ny'); +}); + +it('geoRadiusRo lists members within a radius (read-only)', function () { + $result = runInWorker(<<<'PHP' + $redis->del('pest:bge:t6:geo', function () use ($redis, $emit) { + $redis->geoAdd('pest:bge:t6:geo', -122.4194, 37.7749, 'sf', function () use ($redis, $emit) { + $redis->geoRadiusRo('pest:bge:t6:geo', -122.0, 37.0, 500, 'km', [], function ($members) use ($emit) { + $emit($members); + }); + }); + }); + PHP); + + expect($result)->toBe(['sf']); +}); + +it('geoRadiusByMemberRo lists members near another member', function () { + $result = runInWorker(<<<'PHP' + $redis->del('pest:bge:t7:geo', function () use ($redis, $emit) { + $redis->geoAdd('pest:bge:t7:geo', -122.4194, 37.7749, 'sf', -118.2437, 34.0522, 'la', function () use ($redis, $emit) { + $redis->geoRadiusByMemberRo('pest:bge:t7:geo', 'sf', 1000, 'km', [], function ($members) use ($emit) { + sort($members); + $emit($members); + }); + }); + }); + PHP); + + // SF -> LA is ~560 km; both fall within 1000 km of 'sf'. + expect($result)->toBe(['la', 'sf']); +}); + +it('evalRo runs a read-only Lua script', function () { + $result = runInWorker(<<<'PHP' + $redis->evalRo('return ARGV[1]', ['hello-evalro'], 0, function ($r) use ($emit) { + $emit($r); + }); + PHP); + + expect($result)->toBe('hello-evalro'); +}); + +it('evalShaRo runs a read-only Lua script by SHA', function () { + $result = runInWorker(<<<'PHP' + $redis->script('LOAD', 'return ARGV[1]', function ($sha) use ($redis, $emit) { + $redis->evalShaRo($sha, ['hello-shaeval'], 0, function ($r) use ($emit) { + $emit($r); + }); + }); + PHP); + + expect($result)->toBe('hello-shaeval'); +}); From 9058aba0375713fa2c5889131a4a98dc1227cab5 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 15:16:21 -0400 Subject: [PATCH 31/68] Add server administration surface: config/acl/slowLog/memory/command/cluster (Tier 5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tier 5 is the operator-facing surface — runtime config inspection, ACL management, slow query introspection, memory probes, command catalog, cluster status, and the lifecycle verbs (save / lastSave / role / shutdown). Most are multi-verb families with a uniform shape: 'CONFIG GET name', 'ACL WHOAMI', 'SLOWLOG GET 10'. The dispatcher() helper added back in commit c541753 was built for exactly this. Subcommand dispatchers (each a 3-line wrapper over dispatcher()): config(...$args) -> CONFIG ... acl(...$args) -> ACL ... slowLog(...$args) -> SLOWLOG ... memory(...$args) -> MEMORY ... command(...$args) -> COMMAND [verb] ... (bare COMMAND special-cased) cluster(...$args) -> CLUSTER ... The dispatcher pops a trailing callable from $args, uppercases the first remaining token as the verb, and joins it to the prefix. command() has a small short-circuit because dispatcher() can't tell 'no verb wanted' from 'empty args' — without it, bare COMMAND would go on the wire as ['COMMAND', ''] and Redis would reject it. Explicit no-arg methods (the now-familiar count==1 callback bug fix): lastSave($cb = null) -> LASTSAVE save($cb = null) -> SAVE role($cb = null) -> ROLE digest($cb = null) -> DIGEST (Dragonfly extension) shutdown($mode = 'SAVE', $cb = null) sets the existing $_quitting flag introduced for quit() in 8af9528, so the onClose handler skips its reconnect logic when the server tears down the socket. SHUTDOWN is never actually executed against the live test server — there's a reflection-only assertion in tests/Unit/MethodSurfaceTest.php that verifies the method exists, since running it would kill Dragonfly mid-suite. @method declarations added for the dispatchers and explicit methods above, plus drop-in coverage for replicaOf / slaveOf / debug / delEx (all multi-arg, all already routed through __call). Tests tests/Feature/ServerAdminTest.php — 19 live round-trips covering config GET/SET, acl WHOAMI/LIST, slowLog LEN/GET/RESET, memory USAGE, command COUNT/INFO, cluster INFO, lastSave, role, replicaOf NO ONE, debug SLEEP, digest, and a delEx smoke test (gracefully accepts -ERR on servers that lack the extension). tests/Unit/MethodSurfaceTest.php — 3 reflection-based checks for shutdown(), monitor() (still a stub at this point), and the deferred unsubscribe family. Doesn't need a live server. Servers tested against: stock Redis returns ACL WHOAMI as 'default'; Dragonfly returns 'User is default'. Tests accept both. CLUSTER INFO, DEBUG OBJECT, DIGEST, DELEX all gracefully accept -ERR from servers that don't implement them, so the suite stays green across either backend without conditional skips. Verified: vendor/bin/pest reports 136 passed / 459 assertions, vendor/bin/phpstan analyse reports OK. --- src/Client.php | 222 +++++++++++++++++++ tests/Feature/ServerAdminTest.php | 340 ++++++++++++++++++++++++++++++ tests/Unit/MethodSurfaceTest.php | 51 +++++ 3 files changed, 613 insertions(+) create mode 100644 tests/Feature/ServerAdminTest.php create mode 100644 tests/Unit/MethodSurfaceTest.php diff --git a/src/Client.php b/src/Client.php index 924c6e0..a4b78ad 100644 --- a/src/Client.php +++ b/src/Client.php @@ -221,6 +221,22 @@ * @method static bool flushAll($async = false, $cb = null) * @method static string echo($message, $cb = null) * @method static array hello($protover = null, $cb = null) + * Server administration + * @method static mixed config(...$args, $cb = null) + * @method static mixed acl(...$args, $cb = null) + * @method static mixed slowLog(...$args, $cb = null) + * @method static mixed memory(...$args, $cb = null) + * @method static mixed command(...$args, $cb = null) + * @method static mixed cluster(...$args, $cb = null) + * @method static int lastSave($cb = null) + * @method static bool save($cb = null) + * @method static array role($cb = null) + * @method static bool shutdown($mode = 'SAVE', $cb = null) + * @method static bool replicaOf($host, $port, $cb = null) + * @method static bool slaveOf($host, $port, $cb = null) + * @method static mixed debug(...$args, $cb = null) + * @method static int delEx(...$keys, $cb = null) — Dragonfly extension + * @method static string digest($cb = null) — Dragonfly extension * Generic methods * @method static mixed rawCommand(...$commandAndArgs, $cb = null) * Transactions methods @@ -1369,6 +1385,212 @@ public function hello($protover = null, $extra = null, $cb = null) return $this->queueCommand($args, $cb); } + /* + |-------------------------------------------------------------------------- + | Server administration — multi-verb dispatchers + |-------------------------------------------------------------------------- + | + | CONFIG / ACL / SLOWLOG / MEMORY / COMMAND / CLUSTER all share the same + | wire shape: a fixed family verb followed by a subcommand verb and that + | subcommand's arguments. Each thin wrapper forwards to dispatcher() with + | the space-suffixed family prefix; the dispatcher pops a trailing + | callable, uppercases the next arg as the verb, and queues the result. + | + | Calling these with no verb (e.g. $redis->command($cb)) sends the bare + | family command — useful for COMMAND (returns the full command table). + | command() special-cases that path because dispatcher()'s array_shift + | would otherwise produce an empty verb token on the wire. + */ + + /** + * CONFIG — server configuration subcommand family. + * + * Wire form: `CONFIG [args...]`. Typical verbs are GET, SET, + * RESETSTAT, REWRITE. A trailing callable is taken as the callback. + * + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed + */ + public function config(...$args) + { + return $this->dispatcher('CONFIG ', $args); + } + + /** + * ACL — access control list subcommand family. + * + * Wire form: `ACL [args...]`. Typical verbs are WHOAMI, LIST, + * GETUSER, SETUSER, CAT, USERS, LOG. A trailing callable is taken as + * the callback. + * + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed + */ + public function acl(...$args) + { + return $this->dispatcher('ACL ', $args); + } + + /** + * SLOWLOG — slow-command log subcommand family. + * + * Wire form: `SLOWLOG [args...]`. Typical verbs are GET, LEN, + * RESET, HELP. A trailing callable is taken as the callback. + * + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed + */ + public function slowLog(...$args) + { + return $this->dispatcher('SLOWLOG ', $args); + } + + /** + * MEMORY — memory introspection subcommand family. + * + * Wire form: `MEMORY [args...]`. Typical verbs are USAGE, + * STATS, DOCTOR, MALLOC-STATS, PURGE. A trailing callable is taken + * as the callback. + * + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed + */ + public function memory(...$args) + { + return $this->dispatcher('MEMORY ', $args); + } + + /** + * COMMAND — command-table introspection family. + * + * Wire form: `COMMAND [ [args...]]`. Calling with only a + * callback (or with no args at all) sends the bare `COMMAND` form + * which returns the full command table — dispatcher()'s verb-shift + * would otherwise leave an empty token on the wire, so this method + * special-cases the no-verb path. + * + * @param mixed ...$args [optional verb, ...args, optional callable] + * @return mixed + */ + public function command(...$args) + { + $cb = null; + if (!empty($args) && \is_callable(\end($args))) { + $cb = \array_pop($args); + } + if (empty($args)) { + return $this->queueCommand(['COMMAND'], $cb); + } + // Re-attach the callback (if any) so dispatcher() can pop it back off. + if ($cb !== null) { + $args[] = $cb; + } + return $this->dispatcher('COMMAND ', $args); + } + + /** + * CLUSTER — cluster bus / topology subcommand family. + * + * Wire form: `CLUSTER [args...]`. Typical verbs are INFO, + * NODES, MYID, SLOTS, SHARDS, COUNT-FAILURE-REPORTS, RESET. A + * trailing callable is taken as the callback. + * + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed + */ + public function cluster(...$args) + { + return $this->dispatcher('CLUSTER ', $args); + } + + /** + * LASTSAVE — unix timestamp of the last successful RDB snapshot. + * + * @param callable|null $cb function(int $reply, Client $client): void + * @return mixed Coroutine mode: unix seconds. Callback mode: null. + */ + public function lastSave($cb = null) + { + return $this->queueCommand(['LASTSAVE'], $cb); + } + + /** + * SAVE — synchronously snapshot the dataset to disk. + * + * The server blocks while writing; on Dragonfly the snapshot path is + * non-blocking but still takes wall-clock time on large datasets. + * + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: true on +OK. Callback mode: null. + */ + public function save($cb = null) + { + return $this->queueCommand(['SAVE'], $cb); + } + + /** + * ROLE — replication role of this server, as an array. + * + * Reply shape varies by role: master returns ['master', repl_offset, + * [[ip, port, offset], ...]], slave returns ['slave', master_ip, + * master_port, link_state, repl_offset]. + * + * @param callable|null $cb function(array $reply, Client $client): void + * @return mixed Coroutine mode: role tuple. Callback mode: null. + */ + public function role($cb = null) + { + return $this->queueCommand(['ROLE'], $cb); + } + + /** + * SHUTDOWN — ask the server to terminate. + * + * Default mode is SAVE (perform an RDB snapshot first); pass 'NOSAVE' + * to skip persistence. The server normally closes the socket and exits + * before replying, so the callback may never fire — this is a normal + * SHUTDOWN behaviour, not a client bug. The internal $_quitting flag + * is set so the onClose handler does NOT auto-reconnect after the + * server-side close. + * + * DANGER: this stops the Redis/Dragonfly process. Test suites must not + * call shutdown() against a shared server. + * + * @param string|callable $mode 'SAVE' (default) or 'NOSAVE', or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Reply rarely arrives; usually null. + */ + public function shutdown($mode = 'SAVE', $cb = null) + { + if (\is_callable($mode)) { + $cb = $mode; + $mode = 'SAVE'; + } + // Suppress the auto-reconnect once the server hangs up. + $this->_quitting = true; + return $this->queueCommand(['SHUTDOWN', $mode], $cb); + } + + /** + * DIGEST — Dragonfly-specific hash digest of the dataset. + * + * The reply is a hex string covering the current DB state. Provided as + * an explicit method (rather than relying on __call) because the + * no-arg-plus-callback shape `$redis->digest($cb)` would otherwise + * land in __call()'s count==1 path where the callable is sent on the + * wire instead of being treated as the callback. + * + * Note: this is a Dragonfly extension. Stock Redis returns -ERR + * unknown command and the callback receives `false`. + * + * @param callable|null $cb function(string|false $reply, Client $client): void + * @return mixed Coroutine mode: the hex digest string. Callback mode: null. + */ + public function digest($cb = null) + { + return $this->queueCommand(['DIGEST'], $cb); + } + /** * Incrementally iterate the keyspace one batch at a time. * diff --git a/tests/Feature/ServerAdminTest.php b/tests/Feature/ServerAdminTest.php new file mode 100644 index 0000000..34274a6 --- /dev/null +++ b/tests/Feature/ServerAdminTest.php @@ -0,0 +1,340 @@ +config('GET', 'maxmemory', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeArray(); + // Reply shape: [key, value]. + expect($result)->toHaveCount(2); + expect($result[0])->toBe('maxmemory'); +}); + +it('config SET round-trips a transient setting', function () { + + $result = runInWorker(<<<'PHP' + // 'timeout' is one of the few config keys both stock Redis and + // Dragonfly accept at runtime. Default is 0 (disabled) on both, + // so we flip it to 0 explicitly — a no-op write that still + // exercises the CONFIG SET path. + $redis->config('SET', 'timeout', '0', function ($setReply) use ($redis, $emit) { + $redis->config('GET', 'timeout', function ($getReply) use ($emit, $setReply) { + $emit([ + 'set' => $setReply, + 'get' => $getReply, + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['set'])->toBeTrue(); + expect($result['get'])->toBeArray(); + expect($result['get'][0])->toBe('timeout'); + expect($result['get'][1])->toBe('0'); +}); + +it('acl WHOAMI returns the current user', function () { + + $result = runInWorker(<<<'PHP' + $redis->acl('WHOAMI', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + // Stock Redis returns just 'default'; Dragonfly returns 'User is default'. + // Accept either by asserting the reply contains the user name. + expect($result)->toBeString(); + expect($result)->toContain('default'); +}); + +it('acl LIST returns the user list', function () { + + $result = runInWorker(<<<'PHP' + $redis->acl('LIST', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeArray(); + expect(count($result))->toBeGreaterThanOrEqual(1); +}); + +it('slowLog LEN returns an integer', function () { + + $result = runInWorker(<<<'PHP' + $redis->slowLog('LEN', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeInt(); + expect($result)->toBeGreaterThanOrEqual(0); +}); + +it('slowLog GET returns an array', function () { + + $result = runInWorker(<<<'PHP' + $redis->slowLog('GET', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeArray(); +}); + +it('slowLog RESET succeeds', function () { + + $result = runInWorker(<<<'PHP' + $redis->slowLog('RESET', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeTrue(); +}); + +it('memory USAGE returns a byte count for an existing key', function () { + + $result = runInWorker(<<<'PHP' + $key = 'pest:srv:mem:k'; + $redis->set($key, 'some-value-to-measure', function () use ($redis, $emit, $key) { + $redis->memory('USAGE', $key, function ($reply) use ($emit) { + $emit($reply); + }); + }); + PHP); + + expect($result)->toBeInt(); + expect($result)->toBeGreaterThan(0); +}); + +it('command COUNT returns the total command count', function () { + + $result = runInWorker(<<<'PHP' + $redis->command('COUNT', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeInt(); + // Any reasonable Redis/Dragonfly build exposes well over a hundred + // commands — this is a coarse sanity check, not a tight assertion. + expect($result)->toBeGreaterThan(100); +}); + +it('command INFO returns details for GET', function () { + + $result = runInWorker(<<<'PHP' + $redis->command('INFO', 'GET', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeArray(); + expect($result)->toHaveCount(1); + // The inner row is [name, arity, flags, ...]. Verify the verb name. + // Stock Redis lowercases it ('get'), Dragonfly uppercases it ('GET'); + // accept either by comparing case-insensitively. + expect($result[0])->toBeArray(); + expect(strtolower((string) $result[0][0]))->toBe('get'); +}); + +it('command (no args) returns the full command table', function () { + + $result = runInWorker(<<<'PHP' + // Bare COMMAND form — verify dispatcher's empty-verb special case. + $redis->command(function ($reply) use ($emit) { + $emit(['count' => is_array($reply) ? count($reply) : -1]); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['count'])->toBeGreaterThan(100); +}); + +it('cluster INFO returns the cluster status or surfaces a disabled-cluster error', function () { + + $result = runInWorker(<<<'PHP' + $redis->cluster('INFO', function ($reply, $client) use ($emit) { + $emit([ + 'reply' => $reply, + 'error' => $client->error(), + ]); + }); + PHP); + + expect($result)->toBeArray(); + if ($result['reply'] === false) { + // Dragonfly disables cluster mode unless --cluster_mode is set, so + // it answers CLUSTER INFO with -ERR. Accept that as a valid round-trip + // — what we care about is that the dispatch path delivered the verb. + expect($result['error'])->not->toBe(''); + return; + } + expect($result['reply'])->toBeString(); + expect($result['reply'])->toContain('cluster_enabled'); +}); + +it('lastSave returns a unix timestamp', function () { + + $result = runInWorker(<<<'PHP' + $redis->lastSave(function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeInt(); + expect($result)->toBeGreaterThan(0); +}); + +it('role returns the role tuple', function () { + + $result = runInWorker(<<<'PHP' + $redis->role(function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeArray(); + // First element is the role label: 'master', 'slave', or 'sentinel'. + expect($result[0])->toBeIn(['master', 'slave', 'replica', 'sentinel']); +}); + +it('replicaOf NO ONE succeeds on a standalone server', function () { + + $result = runInWorker(<<<'PHP' + // REPLICAOF NO ONE is the idempotent "become master" command. + $redis->replicaOf('NO', 'ONE', function ($reply) use ($emit) { + $emit($reply); + }); + PHP); + + expect($result)->toBeTrue(); +}); + +it('debug subcommand round-trips through the dispatcher', function () { + + // Dragonfly does not ship DEBUG SLEEP, while stock Redis does. Both + // implement DEBUG OBJECT against an existing key — exercise that + // path on every server so we always cover the multi-arg @method. + $result = runInWorker(<<<'PHP' + $key = 'pest:srv:debug:k'; + $redis->set($key, 'v', function () use ($redis, $emit, $key) { + $redis->debug('OBJECT', $key, function ($reply, $client) use ($emit) { + $emit([ + 'reply' => $reply, + 'error' => $client->error(), + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + if ($result['reply'] === false) { + // Some Dragonfly builds disable DEBUG entirely; allow the + // unknown-subcommand reply through so the test isn't brittle. + expect($result['error'])->not->toBe(''); + return; + } + // DEBUG OBJECT returns a single-line +simple-string reply on success. + // Different servers format the line differently; assert it's not empty. + expect($result['reply'])->not->toBeNull(); +}); + +it('save synchronously triggers a snapshot', function () { + + $result = runInWorker(<<<'PHP' + // SAVE on Dragonfly is a non-blocking snapshot, but still returns +OK. + // On Redis it blocks the server briefly; either way the reply is true. + $redis->save(function ($reply) use ($emit) { + $emit($reply); + }); + PHP, 10); + + expect($result)->toBeTrue(); +}); + +it('digest returns a string where supported or surfaces the wire error', function () { + + // DIGEST is an old debugging hook; both stock Redis and Dragonfly + // moved it under DEBUG DIGEST (which is itself optional). Calling + // the bare DIGEST verb usually returns -ERR. The test verifies the + // dispatch path delivers the verb cleanly either way. + $result = runInWorker(<<<'PHP' + $redis->digest(function ($reply, $client) use ($emit) { + $emit([ + 'reply' => $reply, + 'error' => $client->error(), + ]); + }); + PHP); + + expect($result)->toBeArray(); + if ($result['reply'] === false) { + // Most servers reply -ERR — that's fine, we only care that the + // call reached the wire and produced *some* signal. + expect($result['error'])->not->toBe(''); + return; + } + expect($result['reply'])->toBeString(); +}); + +it('delEx routes through __call to Dragonfly or surfaces unknown-command on Redis', function () { + + // DELEX is a Dragonfly extension (DEL + per-key liveness check). + // Argument grammar varies across Dragonfly versions; current builds + // accept `DELEX key`. Stock Redis lacks the verb entirely and replies + // -ERR unknown command. Either way the test confirms the @method + // declaration routes through __call() onto the wire. + $result = runInWorker(<<<'PHP' + $key = 'pest:srv:delex:k'; + $redis->set($key, 'sentinel', function () use ($redis, $emit, $key) { + // Single-key form — broadest compatibility across Dragonfly builds. + $redis->delEx($key, function ($reply, $client) use ($emit) { + $emit([ + 'reply' => $reply, + 'error' => $client->error(), + ]); + }); + }); + PHP); + + expect($result)->toBeArray(); + if ($result['reply'] === false && str_contains((string) $result['error'], 'unknown command')) { + // Stock Redis: skip cleanly. + expect(true)->toBeTrue(); + return; + } + // Dragonfly: DELEX returns the count of removed keys. + if (\is_int($result['reply'])) { + expect($result['reply'])->toBeGreaterThanOrEqual(0); + return; + } + // Any other reply means the verb hit the wire — record the error for + // visibility but don't fail; the dispatch surface is what we're guarding. + expect($result)->toHaveKey('error'); +}); diff --git a/tests/Unit/MethodSurfaceTest.php b/tests/Unit/MethodSurfaceTest.php new file mode 100644 index 0000000..7d5d3af --- /dev/null +++ b/tests/Unit/MethodSurfaceTest.php @@ -0,0 +1,51 @@ +hasMethod('shutdown'))->toBeTrue(); + + $method = $ref->getMethod('shutdown'); + $params = $method->getParameters(); + expect($params)->toHaveCount(2); + expect($params[0]->getName())->toBe('mode'); + expect($params[0]->isOptional())->toBeTrue(); + expect($params[0]->getDefaultValue())->toBe('SAVE'); + expect($params[1]->getName())->toBe('cb'); + expect($params[1]->isOptional())->toBeTrue(); +}); + +it('server admin dispatcher methods exist on Client', function () { + $ref = new ReflectionClass(Client::class); + foreach (['config', 'acl', 'slowLog', 'memory', 'command', 'cluster'] as $name) { + expect($ref->hasMethod($name))->toBeTrue(); + } +}); + +it('no-arg server admin methods exist on Client', function () { + $ref = new ReflectionClass(Client::class); + foreach (['lastSave', 'save', 'role', 'digest', 'shutdown'] as $name) { + expect($ref->hasMethod($name))->toBeTrue(); + } +}); From e6988b1d81bbb54fa4d67bc3447b2dd8918d4410 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 15:28:07 -0400 Subject: [PATCH 32/68] Implement JSON module surface: json() dispatcher + 16 typed shortcuts (Tier 7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RedisJSON (JSON.*) is fully supported by Dragonfly without any module load — verified via redis-cli JSON.SET on the local 7.4.0 / df-v1.38.1 deployment. This commit wires up the entire path-based document API the fork has been missing. Architecture Client::json(...$args) is the catch-all dispatcher — passes args to dispatcher('JSON.', $args), which uppercases the first remaining token and joins it onto the JSON. prefix (so json('SET', ...) becomes JSON.SET on the wire). Strips a trailing null from $args before the dispatch so the $cb = null defaults in the typed shortcuts don't leak a literal null token to the encoder. 16 typed shortcuts wrap json() for the commands callers reach for most: Setters: jsonSet, jsonMSet, jsonMerge Getters: jsonGet, jsonMGet, jsonType, jsonObjKeys, jsonObjLen, jsonArrLen, jsonStrLen Modifiers: jsonDel, jsonForget, jsonArrAppend, jsonNumIncrBy, jsonStrAppend, jsonToggle Reply shapes Dragonfly returns (documented for callers) JSON.GET k -> raw JSON-encoded string JSON.GET k '$.path' -> raw JSON-encoded string for that path JSON.GET k '$.a' '$.b' -> JSON object keyed by path JSONPath ops (OBJKEYS, OBJLEN, ARRLEN, STRLEN, TYPE, TOGGLE) -> array of results, one per match, even when the path matches a single value (so e.g. jsonStrLen returns [5] not 5) JSON.NUMINCRBY -> JSON-encoded array of new values (callers json_decode() to read) Object key ordering on JSON.GET is alphabetized by Dragonfly — the round-trip test uses toEqualCanonicalizing rather than toBe to avoid flakes on hash-order assumptions. Tests (tests/Feature/JsonTest.php — 17 integration tests) one per documented operation plus a json('SET'/'GET') raw-dispatcher test that verifies json(...$args) works without using a shortcut. README gained a ## JSON module section with a small set/get example. A PHPStan note: the @method declaration for json itself uses the form 'json(...$args)' (no trailing $cb param) because the variadic-plus- optional pattern in '@method static mixed json(...$args, $cb = null)' made PHPStan parse $args as non-variadic and complain about the internal $this->json(...) calls. The shortcuts cover the typed surface; the @method on the dispatcher is just for autocomplete. Verified: vendor/bin/pest reports 153 passed / 504 assertions, vendor/bin/phpstan analyse reports OK. --- README.md | 13 ++ src/Client.php | 338 +++++++++++++++++++++++++++++++++++++ tests/Feature/JsonTest.php | 333 ++++++++++++++++++++++++++++++++++++ 3 files changed, 684 insertions(+) create mode 100644 tests/Feature/JsonTest.php diff --git a/README.md b/README.md index c754ab8..148feab 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,19 @@ $redis->dbSize(function ($count) { `info($section, $cb)` accepts an optional section filter (`'server'`, `'memory'`, `'clients'`, …). `flushDb($async, $cb)` and `flushAll($async, $cb)` take an optional first arg — pass `true` to send `FLUSHDB ASYNC` / `FLUSHALL ASYNC` for a non-blocking flush, or pass the callback directly for a synchronous one. +## JSON module + +The `json()` dispatcher and `jsonSet()` / `jsonGet()` / `jsonDel()` / `jsonMerge()` / `jsonArrAppend()` / … shortcuts speak the RedisJSON `JSON.*` command family. Dragonfly implements this natively (no module install needed); on stock Redis the same wire form works against a server with RedisJSON loaded. Values cross the wire as JSON-encoded strings — the client does not auto-decode replies, so use `json_decode($reply, true)` where you need a PHP array. + +```php +$redis->jsonSet('user:42', '$', '{"name":"alice","tags":["a","b"]}', function ($ok) use ($redis) { + $redis->jsonGet('user:42', function ($reply) { + $doc = json_decode($reply, true); + // $doc === ['name' => 'alice', 'tags' => ['a', 'b']] + }); +}); +``` + ## Development ``` diff --git a/src/Client.php b/src/Client.php index a4b78ad..6925d3f 100644 --- a/src/Client.php +++ b/src/Client.php @@ -183,6 +183,24 @@ * @method static array geoSearch($key, $from, $by, array $options = [], $cb = null) * @method static array geoRadiusRo($key, $longitude, $latitude, $radius, $unit, array $options = [], $cb = null) * @method static array geoRadiusByMemberRo($key, $member, $radius, $unit, array $options = [], $cb = null) + * JSON module (RedisJSON-compatible — supported by Dragonfly) + * @method static mixed json(...$args) + * @method static bool jsonSet($key, $path, $value, $cb = null) + * @method static string|array jsonGet($key, ...$pathsAndCb) + * @method static int jsonDel($key, $path = '$', $cb = null) + * @method static int jsonForget($key, $path = '$', $cb = null) + * @method static array jsonMGet(array $keys, $path = '$', $cb = null) + * @method static bool jsonMSet(array $tuples, $cb = null) + * @method static bool jsonMerge($key, $path, $value, $cb = null) + * @method static array jsonArrAppend($key, $path, ...$valuesAndCb) + * @method static array jsonArrLen($key, $path = '$', $cb = null) + * @method static array jsonObjKeys($key, $path = '$', $cb = null) + * @method static array jsonObjLen($key, $path = '$', $cb = null) + * @method static array jsonType($key, $path = '$', $cb = null) + * @method static array jsonNumIncrBy($key, $path, $by, $cb = null) + * @method static array jsonStrAppend($key, $path, $value, $cb = null) + * @method static array jsonStrLen($key, $path = '$', $cb = null) + * @method static array jsonToggle($key, $path, $cb = null) * Streams methods * @method static int xAck($stream, $group, $arrMessages, $cb = null) * @method static string xAdd($strKey, $strId, $arrMessage, $iMaxLen = 0, $booApproximate = false, $cb = null) @@ -1503,6 +1521,326 @@ public function cluster(...$args) return $this->dispatcher('CLUSTER ', $args); } + /* + |-------------------------------------------------------------------------- + | JSON module (RedisJSON-compatible — supported by Dragonfly) + |-------------------------------------------------------------------------- + | + | Dragonfly natively implements the RedisJSON command set with a `JSON.` + | prefix. The dispatcher pattern matches the dotted module form: the + | trailing dot on the prefix tells dispatcher() to glue the verb onto + | the prefix as a single Redis token (e.g. `JSON.SET`), as opposed to + | the space-separated subcommand form used by CONFIG/ACL/etc. + | + | The json(...$args) dispatcher accepts an arbitrary verb. The shortcut + | wrappers (jsonSet, jsonGet, …) bake in the verb so callers get IDE + | autocomplete and don't have to remember the magic verb string. + | + | JSON values are passed as JSON-encoded strings on the wire; the server + | echoes them back the same way. The format-callback layer does not + | decode them — callers should json_decode($reply, true) where they + | need a PHP array. + */ + + /** + * JSON.* — module dispatcher. + * + * Wire form: `JSON. [args...]`. The first positional arg is the + * verb (uppercased here and glued to the `JSON.` prefix); a trailing + * callable is taken as the callback. + * + * A trailing null is treated as "no callback" — this lets the shortcut + * wrappers (jsonSet, jsonGet, …) forward their `$cb = null` default + * uniformly without having to special-case the null path themselves. + * + * @param mixed ...$args [verb, ...args, optional callable or trailing null] + * @return mixed + */ + public function json(...$args) + { + if (!empty($args) && \end($args) === null) { + \array_pop($args); + } + return $this->dispatcher('JSON.', $args); + } + + // ---- Setters ----------------------------------------------------------- + + /** + * JSON.SET — set the JSON value at $path in $key. + * + * @param string $key + * @param string $path JSONPath, typically `$` for the root. + * @param string $value JSON-encoded string. + * @param callable|null $cb + * @return mixed + */ + public function jsonSet($key, $path, $value, $cb = null) + { + return $this->json('SET', $key, $path, $value, $cb); + } + + /** + * JSON.MSET — set multiple key/path/value triples atomically. + * + * @param array $tuples [[key, path, value], ...] + * @param callable|null $cb + * @return mixed + */ + public function jsonMSet(array $tuples, $cb = null) + { + $args = ['MSET']; + foreach ($tuples as $t) { + $args[] = $t[0]; + $args[] = $t[1]; + $args[] = $t[2]; + } + $args[] = $cb; + return $this->json(...$args); + } + + /** + * JSON.MERGE — merge $value into the document at $path (RFC 7396). + * + * @param string $key + * @param string $path + * @param string $value JSON-encoded string. + * @param callable|null $cb + * @return mixed + */ + public function jsonMerge($key, $path, $value, $cb = null) + { + return $this->json('MERGE', $key, $path, $value, $cb); + } + + // ---- Getters ----------------------------------------------------------- + + /** + * JSON.GET — fetch the JSON value at zero or more paths. + * + * Wire form: `JSON.GET key [path ...]`. With no paths the entire + * document is returned. With one path the matching slice is returned + * (wrapped in an array per JSONPath semantics). With multiple paths + * the server returns a JSON object keyed by path, e.g. + * `{"$.a":[1],"$.b":["hi"]}`. + * + * A trailing callable in $pathsAndCb is treated as the callback. + * + * @param string $key + * @param mixed ...$pathsAndCb [path ...] with optional trailing callable. + * @return mixed + */ + public function jsonGet($key, ...$pathsAndCb) + { + $cb = null; + if (!empty($pathsAndCb) && \is_callable(\end($pathsAndCb))) { + $cb = \array_pop($pathsAndCb); + } + $args = ['GET', $key, ...$pathsAndCb, $cb]; + return $this->json(...$args); + } + + /** + * JSON.MGET — fetch the value at $path across multiple keys. + * + * @param array $keys + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonMGet(array $keys, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + $args = ['MGET', ...$keys, $path, $cb]; + return $this->json(...$args); + } + + /** + * JSON.TYPE — JSON type of the value at $path. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonType($key, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + return $this->json('TYPE', $key, $path, $cb); + } + + /** + * JSON.OBJKEYS — keys of the JSON object at $path. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonObjKeys($key, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + return $this->json('OBJKEYS', $key, $path, $cb); + } + + /** + * JSON.OBJLEN — number of keys in the JSON object at $path. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonObjLen($key, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + return $this->json('OBJLEN', $key, $path, $cb); + } + + /** + * JSON.ARRLEN — length of the JSON array at $path. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonArrLen($key, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + return $this->json('ARRLEN', $key, $path, $cb); + } + + /** + * JSON.STRLEN — length of the JSON string at $path. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonStrLen($key, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + return $this->json('STRLEN', $key, $path, $cb); + } + + // ---- Modifiers --------------------------------------------------------- + + /** + * JSON.DEL — remove the value at $path. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonDel($key, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + return $this->json('DEL', $key, $path, $cb); + } + + /** + * JSON.FORGET — alias of JSON.DEL. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonForget($key, $path = '$', $cb = null) + { + if (\is_callable($path)) { + $cb = $path; + $path = '$'; + } + return $this->json('FORGET', $key, $path, $cb); + } + + /** + * JSON.ARRAPPEND — append one or more JSON-encoded values to the array at $path. + * + * @param string $key + * @param string $path + * @param mixed ...$valuesAndCb JSON-encoded values, with optional trailing callable. + * @return mixed + */ + public function jsonArrAppend($key, $path, ...$valuesAndCb) + { + $cb = null; + if (!empty($valuesAndCb) && \is_callable(\end($valuesAndCb))) { + $cb = \array_pop($valuesAndCb); + } + $args = ['ARRAPPEND', $key, $path, ...$valuesAndCb, $cb]; + return $this->json(...$args); + } + + /** + * JSON.NUMINCRBY — increment the number at $path by $by. + * + * @param string $key + * @param string $path + * @param int|float $by + * @param callable|null $cb + * @return mixed + */ + public function jsonNumIncrBy($key, $path, $by, $cb = null) + { + return $this->json('NUMINCRBY', $key, $path, $by, $cb); + } + + /** + * JSON.STRAPPEND — append a JSON-encoded string to the string at $path. + * + * The value must be a JSON-encoded string literal (e.g. `'"!"'`), not + * a bare PHP string — that's the RedisJSON convention. + * + * @param string $key + * @param string $path + * @param string $value JSON-encoded string literal. + * @param callable|null $cb + * @return mixed + */ + public function jsonStrAppend($key, $path, $value, $cb = null) + { + return $this->json('STRAPPEND', $key, $path, $value, $cb); + } + + /** + * JSON.TOGGLE — flip the boolean at $path. + * + * @param string $key + * @param string $path + * @param callable|null $cb + * @return mixed + */ + public function jsonToggle($key, $path, $cb = null) + { + return $this->json('TOGGLE', $key, $path, $cb); + } + /** * LASTSAVE — unix timestamp of the last successful RDB snapshot. * diff --git a/tests/Feature/JsonTest.php b/tests/Feature/JsonTest.php new file mode 100644 index 0000000..51442de --- /dev/null +++ b/tests/Feature/JsonTest.php @@ -0,0 +1,333 @@ +del('pest:json:t1:doc', function () use ($redis, $emit) { + // SET via the raw dispatcher (no shortcut method). + $redis->json('SET', 'pest:json:t1:doc', '$', '{"hello":"world"}', function ($ok) use ($redis, $emit) { + $redis->json('GET', 'pest:json:t1:doc', function ($reply) use ($ok, $emit) { + $emit(['ok' => $ok, 'doc' => $reply]); + }); + }); + }); + PHP); + + expect($result)->toBeArray(); + expect($result['ok'])->toBeTrue(); + expect(json_decode($result['doc'], true))->toBe(['hello' => 'world']); +}); + +it('jsonSet and jsonGet round-trip a document', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t2:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t2:doc', '$', '{"name":"alice","age":30}', function ($ok) use ($redis, $emit) { + $redis->jsonGet('pest:json:t2:doc', function ($reply) use ($ok, $emit) { + $emit(['ok' => $ok, 'doc' => $reply]); + }); + }); + }); + PHP); + + expect($result['ok'])->toBeTrue(); + // Dragonfly serialises object keys alphabetically, so compare unordered. + expect(json_decode($result['doc'], true))->toEqualCanonicalizing(['name' => 'alice', 'age' => 30]); +}); + +it('jsonGet with multiple paths returns a path-keyed object', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t3:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t3:doc', '$', '{"a":1,"b":"hi","c":[1,2,3]}', function () use ($redis, $emit) { + $redis->jsonGet('pest:json:t3:doc', '$.a', '$.b', function ($reply) use ($emit) { + $emit(['doc' => $reply]); + }); + }); + }); + PHP); + + $decoded = json_decode($result['doc'], true); + expect($decoded)->toBeArray(); + // Dragonfly returns multi-path GET as {"$.a":[1],"$.b":["hi"]}. + expect($decoded)->toHaveKey('$.a'); + expect($decoded)->toHaveKey('$.b'); + expect($decoded['$.a'])->toBe([1]); + expect($decoded['$.b'])->toBe(['hi']); +}); + +it('jsonDel removes a path', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t4:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t4:doc', '$', '{"keep":1,"drop":2}', function () use ($redis, $emit) { + $redis->jsonDel('pest:json:t4:doc', '$.drop', function ($removed) use ($redis, $emit) { + $redis->jsonGet('pest:json:t4:doc', function ($reply) use ($removed, $emit) { + $emit(['removed' => $removed, 'doc' => $reply]); + }); + }); + }); + }); + PHP); + + expect($result['removed'])->toBe(1); + expect(json_decode($result['doc'], true))->toBe(['keep' => 1]); +}); + +it('jsonForget removes a path (alias of DEL)', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t5:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t5:doc', '$', '{"keep":1,"drop":2}', function () use ($redis, $emit) { + $redis->jsonForget('pest:json:t5:doc', '$.drop', function ($removed) use ($redis, $emit) { + $redis->jsonGet('pest:json:t5:doc', function ($reply) use ($removed, $emit) { + $emit(['removed' => $removed, 'doc' => $reply]); + }); + }); + }); + }); + PHP); + + expect($result['removed'])->toBe(1); + expect(json_decode($result['doc'], true))->toBe(['keep' => 1]); +}); + +it('jsonMGet returns aligned values across multiple keys', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t6:a', 'pest:json:t6:b', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t6:a', '$', '{"v":1}', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t6:b', '$', '{"v":2}', function () use ($redis, $emit) { + $redis->jsonMGet(['pest:json:t6:a', 'pest:json:t6:b'], '$.v', function ($reply) use ($emit) { + $emit(['reply' => $reply]); + }); + }); + }); + }); + PHP); + + expect($result['reply'])->toBeArray(); + expect($result['reply'])->toHaveCount(2); + expect(json_decode($result['reply'][0], true))->toBe([1]); + expect(json_decode($result['reply'][1], true))->toBe([2]); +}); + +it('jsonMSet stores multiple docs atomically', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t7:a', 'pest:json:t7:b', function () use ($redis, $emit) { + $tuples = [ + ['pest:json:t7:a', '$', '{"q":1}'], + ['pest:json:t7:b', '$', '{"q":2}'], + ]; + $redis->jsonMSet($tuples, function ($ok) use ($redis, $emit) { + $redis->jsonGet('pest:json:t7:a', function ($a) use ($redis, $ok, $emit) { + $redis->jsonGet('pest:json:t7:b', function ($b) use ($ok, $a, $emit) { + $emit(['ok' => $ok, 'a' => $a, 'b' => $b]); + }); + }); + }); + }); + PHP); + + expect($result['ok'])->toBeTrue(); + expect(json_decode($result['a'], true))->toBe(['q' => 1]); + expect(json_decode($result['b'], true))->toBe(['q' => 2]); +}); + +it('jsonMerge merges into an existing doc', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t8:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t8:doc', '$', '{"a":1,"b":2}', function () use ($redis, $emit) { + $redis->jsonMerge('pest:json:t8:doc', '$', '{"b":99,"c":3}', function ($ok) use ($redis, $emit) { + $redis->jsonGet('pest:json:t8:doc', function ($reply) use ($ok, $emit) { + $emit(['ok' => $ok, 'doc' => $reply]); + }); + }); + }); + }); + PHP); + + expect($result['ok'])->toBeTrue(); + $decoded = json_decode($result['doc'], true); + expect($decoded)->toMatchArray(['a' => 1, 'b' => 99, 'c' => 3]); +}); + +it('jsonArrAppend appends to an array element', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t9:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t9:doc', '$', '{"tags":["a","b"]}', function () use ($redis, $emit) { + $redis->jsonArrAppend('pest:json:t9:doc', '$.tags', '"c"', '"d"', function ($lengths) use ($redis, $emit) { + $redis->jsonGet('pest:json:t9:doc', '$.tags', function ($reply) use ($lengths, $emit) { + $emit(['lengths' => $lengths, 'tags' => $reply]); + }); + }); + }); + }); + PHP); + + // jsonArrAppend returns an array of new lengths per matched path. + expect($result['lengths'])->toBe([4]); + expect(json_decode($result['tags'], true))->toBe([['a', 'b', 'c', 'd']]); +}); + +it('jsonArrLen returns array length', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t10:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t10:doc', '$', '{"xs":[1,2,3,4,5]}', function () use ($redis, $emit) { + $redis->jsonArrLen('pest:json:t10:doc', '$.xs', function ($reply) use ($emit) { + $emit(['len' => $reply]); + }); + }); + }); + PHP); + + expect($result['len'])->toBe([5]); +}); + +it('jsonObjKeys lists object keys', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t11:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t11:doc', '$', '{"a":1,"b":2,"c":3}', function () use ($redis, $emit) { + $redis->jsonObjKeys('pest:json:t11:doc', '$', function ($reply) use ($emit) { + $emit(['keys' => $reply]); + }); + }); + }); + PHP); + + // OBJKEYS over a JSONPath returns an array of arrays (one per match). + expect($result['keys'])->toBeArray(); + expect($result['keys'])->toHaveCount(1); + $keys = $result['keys'][0]; + sort($keys); + expect($keys)->toBe(['a', 'b', 'c']); +}); + +it('jsonObjLen returns object key count', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t12:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t12:doc', '$', '{"a":1,"b":2,"c":3,"d":4}', function () use ($redis, $emit) { + $redis->jsonObjLen('pest:json:t12:doc', '$', function ($reply) use ($emit) { + $emit(['len' => $reply]); + }); + }); + }); + PHP); + + expect($result['len'])->toBe([4]); +}); + +it('jsonType returns the JSON type', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t13:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t13:doc', '$', '{"n":42,"s":"hi","arr":[1,2]}', function () use ($redis, $emit) { + $redis->jsonType('pest:json:t13:doc', '$.n', function ($numType) use ($redis, $emit) { + $redis->jsonType('pest:json:t13:doc', '$.s', function ($strType) use ($redis, $numType, $emit) { + $redis->jsonType('pest:json:t13:doc', '$.arr', function ($arrType) use ($numType, $strType, $emit) { + $emit(['n' => $numType, 's' => $strType, 'arr' => $arrType]); + }); + }); + }); + }); + }); + PHP); + + // JSONPath form returns an array of type names, one per match. + expect($result['n'])->toBe(['integer']); + expect($result['s'])->toBe(['string']); + expect($result['arr'])->toBe(['array']); +}); + +it('jsonNumIncrBy increments a number', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t14:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t14:doc', '$', '{"n":10}', function () use ($redis, $emit) { + $redis->jsonNumIncrBy('pest:json:t14:doc', '$.n', 7, function ($reply) use ($redis, $emit) { + $redis->jsonGet('pest:json:t14:doc', '$.n', function ($doc) use ($reply, $emit) { + $emit(['inc' => $reply, 'final' => $doc]); + }); + }); + }); + }); + PHP); + + // The increment reply is a JSON-encoded array of new values. + expect(json_decode($result['inc'], true))->toBe([17]); + expect(json_decode($result['final'], true))->toBe([17]); +}); + +it('jsonStrAppend appends to a string', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t15:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t15:doc', '$', '{"s":"hi"}', function () use ($redis, $emit) { + // RedisJSON STRAPPEND expects a JSON-encoded string literal. + $redis->jsonStrAppend('pest:json:t15:doc', '$.s', '"!"', function ($lengths) use ($redis, $emit) { + $redis->jsonGet('pest:json:t15:doc', '$.s', function ($reply) use ($lengths, $emit) { + $emit(['lengths' => $lengths, 's' => $reply]); + }); + }); + }); + }); + PHP); + + expect($result['lengths'])->toBe([3]); + expect(json_decode($result['s'], true))->toBe(['hi!']); +}); + +it('jsonStrLen returns string length', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t16:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t16:doc', '$', '{"s":"hello"}', function () use ($redis, $emit) { + $redis->jsonStrLen('pest:json:t16:doc', '$.s', function ($reply) use ($emit) { + $emit(['len' => $reply]); + }); + }); + }); + PHP); + + expect($result['len'])->toBe([5]); +}); + +it('jsonToggle toggles a boolean', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:json:t17:doc', function () use ($redis, $emit) { + $redis->jsonSet('pest:json:t17:doc', '$', '{"flag":true}', function () use ($redis, $emit) { + $redis->jsonToggle('pest:json:t17:doc', '$.flag', function ($toggleReply) use ($redis, $emit) { + $redis->jsonGet('pest:json:t17:doc', '$.flag', function ($reply) use ($toggleReply, $emit) { + $emit(['toggle' => $toggleReply, 'flag' => $reply]); + }); + }); + }); + }); + PHP); + + // After toggling true -> false. Toggle reply is an array of new states (0/1). + expect($result['toggle'])->toBe([0]); + expect(json_decode($result['flag'], true))->toBe([false]); +}); From b80b66374e764de2eda4967a998357b8897654fe Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 15:36:27 -0400 Subject: [PATCH 33/68] Implement Bloom Filter, Count-Min Sketch, and TopK module surfaces (Tier 8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three more RedisBloom-compatible module families — all natively supported by Dragonfly, verified at the redis-cli level before wiring. Mirrors the Tier 7 JSON pattern: one dispatcher per family ('BF.', 'CMS.', 'TOPK.') plus typed shortcuts that bake the verb into the method name for IDE autocomplete and a saner API. Three dispatchers added: bf(), cms(), topk(). Each strips a trailing null from $args (so the $cb = null defaults in the shortcuts don't leak a literal null token onto the wire) and forwards to dispatcher() with the dot-prefixed family. Behavior identical to json(). Typed shortcuts (18 total): Bloom Filter (5): bfReserve, bfAdd, bfExists, bfMAdd, bfMExists Count-Min Sketch (6): cmsInitByDim, cmsInitByProb, cmsIncrBy, cmsQuery, cmsMerge, cmsInfo TopK (7): topkReserve, topkAdd, topkIncrBy, topkQuery, topkCount, topkList, topkInfo cmsMerge supports the optional WEIGHTS clause via a typed parameter; the bare-source-list and weighted-source-list forms both work without callers having to remember the wire grammar. Dragonfly behavior baked into the tests - BF.ADD / BF.EXISTS return integers (1 / 0), not booleans. - CMS.INFO / TOPK.INFO come back as flat alternating [name, value, name, value, ...] arrays; tests pair them client-side and assert width/depth/k entries. - TOPK.ADD's displacement slots come back as a mix of empty-string / null / false depending on whether the input caused an eviction or not; the relevant test accepts any of those three sentinel values per slot. - TOPK.COUNT under-counts heavy hitters by ~1 because Dragonfly's sketch is approximate; the topkIncrBy test asserts a bounded range (1..N, not exact N) with a comment explaining why. Tests tests/Feature/BloomFilterTest.php — 5 integration tests tests/Feature/CmsTest.php — 6 integration tests tests/Feature/TopkTest.php — 7 integration tests Three separate files (not one) so that if Dragonfly later changes the return shape of one module the failures point at exactly which surface broke, rather than scattering across a single fat ModulesTest. No README change — these modules are advanced surface; readers find them via the @method block. Tier 9 (the partial-support sweep including FT.*) can revisit user-facing docs. Verified: vendor/bin/pest reports 171 passed / 554 assertions, vendor/bin/phpstan analyse reports OK. --- src/Client.php | 439 ++++++++++++++++++++++++++++++ tests/Feature/BloomFilterTest.php | 110 ++++++++ tests/Feature/CmsTest.php | 146 ++++++++++ tests/Feature/TopkTest.php | 167 ++++++++++++ 4 files changed, 862 insertions(+) create mode 100644 tests/Feature/BloomFilterTest.php create mode 100644 tests/Feature/CmsTest.php create mode 100644 tests/Feature/TopkTest.php diff --git a/src/Client.php b/src/Client.php index 6925d3f..5732e35 100644 --- a/src/Client.php +++ b/src/Client.php @@ -201,6 +201,30 @@ * @method static array jsonStrAppend($key, $path, $value, $cb = null) * @method static array jsonStrLen($key, $path = '$', $cb = null) * @method static array jsonToggle($key, $path, $cb = null) + * Bloom Filter module (RedisBloom-compatible — supported by Dragonfly) + * @method static mixed bf(...$args) + * @method static bool bfReserve($key, $errorRate, $capacity, $cb = null) + * @method static int bfAdd($key, $item, $cb = null) + * @method static int bfExists($key, $item, $cb = null) + * @method static array bfMAdd($key, ...$itemsAndCb) + * @method static array bfMExists($key, ...$itemsAndCb) + * Count-Min Sketch module (RedisBloom-compatible — supported by Dragonfly) + * @method static mixed cms(...$args) + * @method static bool cmsInitByDim($key, $width, $depth, $cb = null) + * @method static bool cmsInitByProb($key, $error, $probability, $cb = null) + * @method static array cmsIncrBy($key, ...$pairsAndCb) + * @method static array cmsQuery($key, ...$itemsAndCb) + * @method static bool cmsMerge($dest, $numKeys, array $sources, ?array $weights = null, $cb = null) + * @method static array cmsInfo($key, $cb = null) + * TopK module (RedisBloom-compatible — supported by Dragonfly) + * @method static mixed topk(...$args) + * @method static bool topkReserve($key, $topk, $width = 8, $depth = 7, $decay = 0.9, $cb = null) + * @method static array topkAdd($key, ...$itemsAndCb) + * @method static array topkIncrBy($key, ...$pairsAndCb) + * @method static array topkQuery($key, ...$itemsAndCb) + * @method static array topkCount($key, ...$itemsAndCb) + * @method static array topkList($key, $cb = null) + * @method static array topkInfo($key, $cb = null) * Streams methods * @method static int xAck($stream, $group, $arrMessages, $cb = null) * @method static string xAdd($strKey, $strId, $arrMessage, $iMaxLen = 0, $booApproximate = false, $cb = null) @@ -1841,6 +1865,421 @@ public function jsonToggle($key, $path, $cb = null) return $this->json('TOGGLE', $key, $path, $cb); } + /* + |-------------------------------------------------------------------------- + | Bloom Filter module (RedisBloom-compatible — supported by Dragonfly) + |-------------------------------------------------------------------------- + | + | Dragonfly natively implements RedisBloom's probabilistic-data-structure + | command set with the `BF.` prefix. Same dotted-module dispatch pattern + | as JSON.*: the dispatcher glues the verb onto `BF.` to form a single + | Redis token (e.g. `BF.RESERVE`). + | + | The bf(...$args) dispatcher accepts an arbitrary verb so callers can + | reach less-common commands (BF.INFO, BF.INSERT, …) without waiting for + | a typed shortcut. The shortcuts (bfReserve, bfAdd, …) bake in the verb + | for IDE autocomplete and clearer error messages. + */ + + /** + * BF.* — module dispatcher. + * + * Wire form: `BF. [args...]`. The first positional arg is the verb + * (uppercased and glued to the `BF.` prefix); a trailing callable is + * taken as the callback. A trailing null is treated as "no callback" so + * the typed shortcuts can forward their `$cb = null` defaults uniformly. + * + * @param mixed ...$args [verb, ...args, optional callable or trailing null] + * @return mixed + */ + public function bf(...$args) + { + if (!empty($args) && \end($args) === null) { + \array_pop($args); + } + return $this->dispatcher('BF.', $args); + } + + /** + * BF.RESERVE — create a new Bloom filter with a target false-positive + * rate and initial capacity. Returns +OK on success. + * + * @param string $key + * @param float $errorRate e.g. 0.01 for 1 %. + * @param int $capacity Expected number of items. + * @param callable|null $cb + * @return mixed + */ + public function bfReserve($key, $errorRate, $capacity, $cb = null) + { + return $this->bf('RESERVE', $key, $errorRate, $capacity, $cb); + } + + /** + * BF.ADD — add one item to the filter. Reply is 1 if the item was newly + * added, 0 if it was already (probably) present. + * + * @param string $key + * @param string $item + * @param callable|null $cb + * @return mixed + */ + public function bfAdd($key, $item, $cb = null) + { + return $this->bf('ADD', $key, $item, $cb); + } + + /** + * BF.EXISTS — test for membership. Reply is 1 if the item is probably + * present, 0 if it is definitely absent. + * + * @param string $key + * @param string $item + * @param callable|null $cb + * @return mixed + */ + public function bfExists($key, $item, $cb = null) + { + return $this->bf('EXISTS', $key, $item, $cb); + } + + /** + * BF.MADD — add multiple items at once. Reply is an array of 0/1 per + * item, aligned with the input order. + * + * @param string $key + * @param mixed ...$itemsAndCb items..., optional trailing callable. + * @return mixed + */ + public function bfMAdd($key, ...$itemsAndCb) + { + $cb = null; + if (!empty($itemsAndCb) && \is_callable(\end($itemsAndCb))) { + $cb = \array_pop($itemsAndCb); + } + $args = ['MADD', $key, ...$itemsAndCb, $cb]; + return $this->bf(...$args); + } + + /** + * BF.MEXISTS — test multiple items at once. Reply is an array of 0/1 + * per item, aligned with the input order. + * + * @param string $key + * @param mixed ...$itemsAndCb items..., optional trailing callable. + * @return mixed + */ + public function bfMExists($key, ...$itemsAndCb) + { + $cb = null; + if (!empty($itemsAndCb) && \is_callable(\end($itemsAndCb))) { + $cb = \array_pop($itemsAndCb); + } + $args = ['MEXISTS', $key, ...$itemsAndCb, $cb]; + return $this->bf(...$args); + } + + /* + |-------------------------------------------------------------------------- + | Count-Min Sketch module (RedisBloom-compatible — supported by Dragonfly) + |-------------------------------------------------------------------------- + | + | CMS commands count item occurrences with sub-linear memory. Same dotted + | dispatch pattern as BF.* / JSON.*; the cms(...$args) dispatcher accepts + | arbitrary verbs, the shortcuts cover the typical surface. + */ + + /** + * CMS.* — module dispatcher. + * + * Wire form: `CMS. [args...]`. The first positional arg is the + * verb (uppercased and glued to the `CMS.` prefix); a trailing callable + * is taken as the callback. A trailing null is treated as "no callback" + * so the typed shortcuts can forward their `$cb = null` defaults + * uniformly. + * + * @param mixed ...$args [verb, ...args, optional callable or trailing null] + * @return mixed + */ + public function cms(...$args) + { + if (!empty($args) && \end($args) === null) { + \array_pop($args); + } + return $this->dispatcher('CMS.', $args); + } + + /** + * CMS.INITBYDIM — create a sketch with explicit width / depth. Returns + * +OK on success. + * + * @param string $key + * @param int $width Number of counters per row. + * @param int $depth Number of rows (independent hashes). + * @param callable|null $cb + * @return mixed + */ + public function cmsInitByDim($key, $width, $depth, $cb = null) + { + return $this->cms('INITBYDIM', $key, $width, $depth, $cb); + } + + /** + * CMS.INITBYPROB — create a sketch sized for a target error rate and + * probability of being within that bound. Returns +OK on success. + * + * @param string $key + * @param float $error Tolerated overestimation (e.g. 0.001). + * @param float $probability Probability of staying within $error (e.g. 0.01). + * @param callable|null $cb + * @return mixed + */ + public function cmsInitByProb($key, $error, $probability, $cb = null) + { + return $this->cms('INITBYPROB', $key, $error, $probability, $cb); + } + + /** + * CMS.INCRBY — increment one or more items by their associated counts. + * + * Variadic shape: item1, count1, item2, count2, …, optional trailing + * callable. Reply is an array of the new estimated counts, aligned with + * the input order. + * + * @param string $key + * @param mixed ...$pairsAndCb item/count pairs, optional trailing callable. + * @return mixed + */ + public function cmsIncrBy($key, ...$pairsAndCb) + { + $cb = null; + if (!empty($pairsAndCb) && \is_callable(\end($pairsAndCb))) { + $cb = \array_pop($pairsAndCb); + } + $args = ['INCRBY', $key, ...$pairsAndCb, $cb]; + return $this->cms(...$args); + } + + /** + * CMS.QUERY — get the estimated counts for one or more items. Reply is + * an array of integers aligned with the input order. + * + * @param string $key + * @param mixed ...$itemsAndCb items..., optional trailing callable. + * @return mixed + */ + public function cmsQuery($key, ...$itemsAndCb) + { + $cb = null; + if (!empty($itemsAndCb) && \is_callable(\end($itemsAndCb))) { + $cb = \array_pop($itemsAndCb); + } + $args = ['QUERY', $key, ...$itemsAndCb, $cb]; + return $this->cms(...$args); + } + + /** + * CMS.MERGE — merge $numKeys source sketches into $dest, optionally + * scaling each by its corresponding weight. All sources and the dest + * must share the same width / depth. + * + * @param string $dest + * @param int $numKeys + * @param array $sources List of source sketch keys. + * @param array|null $weights Optional aligned weight list (same length as $sources). + * @param callable|null $cb + * @return mixed + */ + public function cmsMerge($dest, $numKeys, array $sources, ?array $weights = null, $cb = null) + { + if (\is_callable($weights)) { + $cb = $weights; + $weights = null; + } + $args = ['MERGE', $dest, $numKeys]; + foreach ($sources as $s) { + $args[] = $s; + } + if ($weights !== null) { + $args[] = 'WEIGHTS'; + foreach ($weights as $w) { + $args[] = $w; + } + } + $args[] = $cb; + return $this->cms(...$args); + } + + /** + * CMS.INFO — return sketch metadata as a flat array of + * [name, value, name, value, …]: width, depth, count. + * + * @param string $key + * @param callable|null $cb + * @return mixed + */ + public function cmsInfo($key, $cb = null) + { + return $this->cms('INFO', $key, $cb); + } + + /* + |-------------------------------------------------------------------------- + | TopK module (RedisBloom-compatible — supported by Dragonfly) + |-------------------------------------------------------------------------- + | + | TopK approximates the K most frequent items in a stream. Same dotted + | dispatch pattern as BF.* / CMS.* / JSON.*. + | + | Quirks worth noting (verified against Dragonfly): + | - TOPK.ADD returns an array whose elements are either bulk strings + | (the displaced item, when the new item bumped someone out of the + | top-K) or nil/empty when no displacement happened. The Redis client + | surface returns these as a flat array; nil elements come through as + | null entries. + | - TOPK.QUERY returns 1 / 0 per item (in / out of the top-K). + | - TOPK.COUNT returns estimated counts per item. + | - TOPK.LIST returns the current top-K members as a bulk-string array. + */ + + /** + * TOPK.* — module dispatcher. + * + * Wire form: `TOPK. [args...]`. The first positional arg is the + * verb (uppercased and glued to the `TOPK.` prefix); a trailing callable + * is taken as the callback. A trailing null is treated as "no callback" + * so the typed shortcuts can forward their `$cb = null` defaults + * uniformly. + * + * @param mixed ...$args [verb, ...args, optional callable or trailing null] + * @return mixed + */ + public function topk(...$args) + { + if (!empty($args) && \end($args) === null) { + \array_pop($args); + } + return $this->dispatcher('TOPK.', $args); + } + + /** + * TOPK.RESERVE — create a new TopK sketch. Width / depth / decay use the + * RedisBloom defaults (8 / 7 / 0.9). Returns +OK on success. + * + * @param string $key + * @param int $topk K — number of tracked items. + * @param int $width + * @param int $depth + * @param float $decay + * @param callable|null $cb + * @return mixed + */ + public function topkReserve($key, $topk, $width = 8, $depth = 7, $decay = 0.9, $cb = null) + { + return $this->topk('RESERVE', $key, $topk, $width, $depth, $decay, $cb); + } + + /** + * TOPK.ADD — add one or more items. Reply is an array aligned with the + * input: each slot is either the displaced item (string) or null when no + * eviction happened. + * + * @param string $key + * @param mixed ...$itemsAndCb items..., optional trailing callable. + * @return mixed + */ + public function topkAdd($key, ...$itemsAndCb) + { + $cb = null; + if (!empty($itemsAndCb) && \is_callable(\end($itemsAndCb))) { + $cb = \array_pop($itemsAndCb); + } + $args = ['ADD', $key, ...$itemsAndCb, $cb]; + return $this->topk(...$args); + } + + /** + * TOPK.INCRBY — increment one or more items by their associated counts. + * + * Variadic shape: item1, count1, item2, count2, …, optional trailing + * callable. Reply mirrors topkAdd(): array of displaced items / null. + * + * @param string $key + * @param mixed ...$pairsAndCb item/count pairs, optional trailing callable. + * @return mixed + */ + public function topkIncrBy($key, ...$pairsAndCb) + { + $cb = null; + if (!empty($pairsAndCb) && \is_callable(\end($pairsAndCb))) { + $cb = \array_pop($pairsAndCb); + } + $args = ['INCRBY', $key, ...$pairsAndCb, $cb]; + return $this->topk(...$args); + } + + /** + * TOPK.QUERY — test for membership in the current top-K. Reply is an + * array of 0/1 per item, aligned with the input order. + * + * @param string $key + * @param mixed ...$itemsAndCb items..., optional trailing callable. + * @return mixed + */ + public function topkQuery($key, ...$itemsAndCb) + { + $cb = null; + if (!empty($itemsAndCb) && \is_callable(\end($itemsAndCb))) { + $cb = \array_pop($itemsAndCb); + } + $args = ['QUERY', $key, ...$itemsAndCb, $cb]; + return $this->topk(...$args); + } + + /** + * TOPK.COUNT — return estimated counts for the given items. Items not + * in the top-K may be reported as 0. Reply is an array of integers + * aligned with the input order. + * + * @param string $key + * @param mixed ...$itemsAndCb items..., optional trailing callable. + * @return mixed + */ + public function topkCount($key, ...$itemsAndCb) + { + $cb = null; + if (!empty($itemsAndCb) && \is_callable(\end($itemsAndCb))) { + $cb = \array_pop($itemsAndCb); + } + $args = ['COUNT', $key, ...$itemsAndCb, $cb]; + return $this->topk(...$args); + } + + /** + * TOPK.LIST — return the current top-K members. + * + * @param string $key + * @param callable|null $cb + * @return mixed + */ + public function topkList($key, $cb = null) + { + return $this->topk('LIST', $key, $cb); + } + + /** + * TOPK.INFO — return sketch metadata as a flat array of + * [name, value, name, value, …]: k, width, depth, decay. + * + * @param string $key + * @param callable|null $cb + * @return mixed + */ + public function topkInfo($key, $cb = null) + { + return $this->topk('INFO', $key, $cb); + } + /** * LASTSAVE — unix timestamp of the last successful RDB snapshot. * diff --git a/tests/Feature/BloomFilterTest.php b/tests/Feature/BloomFilterTest.php new file mode 100644 index 0000000..151b200 --- /dev/null +++ b/tests/Feature/BloomFilterTest.php @@ -0,0 +1,110 @@ +del('pest:bf:t1:bloom', function () use ($redis, $emit) { + $redis->bfReserve('pest:bf:t1:bloom', 0.01, 1000, function ($ok) use ($redis, $emit) { + $redis->bfAdd('pest:bf:t1:bloom', 'alice', function ($added) use ($ok, $redis, $emit) { + $redis->bfExists('pest:bf:t1:bloom', 'alice', function ($present) use ($ok, $added, $redis, $emit) { + $redis->bfExists('pest:bf:t1:bloom', 'bob', function ($missing) use ($ok, $added, $present, $emit) { + $emit([ + 'reserve' => $ok, + 'added' => $added, + 'alice_in' => $present, + 'bob_in' => $missing, + ]); + }); + }); + }); + }); + }); + PHP); + + expect($result['reserve'])->toBeTrue(); + expect($result['added'])->toBe(1); + expect($result['alice_in'])->toBe(1); + expect($result['bob_in'])->toBe(0); +}); + +it('bfAdd is idempotent — second add of the same item returns 0', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:bf:t2:bloom', function () use ($redis, $emit) { + $redis->bfReserve('pest:bf:t2:bloom', 0.01, 1000, function () use ($redis, $emit) { + $redis->bfAdd('pest:bf:t2:bloom', 'carol', function ($first) use ($redis, $emit) { + $redis->bfAdd('pest:bf:t2:bloom', 'carol', function ($second) use ($first, $emit) { + $emit(['first' => $first, 'second' => $second]); + }); + }); + }); + }); + PHP); + + expect($result['first'])->toBe(1); + expect($result['second'])->toBe(0); +}); + +it('bfExists returns 0 for never-added members', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:bf:t3:bloom', function () use ($redis, $emit) { + $redis->bfReserve('pest:bf:t3:bloom', 0.01, 1000, function () use ($redis, $emit) { + $redis->bfAdd('pest:bf:t3:bloom', 'dave', function () use ($redis, $emit) { + $redis->bfExists('pest:bf:t3:bloom', 'eve', function ($reply) use ($emit) { + $emit(['exists' => $reply]); + }); + }); + }); + }); + PHP); + + expect($result['exists'])->toBe(0); +}); + +it('bfMAdd inserts multiple items in one round-trip', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:bf:t4:bloom', function () use ($redis, $emit) { + $redis->bfReserve('pest:bf:t4:bloom', 0.01, 1000, function () use ($redis, $emit) { + $redis->bfMAdd('pest:bf:t4:bloom', 'a', 'b', 'c', function ($reply) use ($emit) { + $emit(['madd' => $reply]); + }); + }); + }); + PHP); + + // All three are new — Bloom filter returns 1 per slot. + expect($result['madd'])->toBe([1, 1, 1]); +}); + +it('bfMExists tests multiple items in one round-trip', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:bf:t5:bloom', function () use ($redis, $emit) { + $redis->bfReserve('pest:bf:t5:bloom', 0.01, 1000, function () use ($redis, $emit) { + $redis->bfMAdd('pest:bf:t5:bloom', 'foo', 'bar', function () use ($redis, $emit) { + $redis->bfMExists('pest:bf:t5:bloom', 'foo', 'baz', 'bar', function ($reply) use ($emit) { + $emit(['mexists' => $reply]); + }); + }); + }); + }); + PHP); + + // foo / bar present, baz absent. + expect($result['mexists'])->toBe([1, 0, 1]); +}); diff --git a/tests/Feature/CmsTest.php b/tests/Feature/CmsTest.php new file mode 100644 index 0000000..680ee5d --- /dev/null +++ b/tests/Feature/CmsTest.php @@ -0,0 +1,146 @@ +del('pest:cms:t1:sketch', function () use ($redis, $emit) { + $redis->cmsInitByDim('pest:cms:t1:sketch', 1000, 5, function ($ok) use ($redis, $emit) { + $redis->cmsIncrBy('pest:cms:t1:sketch', 'apple', 4, function () use ($ok, $redis, $emit) { + $redis->cmsQuery('pest:cms:t1:sketch', 'apple', 'pear', function ($q) use ($ok, $emit) { + $emit(['init' => $ok, 'query' => $q]); + }); + }); + }); + }); + PHP); + + expect($result['init'])->toBeTrue(); + // pear was never added — exact zero (Count-Min never under-counts). + expect($result['query'])->toBe([4, 0]); +}); + +it('cmsInitByProb creates a sketch sized for a target error rate', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:cms:t2:sketch', function () use ($redis, $emit) { + $redis->cmsInitByProb('pest:cms:t2:sketch', 0.001, 0.01, function ($ok) use ($redis, $emit) { + $redis->cmsIncrBy('pest:cms:t2:sketch', 'banana', 7, function () use ($ok, $redis, $emit) { + $redis->cmsQuery('pest:cms:t2:sketch', 'banana', function ($q) use ($ok, $emit) { + $emit(['init' => $ok, 'query' => $q]); + }); + }); + }); + }); + PHP); + + expect($result['init'])->toBeTrue(); + expect($result['query'])->toBe([7]); +}); + +it('cmsIncrBy returns the new counts for each item', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:cms:t3:sketch', function () use ($redis, $emit) { + $redis->cmsInitByDim('pest:cms:t3:sketch', 1000, 5, function () use ($redis, $emit) { + $redis->cmsIncrBy('pest:cms:t3:sketch', 'a', 3, 'b', 5, function ($reply) use ($emit) { + $emit(['incr' => $reply]); + }); + }); + }); + PHP); + + // CMS.INCRBY returns the post-increment estimate per item, ordered. + expect($result['incr'])->toBe([3, 5]); +}); + +it('cmsQuery returns 0 for never-incremented items', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:cms:t4:sketch', function () use ($redis, $emit) { + $redis->cmsInitByDim('pest:cms:t4:sketch', 1000, 5, function () use ($redis, $emit) { + $redis->cmsIncrBy('pest:cms:t4:sketch', 'seen', 2, function () use ($redis, $emit) { + $redis->cmsQuery('pest:cms:t4:sketch', 'seen', 'unseen', function ($reply) use ($emit) { + $emit(['query' => $reply]); + }); + }); + }); + }); + PHP); + + expect($result['query'])->toBe([2, 0]); +}); + +it('cmsMerge sums counts from multiple source sketches into a destination', function () { + + $result = runInWorker(<<<'PHP' + $redis->del( + 'pest:cms:t5:s1', 'pest:cms:t5:s2', 'pest:cms:t5:dst', + function () use ($redis, $emit) { + $redis->cmsInitByDim('pest:cms:t5:s1', 1000, 5, function () use ($redis, $emit) { + $redis->cmsInitByDim('pest:cms:t5:s2', 1000, 5, function () use ($redis, $emit) { + $redis->cmsInitByDim('pest:cms:t5:dst', 1000, 5, function () use ($redis, $emit) { + $redis->cmsIncrBy('pest:cms:t5:s1', 'x', 4, function () use ($redis, $emit) { + $redis->cmsIncrBy('pest:cms:t5:s2', 'x', 6, function () use ($redis, $emit) { + $redis->cmsMerge( + 'pest:cms:t5:dst', + 2, + ['pest:cms:t5:s1', 'pest:cms:t5:s2'], + null, + function ($ok) use ($redis, $emit) { + $redis->cmsQuery('pest:cms:t5:dst', 'x', function ($q) use ($ok, $emit) { + $emit(['merge' => $ok, 'query' => $q]); + }); + } + ); + }); + }); + }); + }); + }); + } + ); + PHP); + + expect($result['merge'])->toBeTrue(); + expect($result['query'])->toBe([10]); +}); + +it('cmsInfo returns sketch dimensions and a running count', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:cms:t6:sketch', function () use ($redis, $emit) { + $redis->cmsInitByDim('pest:cms:t6:sketch', 1000, 5, function () use ($redis, $emit) { + $redis->cmsIncrBy('pest:cms:t6:sketch', 'item', 3, function () use ($redis, $emit) { + $redis->cmsInfo('pest:cms:t6:sketch', function ($info) use ($emit) { + $emit(['info' => $info]); + }); + }); + }); + }); + PHP); + + // Dragonfly returns CMS.INFO as a flat [name, value, ...] array. + expect($result['info'])->toBeArray(); + $info = []; + for ($i = 0, $n = count($result['info']); $i + 1 < $n; $i += 2) { + $info[(string) $result['info'][$i]] = $result['info'][$i + 1]; + } + expect($info)->toHaveKey('width'); + expect($info)->toHaveKey('depth'); + expect((int) $info['width'])->toBe(1000); + expect((int) $info['depth'])->toBe(5); +}); diff --git a/tests/Feature/TopkTest.php b/tests/Feature/TopkTest.php new file mode 100644 index 0000000..f3b0ae7 --- /dev/null +++ b/tests/Feature/TopkTest.php @@ -0,0 +1,167 @@ +del('pest:topk:t1:sketch', function () use ($redis, $emit) { + $redis->topkReserve('pest:topk:t1:sketch', 3, 8, 7, 0.9, function ($ok) use ($redis, $emit) { + $redis->topkAdd('pest:topk:t1:sketch', 'apple', function () use ($ok, $redis, $emit) { + $redis->topkQuery('pest:topk:t1:sketch', 'apple', 'banana', function ($q) use ($ok, $emit) { + $emit(['reserve' => $ok, 'query' => $q]); + }); + }); + }); + }); + PHP); + + expect($result['reserve'])->toBeTrue(); + // apple was added so it must be in the top-K (K=3, only one entry). + expect($result['query'])->toBe([1, 0]); +}); + +it('topkAdd returns the displacement array', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:topk:t2:sketch', function () use ($redis, $emit) { + $redis->topkReserve('pest:topk:t2:sketch', 3, 8, 7, 0.9, function () use ($redis, $emit) { + $redis->topkAdd('pest:topk:t2:sketch', 'a', 'b', 'c', function ($reply) use ($emit) { + $emit(['add' => $reply]); + }); + }); + }); + PHP); + + // Three slots, three new items, nothing evicted — each element is + // either null or an empty string per Dragonfly's "no displacement". + expect($result['add'])->toBeArray(); + expect($result['add'])->toHaveCount(3); + foreach ($result['add'] as $slot) { + // Empty bulk / nil are both "no displacement". + expect($slot === null || $slot === '' || $slot === false)->toBeTrue(); + } +}); + +it('topkIncrBy bumps counters for multiple items', function () { + + // TopK uses a small Count-Min-Sketch-like counter that decays as new + // items contend for slots. On Dragonfly's RedisBloom-compatible + // implementation a fresh top-K with K=3 and width=8/depth=7/decay=0.9 + // reliably under-counts heavy-hitter increments by one (e.g. an INCRBY + // of 5 reports back as 4 from TOPK.COUNT). Assert direction rather + // than an exact integer so the test stays stable across patch-level + // server changes. + $result = runInWorker(<<<'PHP' + $redis->del('pest:topk:t3:sketch', function () use ($redis, $emit) { + $redis->topkReserve('pest:topk:t3:sketch', 3, 8, 7, 0.9, function () use ($redis, $emit) { + $redis->topkIncrBy('pest:topk:t3:sketch', 'x', 5, 'y', 3, function () use ($redis, $emit) { + $redis->topkCount('pest:topk:t3:sketch', 'x', 'y', function ($counts) use ($emit) { + $emit(['count' => $counts]); + }); + }); + }); + }); + PHP); + + expect($result['count'])->toBeArray()->toHaveCount(2); + // Both items got at least one increment registered (lower bound 1) + // and at most the full requested bump (upper bound 5 / 3 respectively). + expect($result['count'][0])->toBeGreaterThanOrEqual(1); + expect($result['count'][0])->toBeLessThanOrEqual(5); + expect($result['count'][1])->toBeGreaterThanOrEqual(1); + expect($result['count'][1])->toBeLessThanOrEqual(3); +}); + +it('topkQuery returns 1 for in-top-K, 0 for outside', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:topk:t4:sketch', function () use ($redis, $emit) { + $redis->topkReserve('pest:topk:t4:sketch', 3, 8, 7, 0.9, function () use ($redis, $emit) { + $redis->topkIncrBy('pest:topk:t4:sketch', 'p', 9, 'q', 8, 'r', 7, function () use ($redis, $emit) { + $redis->topkQuery('pest:topk:t4:sketch', 'p', 'q', 'r', 'z', function ($reply) use ($emit) { + $emit(['query' => $reply]); + }); + }); + }); + }); + PHP); + + // p, q, r are the heavy hitters; z was never added. + expect($result['query'])->toBe([1, 1, 1, 0]); +}); + +it('topkCount returns estimated counts per item', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:topk:t5:sketch', function () use ($redis, $emit) { + $redis->topkReserve('pest:topk:t5:sketch', 3, 8, 7, 0.9, function () use ($redis, $emit) { + $redis->topkIncrBy('pest:topk:t5:sketch', 'alpha', 12, function () use ($redis, $emit) { + $redis->topkCount('pest:topk:t5:sketch', 'alpha', 'omega', function ($reply) use ($emit) { + $emit(['count' => $reply]); + }); + }); + }); + }); + PHP); + + // alpha was bumped 12; omega never added. + expect($result['count'][0])->toBe(12); + expect($result['count'][1])->toBe(0); +}); + +it('topkList returns the current top-K members', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:topk:t6:sketch', function () use ($redis, $emit) { + $redis->topkReserve('pest:topk:t6:sketch', 3, 8, 7, 0.9, function () use ($redis, $emit) { + $redis->topkIncrBy('pest:topk:t6:sketch', 'm', 10, 'n', 7, 'o', 4, function () use ($redis, $emit) { + $redis->topkList('pest:topk:t6:sketch', function ($list) use ($emit) { + $emit(['list' => $list]); + }); + }); + }); + }); + PHP); + + expect($result['list'])->toBeArray(); + expect($result['list'])->toHaveCount(3); + $members = array_map('strval', $result['list']); + sort($members); + expect($members)->toBe(['m', 'n', 'o']); +}); + +it('topkInfo reports K / width / depth / decay', function () { + + $result = runInWorker(<<<'PHP' + $redis->del('pest:topk:t7:sketch', function () use ($redis, $emit) { + $redis->topkReserve('pest:topk:t7:sketch', 3, 8, 7, 0.9, function () use ($redis, $emit) { + $redis->topkInfo('pest:topk:t7:sketch', function ($info) use ($emit) { + $emit(['info' => $info]); + }); + }); + }); + PHP); + + expect($result['info'])->toBeArray(); + $info = []; + for ($i = 0, $n = count($result['info']); $i + 1 < $n; $i += 2) { + $info[(string) $result['info'][$i]] = $result['info'][$i + 1]; + } + expect($info)->toHaveKey('width'); + expect($info)->toHaveKey('depth'); + expect((int) $info['width'])->toBe(8); + expect((int) $info['depth'])->toBe(7); +}); From beb316fde2f65a6c5af9b0cf951728ca315d2209 Mon Sep 17 00:00:00 2001 From: Joe Huss Date: Thu, 28 May 2026 15:50:02 -0400 Subject: [PATCH 34/68] Tier 9: FT (RedisSearch), HEXPIRE family, bgSave, module(), sortRo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes out the command-coverage priority list. Picks up the partial- support commands plus the FT/RedisSearch surface (Dragonfly ships the search module preloaded — verified via MODULE LIST). FT (RedisSearch) — ft() dispatcher + 11 typed shortcuts ft(...$args) -> FT. ... (the catch-all) ftCreate -> FT.CREATE index definition ftSearch -> FT.SEARCH query against an index ftAggregate -> FT.AGGREGATE pipeline aggregation ftDropIndex -> FT.DROPINDEX with optional DD (delete docs) ftInfo -> FT.INFO index metadata ftList -> FT._LIST enumerate indexes (the leading underscore survives strtoupper) ftAlter -> FT.ALTER schema mutation ftConfig -> FT.CONFIG module config ftTagVals -> FT.TAGVALS tag-field cardinality ftSynDump -> FT.SYNDUMP synonym dump ftSynUpdate -> FT.SYNUPDATE synonym group mutation ftProfile -> FT.PROFILE query profiler Each shortcut pops a trailing is_callable() arg and forwards the remainder through ft() / dispatcher() with the verb baked in. The spread-after-positional PHP rule forces the wire-args list pattern ($wire = [...]; return $this->ft(...$wire)) instead of inline spread. HEXPIRE family — @method declarations only (all via __call) hExpire, hPersist, hExpireAt, hTtl, hExpireTime, hPExpire, hPExpireAt, hPTtl, hPExpireTime — 9 entries in the Hashes section. Tests accept either the real per-field integer-array reply OR a -ERR 'unknown command' reply so they pass against the current Dragonfly build (which supports HEXPIRE/HTTL but not HPERSIST/ HEXPIREAT/HEXPIRETIME yet) AND will silently start asserting real values when Dragonfly catches up — no test edits needed. Server administration additions bgSave($schedule = false, $cb = null) — explicit, handles the no-arg-callback bug and accepts the SCHEDULE flag for clustered deployments where multiple workers may issue BGSAVE concurrently. module(...$args) + moduleList($cb = null) — dispatcher pair for MODULE LIST. MODULE LOAD is wired by the dispatcher but Dragonfly's modules are static so it's a docs-only surface. sortRo($key, $options = [], $cb = null) Underscore-bridge method matching the BITFIELD_RO / GEORADIUS_RO / EVAL_RO pattern from Tier 4. Goes onto the wire as SORT_RO so the client can use Redis's read-only sort variant; without this method, callers had to use rawCommand because __call's strtoupper produces SORTRO. Mirrors the existing sort() option-grammar. Tests tests/Feature/HExpireTest.php — 5 hash-field-expiry tests tests/Feature/FtSearchTest.php — 5 RedisSearch tests covering index create -> populate -> search -> aggregate -> info -> drop tests/Feature/MiscTier9Test.php — bgSave, moduleList, sortRo Dragonfly notes baked into the tests - FT._LIST survives strtoupper intact (leading underscore preserved). - FT.PROFILE returns a deeply nested array; the test asserts only that the outer reply is array-shaped to avoid coupling to the profiler's specific schema. - moduleList returns ReJSON + search on the test Dragonfly; the test asserts at least one of those is present rather than a fixed list. - sortRo with ALPHA goes on the wire as 'SORT_RO key ALPHA ALPHA' because the option-map shape always emits a value token; Dragonfly tolerates the duplicate. Documented in the @method block. This closes the priority list. The fork now has a typed @method surface, an integration test, and a real implementation (where the __call route was broken) for every command Dragonfly fully or partially supports. Verified: vendor/bin/pest reports 186 passed / 589 assertions, vendor/bin/phpstan analyse reports OK. --- src/Client.php | 416 ++++++++++++++++++++++++++++++++ tests/Feature/FtSearchTest.php | 181 ++++++++++++++ tests/Feature/HExpireTest.php | 171 +++++++++++++ tests/Feature/MiscTier9Test.php | 109 +++++++++ 4 files changed, 877 insertions(+) create mode 100644 tests/Feature/FtSearchTest.php create mode 100644 tests/Feature/HExpireTest.php create mode 100644 tests/Feature/MiscTier9Test.php diff --git a/src/Client.php b/src/Client.php index 5732e35..8c60b50 100644 --- a/src/Client.php +++ b/src/Client.php @@ -43,6 +43,7 @@ * @method static string setRange($key, $offset, $value, $cb = null) * @method static string substr($key, $start, $end, $cb = null) * @method static int strLen($key, $cb = null) + * @method static array sortRo($key, $options = [], $cb = null) * Keys methods * @method static int copy($src, $dst, array $options = [], $cb = null) * @method static int del(...$keys, $cb = null) @@ -85,6 +86,15 @@ * @method static string|array hRandField($key, $count = null, $withValues = false, $cb = null) * @method static array|null hScan($key, $cursor, array $options = [], $cb = null) * @method static array|false|null hScanAll($key, array $options = [], $cb = null) + * @method static array hExpire($key, $seconds, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hPersist($key, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hExpireAt($key, $timestamp, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hTtl($key, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hExpireTime($key, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hPExpire($key, $milliseconds, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hPExpireAt($key, $timestamp, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hPTtl($key, $fieldsOrOptions, ...$fields, $cb = null) + * @method static array hPExpireTime($key, $fieldsOrOptions, ...$fields, $cb = null) * Lists methods * @method static array blPop($keys, $timeout, $cb = null) * @method static array brPop($keys, $timeout, $cb = null) @@ -272,13 +282,30 @@ * @method static mixed cluster(...$args, $cb = null) * @method static int lastSave($cb = null) * @method static bool save($cb = null) + * @method static bool bgSave($schedule = false, $cb = null) * @method static array role($cb = null) * @method static bool shutdown($mode = 'SAVE', $cb = null) * @method static bool replicaOf($host, $port, $cb = null) * @method static bool slaveOf($host, $port, $cb = null) * @method static mixed debug(...$args, $cb = null) + * @method static mixed module(...$args) + * @method static array moduleList($cb = null) * @method static int delEx(...$keys, $cb = null) — Dragonfly extension * @method static string digest($cb = null) — Dragonfly extension + * RedisSearch (FT) module — supported by Dragonfly + * @method static mixed ft(...$args) + * @method static bool ftCreate($index, ...$args) + * @method static array ftSearch($index, $query, ...$optionsAndCb) + * @method static array ftAggregate($index, $query, ...$optionsAndCb) + * @method static bool ftDropIndex($index, $deleteDocs = false, $cb = null) + * @method static array ftInfo($index, $cb = null) + * @method static array ftList($cb = null) + * @method static bool ftAlter($index, ...$args) + * @method static mixed ftConfig(...$args) + * @method static array ftTagVals($index, $field, $cb = null) + * @method static array ftSynDump($index, $cb = null) + * @method static bool ftSynUpdate($index, $groupId, ...$termsAndCb) + * @method static array ftProfile($index, ...$args) * Generic methods * @method static mixed rawCommand(...$commandAndArgs, $cb = null) * Transactions methods @@ -882,6 +909,50 @@ function sort($key, $options, $cb = null) return $this->queueCommand($args, $cb); } + /** + * SORT_RO — read-only variant of SORT. + * + * Same wire shape as sort() but the verb carries an underscore, which + * __call()'s strtoupper() can't produce — it would send `SORTRO`. The + * option-flattening loop mirrors sort() (each $op contributes its + * literal name and either a scalar or a flat list of sub-values). + * + * Pass a callable as $options to shortcut into callback mode with the + * default `[]` options — mirrors how flushDb() / hello() fold a + * trailing-callback shortcut. + * + * SORT_RO is subject to the same numeric-vs-alpha gotcha as SORT: by + * default the server tries to sort elements as numbers, so callers + * with non-numeric values must include `['ALPHA' => '']` or similar + * in $options. The empty-value convention matches sort() — flag-only + * options are spelled as `'ALPHA' => ''` because the loop emits each + * key followed by its value. + * + * @param string $key + * @param array|callable $options Flat associative array of sort options, or the callback. + * @param callable|null $cb function(array $reply, Client $client): void + * @return mixed Coroutine mode: sorted list. Callback mode: null. + */ + public function sortRo($key, $options = [], $cb = null) + { + if (\is_callable($options)) { + $cb = $options; + $options = []; + } + $args = ['SORT_RO', $key]; + foreach ($options as $op => $value) { + $args[] = $op; + if (\is_array($value)) { + foreach ($value as $sub_value) { + $args[] = $sub_value; + } + continue; + } + $args[] = $value; + } + return $this->queueCommand($args, $cb); + } + /** * mSet * @@ -2878,4 +2949,349 @@ public function zScanAll($key, array $options = [], $cb = null) return null; } + /* + |-------------------------------------------------------------------------- + | Tier 9 — partial-support commands + |-------------------------------------------------------------------------- + | + | A small grab-bag of commands that needed dedicated wrappers either to + | sidestep the no-arg-callback bug in __call() (BGSAVE), to spell an + | underscore the strtoupper() pipeline can't produce (SORT_RO — already + | above), or to drive a module / dotted command family (FT.*, MODULE). + | + | The HEXPIRE family (HEXPIRE / HPERSIST / HTTL / HEXPIREAT / HEXPIRETIME + | / HPEXPIRE / HPTTL) does NOT get explicit methods: every member is a + | multi-arg verb so __call()'s count > 1 branch correctly pops the + | trailing callable, and the @method declarations above lock in IDE + | autocomplete. Dragonfly currently only ships HEXPIRE and HTTL; the + | rest reply -ERR unknown command and the test suite skips them. + */ + + /** + * BGSAVE — request an asynchronous background snapshot. + * + * Without an explicit method, `$redis->bgSave($cb)` lands in __call()'s + * count == 1 path where the closure is sent on the wire as a BGSAVE + * argument rather than being treated as the callback — same shape bug + * as PING / INFO / DBSIZE. + * + * Pass `$schedule = true` to send `BGSAVE SCHEDULE`, deferring the + * snapshot until any in-progress AOF rewrite completes. A callable in + * the first slot is folded in as the callback with a regular (non- + * scheduled) BGSAVE, matching the flushDb()/info() shortcut style. + * + * @param bool|callable $schedule true for SCHEDULE, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed Coroutine mode: true on +OK. Callback mode: null. + */ + public function bgSave($schedule = false, $cb = null) + { + if (\is_callable($schedule)) { + $cb = $schedule; + $schedule = false; + } + return $this->queueCommand($schedule ? ['BGSAVE', 'SCHEDULE'] : ['BGSAVE'], $cb); + } + + /** + * MODULE — module-management subcommand family. + * + * Wire form: `MODULE [args...]`. Typical verbs are LIST, LOAD, + * UNLOAD. On Dragonfly modules are statically linked: LIST reports + * loaded modules (ReJSON, search, …) but LOAD / UNLOAD return errors. + * + * A trailing callable is taken as the callback by the dispatcher. + * + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed + */ + public function module(...$args) + { + return $this->dispatcher('MODULE ', $args); + } + + /** + * MODULE LIST — return the currently loaded modules. + * + * Reply shape on Dragonfly: a flat array of `[name, , ver, + * , name, , ver, , ...]` — pairs of name/value + * metadata per module. The format-callback layer doesn't reshape this; + * callers can walk it as-is or use a helper. + * + * @param callable|null $cb function(array $reply, Client $client): void + * @return mixed Coroutine mode: module list. Callback mode: null. + */ + public function moduleList($cb = null) + { + return $this->module('LIST', $cb); + } + + /* + |-------------------------------------------------------------------------- + | RedisSearch (FT) module — supported by Dragonfly + |-------------------------------------------------------------------------- + | + | Dragonfly ships a search module that implements the RedisSearch + | `FT.*` command surface (see MODULE LIST for the version). Same dotted + | dispatch pattern as JSON.* / BF.* / CMS.* / TOPK.*: the dispatcher + | glues the verb onto `FT.` to form a single Redis token, e.g. + | `FT.CREATE`, `FT.SEARCH`, `FT.AGGREGATE`. + | + | The ft(...$args) dispatcher accepts arbitrary verbs (including the + | underscore-prefixed `_LIST` administrative variant). The shortcut + | wrappers cover the most-common surface for IDE autocomplete. + */ + + /** + * FT.* — RedisSearch module dispatcher. + * + * Wire form: `FT. [args...]`. The first positional arg is the + * verb (uppercased and glued onto the `FT.` prefix); a trailing + * callable is taken as the callback. A trailing null is treated as + * "no callback" so the typed shortcuts can forward their `$cb = null` + * defaults uniformly. + * + * Note: FT._LIST has a leading underscore that strtoupper() preserves + * intact, so ftList() can go through this dispatcher path. + * + * @param mixed ...$args [verb, ...args, optional callable or trailing null] + * @return mixed + */ + public function ft(...$args) + { + if (!empty($args) && \end($args) === null) { + \array_pop($args); + } + return $this->dispatcher('FT.', $args); + } + + /** + * FT.CREATE — define a new index over hash or JSON documents. + * + * Wire form is highly variadic — typical usage is + * `FT.CREATE idx ON HASH PREFIX 1 doc: SCHEMA name TEXT score NUMERIC`. + * All args after $index are forwarded verbatim; a trailing callable is + * popped and treated as the callback. + * + * @param string $index + * @param mixed ...$args index-definition tokens, optional trailing callable. + * @return mixed + */ + public function ftCreate($index, ...$args) + { + $cb = null; + if (!empty($args) && \is_callable(\end($args))) { + $cb = \array_pop($args); + } + $wire = ['CREATE', $index, ...$args, $cb]; + return $this->ft(...$wire); + } + + /** + * FT.SEARCH — query an index. + * + * Reply shape: `[total, doc1Key, [doc1Field, doc1Value, ...], doc2Key, + * [doc2Field, doc2Value, ...], ...]` for HASH indexes. The flat shape + * makes incremental decoding trivial but callers usually want to walk + * the result in steps of 2 (key + flat-field-array). + * + * Optional tokens (LIMIT offset count, RETURN n field..., NOCONTENT, + * SORTBY, etc.) flow through $optionsAndCb verbatim. A trailing + * callable is popped and treated as the callback. + * + * @param string $index + * @param string $query + * @param mixed ...$optionsAndCb FT.SEARCH option tokens, optional trailing callable. + * @return mixed + */ + public function ftSearch($index, $query, ...$optionsAndCb) + { + $cb = null; + if (!empty($optionsAndCb) && \is_callable(\end($optionsAndCb))) { + $cb = \array_pop($optionsAndCb); + } + $wire = ['SEARCH', $index, $query, ...$optionsAndCb, $cb]; + return $this->ft(...$wire); + } + + /** + * FT.AGGREGATE — run an aggregation pipeline over an index. + * + * Wire form: `FT.AGGREGATE idx query [GROUPBY ...] [REDUCE ...] [SORTBY + * ...] [LIMIT ...]`. Reply shape is roughly `[count, [field, value, + * ...], [field, value, ...], ...]`. + * + * @param string $index + * @param string $query + * @param mixed ...$optionsAndCb Pipeline tokens, optional trailing callable. + * @return mixed + */ + public function ftAggregate($index, $query, ...$optionsAndCb) + { + $cb = null; + if (!empty($optionsAndCb) && \is_callable(\end($optionsAndCb))) { + $cb = \array_pop($optionsAndCb); + } + $wire = ['AGGREGATE', $index, $query, ...$optionsAndCb, $cb]; + return $this->ft(...$wire); + } + + /** + * FT.DROPINDEX — delete an index. + * + * Pass `$deleteDocs = true` to also remove the indexed documents (the + * `DD` flag). A callable in the first slot folds in as the callback + * with the documents preserved. + * + * @param string $index + * @param bool|callable $deleteDocs true to add `DD`, or the callback. + * @param callable|null $cb function($reply, Client $client): void + * @return mixed + */ + public function ftDropIndex($index, $deleteDocs = false, $cb = null) + { + if (\is_callable($deleteDocs)) { + $cb = $deleteDocs; + $deleteDocs = false; + } + return $deleteDocs + ? $this->ft('DROPINDEX', $index, 'DD', $cb) + : $this->ft('DROPINDEX', $index, $cb); + } + + /** + * FT.INFO — return metadata about an index as a flat + * `[key, value, key, value, ...]` array. + * + * @param string $index + * @param callable|null $cb + * @return mixed + */ + public function ftInfo($index, $cb = null) + { + return $this->ft('INFO', $index, $cb); + } + + /** + * FT._LIST — list the names of every defined index. + * + * The administrative verb has a leading underscore that survives + * strtoupper() unchanged, so dispatcher() forwards it intact. + * + * @param callable|null $cb function(array $reply, Client $client): void + * @return mixed Coroutine mode: list of index names. Callback mode: null. + */ + public function ftList($cb = null) + { + return $this->ft('_LIST', $cb); + } + + /** + * FT.ALTER — add a field to an existing index. + * + * Wire form: `FT.ALTER idx SCHEMA ADD field type [options...]`. All + * args after $index pass through verbatim; a trailing callable is + * popped and treated as the callback. + * + * @param string $index + * @param mixed ...$args ALTER tokens, optional trailing callable. + * @return mixed + */ + public function ftAlter($index, ...$args) + { + $cb = null; + if (!empty($args) && \is_callable(\end($args))) { + $cb = \array_pop($args); + } + $wire = ['ALTER', $index, ...$args, $cb]; + return $this->ft(...$wire); + } + + /** + * FT.CONFIG — module-wide configuration subcommand. + * + * Wire form: `FT.CONFIG