diff --git a/.codacy.yml b/.codacy.yml new file mode 100644 index 0000000..3cd905a --- /dev/null +++ b/.codacy.yml @@ -0,0 +1,9 @@ +--- +# Exclude the test suite from Codacy static analysis. Test code follows +# different conventions from production code on purpose — snake_case +# `test_*` method names (PHPUnit), long inline fixtures, reflection seams, +# and `runInWorker()` heredoc snippets — so production style/complexity +# rules produce noise rather than signal here. Coverage is still reported +# (the Codacy coverage check is unaffected by exclude_paths). +exclude_paths: + - 'tests/**' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f9ccd7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,186 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + workflow_dispatch: {} + +permissions: + contents: read + +# Force any JS action still declaring `using: node20` (e.g. the artifact, +# codecov, setup-php, codacy actions) to run on Node 24, silencing the +# "Node.js 20 actions are deprecated" runner warnings. checkout/cache are +# additionally bumped to their Node-24-native majors below. +env: + FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true + +jobs: + test: + name: PHPUnit + PHPStan (PHP ${{ matrix.php }} / ${{ matrix.backend }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + # 7.2/7.3/7.4 prove the advertised `php: ">=7.2"` floor: there + # composer resolves PHPUnit 8.5 (on 7.2) or 9.6 (on 7.3/7.4) + Workerman 4 + # (the dev deps that gate the floor — phpstan, revolt — are stripped in + # the install step). 8.1/8.2/8.3/8.5 resolve the newest of everything + # (PHPUnit 12, Workerman 5). + php: ['7.2', '7.3', '7.4', '8.1', '8.2', '8.3', '8.5'] + # Run the full suite against BOTH engines. The redis leg uses + # redis-stack-server so JSON/BF/CMS/TopK/FT module commands run there too. + backend: [dragonfly, redis] + env: + REDIS_URL: redis://127.0.0.1:6379 + REDIS_BACKEND: ${{ matrix.backend }} + # NOTE on per-leg engine selection: GitHub Actions `services:` can't be + # conditionally enabled per matrix value — listing both would start both and + # the two engines would clash on 6379. So we deliberately do NOT use the + # `services:` key and instead start exactly ONE engine container per leg in a + # step gated on `matrix.backend`, both bound to 127.0.0.1:6379. Only one leg + # runs per matrix combination, so the shared host port never clashes, and + # each leg provably talks to the right engine on redis://127.0.0.1:6379. + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Start Dragonfly + if: matrix.backend == 'dragonfly' + run: | + docker run -d --name redis-engine \ + -p 6379:6379 \ + --ulimit memlock=-1 \ + docker.dragonflydb.io/dragonflydb/dragonfly + + - name: Start Redis (redis-stack-server, with modules) + if: matrix.backend == 'redis' + run: | + # redis-stack-server ships RedisJSON, RedisBloom (incl. CMS/TopK) and + # RediSearch on 6379 (RedisInsight on 8001 is irrelevant here). + docker run -d --name redis-engine \ + -p 6379:6379 \ + --health-cmd "redis-cli ping" \ + --health-interval 5s \ + --health-timeout 3s \ + --health-retries 20 \ + redis/redis-stack-server:latest + + - name: Wait for the engine to accept connections + run: | + for i in $(seq 1 60); do + if (exec 3<>/dev/tcp/127.0.0.1/6379) 2>/dev/null; then + echo "engine is up on 127.0.0.1:6379"; exit 0 + fi + echo "waiting for engine on 6379 ($i)..."; sleep 2 + done + echo "engine did not come up on 6379" >&2 + docker logs redis-engine || true + exit 1 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: pcntl, posix, sockets, json + # Coverage instrumentation is only needed on the single coverage leg. + coverage: ${{ (matrix.php == '8.3' && matrix.backend == 'dragonfly') && 'pcov' || 'none' }} + tools: composer:v2 + + - 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@v5 + 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 + # No composer.lock in the repo (it's a library; legs need different + # resolutions), so always `composer update`. On PHP < 8.1 first strip + # the dev deps that gate the floor — phpstan/phpstan needs >=7.4 and + # revolt/event-loop needs >=8.1 — so Composer's platform check can + # auto-select PHPUnit 8.5 (PHP 7.2) or 9.6 (PHP 7.3/7.4) + Workerman 4. + # No version is hard-pinned. + run: | + case "${{ matrix.php }}" in + 7.*) + composer remove --dev --no-update phpstan/phpstan revolt/event-loop + ;; + esac + composer update --prefer-dist --no-progress --no-interaction + + - name: Run PHPStan + # PHPStan 2 won't install on 7.x (and only needs to run once); gate it + # to 8.x. It was stripped from the dev set on the 7.x legs above. + if: ${{ !startsWith(matrix.php, '7.') }} + run: composer analyze + + # Test execution. Coverage runs on exactly ONE leg (php=8.3 && + # backend=dragonfly) via the merge pipeline, which captures Client.php + # (subprocess coverage). The 7.x legs use the legacy config + # (phpunit9.xml.dist: testsuites + env only, no /cacheDirectory, + # so it validates under PHPUnit 8.5 and 9.x alike); the 8.x non-coverage + # legs use the default config (phpunit.xml.dist, PHPUnit 10+). Coroutine + # tests self-skip below 8.1 via coroutineSupported(), so no CI exclusion. + - name: Run PHPUnit (PHP 7.x, legacy config) + if: ${{ startsWith(matrix.php, '7.') }} + run: vendor/bin/phpunit -c phpunit9.xml.dist --colors=never + + - name: Run PHPUnit (PHP 8.x, non-coverage legs) + if: ${{ !startsWith(matrix.php, '7.') && !(matrix.php == '8.3' && matrix.backend == 'dragonfly') }} + run: vendor/bin/phpunit --colors=never + + - name: Run PHPUnit with merged coverage (canonical floor gate) + if: ${{ matrix.php == '8.3' && matrix.backend == 'dragonfly' }} + # composer test:coverage runs bin/run-coverage.sh -> merge-coverage.php + # --min=, which produces coverage.xml AND fails the build if the + # merged total line coverage drops below the floor (currently 90). + run: composer test:coverage + + - name: Upload coverage artifact + if: ${{ matrix.php == '8.3' && matrix.backend == 'dragonfly' }} + 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' && matrix.backend == 'dragonfly' }} + uses: codecov/codecov-action@v4 + with: + files: ./coverage.xml + flags: phpunit + fail_ci_if_error: false + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + - name: Stop the engine + if: always() + run: docker rm -f redis-engine || true + + codacy-coverage-reporter: + name: codacy-coverage-reporter + runs-on: ubuntu-latest + needs: test + steps: + - uses: actions/checkout@v6 + + - 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: + api-token: ${{ secrets.CODACY_API_TOKEN }} + coverage-reports: coverage.xml diff --git a/.gitignore b/.gitignore index f3f9e18..f04b029 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,19 @@ 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/ +/build/ +.caliber/ +async_plan.md +redis_start_prompt.md +tests/Support/*.log +tests/Support/*.pid diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c75df66 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,594 @@ +# Changelog + +All notable changes to this fork of [`workerman/redis`](https://github.com/workerman-php/redis) +are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and the project aims to follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +This fork (`detain/redis`) diverged from upstream at the `Update Redis.php` +commit (`49627c1`). Everything below is **new in the fork** — upstream changes +(SSL support, Workerman v5 support, the reconnect/auth-db fix) predate the fork +point and are not repeated here. + +The headline of the fork is a **complete, typed, Dragonfly-targeted command +surface**: every command [Dragonfly](https://www.dragonflydb.io/) fully or +partially supports now has an `@method` declaration for IDE/PHPStan, an +integration test, and — wherever the generic `__call()` route was broken — a +real explicit implementation. Both execution modes (callback and Revolt +coroutine) are supported throughout. + +--- + +## [Unreleased] — Dragonfly-complete command surface + +### Summary + +| Area | What changed | +|------|--------------| +| **Protocol** | RESP decoder rewritten to parse arbitrarily nested arrays (was flat-only); depth-bounded against stack exhaustion. | +| **SCAN family** | `scan`/`hScan`/`sScan`/`zScan` were throwing stubs — now fully implemented, each with a loop-driving `*All()` iterator helper. | +| **Broken `__call()` paths fixed** | No-arg-plus-callback commands (`ping`, `info`, `quit`, …), underscore verbs (`SORT_RO`, `EVAL_RO`, …), dotted module verbs (`JSON.*`, `BF.*`, …), and `rawCommand` all got explicit methods that route correctly. | +| **New command coverage** | ~140 commands documented/implemented across Strings, Keys, Hashes, Lists, Sets, Sorted Sets, Streams, Pub/Sub, Bitmap, Geo, Scripting, Server admin, and the JSON / Bloom / CMS / TopK / RediSearch modules. | +| **Pub/Sub** | Sharded pub/sub (`sPublish`/`sSubscribe`), the full `unsubscribe`/`pUnsubscribe`/`sUnsubscribe` teardown family, and `monitor()` streaming. | +| **Tooling** | PHPUnit test harness (unit + subprocess-based integration), PHPStan with baseline, GitHub Actions CI on PHP 7.2/7.3/7.4/8.1/8.2/8.3/8.5 × {Dragonfly, Redis}, Codecov + Codacy coverage. | +| **Test/coverage build-out** | Suite runs against **both engines** — **430 tests** (145 unit + 285 feature), Dragonfly 3-skipped / Redis 0-skipped — via Workerman subprocess-coverage merge; **~93% merged line coverage** (`Client.php` 92.44%, `Protocols/Redis.php` 100%) behind a CI-enforced coverage floor of **90**. | +| **Requirements** | `require` stays `php: >=7.2` and `workerman: ^4.1.0 \|\| ^5.0.0` (== upstream); the PHP 7.2/7.3/7.4 CI legs continuously prove the `>=7.2` floor. | + +--- + +### Changed — Test framework (Pest → PHPUnit) + PHP 7.x CI floor + +Replaced Pest with PHPUnit and added PHP 7.2/7.3/7.4 CI legs that actually run +the converted suite, so the advertised `php: ">=7.2"` floor is continuously +proven. No loss of tests or coverage. + +- **Pest → PHPUnit.** All 43 test files (8 unit, 35 feature) converted to + global-namespace `final` classes; `it()` closures became `test_*` methods and + every Pest matcher was mapped to its PHPUnit assertion (operand order flipped). + Per-file reflection helpers and the `runInWorker()` subprocess heredoc bodies + were preserved verbatim. **430 tests / ~1150 assertions**, no silent drops; + merged line coverage holds at **93.09%** (floor 90). Pest, `tests/Pest.php`, + and the unused `mockery/mockery` dev dep were removed; the global test helpers + moved to `tests/helpers.php`. +- **Cross-version dev tooling (ranges, not pins).** `require-dev` now declares + `phpunit/phpunit: "^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.0"`. Each PHP version + resolves a compatible runner: **7.2 → PHPUnit 8.5**, **7.3/7.4 → 9.6**, + **8.1+ → 10–12**. On the 7.x legs CI strips `phpstan/phpstan` (needs ≥7.4) and + `revolt/event-loop` (needs ≥8.1) before `composer update`, so Workerman + resolves to v4 there. A second config `phpunit9.xml.dist` (testsuites + env + only, no ``) is used by the 7.x legs and validates under PHPUnit + 8.5 and 9 alike. +- **PHP 7.2 test-suite compatibility.** Downconverted 17 arrow functions + (`fn () =>`, PHP 7.4+) to closures; rewrote the `ProtocolTest` + `ConnectionInterface` stub to 7.1-safe signatures (`mixed`/`bool|null` → + untyped params / `?bool`) that stay LSP-compatible with both Workerman v4 + (untyped) and v5 (typed); and routed the `skipOnBackend()`/`skipTest()` + helpers through `Assert::markTestSkipped()` (the PHPUnit-10+ + `SkippedWithMessageException` class does not exist in PHPUnit 8.5/9). +- **CI matrix** is now `{7.2, 7.3, 7.4, 8.1, 8.2, 8.3, 8.5} × {Dragonfly, + Redis}`. PHPStan runs on the 8.x legs only; coverage + the floor gate run on + exactly one leg (`8.3 + Dragonfly`). `composer.lock` is not committed (it's a + library; the legs need different resolutions), so CI uses `composer update`. + Coroutine-mode tests self-skip below PHP 8.1 via the `coroutineSupported()` + guard. Pinned actions bumped (`checkout@v6`, `cache@v5`) and + `FORCE_JAVASCRIPT_ACTIONS_TO_NODE24` set to clear the Node 20 deprecation + warnings. + +--- + +### Added — Commands by family + +#### Strings +`getDel`, `getEx`, `substr` documented (`@method` + tests). All routed through +`__call()` already; the declarations expose them to IDE autocomplete and PHPStan. + +#### Keys +- `copy`, `touch`, `expireTime`, `pExpireTime` documented. +- **`scan` / `scanAll`** — see *SCAN family* below. + +#### Hashes +- `hRandField` documented. +- **`hScan` / `hScanAll`** — see *SCAN family*. +- **HEXPIRE family** documented (`@method` only, via `__call`): `hExpire`, + `hPersist`, `hExpireAt`, `hTtl`, `hExpireTime`, `hPExpire`, `hPExpireAt`, + `hPTtl`, `hPExpireTime`. *(Dragonfly: partial — currently supports + `HEXPIRE`/`HTTL`; the tests accept either a real per-field integer-array reply + or an `-ERR unknown command`, so they start asserting real values + automatically as Dragonfly catches up.)* + +#### Lists +`lMove`, `lMPop`, `lPos`, `blMove`, `blMPop` documented. + +#### Sets +- `sMIsMember`, `sInterCard` documented. +- **`sScan` / `sScanAll`** — see *SCAN family*. + +#### Sorted Sets +`zRandMember`, `zMScore`, `zDiff`, `zDiffStore`, `zInter`, `zInterCard`, +`zUnion`, `zRangeStore`, `zMPop`, `bzMPop`, `zRevRangeByLex`, `zRemRangeByLex`, +`zLexCount` documented. Plus **`zScan` / `zScanAll`** (see *SCAN family*). + +#### Streams +- `xAutoClaim`, `xSetId` documented. +- **`xAdd()` — new explicit method (encoder fix).** The RESP encoder flattens a + nested array argument by emitting its *values only*, so a + `['field' => 'value']` message passed to `XADD` through `__call()` lost the + field names and the server rejected it. `xAdd($key, $id, $message, $maxLen = 0, + $approximate = false, $cb = null)` flattens the message itself so the natural + field-map shape works. Signature mirrors phpredis; `MAXLEN [~] n` is emitted + before the id; an empty message throws `InvalidArgumentException`. + +#### Bitmap +- `bitOp`, `bitPos`, `bitField` documented (route cleanly through `__call`). +- **`bitFieldRo`** — explicit underscore-bridge method (wire verb + `BITFIELD_RO`; `__call`'s `strtoupper` would have produced `BITFIELDRO`). + +#### Geo +- `geoSearch` documented. +- **`geoRadiusRo`, `geoRadiusByMemberRo`** — explicit underscore-bridge methods + (`GEORADIUS_RO`, `GEORADIUSBYMEMBER_RO`). + +#### Scripting +- **`evalRo`, `evalShaRo`** — explicit underscore-bridge methods (`EVAL_RO`, + `EVALSHA_RO`). + +#### Pub/Sub +- **`sPublish`** documented; **`sSubscribe()`** explicit (mirrors + `pSubscribe()`; `process()` now flips the subscribe-lock on `SSUBSCRIBE` too). +- **`unsubscribe()` / `pUnsubscribe()` / `sUnsubscribe()`** — explicit, with a + lock bypass. A subscribed connection refuses queued commands, so these write + the teardown frame straight to the socket via `$_connection->send()`, then a + new `handleUnsubscribeAck()` clears `$_subscribe`, drops the stale `SUBSCRIBE` + entry, and drains anything queued while locked. The optional trailing callback + fires `(true, $client)` once the connection is fully back in normal mode (held + until the *last* channel drops on a partial unsubscribe). Calling when not + subscribed is a no-op that still invokes the callback. + +#### Connection / server +- **`ping`, `info`, `dbSize`, `time`, `flushDb`, `flushAll`** — explicit, fixes + the no-arg-plus-callback bug (see *Fixed*). `flushDb`/`flushAll` take an + optional `ASYNC` boolean. +- **`quit`** — explicit, with don't-reconnect semantics (a new `$_quitting` flag + the `onClose` handler honours, skipping the 5s reconnect timer). +- `echo`, `hello` documented; `hello()` is explicit so `hello($cb)` folds the + closure into the `$cb` slot. + +#### Server administration +- **Subcommand dispatchers** (thin wrappers over `dispatcher()`): + `config()`, `acl()`, `slowLog()`, `memory()`, `command()`, `cluster()`. +- **Explicit lifecycle verbs**: `lastSave()`, `save()`, `role()`, + `bgSave($schedule = false, …)`, `digest()` *(Dragonfly extension)*, + `shutdown($mode = 'SAVE', …)` *(sets `$_quitting` so the socket teardown + doesn't trigger a reconnect)*. +- **`monitor($cb)`** — streams every command the server processes. Long-lived + like `subscribe()`, but with its own `$_monitoring` lock (there is no + `UNMONITOR`; stop it by `close()`ing the client). The opening `+OK` handshake + is swallowed; each later call is one raw monitor line. A re-entry guard ignores + `monitor()` on an already-streaming connection. +- `replicaOf`, `slaveOf`, `debug`, `delEx` *(Dragonfly extension)* documented. + +#### JSON module (RedisJSON-compatible, native in Dragonfly) +- **`json(...$args)`** dispatcher (`JSON.` prefix). +- **16 typed shortcuts**: `jsonSet`, `jsonMSet`, `jsonMerge`, `jsonGet`, + `jsonMGet`, `jsonType`, `jsonObjKeys`, `jsonObjLen`, `jsonArrLen`, + `jsonStrLen`, `jsonDel`, `jsonForget`, `jsonArrAppend`, `jsonNumIncrBy`, + `jsonStrAppend`, `jsonToggle`. + +#### Bloom Filter / Count-Min Sketch / TopK modules (RedisBloom-compatible) +- **Dispatchers**: `bf()` (`BF.`), `cms()` (`CMS.`), `topk()` (`TOPK.`). +- **Bloom Filter (5)**: `bfReserve`, `bfAdd`, `bfExists`, `bfMAdd`, `bfMExists`. +- **Count-Min Sketch (6)**: `cmsInitByDim`, `cmsInitByProb`, `cmsIncrBy`, + `cmsQuery`, `cmsMerge` (optional `WEIGHTS` clause), `cmsInfo`. +- **TopK (7)**: `topkReserve`, `topkAdd`, `topkIncrBy`, `topkQuery`, + `topkCount`, `topkList`, `topkInfo`. + +#### RediSearch / FT module (preloaded in Dragonfly) +- **`ft(...$args)`** dispatcher (`FT.` prefix) + **11 typed shortcuts**: + `ftCreate`, `ftSearch`, `ftAggregate`, `ftDropIndex` (optional `DD`), + `ftInfo`, `ftList` (`FT._LIST`), `ftAlter`, `ftConfig`, `ftTagVals`, + `ftSynDump`, `ftSynUpdate`, `ftProfile`. + +#### Modules introspection +- **`module(...$args)`** dispatcher + **`moduleList()`** (`MODULE LIST`). + `MODULE LOAD` is wired but docs-only — Dragonfly's modules are static. + +#### Read-only / underscore-verb bridges +- **`sortRo()`** — explicit, emits `SORT_RO` (matches the `bitFieldRo` / + `geoRadiusRo` / `evalRo` pattern). Mirrors `sort()`'s option grammar. +- **`rawCommand(...$args)`** — explicit escape hatch (see *Fixed*). + +### Added — SCAN family (was throwing stubs) + +`scan`, `hScan`, `sScan`, `zScan` previously `throw new Exception('Not +implemented')`. All four are now real, each with a loop-driving `*All()` +iterator helper that supports both callback and Revolt coroutine modes: + +| Single-call | Iterator | Reply reshaped to | Notes | +|-------------|----------|-------------------|-------| +| `scan($cursor, $opts, $cb)` | `scanAll($opts, $cb)` | `['cursor' => …, 'keys' => […]]` | may yield duplicate key *names* across the keyspace (documented caller responsibility) | +| `hScan($key, $cursor, $opts, $cb)` | `hScanAll($key, $opts, $cb)` | `['cursor' => …, 'fields' => assoc]` | duplicate fields overwrite (unique by definition) | +| `sScan($key, $cursor, $opts, $cb)` | `sScanAll($key, $opts, $cb)` | `['cursor' => …, 'members' => […]]` | `sScanAll` dedupes via a string-keyed map (defeats PHP numeric-string coercion) | +| `zScan($key, $cursor, $opts, $cb)` | `zScanAll($key, $opts, $cb)` | `['cursor' => …, 'members' => member=>score]` | **scores kept as raw strings** to preserve precision | + +- Options (`MATCH`, `COUNT`, `TYPE` for `scan`) are **case-insensitive**; + unknown keys are silently ignored. +- `*All()` accepts a `'limit'` option (default `100000`) so a growing keyspace + can't loop forever. +- On a Redis-side error the callback receives `false` (matches the client's + error convention). + +### Added — Tooling & infrastructure + +- **Pest test harness** with separate Unit and Feature suites. + - Unit: `ProtocolTest` (RESP encode/decode round-trips, no server needed), + `MethodSurfaceTest` (reflection guards for methods that can't run live — + `shutdown`, `monitor`, the unsubscribe family). + - Feature: a **subprocess-based integration harness** — `runInWorker($snippet)` + `proc_open`s a short-lived PHP child running the snippet inside a Workerman + worker with `$redis`, `$emit($value)`, `$fail($msg)` in scope, returning the + result over fd 3 (stdout carries Workerman's boot banner). Tests skip + cleanly when no Redis/Dragonfly is reachable at `REDIS_URL` + (default `redis://127.0.0.1:6379`). **198 tests / 620 assertions**, all + passing against a live Dragonfly. +- **PHPStan** at level 5 with a baseline (`phpstan-baseline.neon`) snapshotting + pre-existing legacy typing issues so new commits can't regress past that line. + The baseline shrank from 44 → 9 entries as the refactors fixed typing nits. +- **GitHub Actions CI** (`.github/workflows/ci.yml`): Pest + PHPStan on PHP 8.1, + 8.2, 8.3 against a live Dragonfly (installed via APT / Docker image), with + Composer caching, Codecov upload (8.3 leg only), and a separate Codacy + coverage-reporter job. +- **`composer.json`**: description/keywords/authors filled in; + `require-dev` (Pest, Mockery, PHPStan); `suggest` revolt/event-loop; + `Tests\\` autoload-dev; `analyze` / `test` / `test:coverage` scripts. +- **README**: badges (CI, Codecov, Codacy grade + coverage, Packagist, license, + PHP version), coverage-graph visualizations, and usage sections for every new + surface. + +#### Test infrastructure — subprocess coverage merge + dual-backend + +- **Subprocess coverage merge.** Feature tests execute each assertion inside a + `proc_open`ed Workerman worker child, so pcov in the parent PHPUnit process + never instrumented `src/Client.php` — it reported a false **0.0%**. The worker + (`tests/Support/run-in-worker.php`) now collects coverage *inside the child* + (gated on a `COVERAGE_DIR` env) and dumps a unique `cov-.cov`; + `bin/merge-coverage.php` merges every `.cov` (Feature children + the in-process + Unit `unit.cov`) into `coverage.xml` (Clover) plus a text summary, and + `bin/run-coverage.sh` orchestrates the whole run. `composer test:coverage` now + runs `sh bin/run-coverage.sh`. With the merge in place `Client.php` shows its + real **~66.5%** (was a misleading 0.0%); total line coverage is **~68.6%** + (up from a reported 7.6%). +- **Dual-backend testing (CI + local).** The full suite now runs against **both + Dragonfly and Redis**. CI (`.github/workflows/ci.yml`) gained a + `backend: [dragonfly, redis]` matrix axis crossed with `php: [8.1, 8.2, 8.3]` + (fail-fast off); each leg starts exactly one engine on `127.0.0.1:6379` — the + Dragonfly image, or `redis/redis-stack-server:latest` on the Redis leg so the + JSON/Bloom/CMS/TopK/FT modules are present. Coverage is collected on the single + `php=8.3 && backend=dragonfly` leg. Locally, `make test-dragonfly` / + `make test-redis` / `make test-all` / `make coverage` (plus `scripts/start-redis.sh` + and `scripts/start-dragonfly.sh`) drive Dragonfly on `:6379` and Redis on + `:63790`. +- **Coverage floor gate.** `bin/merge-coverage.php` accepts `--min=` / + `COVERAGE_MIN` and exits non-zero (code 3) below the floor. Initial floor is + **65** (set in `bin/run-coverage.sh`, overridable via `COVERAGE_MIN`), to be + ratcheted toward 95 in later groups. This is the canonical gate — CI fails below it. +- **Backend-aware skip helpers.** Free functions `currentBackend()` and + `skipOnBackend($backend, $reason)` in `tests/Pest.php` (and `runInWorker()` + forwarding `REDIS_BACKEND` to the child) let an engine-specific case skip *with + a logged reason* — every skip prints `[] `; no silent skips. + Current results: Dragonfly **201 passed / 0 skipped**; Redis **196 passed / + 5 skipped** (the 5 are the RediSearch FT family in `tests/Feature/FtSearchTest.php` + — see *Compatibility* in the README). + +#### Protocol coverage — `Protocols/Redis.php` to 100% + +- Added ~23 in-process unit tests (`tests/Unit/ProtocolTest.php`) for the RESP + codec, taking `src/Protocols/Redis.php` from ~90% to **100%** line + method + coverage. They cover the `input()`/`measure()` frame-length probe (every + branch, including the `MAX_DEPTH` sentinel and the null bulk/array fast paths), + incomplete-frame handling (returns 0 = "need more bytes"), and the + `decode()`/`decodeOne()` edge branches: binary-safe bulk strings with embedded + CRLF / null bytes, large multi-KB bulks, negative integers, the protocol-error + tuple for unknown/empty/no-CRLF input, and depth-exceeded propagation. All are + server-free (no backend required). Total merged line coverage rose to **69.48%** + and the coverage floor was ratcheted to **69**. + +#### Client pure-logic coverage — in-process unit tests + +- Added 34 in-process unit tests (`tests/Unit/ClientCommandShapingTest.php`, + `ClientScanAllTest.php`, `ClientUnsubscribeAckTest.php`) that drive + `src/Client.php`'s pure command-shaping and aggregation logic **without the + Workerman event loop or a live server** — using + `ReflectionClass::newInstanceWithoutConstructor()` so commands queue (rather + than send) and the queued wire arrays can be asserted. Covers: `__call` + trailing-callable popping (incl. the lone-callable footgun and the + `randomKey/multi/exec/discard` exception list), `dispatcher` dot-glue vs + space-split, `rawCommand` verbatim + empty-args `\InvalidArgumentException`, + `select`/`auth` argument shaping, `error()`, the `scanAll`/`hScanAll`/`sScanAll`/ + `zScanAll` callback-mode aggregation (cursor termination, multi-page + accumulation, LIMIT cap, error abort, MATCH/COUNT/TYPE forwarding, set dedup, + zScanAll score-string precision), and `handleUnsubscribeAck` lock bookkeeping. + `Client.php` merged coverage **66.5% → 68.5%**; total merged **69.5% → 71.3%**; + coverage floor ratcheted to **70**. (The Revolt coroutine-mode branches of + `*ScanAll`/`suspenstion()` remain for the Revolt group.) + +#### Connection / lifecycle coverage — Feature tests + +- Added `tests/Feature/ConnectionLifecycleTest.php` (7 cases) covering connection + verbs not exercised elsewhere: `auth` no-password error path, `auth` not + poisoning `_auth` after a rejected credential, `select` to a valid DB (tracks + `_db`) and to an out-of-range index (error reply, no state advance), + `closeConnection()` / `close()` teardown (connection nulled, queue emptied), + and a `hello(2, …)` handshake that pins the reply map (`server`/`proto`/`role`/ + `version`) rather than just asserting array shape. The `auth` cases gate on the + **observed reply** (skip when the server accepts AUTH with no password set, as + Dragonfly does) so the file is correct under any invocation. Added a `skipTest()` + free helper in `tests/Pest.php` for behaviour-gated (non-backend-name) skips. + `Client.php` merged **68.5% → 70.4%**; total merged **71.3% → 73.0%**. + +#### Data-type command sweep — Feature tests + +- Added 57 Feature cases across `tests/Feature/KeyspaceCommandsTest.php` (13), + `StringsCountersTest.php` (13), `ListSetZsetExtraTest.php` (19) and + `HashStreamExtraTest.php` (13), covering the classic data-type and keyspace + verbs not already exercised by `ModernCommandsTest`/`StringsKeysExtraTest`/the + SCAN-family tests: + - **Keyspace:** `type`, `rename`/`renameNx`, `persist`, `expire`/`pExpire`, + `exists` (multi + repeat), `unlink`, `keys`, `randomKey`, `dump`+`restore` + (binary cross-key round-trip), `object` ENCODING/REFCOUNT, `move`. + - **Strings/counters:** `append`, `strLen`, `setRange`/`getRange`, `getSet`, + `incrBy`/`decrBy`/`incrByFloat`, `setEx`/`pSetEx`/`setNx`, `setBit`/`getBit`, + `mSet`/`mGet`, `mSetNx`. + - **Lists/sets/zsets:** the classic `lPush`…`lTrim`/`rPopLPush`, + `sAdd`…`sDiffStore`, `zAdd`…`zPopMin`/`zPopMax` families. + - **Hashes/streams:** `hSet`…`hStrLen`, `xAdd`/`xLen`/`xRange`/`xRevRange`/ + `xRead`/`xDel`/`xTrim`. + Assertions pin real values (counts, members, scores-as-strings, `hGetAll`/ + `hMGet` maps, dump→restore value equality). One backend-gated skip + (`OBJECT` is unknown on Dragonfly; the test runs on Redis). `Client.php` merged + **70.4% → 72.95%**; total merged **73.0% → 75.34%**. + +#### Module command coverage + FT un-gating — Feature tests + +- Added `tests/Feature/FtModuleTest.php` (5 cases) for the six RediSearch verbs + not previously asserted: `ftAlter`, `ftConfig`, `ftTagVals`, `ftSynUpdate` + + `ftSynDump` (synonym round-trip), and `ftProfile` (asserts the embedded search + result). The JSON/Bloom/CMS/TopK families were already fully covered at the + shortcut level (`JsonTest`/`BloomFilterTest`/`CmsTest`/`TopkTest`) — not + duplicated. +- **Removed the 5 stale `skipOnBackend('redis', …)` gates in + `tests/Feature/FtSearchTest.php`.** They were defending against an + FT.SEARCH `SEARCH_INDEX_NOT_FOUND` divergence on an earlier Redis build that no + longer reproduces on Redis 8.8 + RediSearch 80800 — verified that FT.CREATE / + SEARCH / AGGREGATE / INFO / CONFIG all work, and confirmed stable across three + consecutive `make test-redis` runs. The FT family is now exercised on **both** + engines, and the **Redis leg has zero skips** (was 5). + `Client.php` merged **72.95% → 75.26%**; total merged **75.34% → 77.45%**. + +#### Pub/Sub delivery coverage — Feature tests + +- Added `tests/Feature/PubSubDeliveryTest.php` (8 cases) for the plain pub/sub + delivery paths not previously covered (the existing tests covered the *sharded* + family, unsubscribe lock-clearing, and monitor): `subscribe`→`publish` message + delivery (channel + payload pinned), `pSubscribe` pattern delivery (pattern + + channel + payload), `publish` receiver count (`0` with no subscriber, `≥1` + with one), `pubSub('NUMSUB', …)` and `pubSub('NUMPAT')` introspection, + multi-channel `subscribe([...])` delivery, and a negative test proving a message + is NOT delivered after `unsubscribe`. Every streaming test is bounded — the 2nd + client publishes from a `Timer` only after the subscribe ack, the message + callback `$emit`s once, and a non-recurring `Timer` `$fail`s before the harness + timeout — verified flake-free across 5 consecutive runs per engine. + `Client.php` merged **75.26% → 75.79%**; total merged **77.45% → 77.93%**. + +#### Command-surface completeness + error-path coverage — Feature tests + +- Added `tests/Feature/SurfaceCompletenessTest.php` (16 cases) covering + `@method`-declared verbs with no prior assertion — `bitCount`, + `blPop`/`brPop`/`bRPopLPush` and `bzPopMax`/`bzPopMin` (exercised on + pre-populated keys so the blocking path returns immediately, never hangs), + `zRangeByLex`/`zRevRangeByScore`/`zRemRangeByRank`/`zRemRangeByScore`/ + `zinterstore`/`zunionstore`, the HyperLogLog `pfAdd`/`pfCount`/`pfMerge`, the geo + `geoDist`/`geoHash`/`geoPos`, `watch`/`unwatch`, and the stream consumer-group + `xAck`/`xClaim`/`xInfo`/`xPending`. Assertions pin real values (bit counts, + popped members, cardinalities, distances, geohashes, pending/acked counts). +- Added `tests/Feature/ErrorRepliesTest.php` (6 cases) asserting the client's + error-delivery contract end-to-end: WRONGTYPE, unknown command, wrong arg count, + value-not-integer, and syntax-error replies all arrive as `$reply === false` + with a non-empty `$client->error()` (keyword-checked, wording-tolerant across + engines), plus that `error()` resets to `''` after a subsequent successful + command. This covers the `onMessage` error branch and the `error()` getter. +- This group adds command-surface and error-contract assertion coverage rather + than new `Client.php` lines (the new verbs route through the already-covered + `queueCommand`→`encode`→`onMessage` path), so the merged number holds at + ~**77.9%**. The Redis leg stays at **zero skips**. + +#### Revolt coroutine-mode coverage — Feature tests + +- The client's coroutine path — when no callback is passed and `Revolt\EventLoop` + is loaded, `queueCommand()` suspends the current fiber and RETURNS the reply + synchronously (via `suspenstion()` + `onMessage` resume) — was previously + untested (every existing test runs callback mode). Added `revolt/event-loop` as + a dev dependency and `tests/Support/run-in-worker-coroutine.php`, a worker that + boots Workerman on its Revolt-backed `Workerman\Events\Fiber` driver so + `onWorkerStart` runs inside a fiber and callback-less commands return their + replies directly. Exposed via a new `runInCoroutineWorker()` helper in + `tests/Pest.php` (the shared proc_open logic is factored into + `runInWorkerScript()`; `runInWorker()` behaviour is unchanged). +- Added `tests/Feature/CoroutineModeTest.php` (4 cases): synchronous `set`/`get`/ + `incr`/`del` returns; `scanAll` returning the full key set synchronously; the + `hScanAll`/`sScanAll`/`zScanAll` coroutine aggregation loops; and the + `queueCommand` guard that throws when a coroutine-mode command is issued while + the connection is subscribe/monitor-locked (a fiber that could never resume). + This exercises the suspend/resume branch and all four coroutine `*ScanAll` + helpers. **Safety:** the existing callback worker references no Revolt/EventLoop + symbols, so `class_exists(EventLoop::class, false)` stays false there and the + callback suite is unaffected. `Client.php` merged **75.79% → 81.16%**; total + merged **77.93% → 82.82%**. + +#### Coverage close-out — remaining reachable branches + +- Added in-process unit + targeted feature tests covering the genuinely-reachable + `Client.php` branches the integration suite couldn't hit cheaply: + `tests/Unit/ClientShapingTier9Test.php` (34 cases — the `set`/`incr`/`decr` + second-form overloads → SETEX/INCRBY/DECRBY, `sort`/`sortRo` option flattening, + `xAdd` empty-message guard + MAXLEN shaping, dotted-dispatcher trailing-callable + pops, formatter early-returns, `shutdown` `_quitting` flag), + `tests/Unit/ClientSubscribeDispatchTest.php` (12 cases — the + subscribe/pSubscribe/sSubscribe message/pmessage/smessage forwarding arms, the + error-bail and unknown-type diagnostic arms, the unsubscribe-ack teardown, and + the second-stream `assertNoActiveStream` throw), and + `tests/Feature/ReconnectPrependTest.php` (1 case — the dead-port connection + failure reported through the connection callback). `Client.php` merged + **81.16% → 92.32%** (methods **65.85% → 91.06%**); total merged + **82.82% → 92.99%**, and the coverage floor was ratcheted to **90**. +- The residual ~7% of `Client.php` is genuinely impractical to cover without + socket fault injection — connection/socket failure paths, the `onClose` + immediate-vs-delayed auto-reconnect timing, `onMessage` exception re-throw + + reconnect-on-`!`, diagnostic `echo new Exception` sinks, and the *coroutine* + error/LIMIT-cap arms of the `*ScanAll` loops (whose logic is already proven in + callback mode). These are enumerated line-by-line in `docs/TEST_COVERAGE_PLAN.md` + under *Coverage close-out (Group 9)*. + +### Fixed + +- **Broken phpredis-compat `@method` stubs → real local accessors.** Eleven + `@method` declarations (`getHost`, `getPort`, `getDbNum`, `getAuth`, + `getTimeout`, `getReadTimeout`, `isConnected`, `getLastError`, + `clearLastError`, `getPersistentID`, and `getMultiple`) had no implementation + and no `__call` mapping, so calling them sent the uppercased verb (`GETHOST`, + `ISCONNECTED`, `GETMULTIPLE`, …) to the server, which both engines reject as + unknown commands. They are now **real public methods**: the accessors return + client state synchronously (`getHost`/`getPort` parse `_address`; + `getDbNum`/`getAuth` mirror `_db`/`_auth`; `getTimeout`/`getReadTimeout` read + the `connect_timeout`/`wait_timeout` options the client actually uses; + `isConnected` checks the connection status null-safely; `getLastError` returns + `null` when clean, `clearLastError` resets it; `getPersistentID` is `null` — + this async client has no persistent connections), and `getMultiple()` is a real + `MGET` alias routed through `queueCommand()` (works in both callback and + coroutine modes). Covered by `tests/Unit/ClientAccessorsTest.php` (20 cases) + and `tests/Feature/GetMultipleTest.php`. +- **Malformed `@method` PHPDoc (named param after a variadic).** `rawCommand`'s + `@method` read `(...$commandAndArgs, $cb = null)` — invalid, and it made PHPStan + see a 2-arg cap on a fully-variadic method. Fixed to `(...$commandAndArgs)`, and + the same drop-the-trailing-`$cb` fix was applied to every other `@method` line + with a parameter after the variadic. +- **Test-harness temp-file leak.** The Feature-test subprocess runners + (`tests/Support/run-in-worker.php` and `run-in-worker-coroutine.php`) left a + `wm-redis-test-*.{pid,log}` pair per invocation in the system temp dir — the + bottom-of-file cleanup never ran because Workerman exits first, so a full suite + run leaked tens of thousands of files. The pid/log files are now scoped to a + dedicated `wm-redis-tests/` subdir and removed via a `register_shutdown_function` + plus an in-handler unlink before each child `exit()`, with a start-of-run + containment sweep in `bin/run-coverage.sh`. A full run now leaves zero residue. + +- **Nested-array RESP replies.** The decoder (`src/Protocols/Redis.php`) was + flat-only and could not parse multi-bulk replies like SCAN's + `[cursor, [keys]]`. Rewritten into recursive `measure()` / `decodeOne()` + helpers that walk any RESP type at any depth, bounded by `MAX_DEPTH = 64` + (deeper replies surface as a protocol error rather than blowing PHP's stack). + Null bulks (`$-1`) and null arrays (`*-1`) now detect via + `$offset === strpos(...)` instead of `0 === strpos(...)`, so they decode + correctly when *nested* (the old form only matched at buffer offset 0, + breaking nested nils inside MGET-style replies). All flat-reply contracts + preserved. +- **No-arg-plus-callback commands silently broke.** `__call()` only extracts a + trailing callable when `count($args) > 1` (or for a tiny allowlist), so + `$redis->ping($cb)` shipped the closure to the server as a `PING` argument. + Fixed for `ping`, `info`, `dbSize`, `time`, `flushDb`, `flushAll`, `quit`, + `hello`, `lastSave`, `save`, `role`, `bgSave`, `digest`, `shutdown`, `monitor` + via explicit methods (rather than touching `__call()`, where + `is_callable('phpinfo')`-style false positives would corrupt single-string + commands). +- **`rawCommand` always failed.** It was `@method`-declared but unbacked, so it + fell through `__call()`, which uppercased the method *name* and prepended it — + `$redis->rawCommand('GET', 'k')` went on the wire as `['RAWCOMMAND','GET','k']` + and every server returned `-ERR unknown command 'RAWCOMMAND'`. Now an explicit + method forwards args verbatim and pops a trailing callable as the callback; + throws `InvalidArgumentException` when no command parts remain. +- **Underscore-verb commands unreachable via `__call()`.** `strtoupper` on a + camelCase name drops the underscore (`bitFieldRo` → `BITFIELDRO`). Bridged + with explicit methods: `bitFieldRo`, `geoRadiusRo`, `geoRadiusByMemberRo`, + `evalRo`, `evalShaRo`, `sortRo`. +- **Dotted module verbs uncallable in PHP.** `JSON.SET`, `BF.ADD`, etc. can't be + method names. Solved with the `json()`/`bf()`/`cms()`/`topk()`/`ft()` + dispatchers and typed shortcuts. +- **`xAdd` field names dropped** — see *Streams* above. +- **Wait-timeout timer permanently deleted while streaming.** The constructor's + timeout timer used to delete itself during a subscribe/monitor stream, so + after the stream ended (a monitor rejection, an unsubscribe) queued commands + lost their timeout guard for the life of the connection. It now *skips* while + streaming and resumes afterward. + +#### Async hardening (full-source review pass) + +- **Wait-timeout scan leaked its timer — the client could never be GC'd.** The + constructor's `Timer::add(1, …)` handle was never stored, so `close()` could + not delete it: the timer kept firing forever, and because its closure captures + `$this` the client object stayed pinned in memory (defeating the + `gc_collect_cycles()` in `close()`). In a worker that creates clients + dynamically this leaked one object + one timer per client. The handle is now + kept in the (previously unused) `$_waitTimeoutTimer` property and torn down in + `close()`. +- **Coroutine command on a subscribe/monitor-locked connection hung the fiber.** + In Revolt mode `queueCommand()` suspends the current fiber until the reply + arrives — but `process()` refuses to send anything while the connection is in + subscribe/monitor mode, so the reply (and the resume) could never come while + the lock held. That was a silent, unrecoverable hang. It now throws a clear + `Workerman\Redis\Exception` instead of suspending. +- **A second `subscribe()` / `pSubscribe()` / `sSubscribe()` was silently + dropped.** This client pins one stream entry at the head of the queue and + routes every message to that entry's callback; a second subscribe while one is + active or pending can't reach the wire (the lock) and its messages would go to + the first callback anyway. It now throws rather than failing silently. The + guard inspects both the live flags **and** the queue, so it also catches + back-to-back calls issued before the first frame is sent (when the flags are + still false). `monitor()` keeps its documented silent-ignore contract but + reuses the same active-or-pending detection. +- **`select()` / `auth()` cached state on a *failed* reply.** Their `format` + callbacks ran regardless of success, so a rejected `SELECT`/`AUTH` still + updated `$_db`/`$_auth` — which the next reconnect would then replay. They now + mutate only when the reply was not an error. +- **Wait-timeout false positives around blocking commands.** The scan only + exempted `BLPOP`/`BRPOP`, so a long-blocking `BRPOPLPUSH`/`BLMOVE`/`BLMPOP`/ + `BZPOPMIN`/`BZPOPMAX`/`BZMPOP` at the head could trip a reconnect, and commands + queued *behind* any blocker were failed with a spurious "Wait Timeout" despite + never having been sent. A `BLOCKING_COMMANDS` set now covers the full family, + and when the head is a blocking command the scan returns early — nothing behind + it is timed out. +- **Callback exceptions caught `\Exception`, not `\Throwable`.** An `\Error` + (e.g. `TypeError`) thrown from a user callback escaped `onMessage()` before + `process()` could pump the next command, wedging the queue. Widened to + `catch (\Throwable …)` (still re-thrown after the pump runs). + +### Changed + +- **Requirement: PHP `>=7` → `>=8.1`** (Pest 3+/4 needs it). +- **Internal refactor — `queueCommand()` + `dispatcher()` helpers.** Every + explicit command method previously repeated the same ~10-line block + (Revolt-suspension check, queue push, `process()`, conditional `suspend()`). + `queueCommand(array $args, $cb, $format)` collapses it to a single + `return` per method, and `__call()` routes through it too — so the + callback-vs-coroutine decision lives in exactly one place. + `dispatcher(string $prefix, array $args)` is the multi-verb / dotted-module + counterpart (`'CLUSTER '` → `['CLUSTER','INFO',…]`; `'JSON.'` → + `['JSON.SET',…]`). Net −76 lines despite adding both helpers. + +### Known limitations / partial support + +These are documented and dispatched, but Dragonfly may not implement every +option (the PHPDoc and tests note it): `SORT_RO`, `COPY`, the `HEXPIRE` family, +`CLIENT KILL` / `CLIENT TRACKING`, `BGSAVE`, several `ACL` write verbs, +`MODULE LOAD`, `BF.RESERVE`, and the `FT.*` search surface across the board. + +Commands Dragonfly does **not** support are intentionally **not** added (e.g. +`LCS`, `MIGRATE`, `OBJECT ENCODING`, `WAIT`, `FUNCTION`/`FCALL`, `SWAPDB`, +Cuckoo Filter, Graph, TimeSeries, T-Digest, cluster write ops). See +`async_plan.md` for the full inclusion/exclusion rationale. + +--- + +## Upstream baseline + +Everything prior to fork point `49627c1` is upstream `workerman/redis` +(latest tag `v2.0.5`), including SSL support, Workerman v5 support, and the +post-reconnect auth-db fix. See the upstream repository for its history. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c979c88 --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +# Dual-backend test orchestration for workerman/redis. +# +# The suite runs against two engines selected by the REDIS_URL + REDIS_BACKEND +# pair: Dragonfly on :6379 and a real Redis on :63790. Coverage is collected +# from the Dragonfly leg only (single source of truth, avoids double counting). +# +# These targets are thin wrappers around composer scripts. + +DRAGONFLY_URL ?= redis://127.0.0.1:6379 +REDIS_URL_REDIS ?= redis://127.0.0.1:63790 + +.PHONY: help test-dragonfly test-redis test-all coverage analyze + +help: + @echo "Targets:" + @echo " test-dragonfly Run the suite against Dragonfly ($(DRAGONFLY_URL))" + @echo " test-redis Run the suite against real Redis ($(REDIS_URL_REDIS))" + @echo " test-all Run both engines sequentially; fail if either fails" + @echo " coverage Merged subprocess coverage (Dragonfly leg only)" + @echo " analyze Run PHPStan (composer analyze)" + +test-dragonfly: + REDIS_URL=$(DRAGONFLY_URL) REDIS_BACKEND=dragonfly composer test + +test-redis: + REDIS_URL=$(REDIS_URL_REDIS) REDIS_BACKEND=redis composer test + +test-all: + @echo "=== Dragonfly leg ===" + $(MAKE) test-dragonfly + @echo "=== Redis leg ===" + $(MAKE) test-redis + +coverage: + REDIS_URL=$(DRAGONFLY_URL) REDIS_BACKEND=dragonfly composer test:coverage + +analyze: + composer analyze diff --git a/README.md b/README.md index badde39..da6f9e3 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,56 @@ # redis -Asynchronous redis client for PHP based on workerman. -# Install +Asynchronous Redis client for PHP, built on Workerman. + +[![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) +[![CI](https://github.com/detain/redis/actions/workflows/ci.yml/badge.svg?branch=master)](https://github.com/detain/redis/actions/workflows/ci.yml) +[![Codacy Badge](https://app.codacy.com/project/badge/Grade/1860b96ba19b45b695b7724524f01dfa)](https://app.codacy.com/gh/detain/redis/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_grade) +[![Codacy Coverage](https://app.codacy.com/project/badge/Coverage/1860b96ba19b45b695b7724524f01dfa)](https://app.codacy.com/gh/detain/redis/dashboard?utm_source=gh&utm_medium=referral&utm_content=&utm_campaign=Badge_coverage) +[![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) *Sunburst* | ![Grid](https://codecov.io/gh/detain/redis/graphs/tree.svg?token=ntRuLnxa2V) *Grid* | ![Icicle](https://codecov.io/gh/detain/redis/graphs/icicle.svg?token=ntRuLnxa2V) *Icicle* | +|------|------|------| +| 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. | 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. | 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. +- **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. + +## Requirements + +- **Runtime: PHP ≥ 7.2** for callback mode — the library code itself carries no + PHP 8-only syntax, and Composer resolves Workerman to v4 on PHP 7. +- **Coroutine mode requires PHP ≥ 8.1** with + [`revolt/event-loop`](https://github.com/revoltphp/event-loop) installed (and, + for Workerman v5's fiber loop, Workerman ≥ 5). It is gated at runtime via + `class_exists()`, so on PHP 7 it simply stays off and you use callbacks. +- **Development/test tooling.** Dev dependencies are declared as version + **ranges** (not pins), so each PHP version resolves a compatible toolchain and + the suite runs from **PHP ≥ 7.2** up to 8.3: + - PHP **7.2 / 7.3 / 7.4** → PHPUnit 9 + Workerman 4 (CI strips + `phpstan/phpstan` and `revolt/event-loop` — which need newer PHP — before + `composer update`, and uses `phpunit9.xml.dist`). + - PHP **8.1 / 8.2 / 8.3** → PHPUnit 12 + Workerman 5 (uses `phpunit.xml.dist`). + + Running the suite does **not** affect applications that pull the package in as + a dependency — these are dev-only requirements. + +## Install ``` composer require workerman/redis ``` -# Usage -```php +## Usage +```php require_once __DIR__ . '/vendor/autoload.php'; use Workerman\Redis\Client; use Workerman\Worker; @@ -23,15 +64,566 @@ $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 +## 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`. + +## 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`. + +## 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`. + +## 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`. + +## 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. + +## Pub/Sub + +`subscribe()` / `pSubscribe()` / `sSubscribe()` put the connection into +subscribe mode and stream messages to your callback. Once subscribed, the +connection is *locked* — Redis only allows (un)subscribe commands on it — so +the matching `unsubscribe()` / `pUnsubscribe()` / `sUnsubscribe()` methods are +how you hand it back for ordinary commands. They write the teardown frame +straight to the socket (bypassing the lock), and the connection resumes normal +work as soon as the server confirms zero remaining subscriptions. + +```php +$redis->subscribe(['news', 'sport'], function ($channel, $message) { + // fires for every published message +}); + +// Later — e.g. from a Timer — drop the subscription and resume normal use. +$redis->unsubscribe(); // omit args to drop every channel +$redis->get('some:key', function ($value) { + // runs now that the connection is no longer locked +}); +``` + +`unsubscribe()` takes an optional list of channels (omit to drop them all) and +an optional trailing callback that fires with `(true, $client)` once the +connection has fully left subscribe mode. That callback means "back to normal +command mode" — on a partial unsubscribe (dropping some of several channels) it +is held until the last channel goes too, so track per-channel state in your +subscribe callback if you need it. `pUnsubscribe()` mirrors it for +`pSubscribe()` patterns, and `sUnsubscribe()` for `sSubscribe()` shard +channels. Calling them when not subscribed is a no-op that still invokes the +callback. To stop listening entirely you can also just `close()` the client. + +> **One stream per connection.** A `Client` pins a single streaming entry and +> routes every incoming message to its callback, so a connection can host **one** +> active subscription at a time. Subscribe to every channel/pattern you need in a +> single call — `subscribe(['a', 'b', 'c'], $cb)`. A second `subscribe()` / +> `pSubscribe()` / `sSubscribe()` (or mixing the families) on a connection that +> already has an active or pending stream throws a `Workerman\Redis\Exception` +> rather than silently doing nothing; use a separate `Client` for an additional +> stream. The same one-stream rule applies in coroutine mode: issuing an ordinary +> (suspending) command while the connection is subscribe/monitor-locked throws, +> because its reply could never arrive to resume the fiber — run ordinary +> commands on a different `Client`. + +## Monitor + +`monitor()` streams every command the server processes to your callback — the +debugging counterpart to `redis-cli MONITOR`. Like `subscribe()` it locks the +connection (its own internal flag); there is no "unmonitor", so you stop it by +`close()`ing the client. The opening `+OK` handshake is swallowed; each later +call is one raw monitor line. Like the subscribe family it is one-stream-per- +connection — calling `monitor()` on a connection that already has an active or +pending stream is ignored. + +```php +$debug = new Client('redis://127.0.0.1:6379'); +$debug->monitor(function ($line) { + // e.g. 1700000000.123456 [0 127.0.0.1:6379] "set" "key" "value" + error_log($line); +}); +``` + +> **Heads up:** MONITOR mirrors *all* traffic the server handles and measurably +> lowers its throughput. Use a dedicated client for it and never leave one +> running on a hot path. + +## 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. + +## 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']] + }); +}); +``` + +## Coroutine mode + +Every command accepts an optional **trailing callback** as shown above. If you +install [`revolt/event-loop`](https://github.com/revoltphp/event-loop), you can +instead **omit the callback** and the current fiber suspends until the reply +arrives, so the call reads synchronously and returns the value directly: + +```php +// Callback mode (always available): +$redis->get('key', function ($value) { /* ... */ }); + +// Coroutine mode (with revolt/event-loop installed): +$value = $redis->get('key'); // suspends this fiber, returns the value +``` + +This applies to the whole command surface below — including the `scanAll()` +iterators, the module dispatchers, and the server-admin helpers. + +## Command reference + +The fork exposes a typed `@method` surface for every command +[Dragonfly](https://www.dragonflydb.io/) fully or partially supports, so IDEs +and PHPStan see them. Commands marked **new** were added or fixed in this fork +(see [`CHANGELOG.md`](CHANGELOG.md)); the rest carried over from upstream and now +just have declarations and tests. Commands not listed (e.g. `set`, `get`, `del`, +`hSet`, `zAdd`, `lPush`, the `x*` stream verbs) work exactly as before. + +| Family | New / fixed in this fork | +|--------|--------------------------| +| **Strings** | `getDel`, `getEx`, `substr` | +| **Keys** | `copy`, `touch`, `expireTime`, `pExpireTime`, **`scan`/`scanAll`** | +| **Hashes** | `hRandField`, **`hScan`/`hScanAll`**, HEXPIRE family (`hExpire`, `hPersist`, `hExpireAt`, `hTtl`, `hExpireTime`, `hPExpire`, `hPExpireAt`, `hPTtl`, `hPExpireTime`) | +| **Lists** | `lMove`, `lMPop`, `lPos`, `blMove`, `blMPop` | +| **Sets** | `sMIsMember`, `sInterCard`, **`sScan`/`sScanAll`** | +| **Sorted sets** | `zRandMember`, `zMScore`, `zDiff`, `zDiffStore`, `zInter`, `zInterCard`, `zUnion`, `zRangeStore`, `zMPop`, `bzMPop`, `zRevRangeByLex`, `zRemRangeByLex`, `zLexCount`, **`zScan`/`zScanAll`** | +| **Streams** | `xAutoClaim`, `xSetId`, **`xAdd`** (explicit, flattens the field map) | +| **Bitmap** | `bitOp`, `bitPos`, `bitField`, **`bitFieldRo`** | +| **Geo** | `geoSearch`, **`geoRadiusRo`**, **`geoRadiusByMemberRo`** | +| **Scripting** | **`evalRo`**, **`evalShaRo`** | +| **Pub/Sub** | `sPublish`, **`sSubscribe`**, **`unsubscribe`/`pUnsubscribe`/`sUnsubscribe`** | +| **Connection/server** | **`ping`/`info`/`dbSize`/`time`/`flushDb`/`flushAll`/`quit`**, `echo`, `hello` | +| **Server admin** | **`config`/`acl`/`slowLog`/`memory`/`command`/`cluster`** dispatchers; **`lastSave`/`save`/`role`/`bgSave`/`shutdown`/`digest`/`monitor`**; `replicaOf`, `slaveOf`, `debug`, `delEx` | +| **Read-only bridges** | **`sortRo`**, **`rawCommand`** | +| **JSON module** | **`json()`** + 16 `json*` shortcuts | +| **Bloom Filter** | **`bf()`** + `bfReserve`, `bfAdd`, `bfExists`, `bfMAdd`, `bfMExists` | +| **Count-Min Sketch** | **`cms()`** + `cmsInitByDim`, `cmsInitByProb`, `cmsIncrBy`, `cmsQuery`, `cmsMerge`, `cmsInfo` | +| **TopK** | **`topk()`** + `topkReserve`, `topkAdd`, `topkIncrBy`, `topkQuery`, `topkCount`, `topkList`, `topkInfo` | +| **RediSearch (FT)** | **`ft()`** + `ftCreate`, `ftSearch`, `ftAggregate`, `ftDropIndex`, `ftInfo`, `ftList`, `ftAlter`, `ftConfig`, `ftTagVals`, `ftSynDump`, `ftSynUpdate`, `ftProfile` | +| **Modules** | **`module()`**, `moduleList` | + +The SCAN family, `rawCommand`, Pub/Sub, `monitor()`, the no-arg server commands, +and the JSON module each have their own section above. The remaining families are +documented below. + +## Streams — `xAdd()` + +`xAdd()` takes the message as a natural `['field' => 'value']` map and flattens +it onto the wire itself, so field names survive (passing the map through the +generic dispatcher would emit values only and the server would reject it): + +```php +$redis->xAdd('mystream', '*', ['sensor' => 'temp', 'value' => '21.5'], function ($id) { + // $id === e.g. '1700000000000-0' +}); + +// Cap the stream length (MAXLEN [~] n) — the 4th/5th args, or pass the callback directly. +$redis->xAdd('mystream', '*', ['k' => 'v'], 1000, true, function ($id) { /* ~1000 cap */ }); +``` + +`xAutoClaim()` and `xSetId()` route through `__call()` and take their usual +arguments plus an optional trailing callback. + +## Modern list / set / sorted-set commands + +These all take a trailing callback (or return a value in coroutine mode): + +```php +// Lists +$redis->lMove('src', 'dst', 'LEFT', 'RIGHT', function ($moved) {}); +$redis->lPos('mylist', 'needle', ['RANK' => 1, 'COUNT' => 2], function ($positions) {}); + +// Sets +$redis->sMIsMember('myset', 'a', 'b', 'c', function ($flags) {}); // [1, 0, 1] +$redis->sInterCard(2, ['s1', 's2'], 10, function ($card) {}); // bounded cardinality + +// Sorted sets +$redis->zMScore('z', 'm1', 'm2', function ($scores) {}); // ['1', '2'] (strings) +$redis->zRangeStore('dst', 'src', 0, -1, [], function ($count) {}); +$redis->zUnion(2, ['z1', 'z2'], ['WITHSCORES'], function ($rows) {}); +``` + +## Bitmap, Geo & read-only scripting + +The `*_RO` (read-only) verbs need explicit methods because uppercasing a +camelCase name drops the underscore. Each accepts the callable-as-last-arg +shortcut: + +```php +$redis->bitFieldRo('bf', 'GET', 'i5', 0, function ($vals) {}); +$redis->geoRadiusRo('geo', 13.4, 52.5, 200, 'km', ['WITHDIST'], function ($rows) {}); +$redis->geoRadiusByMemberRo('geo', 'Berlin', 200, 'km', [], function ($rows) {}); +$redis->evalRo('return ARGV[1]', ['hello'], 0, function ($r) {}); +$redis->evalShaRo($sha, ['hello'], 0, function ($r) {}); + +// SORT_RO — same option grammar as sort(): +$redis->sortRo('mylist', ['ALPHA' => true, 'LIMIT' => [0, 10]], function ($sorted) {}); +``` + +`bitOp`, `bitPos`, `bitField`, and `geoSearch` route cleanly through `__call()`. + +## Server administration + +Multi-verb admin families are reached through dispatchers — the first argument is +the subcommand, the rest are its arguments, and an optional trailing callable is +the callback: + +```php +$redis->config('GET', 'maxmemory', function ($pairs) {}); // CONFIG GET maxmemory +$redis->config('SET', 'maxmemory', '256mb', function ($ok) {}); +$redis->acl('WHOAMI', function ($user) {}); // ACL WHOAMI +$redis->slowLog('GET', 10, function ($entries) {}); // SLOWLOG GET 10 +$redis->memory('USAGE', 'mykey', function ($bytes) {}); // MEMORY USAGE mykey +$redis->command('COUNT', function ($n) {}); // COMMAND COUNT +$redis->cluster('INFO', function ($info) {}); // CLUSTER INFO + +// Lifecycle verbs (explicit — these fix the no-arg-callback bug): +$redis->lastSave(function ($ts) {}); +$redis->save(function ($ok) {}); +$redis->role(function ($role) {}); +$redis->bgSave(false, function ($ok) {}); // pass true for BGSAVE SCHEDULE +// $redis->shutdown('SAVE'); // closes the connection; no reconnect +``` + +`shutdown()` sets the same don't-reconnect flag `quit()` uses, so the client +won't silently re-open the socket the server just closed. + +## Bloom Filter / Count-Min Sketch / TopK modules + +RedisBloom-compatible probabilistic structures — native in Dragonfly. Each +family has a dispatcher (`bf()`, `cms()`, `topk()`) plus typed shortcuts: + +```php +// Bloom Filter +$redis->bfReserve('seen', 0.01, 100000, function ($ok) {}); +$redis->bfAdd('seen', 'user:42', function ($added) {}); // 1 first time, 0 after +$redis->bfMExists('seen', 'a', 'b', 'c', function ($flags) {}); // [1, 0, 0] + +// Count-Min Sketch +$redis->cmsInitByProb('freq', 0.001, 0.01, function ($ok) {}); +$redis->cmsIncrBy('freq', 'page:/', 1, function ($counts) {}); +$redis->cmsQuery('freq', 'page:/', function ($estimates) {}); + +// TopK +$redis->topkReserve('top', 10, function ($ok) {}); // width/depth/decay default +$redis->topkAdd('top', 'apple', 'banana', function ($dropped) {}); +$redis->topkList('top', function ($leaders) {}); +``` + +> Replies follow Dragonfly's shapes: `BF.ADD`/`BF.EXISTS` return ints (1/0); +> `CMS.INFO`/`TOPK.INFO` come back as flat `[name, value, …]` arrays; `TOPK.COUNT` +> is approximate and may under-count by ~1. + +## RediSearch (FT) module + +Full-text/secondary-index search — preloaded in Dragonfly. The `ft()` dispatcher +prepends `FT.`; typed shortcuts cover the common verbs: + +```php +$redis->ftCreate('idx', 'ON', 'HASH', 'PREFIX', 1, 'doc:', 'SCHEMA', 'title', 'TEXT', function ($ok) { + $redis->ftSearch('idx', 'hello', function ($results) { + // [total, 'doc:1', ['title', 'hello world'], ...] + }); +}); + +$redis->ftInfo('idx', function ($meta) {}); +$redis->ftList(function ($indexes) {}); // FT._LIST +$redis->ftDropIndex('idx', true, function ($ok) {}); // true => DD (also delete docs) +``` + +Anything without a shortcut goes through the dispatcher directly, e.g. +`$redis->ft('AGGREGATE', 'idx', '*', 'GROUPBY', 1, '@title', $cb)`. + +## Development + +``` +composer install +composer test # PHPUnit (phpunit --colors=always) +composer analyze # PHPStan (8.x only) +composer test:coverage # merged subprocess coverage (sh bin/run-coverage.sh; needs PCOV or Xdebug) +``` + +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. + +The suite is run against **both engines** via the `Makefile` — see *Testing & +continuous integration* below. Any change must stay green on **both** Dragonfly +and Redis; a case that can only pass on one engine must be skipped with a logged +reason via `skipOnBackend()` (never a silent skip) and listed under *Compatibility*. + +> The test suite runs on **PHP ≥ 7.2** (PHPUnit 8.5 on 7.2, 9.6 on 7.3/7.4, +> all with Workerman 4; PHPUnit 12 + Workerman 5 on 8.1–8.5). PHPStan runs on +> the **8.x** legs only. Coroutine mode (and its tests) needs **PHP ≥ 8.1** with +> `revolt/event-loop`; the `CoroutineModeTest` cases self-skip below 8.1 via the +> `coroutineSupported()` guard. The library itself runs on **PHP ≥ 7.2** in +> callback mode. +> +> **`composer.lock` is not committed.** This is a library, and the CI legs need +> different dependency resolutions per PHP version, so the lockfile is +> `.gitignore`d and CI runs `composer update` (never `composer install` against a +> committed lock). + +## Testing & continuous integration + +The fork ships with a [PHPUnit](https://phpunit.de/) test suite — **430 tests +(145 Unit + 285 Feature)** — run against **both Dragonfly and Redis**. The Redis +leg is skip-free; the only skips are behaviour-gated server divergences on +Dragonfly (see *Compatibility* below). It is split into two tiers, so most of the +code can be exercised without a server and the rest is verified end-to-end +against a live engine: + +- **Unit suite (`tests/Unit/`) — no server needed.** Pure, mock-style tests that + run anywhere: + - `ProtocolTest` round-trips the RESP encoder/decoder directly — nested + arrays, null bulks/arrays, deep-nesting within `MAX_DEPTH`, the + depth-overflow protocol-error path, truncated frames, and empty replies. + - `MethodSurfaceTest` uses reflection to lock in the method surface for + commands that can't be run live (`shutdown`, `monitor`, the unsubscribe + family). +- **Feature suite (`tests/Feature/`) — live integration.** Because + `Worker::runAll()` takes over the process, each integration assertion runs in + its own short-lived Workerman subprocess via a `runInWorker($snippet)` helper: + the snippet executes inside a real worker with `$redis`, `$emit()`, and + `$fail()` in scope and returns its result over a dedicated pipe. Every command + family — Strings, Keys, Hashes, Lists, Sets, Sorted Sets, the SCAN iterators, + Streams, Pub/Sub, Bitmap, Geo, scripting, server admin, and the JSON / Bloom / + CMS / TopK / RediSearch modules — has live round-trip coverage. The suite + **skips cleanly** when no server is reachable, so `composer test` stays green + on a bare checkout. + - **Revolt coroutine mode.** A second worker variant + (`tests/Support/run-in-worker-coroutine.php`) runs the same snippets under a + Revolt event loop so the fiber-suspend (`await`) path through `Client.php` is + exercised end-to-end alongside the default callback mode. + +Static analysis runs alongside the tests: **PHPStan** (level 5, with a baseline +that freezes legacy typing issues so new code can't regress). + +### Running against both backends + +The suite runs against **both** Dragonfly and a real Redis, locally and in CI. +Locally the two engines listen on different ports and the `Makefile` selects one +per leg via the `REDIS_URL` + `REDIS_BACKEND` pair: + +| Target | Engine | `REDIS_URL` | +|--------|--------|-------------| +| `make test-dragonfly` | Dragonfly | `redis://127.0.0.1:6379` | +| `make test-redis` | Redis | `redis://127.0.0.1:63790` | +| `make test-all` | both, sequentially (fails if either leg fails) | — | +| `make coverage` | Dragonfly leg only | `redis://127.0.0.1:6379` | + +`scripts/start-dragonfly.sh` and `scripts/start-redis.sh` are idempotent +helpers that detect-and-confirm a running engine on each port (`make help` lists +every target). **Both engines must stay green.** A case that can only pass on one +engine is skipped — never silently — with `skipOnBackend('redis', 'reason')` / +`skipOnBackend('dragonfly', 'reason')` (free helpers in `tests/helpers.php`, keyed on +`REDIS_BACKEND`); every skip prints `[] ` and is documented under +*Compatibility* below. + +### Coverage + +Feature tests run each assertion inside a `proc_open`ed Workerman worker child, +so pcov in the parent process never instrumented `src/Client.php` — it reported +a false **0.0%**. The worker now collects coverage *inside the child* (gated on a +`COVERAGE_DIR` env) and dumps a per-invocation `.cov`; `bin/merge-coverage.php` +merges every child `.cov` (plus the in-process Unit `unit.cov`) into +`coverage.xml` (Clover) and a text summary, and `bin/run-coverage.sh` orchestrates +the run. Run it with `make coverage` or `composer test:coverage` (both need PCOV +or Xdebug). + +With the merge in place the real numbers are: + +| File | Line coverage | +|------|---------------| +| `src/Client.php` | **92.32%** (877/950 lines; 91.06% of methods, 112/123) | +| `src/Protocols/Redis.php` | **100%** | +| `src/Exception.php` | **100%** | +| **Total** | **92.99%** (969/1042) | + +A Revolt coroutine-mode worker variant (`run-in-worker-coroutine.php`) re-runs the +Feature snippets under a fiber event loop so the coroutine `await` path is merged +into the same numbers. + +`bin/merge-coverage.php` enforces a **coverage floor** (`--min` / `COVERAGE_MIN`, +default set in `bin/run-coverage.sh`) and exits non-zero below it; the floor is +currently **90**. This is the canonical gate — CI fails below it. + +The residual ~7% is documented as genuinely impractical to cover (socket fault +injection, `onClose` auto-reconnect timing, `onMessage` exception/reconnect, +echo-`Exception` diagnostic sinks, and the coroutine `*ScanAll` error arms) — see +*Coverage close-out (Group 9)* in [`docs/TEST_COVERAGE_PLAN.md`](docs/TEST_COVERAGE_PLAN.md). + +### Compatibility + +The suite is green on both engines except for documented, server-side divergences +that are skipped on the affected backend with a logged reason (`skipOnBackend`), +never silently. The **Redis leg is skip-free (0 skips)**; the only remaining +divergences are **3 Dragonfly behaviour-gated skips**: + +| Skipped on | Tests | Reason | +|------------|-------|--------| +| Dragonfly | auth-with-no-password (2 cases) | `AUTH` against a server with no password configured returns `+OK` on Dragonfly instead of the `-ERR` Redis returns, so the error-path assertion is gated off there. | +| Dragonfly | `OBJECT` subcommand | `OBJECT` (e.g. `OBJECT ENCODING`/`REFCOUNT`) is reported as an unknown command on the Dragonfly build under test. | + +The RediSearch **FT family runs in full on both engines** — the earlier +`FT.SEARCH` `SEARCH_INDEX_NOT_FOUND` divergence no longer reproduces on the +current Redis 8.8 + RediSearch build, so those tests were un-gated and now run on +Redis too. + +### GitHub Actions + +[`.github/workflows/ci.yml`](.github/workflows/ci.yml) runs on every push and +pull request to `master` (and on demand via `workflow_dispatch`): + +- **Matrix across PHP 7.2, 7.3, 7.4, 8.1, 8.2, and 8.3 × backend + `[dragonfly, redis]`** (12 legs, fail-fast disabled), so the full suite runs + against **both engines** on every push and PR — not just mocks, and not just + Dragonfly. The 7.x legs prove the advertised `>=7.2` floor: Composer resolves + **PHPUnit 9 + Workerman 4** there (using `phpunit9.xml.dist`), after the + install step strips `phpstan/phpstan` + `revolt/event-loop` so the platform + check can pick the older line; the 8.x legs resolve **PHPUnit 12 + Workerman + 5** (`phpunit.xml.dist`). `composer.lock` is not committed — each leg runs + `composer update`. +- **Each leg starts exactly one engine** on `127.0.0.1:6379` as a service + container: the Dragonfly image + (`docker.dragonflydb.io/dragonflydb/dragonfly`), or + `redis/redis-stack-server:latest` on the Redis leg so the JSON / Bloom / CMS / + TopK / FT modules are available there too. +- **PHPUnit** runs on every leg (**PHPStan** on the 8.x legs only); the + coroutine-mode tests self-skip below PHP 8.1 via the `coroutineSupported()` + guard and run only on the 8.1/8.2/8.3 legs. The single + `php=8.3 && backend=dragonfly` leg additionally collects merged line coverage + with **PCOV** (via `composer test:coverage`) and uploads a Clover report to + **[Codecov](https://codecov.io/gh/detain/redis)** and + **[Codacy](https://app.codacy.com/gh/detain/redis/dashboard)** (a dedicated + `codacy-coverage-reporter` job consumes the artifact). The coverage floor gate + fails the run below the minimum (see *Coverage* above). +- Composer downloads are cached per PHP version to keep runs fast. + +Current merged coverage is **92.99%** total (`Client.php` 92.32%, +`Protocols/Redis.php` 100%) — the subprocess-merge fix surfaced the real +end-to-end numbers that pcov in the parent process used to miss. The floor is held +at **90** and ratcheted upward as coverage grows. Coverage badges at the top of +this README reflect the latest `master` run. + +## Documentation http://doc.workerman.net/components/workerman-redis.html diff --git a/bin/merge-coverage.php b/bin/merge-coverage.php new file mode 100755 index 0000000..484d444 --- /dev/null +++ b/bin/merge-coverage.php @@ -0,0 +1,197 @@ +.cov via the PHP report. The Unit tier runs in-process + * and is captured separately by PHPUnit as a single --coverage-php file. + * + * This script merges every *.cov file in the coverage dir (plus an optional + * extra in-process .cov) into one CodeCoverage, then emits a Clover report and a + * text summary so the real src/Client.php number shows up. + * + * Usage: + * php bin/merge-coverage.php [coverage-dir] [clover-out] [--min=] + * + * Environment (override the positional args): + * COVERAGE_DIR directory of *.cov dumps (default: build/coverage) + * COVERAGE_UNIT extra in-process .cov to merge (default: /unit.cov if present) + * COVERAGE_XML clover output path (default: coverage.xml) + * COVERAGE_HTML optional HTML report directory (default: none) + * COVERAGE_TEXT optional text report file (default: stdout only) + * COVERAGE_MIN minimum total line coverage % (default: none; --min wins) + * + * Coverage floor (Step 0.4): when a minimum is supplied via the `--min=` + * flag (takes precedence) or the COVERAGE_MIN env, the script computes the + * merged total line coverage % and exits NON-ZERO (code 3) if it is below the + * floor, printing both the achieved % and the floor. This is the canonical gate + * for both local `make coverage` and CI — it runs on the MERGED number, which is + * the only meaningful figure given the subprocess coverage model (a plain + * `phpunit --min` can't see Client.php; see Step 0.1). + * + * Exits non-zero (loudly) when no *.cov files are found (code 2) or coverage is + * below the floor (code 3). + */ + +declare(strict_types=1); + +$autoload = dirname(__DIR__) . '/vendor/autoload.php'; +if (!is_file($autoload)) { + fwrite(STDERR, "autoload not found at {$autoload}\n"); + exit(1); +} +require $autoload; + +use SebastianBergmann\CodeCoverage\CodeCoverage; +use SebastianBergmann\CodeCoverage\Driver\Selector; +use SebastianBergmann\CodeCoverage\Filter; +use SebastianBergmann\CodeCoverage\Report\Clover; +use SebastianBergmann\CodeCoverage\Report\Html\Facade as HtmlReport; +use SebastianBergmann\CodeCoverage\Report\PHP as PhpReport; +use SebastianBergmann\CodeCoverage\Report\Text as TextReport; +use SebastianBergmann\CodeCoverage\Report\Thresholds; + +$root = dirname(__DIR__); + +// Separate option flags (--min=) from positional args so the positional +// [coverage-dir] [clover-out] order is preserved regardless of flag placement. +$minOpt = null; +$positional = []; +foreach (array_slice($argv, 1) as $arg) { + if (strncmp($arg, '--min=', 6) === 0) { + $minOpt = substr($arg, 6); + } else { + $positional[] = $arg; + } +} + +$envDir = getenv('COVERAGE_DIR'); +$coverageDir = $positional[0] ?? (is_string($envDir) && $envDir !== '' ? $envDir : $root . '/build/coverage'); + +$envXml = getenv('COVERAGE_XML'); +$cloverOut = $positional[1] ?? (is_string($envXml) && $envXml !== '' ? $envXml : $root . '/coverage.xml'); + +// Coverage floor: --min= takes precedence over the COVERAGE_MIN env. +$envMin = getenv('COVERAGE_MIN'); +$minRaw = $minOpt ?? (is_string($envMin) && $envMin !== '' ? $envMin : null); +$minPct = ($minRaw !== null && is_numeric($minRaw)) ? (float) $minRaw : null; +if ($minRaw !== null && $minPct === null) { + fwrite(STDERR, "invalid --min/COVERAGE_MIN value: {$minRaw}\n"); + exit(1); +} + +$envUnit = getenv('COVERAGE_UNIT'); +$unitCov = is_string($envUnit) && $envUnit !== '' ? $envUnit : null; + +$htmlDir = getenv('COVERAGE_HTML'); +$htmlDir = is_string($htmlDir) && $htmlDir !== '' ? $htmlDir : null; + +$textOut = getenv('COVERAGE_TEXT'); +$textOut = is_string($textOut) && $textOut !== '' ? $textOut : null; + +if (!is_dir($coverageDir)) { + fwrite(STDERR, "coverage dir not found: {$coverageDir}\n"); + exit(1); +} + +// Build the merge target with the same src/ filter the children used. +require_once $root . '/tests/Support/coverage-filter.php'; +$filter = new Filter(); +$filter->includeFiles(workerman_redis_src_files()); +$driver = (new Selector())->forLineCoverage($filter); +$merged = new CodeCoverage($driver, $filter); + +$files = glob(rtrim($coverageDir, '/') . '/*.cov') ?: []; + +// Default the unit dump to /unit.cov if it exists and was not given. +if ($unitCov === null) { + $maybeUnit = rtrim($coverageDir, '/') . '/unit.cov'; + if (is_file($maybeUnit)) { + $unitCov = $maybeUnit; + } +} + +$mergedCount = 0; +foreach ($files as $file) { + try { + /** @var CodeCoverage $partial */ + $partial = include $file; + if ($partial instanceof CodeCoverage) { + $merged->merge($partial); + $mergedCount++; + } else { + fwrite(STDERR, "skipping {$file}: not a CodeCoverage object\n"); + } + } catch (\Throwable $e) { + fwrite(STDERR, "failed to merge {$file}: {$e->getMessage()}\n"); + } +} + +// Merge the in-process unit .cov if it was passed explicitly and not already in +// the glob above (it lives in the same dir, so it usually already is). +if ($unitCov !== null && is_file($unitCov) && !in_array($unitCov, $files, true)) { + try { + /** @var CodeCoverage $partial */ + $partial = include $unitCov; + if ($partial instanceof CodeCoverage) { + $merged->merge($partial); + $mergedCount++; + } + } catch (\Throwable $e) { + fwrite(STDERR, "failed to merge unit cov {$unitCov}: {$e->getMessage()}\n"); + } +} + +if ($mergedCount === 0) { + fwrite(STDERR, "no .cov files merged from {$coverageDir} — coverage pipeline is broken\n"); + exit(2); +} + +// Clover report (consumed by CI / the future --min floor in Step 0.4). +(new Clover())->process($merged, $cloverOut); + +// Optional HTML report. +if ($htmlDir !== null) { + (new HtmlReport())->process($merged, $htmlDir); +} + +// Text summary — printed to stdout (and optionally a file). +$text = (new TextReport(Thresholds::default(), false, false))->process($merged); +echo $text; +if ($textOut !== null) { + file_put_contents($textOut, $text); +} + +fwrite(STDERR, "merged {$mergedCount} coverage file(s) -> {$cloverOut}\n"); + +// Coverage floor gate (Step 0.4). Compute merged total line coverage from the +// report's own counters so the gated number matches the clover/text summary. +if ($minPct !== null) { + $report = $merged->getReport(); + $executable = $report->numberOfExecutableLines(); + $executed = $report->numberOfExecutedLines(); + $achieved = $executable > 0 ? ($executed / $executable) * 100.0 : 0.0; + + $achievedStr = number_format($achieved, 2); + $floorStr = number_format($minPct, 2); + + if ($achieved + 1e-9 < $minPct) { + fwrite( + STDERR, + "COVERAGE FLOOR FAILED: total line coverage {$achievedStr}% " + . "({$executed}/{$executable}) is below the floor of {$floorStr}%\n" + ); + exit(3); + } + + fwrite( + STDERR, + "coverage floor OK: total line coverage {$achievedStr}% " + . "({$executed}/{$executable}) >= floor {$floorStr}%\n" + ); +} + +exit(0); diff --git a/bin/run-coverage.sh b/bin/run-coverage.sh new file mode 100755 index 0000000..e902c8a --- /dev/null +++ b/bin/run-coverage.sh @@ -0,0 +1,47 @@ +#!/bin/sh +# Orchestrate merged code coverage for workerman/redis. +# +# 1. clear + create build/coverage +# 2. Unit tier in-process -> build/coverage/unit.cov (pcov in PHPUnit process) +# 3. Feature tier -> build/coverage/cov-*.cov (pcov in each worker child, +# dumped by tests/Support/run-in-worker.php) +# 4. merge everything -> coverage.xml + text summary +# +# POSIX sh only. Run from the project root via `composer test:coverage`. + +set -eu + +ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd) +COVDIR="$ROOT/build/coverage" + +# Coverage floor (Step 0.4). The merge script fails non-zero if the MERGED total +# line coverage drops below this. Ratcheted up as each group landed more tests: +# Group 0 68.62% -> Group 8 82.82% -> Group 9 (close-out) 92.99% total / Client.php +# 92.32%. Floor set to 90 — a few points below the achieved 92.99% to absorb the +# minor subprocess-dump nondeterminism (the merged total can vary by a line or two +# between runs). The residual ~7% is genuinely impractical fault-injection paths +# (socket failures, auto-reconnect timing, coroutine error arms) documented in +# docs/TEST_COVERAGE_PLAN.md "Coverage close-out". Override with COVERAGE_MIN=. +COVERAGE_MIN="${COVERAGE_MIN:-90}" + +# 0. purge any residual per-process pid/log files from prior runs. The worker +# runners self-clean (register_shutdown_function + in-handler unlink), so this +# is just a containment sweep in case a child was hard-killed before exit(). +rm -rf "${TMPDIR:-/tmp}/wm-redis-tests" + +# 1. fresh coverage dir +rm -rf "$COVDIR" +mkdir -p "$COVDIR" + +# 2. Unit tier in-process (captured by PHPUnit as a single .cov). +php -d pcov.enabled=1 "$ROOT/vendor/bin/phpunit" \ + --testsuite Unit \ + --coverage-php="$COVDIR/unit.cov" + +# 3. Feature tier — children dump their own .cov into COVERAGE_DIR. +COVERAGE_DIR="$COVDIR" php -d pcov.enabled=1 "$ROOT/vendor/bin/phpunit" \ + --testsuite Feature + +# 4. Merge + report + enforce the floor (the canonical coverage gate). +COVERAGE_DIR="$COVDIR" COVERAGE_XML="$ROOT/coverage.xml" \ + php "$ROOT/bin/merge-coverage.php" --min="$COVERAGE_MIN" diff --git a/composer.json b/composer.json index 4bbdbaf..c4a02a6 100644 --- a/composer.json +++ b/composer.json @@ -1,13 +1,42 @@ { - "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": ">=7.2", "workerman/workerman": "^4.1.0||^5.0.0" }, + "require-dev": { + "phpunit/phpunit": "^8.5 || ^9.6 || ^10.5 || ^11.5 || ^12.0", + "phpstan/phpstan": ">=2.2.1", + "revolt/event-loop": "^1.0" + }, + "suggest": { + "revolt/event-loop": "Enables coroutine-style synchronous return values when no callback is provided (requires PHP >=8.1)." + }, "autoload": { "psr-4": {"Workerman\\Redis\\": "./src"} + }, + "autoload-dev": { + "files": ["tests/helpers.php"], + "psr-4": {"Tests\\": "tests/"} + }, + "minimum-stability": "dev", + "prefer-stable": true, + "scripts": { + "analyze": "php -d memory_limit=1G vendor/bin/phpstan analyse", + "test": "phpunit --colors=always", + "test:coverage": "sh bin/run-coverage.sh" } } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..4cebc2c --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,80 @@ +parameters: + ignoreErrors: + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertNull\(\) with null will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Unit/ClientAccessorsTest.php + + - + message: '#^Call to method PHPUnit\\Framework\\Assert\:\:assertIsString\(\) with ''3\.0000000000000004'' will always evaluate to true\.$#' + identifier: method.alreadyNarrowedType + count: 1 + path: tests/Unit/ClientScanAllTest.php + + - + 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: 4 + path: src/Client.php + + - + message: '#^Negated boolean expression is always false\.$#' + identifier: booleanNot.alwaysFalse + count: 2 + path: src/Client.php + + - + message: '#^Parameter \#1 \$timerId of static method Workerman\\Timer\:\:del\(\) expects int, Workerman\\Timer given\.$#' + identifier: argument.type + count: 3 + 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: '#^Property Workerman\\Redis\\Client\:\:\$_waitTimeoutTimer \(Workerman\\Timer\) does not accept int\.$#' + identifier: assign.propertyType + count: 1 + path: src/Client.php + + - + message: '#^Property Workerman\\Redis\\Client\:\:\$_waitTimeoutTimer \(Workerman\\Timer\) 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 + 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/phpunit9.xml.dist b/phpunit9.xml.dist new file mode 100644 index 0000000..ce70414 --- /dev/null +++ b/phpunit9.xml.dist @@ -0,0 +1,21 @@ + + + + + ./tests/Unit + + + ./tests/Feature + + + + + + + diff --git a/scripts/start-dragonfly.sh b/scripts/start-dragonfly.sh new file mode 100755 index 0000000..79b9c6d --- /dev/null +++ b/scripts/start-dragonfly.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# Convenience helper: confirm a Dragonfly server is up for the test suite. +# +# This is NOT an installer. Dragonfly is installed via APT on this machine +# (see project memory: reference_dragonfly_install.md). The script is +# idempotent: if something already answers PING on the port it reports +# "already running" and exits 0; otherwise it prints guidance (Dragonfly +# is run as a service, not casually daemonised like redis-server). +# +# Env: DRAGONFLY_PORT (default 6379). +# Prints the connection URL on success. +set -eu + +PORT="${DRAGONFLY_PORT:-6379}" +HOST=127.0.0.1 +URL="redis://${HOST}:${PORT}" + +if redis-cli -h "$HOST" -p "$PORT" ping 2>/dev/null | grep -q PONG; then + echo "Dragonfly already running on ${HOST}:${PORT}" + echo "$URL" + exit 0 +fi + +echo "Nothing answered PING on ${HOST}:${PORT}." >&2 +echo "Dragonfly is APT-installed here; start it via its service, e.g.:" >&2 +echo " sudo systemctl start dragonfly" >&2 +echo " or run the dragonfly binary directly:" >&2 +echo " dragonfly --port ${PORT} --bind ${HOST}" >&2 +echo "(see project memory reference_dragonfly_install.md for the APT recipe)" >&2 +exit 1 diff --git a/scripts/start-redis.sh b/scripts/start-redis.sh new file mode 100755 index 0000000..0f21fa1 --- /dev/null +++ b/scripts/start-redis.sh @@ -0,0 +1,49 @@ +#!/bin/sh +# Convenience helper: start/confirm a real Redis server for the test suite. +# +# This is NOT an installer. Redis is already installed on this machine. +# The script is idempotent: if something already answers PING on the port +# it reports "already running" and exits 0; otherwise it tries to launch a +# daemonised redis-server on that port. +# +# Module commands (ReJSON / RedisBloom / RediSearch / TimeSeries) must be +# configured separately (loadmodule directives / redis-stack-server); a +# bare `redis-server` started here will NOT have them. +# +# Env: REDIS_PORT (default 63790). +# Prints the connection URL on success. +set -eu + +PORT="${REDIS_PORT:-63790}" +HOST=127.0.0.1 +URL="redis://${HOST}:${PORT}" + +if redis-cli -h "$HOST" -p "$PORT" ping 2>/dev/null | grep -q PONG; then + echo "Redis already running on ${HOST}:${PORT}" + echo "$URL" + exit 0 +fi + +if ! command -v redis-server >/dev/null 2>&1; then + echo "redis-server not found on PATH; install Redis (or redis-stack-server for modules)" >&2 + exit 1 +fi + +echo "Starting redis-server on port ${PORT} (daemonised)..." +echo "Note: modules (ReJSON/RedisBloom/RediSearch/TimeSeries) are NOT loaded by this bare launch; configure them separately." +redis-server --port "$PORT" --daemonize yes + +# Give it a moment, then confirm. +i=0 +while [ "$i" -lt 20 ]; do + if redis-cli -h "$HOST" -p "$PORT" ping 2>/dev/null | grep -q PONG; then + echo "Redis is up on ${HOST}:${PORT}" + echo "$URL" + exit 0 + fi + i=$((i + 1)) + sleep 0.25 +done + +echo "redis-server did not answer PING on ${PORT} after startup" >&2 +exit 1 diff --git a/src/Client.php b/src/Client.php index 201920b..3295b72 100644 --- a/src/Client.php +++ b/src/Client.php @@ -28,27 +28,33 @@ * @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) * @method static float incrByFloat($key, $value, $cb = null) * @method static array mGet(array $keys, $cb = null) - * @method static array getMultiple(array $keys, $cb = null) * @method static bool setBit($key, $offset, $value, $cb = null) * @method static bool setEx($key, $ttl, $value, $cb = null) * @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) + * @method static array sortRo($key, $options = [], $cb = null) * Keys methods - * @method static int del(...$keys, $cb = null) - * @method static int unlink(...$keys, $cb = null) + * @method static int copy($src, $dst, array $options = [], $cb = null) + * @method static int del(...$keys) + * @method static int unlink(...$keys) * @method static false|string dump($key, $cb = null) - * @method static int exists(...$keys, $cb = null) + * @method static int exists(...$keys) * @method static bool expire($key, $ttl, $cb = null) * @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,22 +63,37 @@ * @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) * @method static string type($key, $cb = null) * @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) * @method static false|string hGet($key, $hashKey, $cb = null) * @method static false|int hLen($key, $cb = null) - * @method static false|int hDel($key, ...$hashKeys, $cb = null) + * @method static false|int hDel($key, ...$hashKeys) * @method static array hKeys($key, $cb = null) * @method static array hVals($key, $cb = null) * @method static bool hExists($key, $hashKey, $cb = null) * @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) + * @method static array hExpire($key, $seconds, $fieldsOrOptions, ...$fields) + * @method static array hPersist($key, $fieldsOrOptions, ...$fields) + * @method static array hExpireAt($key, $timestamp, $fieldsOrOptions, ...$fields) + * @method static array hTtl($key, $fieldsOrOptions, ...$fields) + * @method static array hExpireTime($key, $fieldsOrOptions, ...$fields) + * @method static array hPExpire($key, $milliseconds, $fieldsOrOptions, ...$fields) + * @method static array hPExpireAt($key, $timestamp, $fieldsOrOptions, ...$fields) + * @method static array hPTtl($key, $fieldsOrOptions, ...$fields) + * @method static array hPExpireTime($key, $fieldsOrOptions, ...$fields) * Lists methods * @method static array blPop($keys, $timeout, $cb = null) * @method static array brPop($keys, $timeout, $cb = null) @@ -80,7 +101,7 @@ * @method static false|string lIndex($key, $index, $cb = null) * @method static int lInsert($key, $position, $pivot, $value, $cb = null) * @method static false|string lPop($key, $cb = null) - * @method static false|int lPush($key, ...$entries, $cb = null) + * @method static false|int lPush($key, ...$entries) * @method static false|int lPushx($key, $value, $cb = null) * @method static array lRange($key, $start, $end, $cb = null) * @method static false|int lRem($key, $value, $count, $cb = null) @@ -88,9 +109,14 @@ * @method static false|array lTrim($key, $start, $end, $cb = null) * @method static false|string rPop($key, $cb = null) * @method static false|string rPopLPush($srcKey, $dstKey, $cb = null) - * @method static false|int rPush($key, ...$entries, $cb = null) + * @method static false|int rPush($key, ...$entries) * @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) @@ -103,9 +129,13 @@ * @method static bool sMove($src, $dst, $member, $cb = null) * @method static false|string|array sPop($key, $count = 0, $cb = null) * @method static false|string|array sRandMember($key, $count = 0, $cb = null) - * @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 int sRem($key, ...$members) + * @method static array sUnion(...$keys) + * @method static false|int sUnionStore($dst, ...$keys) + * @method static array sMIsMember($key, ...$members) + * @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 * @method static array bzPopMin($keys, $timeout, $cb = null) * @method static array bzPopMax($keys, $timeout, $cb = null) @@ -122,26 +152,91 @@ * @method static array zRangeByLex($key, $min, $max, $offset = 0, $limit = 0, $cb = null) * @method static int zRank($key, $member, $cb = null) * @method static int zRevRank($key, $member, $cb = null) - * @method static int zRem($key, ...$members, $cb = null) + * @method static int zRem($key, ...$members) * @method static int zRemRangeByRank($key, $start, $end, $cb = null) * @method static int zRemRangeByScore($key, $start, $end, $cb = null) * @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) + * @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 * @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) + * @method static int bitPos($key, $bit, $start = 0, $end = -1, $byte = false, $cb = null) + * @method static array bitField($key, ...$ops) + * @method static array bitFieldRo($key, ...$ops) * Geocoding methods - * @method static int geoAdd($key, $longitude, $latitude, $member, ...$items, $cb = null) - * @method static array geoHash($key, ...$members, $cb = null) - * @method static array geoPos($key, ...$members, $cb = null) + * @method static int geoAdd($key, $longitude, $latitude, $member, ...$items) + * @method static array geoHash($key, ...$members) + * @method static array geoPos($key, ...$members) * @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) + * 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) + * 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) + * @method static string xAdd($key, $id, array $message, $maxLen = 0, $approximate = false, $cb = null) * @method static array xClaim($strKey, $strGroup, $strConsumer, $minIdleTime, $arrIds, $arrOptions = [], $cb = null) * @method static int xDel($strKey, $arrIds, $cb = null) * @method static mixed xGroup($command, $strKey, $strGroup, $strMsgId, $booMKStream = null, $cb = null) @@ -153,11 +248,62 @@ * @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 int sPublish($channel, $message, $cb = null) * @method static mixed pubSub($keyword, $argument = null, $cb = null) + * @method static void sSubscribe($channels, $cb) + * @method static void unsubscribe(...$channelsAndCb) + * @method static void pUnsubscribe(...$patternsAndCb) + * @method static void sUnsubscribe(...$channelsAndCb) + * 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) + * @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) + * Server administration + * @method static mixed config(...$args) + * @method static mixed acl(...$args) + * @method static mixed slowLog(...$args) + * @method static mixed memory(...$args) + * @method static mixed command(...$args) + * @method static mixed cluster(...$args) + * @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 void monitor($cb) + * @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) + * @method static mixed module(...$args) + * @method static array moduleList($cb = null) + * @method static int delEx(...$keys) — 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) + * @method static mixed rawCommand(...$commandAndArgs) * Transactions methods * @method static multi($cb = null) * @method static mixed exec($cb = null) @@ -167,26 +313,31 @@ * 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 script($command, ...$scripts, $cb = null) - * @method static mixed client(...$args, $cb = null) - * @method static null|string getLastError($cb = null) - * @method static bool clearLastError($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) + * @method static mixed client(...$args) * @method static mixed _prefix($value, $cb = null) * @method static mixed _serialize($value, $cb = null) * @method static mixed _unserialize($value, $cb = null) - * Introspection methods - * @method static bool isConnected($cb = null) - * @method static mixed getHost($cb = null) - * @method static mixed getPort($cb = null) - * @method static false|int getDbNum($cb = null) - * @method static false|double getTimeout($cb = null) - * @method static mixed getReadTimeout($cb = null) - * @method static mixed getPersistentID($cb = null) - * @method static mixed getAuth($cb = null) */ #[\AllowDynamicProperties] class Client { + /** + * Commands that legitimately hold the connection open until the server + * replies (or the command's own server-side timeout fires). When one of + * these is the in-flight head of the queue the wait-timeout scan must not + * treat the connection as hung — nor time out the commands queued behind + * it, which are simply waiting on the block rather than stalled. + * + * @var string[] + */ + const BLOCKING_COMMANDS = [ + 'BLPOP', 'BRPOP', 'BRPOPLPUSH', 'BLMOVE', 'BLMPOP', + 'BZPOPMIN', 'BZPOPMAX', 'BZMPOP', + ]; + /** * @var AsyncTcpConnection */ @@ -252,11 +403,39 @@ class Client */ protected $_subscribe = false; + /** + * Set to true while a MONITOR stream is active. Like $_subscribe it locks + * the connection — process() stops dispatching queued commands and + * onMessage() keeps the MONITOR entry pinned at the head of the queue so + * its callback receives every line — but MONITOR has no unsubscribe, so the + * only way to stop the stream is to close() the client. + * + * @var bool + */ + protected $_monitoring = false; + /** * @var bool */ 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; + + /** + * Callbacks registered by unsubscribe() / pUnsubscribe() / sUnsubscribe(), + * fired with (true, $client) once the connection has fully left subscribe + * mode (the server reports zero remaining subscriptions). + * + * @var array + */ + protected $_unsubscribeCallbacks = []; + /** * Client constructor. * @param $address @@ -272,27 +451,38 @@ public function __construct($address, $options = [], $callback = null) $this->_options = $options; $this->_connectionCallback = $callback; $this->connect(); - $timer = Timer::add(1, function () use (&$timer) { + // Periodic wait-timeout scan. Store the handle so close() can delete it + // — left dangling it would fire forever and, by capturing $this, keep + // the client object alive (a leak in workers that create clients + // dynamically). + $this->_waitTimeoutTimer = Timer::add(1, function () { if (empty($this->_queue)) { return; } - if ($this->_subscribe) { - Timer::del($timer); + if ($this->_subscribe || $this->_monitoring) { + // A subscribe / monitor stream pins an entry at the head of the + // queue indefinitely; skip the timeout scan so it isn't evicted. + // Don't delete the timer — when the stream ends (unsubscribe, a + // rejected monitor, or a reconnect) it must resume guarding + // queued commands again. return; } reset($this->_queue); $current_queue = current($this->_queue); $current_command = $current_queue[0][0]; - $ignore_first_queue = in_array($current_command, ['BLPOP', 'BRPOP']); + if (\in_array($current_command, self::BLOCKING_COMMANDS, true)) { + // A blocking command at the head legitimately holds the + // connection until it returns (or hits its own server-side + // timeout); everything queued behind it is waiting on that + // block, not hung. Time out none of it — otherwise queued + // commands would be failed with a spurious "Wait Timeout" + // while they had never even been sent. + return; + } $time = time(); $timeout = isset($this->_options['wait_timeout']) ? $this->_options['wait_timeout'] : 600; $has_timeout = false; - $first_queue = true; foreach ($this->_queue as $key => $queue) { - if ($first_queue && $ignore_first_queue) { - $first_queue = false; - continue; - } if ($time - $queue[1] > $timeout) { $has_timeout = true; unset($this->_queue[$key]); @@ -305,7 +495,7 @@ public function __construct($address, $options = [], $callback = null) } } } - if ($has_timeout && !$ignore_first_queue) { + if ($has_timeout) { $this->closeConnection(); $this->connect(); } @@ -368,6 +558,7 @@ public function connect() $this->_connection->onClose = function () use ($time_start) { $this->_subscribe = false; + $this->_monitoring = false; if ($this->_connectTimeoutTimer) { Timer::del($this->_connectTimeoutTimer); } @@ -376,6 +567,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 { @@ -392,7 +587,7 @@ public function connect() $queue = current($this->_queue); $cb = $queue[2]; $type = $data[0]; - if (!$this->_subscribe) { + if (!$this->_subscribe && !$this->_monitoring) { unset($this->_queue[key($this->_queue)]); } if (empty($this->_queue)) { @@ -419,7 +614,12 @@ public function connect() } try { \call_user_func($cb, $result, $this); - } catch (\Exception $exception) { + } catch (\Throwable $exception) { + // Catch \Throwable (not just \Exception) so a user callback + // that raises an Error — TypeError, DivisionByZeroError, … — + // can't escape before process() pumps the next command and + // wedge the queue. The captured throwable is re-thrown below, + // after the pump has run. } if ($type === '!') { @@ -455,19 +655,159 @@ public function connect() */ public function process() { - if (!$this->_connection || $this->_waiting || empty($this->_queue) || $this->_subscribe) { + if (!$this->_connection || $this->_waiting || empty($this->_queue) || $this->_subscribe || $this->_monitoring) { return; } \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; } + if ($queue[0][0] === 'MONITOR') { + $this->_monitoring = true; + } $this->_waiting = true; $this->_connection->send($queue[0]); $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 && ($this->_subscribe || $this->_monitoring)) { + // In coroutine mode an ordinary command would suspend the current + // fiber until its reply arrives — but process() refuses to send + // anything while the connection is subscribe/monitor-locked, so the + // reply (and the resume) can never come while the lock holds. That + // is a silent, unrecoverable fiber hang. Fail loudly instead. Use a + // dedicated Client for ordinary commands, or pass an explicit + // callback (callback-mode commands queue and drain after the stream + // ends rather than suspending). + throw new Exception( + 'Cannot issue a coroutine-mode Redis command while the connection ' + . 'is in subscribe/monitor mode: the fiber could never be resumed. ' + . 'Use a separate Client, or pass an explicit callback.' + ); + } + 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); + } + + /** + * Whether a streaming command (SUBSCRIBE / PSUBSCRIBE / SSUBSCRIBE / + * MONITOR) is already active OR sitting in the queue waiting to be sent. + * + * The flag pair ($_subscribe / $_monitoring) only flips once process() + * actually puts the stream on the wire. Checking it alone misses the most + * common misuse — two subscribe() calls in a row before the first frame has + * been sent — where both entries are still queued and the flags are still + * false. So this also scans the queue for a pending stream verb. + * + * @return bool + */ + protected function streamActiveOrPending() + { + if ($this->_subscribe || $this->_monitoring) { + return true; + } + foreach ($this->_queue as $entry) { + $verb = $entry[0][0] ?? ''; + if ($verb === 'SUBSCRIBE' || $verb === 'PSUBSCRIBE' + || $verb === 'SSUBSCRIBE' || $verb === 'MONITOR') { + return true; + } + } + return false; + } + + /** + * Guard a subscribe-family entry point against the single-stream limit. + * + * This client pins ONE stream entry at the head of the queue and routes + * every incoming message to that entry's callback. A second subscribe while + * a stream is active or pending can't be honoured — process() is locked, so + * the frame would never reach the wire, and even if it did its messages + * would be delivered to the first callback, not this one. Rather than drop + * it silently, fail loudly: subscribe to every channel in a single call, or + * use a separate Client per stream. + * + * @param string $method The calling method name, for the error message. + * @return void + */ + protected function assertNoActiveStream($method) + { + if ($this->streamActiveOrPending()) { + throw new Exception( + "$method: the connection already has an active or pending " + . 'subscribe/monitor stream. This client supports one stream per ' + . 'connection — pass all channels/patterns in a single subscribe ' + . 'call, or use a separate Client for the additional stream.' + ); + } + } + /** * subscribe * @@ -476,6 +816,7 @@ public function process() */ public function subscribe($channels, $cb) { + $this->assertNoActiveStream('subscribe'); $new_cb = function ($result) use ($cb) { if (!$result) { echo $this->error(); @@ -488,6 +829,13 @@ public function subscribe($channels, $cb) case 'message': \call_user_func($cb, $result[1], $result[2], $this); return; + case 'unsubscribe': + case 'punsubscribe': + case 'sunsubscribe': + // Any unsubscribe-family ack clears the lock — see + // handleUnsubscribeAck() for why all three types are accepted. + $this->handleUnsubscribeAck($result); + return; default: echo 'unknow response type for subscribe. buffer:' . serialize($result) . "\n"; } @@ -504,6 +852,7 @@ public function subscribe($channels, $cb) */ public function pSubscribe($patterns, $cb) { + $this->assertNoActiveStream('pSubscribe'); $new_cb = function ($result) use ($cb) { if (!$result) { echo $this->error(); @@ -516,6 +865,13 @@ public function pSubscribe($patterns, $cb) case 'pmessage': \call_user_func($cb, $result[1], $result[2], $result[3], $this); return; + case 'unsubscribe': + case 'punsubscribe': + case 'sunsubscribe': + // Any unsubscribe-family ack clears the lock — see + // handleUnsubscribeAck() for why all three types are accepted. + $this->handleUnsubscribeAck($result); + return; default: echo 'unknow response type for psubscribe. buffer:' . serialize($result) . "\n"; } @@ -524,58 +880,300 @@ 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(). Call sUnsubscribe() to drop the shard subscription and hand + * the connection back for ordinary commands (or close() the client). + * + * @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) + { + $this->assertNoActiveStream('sSubscribe'); + $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; + case 'unsubscribe': + case 'punsubscribe': + case 'sunsubscribe': + // Any unsubscribe-family ack clears the lock — see + // handleUnsubscribeAck() for why all three types are accepted. + $this->handleUnsubscribeAck($result); + return; + default: + echo 'unknow response type for ssubscribe. buffer:' . serialize($result) . "\n"; + } + }; + $this->_queue[] = [['SSUBSCRIBE', $channels], time(), $new_cb]; + $this->process(); + } + + /** + * UNSUBSCRIBE — stop listening on zero or more channels. + * + * Pass no channels to drop every active subscription; pass specific + * channel names to drop only those. An optional trailing callable fires + * with (true, $client) once the connection has fully left subscribe mode + * (zero remaining subscriptions). The callback signals "back to normal + * command mode", not the teardown of a specific channel: on a partial + * unsubscribe — dropping some of several channels — it is held until the + * connection eventually leaves subscribe mode entirely. Callers that need + * per-channel notification should track that in their subscribe callback. + * + * subscribe() locks the connection (process() refuses to send anything + * while $this->_subscribe is true), so an unsubscribe routed through + * __call() would sit in the queue forever. This method writes the frame + * straight to the socket, bypassing that lock; handleUnsubscribeAck() + * clears the lock when the server reports zero remaining subscriptions. + * + * @param mixed ...$channelsAndCb channel names, optional trailing callable. + * @return null + */ + public function unsubscribe(...$channelsAndCb) + { + return $this->writeUnsubscribe('UNSUBSCRIBE', $channelsAndCb); + } + + /** + * PUNSUBSCRIBE — stop listening on zero or more patterns. + * + * Mirror of unsubscribe() for pSubscribe() pattern subscriptions. Pass no + * patterns to drop them all; an optional trailing callable fires with + * (true, $client) once the connection has fully left subscribe mode. + * + * @param mixed ...$patternsAndCb pattern strings, optional trailing callable. + * @return null + */ + public function pUnsubscribe(...$patternsAndCb) + { + return $this->writeUnsubscribe('PUNSUBSCRIBE', $patternsAndCb); + } + + /** + * SUNSUBSCRIBE — stop listening on zero or more shard channels. + * + * Mirror of unsubscribe() for sSubscribe() shard subscriptions. Pass no + * channels to drop them all; an optional trailing callable fires with + * (true, $client) once the connection has fully left subscribe mode. + * + * @param mixed ...$channelsAndCb shard channel names, optional trailing callable. + * @return null + */ + public function sUnsubscribe(...$channelsAndCb) + { + return $this->writeUnsubscribe('SUNSUBSCRIBE', $channelsAndCb); + } + + /** + * Write an UNSUBSCRIBE-family frame directly to the connection, bypassing + * the queue/process() lock that subscribe() puts in place. + * + * A trailing callable in $args is popped and registered to fire once the + * connection has fully unsubscribed (see handleUnsubscribeAck()). If the + * client is not currently in subscribe mode there is nothing to tear down + * — and a bare UNSUBSCRIBE would produce a reply onMessage() couldn't match + * to any queued command — so we honour the callback contract and return + * without touching the wire. + * + * @param string $verb 'UNSUBSCRIBE' | 'PUNSUBSCRIBE' | 'SUNSUBSCRIBE'. + * @param array $args channel/pattern names, optional trailing callable. + * @return null + */ + protected function writeUnsubscribe($verb, array $args) + { + $cb = null; + if (!empty($args) && \is_callable(\end($args))) { + $cb = \array_pop($args); + } + // A live connection is implied here: closeConnection() clears the + // _subscribe flag and the socket together, so being in subscribe mode + // guarantees _connection is set. + if (!$this->_subscribe) { + if ($cb !== null) { + \call_user_func($cb, true, $this); + } + return null; + } + if ($cb !== null) { + $this->_unsubscribeCallbacks[] = $cb; + } + $this->_connection->send(\array_merge([$verb], $args)); + return null; + } + + /** + * Handle an UNSUBSCRIBE / PUNSUBSCRIBE / SUNSUBSCRIBE acknowledgement frame. + * + * All three subscribe callbacks route every unsubscribe-family ack type + * here, not just their matching one: a SUNSUBSCRIBE issued with no channel + * argument is acked by Dragonfly as type 'unsubscribe' (not 'sunsubscribe'), + * so keying off a single type would miss the unsubscribe-all teardown and + * leave the connection locked forever. + * + * Redis sends one ack per channel, each carrying the running count of + * remaining subscriptions ($result[2]). While that count is non-zero the + * connection is still subscribed and we leave the lock in place. When it + * reaches zero the connection has left subscribe mode, so we: + * - clear $this->_subscribe so process() can resume sending, + * - drop the now-stale SUBSCRIBE entry that has been pinned at the head + * of the queue (onMessage() never removes it while subscribed; without + * this the next process() would re-send SUBSCRIBE), and + * - fire any callbacks registered by the unsubscribe* methods. + * + * onMessage() calls process() immediately after this returns, which drains + * whatever the caller queued while the connection was locked. + * + * @param array $result [verb, channel|null, remaining_count]. + * @return void + */ + protected function handleUnsubscribeAck($result) + { + // Default the count to 0 (trigger teardown) when the element is absent: + // the safe failure mode on a malformed ack is to unlock rather than + // stay locked forever. + $remaining = isset($result[2]) ? (int)$result[2] : 0; + if ($remaining > 0) { + // Still subscribed to other channels — keep the lock and hold any + // registered completion callbacks until the connection is fully + // unsubscribed (see writeUnsubscribe()). + return; + } + $this->_subscribe = false; + \reset($this->_queue); + $headKey = \key($this->_queue); + if ($headKey !== null) { + $verb = $this->_queue[$headKey][0][0] ?? ''; + if ($verb === 'SUBSCRIBE' || $verb === 'PSUBSCRIBE' || $verb === 'SSUBSCRIBE') { + unset($this->_queue[$headKey]); + } + } + if (!empty($this->_unsubscribeCallbacks)) { + $callbacks = $this->_unsubscribeCallbacks; + $this->_unsubscribeCallbacks = []; + foreach ($callbacks as $callback) { + \call_user_func($callback, true, $this); + } + } + } + + /** + * MONITOR — stream every command the server processes to $cb. + * + * Like subscribe(), MONITOR is long-lived and locks the connection: once it + * is sent, process() stops dispatching queued commands and onMessage() + * keeps this entry pinned at the head of the queue so its callback receives + * every monitor line. It uses its own $_monitoring flag rather than + * $_subscribe because there is no UNMONITOR — the only way to stop the + * stream is to close() the client (which clears the flag). + * + * The server's initial reply is +OK, delivered here as boolean true by the + * onMessage OK-normalisation; that handshake is swallowed. Every subsequent + * reply is a raw monitor line passed verbatim to $cb, e.g. + * 1700000000.123456 [0 127.0.0.1:6379] "set" "key" "value" + * + * DANGER: MONITOR streams ALL traffic the server handles and measurably + * reduces its throughput — use it for debugging, never as a steady-state + * listener, and keep a monitoring client off your hot-path connections. + * + * If the connection is already running a subscribe or monitor stream the + * call is ignored (silently, like connect() when already connected) — you + * cannot layer two streaming commands on one connection, and a queued + * second MONITOR would otherwise fire when the first stream ends, re-locking + * the connection with no way to recover. + * + * @param callable $cb function(string $line, Client $client): void + * @return void + */ + public function monitor($cb) + { + // Silently ignore (documented contract) if a stream is already active — + // or merely queued and not yet sent, which the bare flag check would + // miss. See streamActiveOrPending(). + if ($this->streamActiveOrPending()) { + return; + } + $new_cb = function ($result) use ($cb) { + if ($result === true) { + // The +OK handshake — monitoring has started. Swallow it. + return; + } + if ($result === false) { + // MONITOR was rejected (e.g. ACL). The server never entered + // monitor mode, so release our lock and drop the pinned entry + // so the client stays usable, then surface the failure. The + // onMessage() that invoked this closure calls process() right + // after it returns, which drains any commands the caller queued + // (or had waiting) now that the lock is clear. + $this->_monitoring = false; + \reset($this->_queue); + $headKey = \key($this->_queue); + if ($headKey !== null && ($this->_queue[$headKey][0][0] ?? '') === 'MONITOR') { + unset($this->_queue[$headKey]); + } + \call_user_func($cb, false, $this); + return; + } + \call_user_func($cb, $result, $this); + }; + $this->_queue[] = [['MONITOR'], time(), $new_cb]; + $this->process(); + } + /** * select * - * @param $db - * @param null $cb + * @param int $db + * @param callable|null $cb * @return mixed */ public function select($db, $cb = null) { $format = function ($result) use ($db) { - $this->_db = $db; + // Only record the new DB once the server has confirmed the switch. + // On a failed SELECT onMessage hands the formatter $result === false; + // updating _db then would make the next reconnect re-issue a SELECT + // to a DB the server never accepted. + if ($result !== false) { + $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) { $format = function ($result) use ($auth) { - $this->_auth = $auth; + // Only remember the credential once the server has accepted it. + // A failed AUTH delivers $result === false here; recording it would + // make the next reconnect replay a credential the server rejected. + if ($result !== false) { + $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 +1186,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; - } - $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(); + $cb = $args[3] ?? null; + return $this->queueCommand(['SETEX', $key, $timeout, $value], $cb); } - return null; + return $this->queueCommand(['SET', $key, $value], $cb); } /** @@ -627,34 +1204,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 +1223,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; - } - $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(); + $cb = $args[2] ?? null; + return $this->queueCommand(['DECRBY', $key, $num], $cb); } - return null; + return $this->queueCommand(['DECR', $key], $cb); } /** @@ -715,7 +1250,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 +1259,51 @@ 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(); + 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 = []; } - $this->_queue[] = [$args, time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); + $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 null; + return $this->queueCommand($args, $cb); } /** @@ -773,16 +1343,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 +1370,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 +1402,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 +1417,70 @@ 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(); + return $this->queueCommand($args, $cb); + } + + /** + * XADD — append an entry to a stream. + * + * Explicit method because the RESP encoder flattens a nested array arg by + * emitting its VALUES ONLY (foreach ($item as $str) in Protocols\Redis:: + * encode()). Passing an associative field=>value message through __call() + * therefore drops the field names and the server rejects the command with + * "wrong number of arguments". This method flattens the message itself — + * each field and its value become separate wire tokens — so callers can + * use the natural ['field' => 'value'] shape. + * + * $message must be a non-empty associative map of field => value. A bare + * indexed list would be sent as field "0", value , … which is not + * what you want; keep field names explicit. + * + * Optional capping mirrors phpredis: pass $maxLen > 0 to add `MAXLEN n`, + * and $approximate = true to make it `MAXLEN ~ n` (cheaper trimming). A + * callable in the $maxLen or $approximate slot is taken as the callback, + * so `$redis->xAdd($key, '*', $msg, $cb)` works without spelling out the + * cap arguments — the same trailing-callback shortcut used by flushDb() + * and bgSave(). + * + * @param string $key + * @param string $id Entry ID, or '*' to let the server assign one. + * @param array $message Associative field => value map (non-empty). + * @param int|callable $maxLen Cap the stream length, or the callback. + * @param bool|callable $approximate true for `MAXLEN ~`, or the callback. + * @param callable|null $cb function(string $id, Client $client): void + * @return mixed Coroutine mode: the entry ID. Callback mode: null. + */ + public function xAdd($key, $id, array $message, $maxLen = 0, $approximate = false, $cb = null) + { + if (\is_callable($maxLen)) { + $cb = $maxLen; + $maxLen = 0; + $approximate = false; + } elseif (\is_callable($approximate)) { + $cb = $approximate; + $approximate = false; } - $this->_queue[] = [$args, time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); + if (empty($message)) { + throw new \InvalidArgumentException('xAdd requires a non-empty field => value message'); } - return null; + $args = ['XADD', $key]; + if ($maxLen > 0) { + $args[] = 'MAXLEN'; + if ($approximate) { + $args[] = '~'; + } + $args[] = $maxLen; + } + $args[] = $id; + foreach ($message as $field => $value) { + $args[] = $field; + $args[] = $value; + } + return $this->queueCommand($args, $cb); } /** @@ -900,23 +1493,192 @@ 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(); + 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); } - $this->_queue[] = [$args, time(), $cb]; - $this->process(); - if ($need_suspend) { - return $suspension->suspend(); + if (empty($args)) { + throw new \InvalidArgumentException('rawCommand requires at least the command name'); } - return null; + 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); } /** @@ -940,6 +1702,7 @@ public function closeConnection() return; } $this->_subscribe = false; + $this->_monitoring = false; $this->_connection->onConnect = $this->_connection->onError = $this->_connection->onClose = $this->_connection->onMessage = null; $this->_connection->close(); @@ -962,12 +1725,181 @@ function error() return $this->_error; } + /** + * getHost — local accessor (phpredis-compat). + * + * Returns the host parsed out of the stored connection URL ($_address, + * e.g. "redis://127.0.0.1:6379"). This is client state, NOT a server + * round-trip — it never touches the connection or the command queue. + * + * @return string|null The host, or null if $_address cannot be parsed. + */ + public function getHost() + { + $host = \parse_url($this->_address, \PHP_URL_HOST); + + return $host === false ? null : $host; + } + + /** + * getPort — local accessor (phpredis-compat). + * + * Returns the port parsed out of the stored connection URL. Defaults to + * 6379 when the URL carries no explicit port, matching the conventional + * Redis port the address is built around. Purely local, no round-trip. + * + * @return int + */ + public function getPort() + { + $port = \parse_url($this->_address, \PHP_URL_PORT); + + return $port === false || $port === null ? 6379 : (int)$port; + } + + /** + * getDbNum — local accessor (phpredis-compat). + * + * Returns the currently selected database index as tracked locally by + * select()/connect(). No SELECT is sent; this reflects $_db only. + * + * @return int + */ + public function getDbNum() + { + return (int)$this->_db; + } + + /** + * getAuth — local accessor (phpredis-compat). + * + * Returns the stored auth credential exactly as supplied to auth()/the + * options: a string password, a [user, pass] array for ACL auth, or null + * when no credential was set. Purely local, no AUTH round-trip. + * + * @return mixed + */ + public function getAuth() + { + return $this->_auth; + } + + /** + * getTimeout — local accessor (phpredis-compat). + * + * Returns the configured connect timeout from the client options + * ('connect_timeout'), or null if none was configured. Local only. + * + * @return mixed The configured connect timeout, or null. + */ + public function getTimeout() + { + return $this->_options['connect_timeout'] ?? null; + } + + /** + * getReadTimeout — local accessor (phpredis-compat). + * + * Returns the configured read/wait timeout from the client options + * ('wait_timeout' — the key the wait-timeout scan reads), or null if not + * configured. Local only, no round-trip. + * + * @return mixed The configured wait timeout, or null. + */ + public function getReadTimeout() + { + return $this->_options['wait_timeout'] ?? null; + } + + /** + * isConnected — local accessor (phpredis-compat). + * + * Reports whether this client currently holds an established connection. + * $_connection is null until connect() runs and is reset to null on + * teardown, so a non-null connection in the ESTABLISHED state means we + * are connected. + * + * @return bool + */ + public function isConnected() + { + return $this->_connection !== null + && $this->_connection->getStatus(false) === 'ESTABLISHED'; + } + + /** + * getLastError — local accessor (phpredis-compat). + * + * Returns the last stored error string, or null when there is none. + * phpredis returns null (not an empty string) when no error has occurred, + * so the internal '' sentinel is normalised to null here. Local only. + * + * @return string|null + */ + public function getLastError() + { + return $this->_error === '' ? null : $this->_error; + } + + /** + * clearLastError — local accessor (phpredis-compat). + * + * Clears the stored last-error string and returns true, mirroring + * phpredis. Local only, no round-trip. + * + * @return bool + */ + public function clearLastError() + { + $this->_error = ''; + + return true; + } + + /** + * getPersistentID — local accessor (phpredis-compat). + * + * This async client never opens persistent connections (every connection + * is a fresh AsyncTcpConnection torn down on close()), so there is no + * persistent-connection identifier to report. Always null. Local only. + * + * @return null + */ + public function getPersistentID() + { + return null; + } + + /** + * getMultiple — phpredis MGET alias. + * + * Unlike the other accessors here, this IS a server command: phpredis + * exposes getMultiple() as an alias for MGET, so it delegates to MGET via + * the normal command queue (works in both callback and coroutine modes). + * Returns the values for $keys in order, with null for any missing key. + * + * @param array $keys + * @param callable|null $cb + * @return mixed + */ + public function getMultiple(array $keys, $cb = null) + { + return $this->queueCommand(\array_merge(['MGET'], $keys), $cb); + } + /** * close */ public function close() { $this->closeConnection(); + // Tear down the periodic wait-timeout scan installed by the + // constructor; without this the timer keeps firing and its closure + // keeps $this alive, defeating the gc_collect_cycles() below. + if ($this->_waitTimeoutTimer) { + Timer::del($this->_waitTimeoutTimer); + $this->_waitTimeoutTimer = null; + } $this->_queue = []; gc_collect_cycles(); if (function_exists('gc_mem_caches')) { @@ -975,44 +1907,1973 @@ 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); + } + + /** + * 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. + * + * 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); + } + /** - * scan + * 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. * - * @throws Exception + * 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 scan() + public function hello($protover = null, $extra = null, $cb = null) { - throw new Exception('Not implemented'); + 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); } + /* + |-------------------------------------------------------------------------- + | 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. + */ + /** - * hScan + * CONFIG — server configuration subcommand family. + * + * Wire form: `CONFIG [args...]`. Typical verbs are GET, SET, + * RESETSTAT, REWRITE. A trailing callable is taken as the callback. * - * @throws Exception + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed */ - public function hScan() + public function config(...$args) { - throw new Exception('Not implemented'); + return $this->dispatcher('CONFIG ', $args); } /** - * hScan + * 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. * - * @throws Exception + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed */ - public function sScan() + public function acl(...$args) { - throw new Exception('Not implemented'); + return $this->dispatcher('ACL ', $args); } /** - * hScan + * 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. * - * @throws Exception + * @param mixed ...$args [verb, ...args, optional callable] + * @return mixed */ - public function zScan() + public function slowLog(...$args) { - throw new Exception('Not implemented'); + 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); + } + + /* + |-------------------------------------------------------------------------- + | 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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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); + } + + /* + |-------------------------------------------------------------------------- + | 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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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. + * + * @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. + * + * 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($cursor, array $options = [], $cb = null) + { + $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; + } + + /** + * Incrementally iterate one hash one batch of fields at a time. + * + * 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($key, $cursor, array $options = [], $cb = null) + { + $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; + } + + /** + * Incrementally iterate one set one batch of members at a time. + * + * 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($key, $cursor, array $options = [], $cb = null) + { + $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; + } + + /** + * Incrementally iterate one sorted set one batch of member=>score pairs at a time. + * + * 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($key, $cursor, array $options = [], $cb = null) + { + $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; + } + + /* + |-------------------------------------------------------------------------- + | 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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['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 = \array_merge(['ALTER', $index], $args, [$cb]); + return $this->ft(...$wire); + } + + /** + * FT.CONFIG — module-wide configuration subcommand. + * + * Wire form: `FT.CONFIG