From 5dd1de3778e2a452151f95cf846beda1bef71a6a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:46:25 +0000 Subject: [PATCH 01/15] docs(plan): define the Redis lifecycle audit Record the complete Redis pooling, subscriber transport, topology, and Laravel parity design before implementation. Capture the verified phpredis and RESP boundaries, protected Hypervel capabilities, accepted intentional differences, implementation order, regression matrix, hot-path assessment, and anti-overengineering constraints that govern the work unit. --- ...s-pooling-subscriber-and-laravel-parity.md | 1827 +++++++++++++++++ 1 file changed, 1827 insertions(+) create mode 100644 docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md diff --git a/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md b/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md new file mode 100644 index 000000000..12b2af7ae --- /dev/null +++ b/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md @@ -0,0 +1,1827 @@ +# Complete Redis Pooling, Subscriber Transport, and Current Laravel Parity + +## Status + +Plan signed off after the full Redis package audit, pre-implementation second-opinion consensus, fresh source review, and independent plan review. The owner approved the consensus scope and its additive capabilities, public-surface corrections, behavior changes, and source-proven hot-path costs. During implementation, focused second-opinion loops amended the connection-context, Sentinel endpoint, and subscriber transport requirements after live Redis integration and self-review exposed empty-context and context-implied TLS mismatches. The amendments are recorded in Sections 3, 4, and 7. + +This is the authoritative implementation plan for the Redis work unit. After compaction during implementation, re-read `AGENTS.md` and this plan in full before editing. Do not rely on a compacted summary and do not re-read the main framework audit plan unless a newly discovered issue requires its wider routing context. The anti-overengineering, implementation, testing, review, and completion rules needed for this work are repeated here deliberately. + +## Scope + +Complete the `redis` package audit as one coherent Redis, Reverb, Horizon, Telescope, Sentry, Testing, Support-facade, metadata, and documentation work unit. + +The finding table identifies the complete proposed work. The numbered implementation sections are authoritative for behavior, code shape, tests, and rejected alternatives; the architecture and summary sections orient the work without redefining those decisions. + +The work must not weaken or remove these Hypervel features: + +- `transform: true|false`; +- `withConnection()` and `withPinnedConnection()`; +- immediate callback-form pipeline and transaction release; +- one owner-coroutine terminal defer per pool for raw same-connection state; +- `withoutSerializationOrCompression()`; +- SafeScan and `flushByPattern()`; +- serializer, compression, digest, pack, unpack, and option helpers; +- `evalWithShaCache()`; +- nested per-connection pool, Sentinel, and Cluster configuration; +- explicit concurrency leases; +- exact task and process cleanup. + +Hyperf parity is not a goal. Laravel remains the public API and documentation reference where its API fits a phpredis-only coroutine pool. Hypervel's pooled ownership and connection-local topology take priority where Laravel's request-lifetime connector model does not fit. + +## Desired final architecture + +| Surface | Final owner and lifetime | +|---|---| +| Named Redis access | One worker-lifetime `RedisProxy` per configured name | +| Ordinary commands | One checked-out `RedisConnection` wrapper per proxy operation, returned or discarded after events settle | +| Stateful command sequences | The exact wrapper remains pinned in coroutine or fallback context until its real terminal operation | +| Callback pipeline/transaction | Newly pinned wrapper returns immediately when the callback finishes | +| Terminal deferred cleanup | At most one owner-ID defer per pool and coroutine; copied child context cannot inherit ownership falsely | +| Failed command disposition | `RedisConnection` classifies the local phpredis failure without replaying the command | +| Server error reply | Retain a protocol-synchronized generation unless Sentinel can resolve a different master | +| Transport/protocol failure | Mark the generation invalid for lazy reconnect | +| Subscriber transport | One dedicated hooked PHP stream and exact RESP2 decoder per `Subscriber` | +| Subscriber command routing | `CommandInvoker` owns receive-loop cause retention and result, PING, and message channels | +| Subscriber topology | `RedisProxy` maps the configured standalone, Sentinel, or Cluster connection to a dedicated subscriber endpoint | +| Sentinel discovery | `RedisSentinelFactory` owns shuffled node attempts and one aggregated total-failure exception | +| Reverb publishing | The injected pooled `RedisProxy` publishes independently of subscriber state | +| Redis macros | Static macro registry on `RedisConnection`; proxy remains the event and lease owner | +| Event enablement | Nullable worker-owned override on `RedisConfig`, applied only when a pool assembles its connection config | +| Horizon connection | Full named Hypervel Redis config copied to `database.redis.horizon`; clustered prefixes are hash-tagged | +| Facade metadata | Production proxy constant is the callable-boundary source; tests enforce its inclusion in facade ignores | +| Telescope observability | Recursive scalar-safe formatting and exact batch-opener filtering owned by `RedisWatcher` | +| Static test cleanup | `AfterEachTestSubscriber` flushes `RedisConnection` macros | + +## Finding summary + +| ID | Category | Severity | Verified failure | Final boundary | +|---|---|---:|---|---| +| `redis-09` | Defect | Critical | A caught `RedisException` reconnects and repeats a command that Redis may already have committed | Never replay; retain synchronized server errors, invalidate unknown transport/protocol state, and invalidate stale Sentinel master replies | +| `redis-10` | Defect | Major | CRLF EOF framing corrupts bulk values containing CRLF, ignores error frames, poisons later acknowledgements after simple PONG, and can leave foreground waits blocked without a cause | Use an exact hooked-stream RESP2 decoder, retain the first receive failure, and bound foreground waits | +| `redis-11` | Defect | Major | Dedicated subscribers drop valid credentials, TLS/context, Unix, Sentinel, and Cluster configuration | Resolve complete subscriber endpoints and credentials from each connection's real topology | +| `reverb-05` | Defect | Major | Reverb gates publishing on the wrong subscriber owner, grows memory without a bound, can report a false empty metrics result, and retains pending metrics state when publishing fails | Publish immediately through the independent pooled proxy, propagate real failures, and clean the registered metric/listener on failure | +| `redis-12` | Defect and upstream defect | Major | A release failure replaces a callback failure in both concurrency-limiter public callback paths | Use two direct primary-failure-preserving blocks and test the full matrix at both sites | +| `redis-13` | Supported current Laravel parity and defects | Major/Minor | Host, scheme, context, option, ACL, prefix, client-name, and Cluster failover behavior is missing, stale, or falsey-zero unsafe | Port current PhpRedis behavior into existing pooled connection classes | +| `redis-14` | Supported current Laravel parity | Improvement | Redis connections lack current Laravel macros | Add Macroable to exact wrappers, register without a Redis checkout, preserve proxy event granularity, and flush static state after tests | +| `redis-15` | API/configuration correction | Major | Telescope and Sentry duplicate destructive config loops, while Hypervel's `extend()` uses Laravel's name for an incompatible named-proxy API | Add boot-only manager event controls through a tri-state config override and delete the false extension API | +| `horizon-01` | Defect and parity defect | Major | `Horizon::use()` reads a dead top-level cluster shape, can reduce a Cluster to one seed, and fails to hash-tag or publish the resolved prefix | Copy the complete named Hypervel config and normalize the Cluster prefix | +| `redis-16` | Defect and intentional difference | Major | Advertised pooled `ssubscribe()` can capture a wrapper indefinitely in native sharded-subscriber mode | Reject it beside subscribe/psubscribe and remove its advertised surface | +| `redis-17` | Metadata defect | Minor | Proxy-bound methods and generated facade exclusions have already drifted apart | Enforce one production-owner subset invariant and derive proxy test cases from the production constant | +| `redis-18` | Dead compatibility and duplicate-normalization defect | Minor | Redis 6.0 source/test compatibility is unreachable at the declared 6.1 floor, and config-name validation bypasses the canonical normalizer | Delete the unsupported branches and route both config paths through one parser | +| `redis-19` | Critical native-boundary defect and intentional difference | Critical | Native RESET is process-fatal in PIPELINE mode and silently leaves a pooled wrapper working against database 0 | Reject pooled RESET before native dispatch, transformation, or queue handling | +| `redis-20` | Defect | Major | Mixed-case commands bypass proxy routing, WATCH bookkeeping, and pooled subscription/RESET guards | Preserve exact macro names, normalize proxy routing once, and normalize wrapper-native dispatch once | +| `telescope-01` | Defect and upstream defect | Major | Redis observability formatting can run `jsonSerialize()` or implicit object conversion and replace a successful command result with a formatter failure | Recursively format arrays and use `get_debug_type()` for every non-scalar leaf | +| `telescope-02` | Defect | Minor | Telescope's dead `transaction` ignore records real `multi` openers, while mixed-case pipeline openers bypass its non-strict case-sensitive filter | Strictly filter the actual `pipeline` and `multi` event names case-insensitively | +| `sentry-01` | Defect | Major | Redis spans read the test-only `db` key and report database 0 for real non-zero `database` configs | Read the canonical normalized key in both success and failure spans and correct the masking test fixture | + +## Backing research and fixed assumptions + +### Carried Redis ownership invariants + +The Database work unit already established and tested: + +- exact wrapper ownership in `RedisProxy`; +- one terminal defer per pool and owning coroutine ID; +- no defer outside a coroutine; +- copied coroutine context does not copy defer ownership; +- newly pinned callback-form pipeline/transaction wrappers return immediately; +- native MULTI, PIPELINE, and WATCH state causes terminal discard rather than requeue; +- successful terminal operations settle WATCH correctly; +- Laravel-facing DISCARD reaches phpredis rather than Pool lifecycle teardown; +- event failures cannot skip connection handoff or terminal cleanup; +- task and pre-fork cleanup discard exact inherited wrappers and flush already-resolved pools; +- pool and command durations use monotonic time. + +Implementation must preserve those tests and source paths. Do not reintroduce: + +- one defer per pin; +- a boolean or object defer marker that copied context can inherit falsely; +- eager-release marker helpers; +- a PHP mirror of phpredis queue mode; +- reconstructed connection identity; +- release during fork cleanup; +- command-listener failure before ownership cleanup. + +### Current Laravel workflow + +Historical Laravel changes are discovery evidence only. For each accepted current feature, reopen the corresponding current file and its current tests before implementing. The actual source reference is the local default branch: + +- framework: `examples/laravel/framework`, commit `23e9e71f382b`; +- docs: `examples/laravel/docs`, commit `ce4a1bf093c2`. + +The Redis discovery history is: + +| Commit / PR | Discovery surface | +|---|---| +| `6ce9d6f247` / #56941 | Standalone `pack_ignore_numbers`; connector source | +| `26013caaf6` / #59569 | Standalone and Cluster context normalization; connector source and `PhpRedisConnectorTest` | +| `27ddc2e67b` | Redis Connection macros; connection source and `RedisConnectionTest` | +| `d2b2cdc9b4` / #59158 | `tcp_keepalive`; connector source and `RedisConnectorTest` | +| `84d28db0bb` / #49560 | Do not send `CLIENT SETNAME` to Redis Cluster | +| `d12b613ad6` / #35402 | Standalone client name; connector and tests | +| `3e7df8b7ed` / #60237 | Non-empty host and matching scheme; PhpRedis and Predis discovery files and tests | +| `0dc2d8a8f3` / #40282 | Serializer/compression support and related connection tests | +| `e720b86e1c` / #31182 | Current PhpRedis option support | +| `0b6d69917d` / #54191 | Native max-retry and backoff settings | +| `d7d4cc892f` | Manager event enable/disable API | +| `847b196c7b` | Laravel connector-driver `extend()` and `setDriver()` surface, intentionally not ported | + +Current files to use during implementation: + +- `examples/laravel/framework/src/Illuminate/Redis/Connectors/PhpRedisConnector.php`; +- `examples/laravel/framework/src/Illuminate/Redis/Connections/Connection.php`; +- `examples/laravel/framework/src/Illuminate/Redis/RedisManager.php`; +- `examples/laravel/framework/tests/Redis/PhpRedisConnectorTest.php`; +- `examples/laravel/framework/tests/Redis/RedisConnectorTest.php`; +- `examples/laravel/framework/tests/Redis/RedisConnectionTest.php`; +- `examples/laravel/framework/tests/Redis/RedisManagerExtensionTest.php`; +- `examples/laravel/docs/redis.md`; +- `examples/laravel/horizon/src/Horizon.php`. + +Preserve current upstream member order where current Laravel members are merged into Laravel-shaped Hypervel classes. Adapt only the owner, topology, pooling, typing, coroutine, event, and supported-platform boundaries recorded here. + +### Verified phpredis failure behavior + +The failure discriminator is structural in phpredis: + +1. a RESP `-` reply stores its text in the client's last-error slot; +2. `redis_error_throw()` throws from that same stored buffer for error classes it chooses to throw; +3. a thrown synchronized server reply therefore has `getLastError() === $exception->getMessage()`; +4. transport and truncated-protocol failures do not produce that equality; +5. if an older supported extension ever fails to set the slot, inequality degrades conservatively to invalidation. + +PhpRedis has an open-ended throw set. It throws for many server replies, including `LOADING`, `MISCONF`, `OOM`, `READONLY`, `MASTERDOWN`, `CROSSSLOT`, and module-defined error codes. Reconnecting on every thrown server error would create reconnect storms for conditions a new generation would hit identically. + +The final rule is based on whether a new generation can behave differently: + +- unknown transport or protocol state: invalidate; +- synchronized server error against the same endpoint: retain; +- synchronized `READONLY` or `MASTERDOWN` from a Sentinel connection: invalidate because the next generation resolves the current master; +- Cluster redirection: leave to phpredis; +- never repeat the failed command. + +Match the exact Redis error-code prefix, not a broad substring. + +### Verified RESET behavior + +Current phpredis `PHP_METHOD(Redis, reset)`: + +- raises `php_error_docref(E_ERROR)` in PIPELINE mode, so PHP terminates before framework code can catch it; +- resets the client status to connected, mode to atomic, selected database to `0`, and native watch state to false; +- causes the Redis server to deauthenticate; +- leaves phpredis's stored auth configuration intact, so the next command transparently authenticates; +- leaves phpredis's stored database as `0`, so the reconnect/open path never reselects the configured non-zero database; +- therefore leaves the client apparently healthy while all later commands use database 0. + +This is not a Hypervel flag-sync issue. RESET destroys connection configuration that the pool owns. Reconnecting inside `reset()` would swap the exact socket behind `withConnection()` / `withPinnedConnection()` and could turn a server-successful RESET into a later connect failure. The honest pooled contract is to reject RESET before native dispatch. + +The relevant local phpredis references are: + +- `examples/phpredis/redis.c`, `PHP_METHOD(Redis, reset)`; +- `examples/phpredis/library.c`, `redis_sock_server_open()` and error-reply handling; +- `examples/phpredis/CHANGELOG.md`, Redis 6.2 `OPT_PACK_IGNORE_NUMBERS`. + +The native PIPELINE fatal is a valid upstream phpredis improvement candidate because the same method reports other invalid modes normally. Do not put a framework TODO in Redis source: pooled RESET remains incompatible even if phpredis later throws an exception. + +### Verified subscriber transport facts + +The current subscriber uses Engine EOF packet framing with `"\r\n"`. A RESP bulk payload containing CRLF is split before its declared length is honored. A NUL-only payload is not by itself corrupted; tests and docs must state the narrower true failure. + +Engine cannot be repaired by mixing `recvPacket()` and exact `recvAll()` on the same socket because `recvPacket()` can buffer bytes that `recvAll()` cannot then see. `SocketFactory` also hardcodes an internet socket family and cannot represent Unix sockets. + +A hooked PHP stream: + +- is coroutine-nonblocking under the same TCP hook already required for pooled phpredis; +- supports `stream_socket_client()`, `fgets()`, exact repeated `fread()`, repeated `fwrite()`, and sibling `fclose()` wakeup; +- supports TCP, TLS, IPv4, IPv6, and Unix endpoints; +- preserves bulk bytes containing CRLF and NUL exactly. + +This is a transport correction, not a second networking stack or a new effective runtime requirement. Do not add a public stream factory, transport contract, hook option, or user-facing hook architecture section. + +### Subscriber topology facts + +- standalone config may use host/port, TLS scheme, IPv6, Unix path, username, scalar/array password, stream context, timeout, and prefix; +- username and password value `"0"` are valid and must not be lost to truthiness; +- Sentinel nodes and Sentinel auth are separate from the resolved master's connection credentials; +- every new Sentinel subscriber must resolve the current master rather than use a pooled wrapper's possibly stale endpoint; +- any current Redis Cluster master is valid for ordinary SUBSCRIBE/PSUBSCRIBE; +- Cluster master discovery may borrow a pooled wrapper briefly, read its local/native master list, then release it before opening the dedicated subscriber; +- sharded Pub/Sub needs slot routing and is deliberately not represented by this ordinary subscriber. + +### Event and extension facts + +- a Redis pool snapshots its config when it is created; +- changing user config or an override cannot retrofit existing checked-out or idle wrappers; +- a per-command config read would put configuration work on every command; +- `RedisConfig` already owns connection-config assembly and is an auto-singleton; +- a nullable worker-owned override can preserve explicit per-connection config when null and override future pool creation when true or false; +- Telescope and Sentry currently duplicate boot-time config mutation loops; +- current Hypervel `extend($connectionName, callable)` creates a named proxy and is not Laravel's connector-driver `extend($driver, Closure)`; +- the repository has no production consumer of the named-proxy extension surface; +- macros and explicit held raw access cover the useful supported extension needs without adding Predis or connector abstraction. + +### Owner-approved gates + +The owner has explicitly approved: + +1. rejecting pooled `reset()` although current Laravel advertises the native method; +2. adding one static macro-table lookup to every wrapper command; +3. deleting the documented Hypervel automatic replay promise; +4. deleting public Hypervel `extend()` / `forgetExtension()` with no compatibility shim; +5. changing Reverb outage behavior from silent queueing and `0` to an honest thrown publish failure; +6. adding Laravel-shaped boot-only event controls; +7. preserving Hypervel's connection-local topology instead of adding Laravel's top-level clusters tree; +8. rejecting pooled `ssubscribe()` rather than adding sharded Pub/Sub machinery; +9. every other additive capability and current-Laravel parity item in the pre-plan consensus. + +The final owner summary must separately call out narrower corrections discovered during the fresh plan review, including proxy and wrapper method-name normalization, exact RESP integer validation, failed Reverb metrics-request cleanup, exact Telescope batch-opener filtering, and `sentry-01`. It must state that Telescope records no entry for callback-form pipelines or transactions because their queued commands and EXEC run directly on the held native client; recording a bare opener would imply command visibility Telescope does not have. + +## Embedded implementation rules + +These rules are copied here because this plan, not the main audit plan, will be the implementation context after compaction. + +### Correctness and ownership + +- Require a supported realistic path and meaningful harm before treating a concern as a defect. +- Trace the exact state or resource owner, acquisition, publication, use, handoff, and every terminal path before editing. +- Fix a shared defect at its lowest owning boundary and update every affected consumer in the same change. +- Preserve the earliest operation failure while still attempting independent cleanup. +- Never replay an operation whose commit state is unknown. +- Never requeue a connection with unknown protocol, transaction, watch, subscriber, or session state. +- Keep context ownership exact; never reconstruct a borrowed wrapper from mutable configuration. +- Do not weaken a verified correctness requirement to reduce churn. + +### Avoid overengineering + +- Prefer direct code and existing PHP, Laravel, Hypervel, phpredis, Channel, and stream primitives. +- Do not add a registry, state machine, retry loop, connector hierarchy, transport abstraction, configurable timeout family, pool retrofit path, generic finalizer, or extension point without a demonstrated need. +- Do not add machinery merely because it sounds robust or future-proof. +- Deliberate raw/native escape hatches remain escape hatches unless the public contract promises framework safety through them. +- Avoiding overengineering never justifies an incomplete fix, stale code, missing security or lifecycle safeguards, or deferred worthwhile work. + +### Hot-path discipline + +For every change, check: + +- allocations; +- static and container lookups; +- locks and atomics; +- context reads/writes; +- hashing and serialization; +- yields, sleeps, polling, and retries; +- network commands and reconnects; +- logging and exception construction; +- retained worker memory. + +Any measured or source-proven regression beyond the itemized noise-level costs in Section 19 requires a new owner gate before implementation. The proxy and wrapper name normalization found during the fresh plan review must be called out in the owner summary before implementation. A cold connection-creation, subscriber-construction, boot, failure, teardown, or observability path is not the ordinary command hot path. + +### Source and PHPStan discipline + +- Read each changed source file in full or consecutive chunks before editing. +- Inspect each touched method and adjacent code for stale guards, wrong types, dead compatibility, and false framework assumptions. +- Do not make runtime code more complex, slower, or less truthful to satisfy PHPStan. +- Fix real native/docblock type defects first; then use a truthful local `@var`; then use a line- or identifier-scoped ignore only when PHPStan cannot model correct code. +- Do not widen contracts with implementation-specific methods to satisfy analysis. +- Do not add global PHPStan ignores without a separate owner decision. + +### Testing discipline + +- Edit one file at a time. +- Run each changed or new test file immediately before moving to another test file. +- Tests must assert supported public behavior, verified failure paths, exact ownership, and deterministic cleanup. +- Use the existing `InteractsWithRedis` trait for every external Redis/Valkey test. +- Keep assigned `TEST_TOKEN`; never hardcode or overwrite runner-owned worker identity. +- Bind fake servers to port `0`. +- Close every stream, socket, subscriber, channel, and coroutine-owned fixture in exception-safe cleanup. +- Use `ParallelTesting::tempDir()` for Unix socket paths and other scratch files. +- Do not weaken or delete a regression to make source pass. +- Do not add a source abstraction solely to inject test behavior. + +### Unexpected findings during implementation + +If implementation exposes an unexpected bug, native edge, lower-level contradiction, same-family omission, or design change: + +1. stop editing that path; +2. trace the full cause and every sibling; +3. prepare the smallest complete proposed correction, with tests and cost; +4. send it through a focused second-opinion loop; +5. continue only after consensus; +6. amend this plan and the final ledger decision when the accepted design changes. + +Straightforward implementation mistakes such as a namespace typo do not need a design loop. + +## Implementation order + +Implement in the order below. Each section names the owner and the affected tests. Keep each source/test pair green before moving forward. + +1. Preserve the carried Redis ownership baseline. +2. Remove command replay and add safe connection disposition. +3. Replace the subscriber transport and RESP parser. +4. Complete subscriber accounting, failures, and topology. +5. Remove Reverb's subscriber-gated publish queue. +6. Correct concurrency-limiter failure precedence. +7. Port current PhpRedis validation and options. +8. Add connection macros and static cleanup. +9. Add manager event controls and remove incompatible extension APIs. +10. Correct Horizon configuration. +11. Reject pooled `ssubscribe()` and enforce facade metadata. +12. Delete dead Sentinel/config normalization paths. +13. Reject pooled RESET. +14. Correct Telescope formatting and filtering, and Sentry database metadata. +15. Complete split metadata, provenance, documentation, and intentional-difference records. +16. Remove every replaced path. +17. Run focused, integration, and full validation. +18. Perform a fresh full-diff self-review and independent code review. + +## 1. Preserve the carried ownership baseline + +### Files + +- `src/redis/src/RedisConnection.php` +- `src/redis/src/RedisProxy.php` +- `src/redis/src/Traits/MultiExec.php` +- `src/redis/src/Listeners/RedisConnectionLifecycleListener.php` +- `src/redis/src/RedisManager.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/RedisProxyTest.php` +- `tests/Redis/MultiExecTest.php` +- `tests/Integration/Redis/RedisConnectionIntegrationTest.php` +- `tests/Integration/Redis/RedisProxyIntegrationTest.php` +- `tests/Integration/Redis/RedisProxyNonCoroutineIntegrationTest.php` + +### Required invariant + +Before changing command dispatch, macros, RESET, or subscription guards: + +1. trace ordinary borrow/release; +2. trace existing-context reuse; +3. trace `multi`, `pipeline`, `select`, and `watch` publication; +4. trace callback-form `MultiExec` immediate release; +5. trace owner-ID defer registration and copied context; +6. trace event failure, release failure, and same-connection handoff precedence; +7. trace native mode and WATCH checks on release; +8. trace task, purge, fork, and worker-start discard. + +Do not refactor these paths merely to make later edits easier. Add only the settled command, macro, RESET, and metadata behavior around the existing ownership model. + +## 2. Remove unsafe replay and classify connection disposition + +### Files + +- `src/redis/src/RedisConnection.php` +- `src/redis/src/RedisProxy.php` +- `src/redis/src/PhpRedisConnection.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/RedisProxyTest.php` +- `tests/Redis/Fixtures/PhpRedisConnectionStub.php` +- `tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php` +- `tests/Integration/Redis/RedisConnectionIntegrationTest.php` +- `src/boost/docs/redis.md` + +### Command boundary + +Delete `retry()` and every test/stub override that exists only for replay. + +Use this control-flow shape inside `RedisConnection::__call()`: + +```php +public function __call($name, $arguments) +{ + try { + if (static::hasMacro($name)) { + return $this->macroCall($name, $arguments); + } + + $name = strtolower($name); + $result = $this->executeCommand($name, $arguments); + } catch (RedisException $exception) { + if ($this->shouldInvalidateAfter($exception)) { + $this->markInvalid(); + } + + throw $exception; + } + + if ($name === 'watch' && $result !== false) { + $this->watching = true; + } elseif ( + $name === 'exec' + || ($name === 'unwatch' && $result !== false) + ) { + $this->watching = false; + } + + return $result; +} +``` + +Section 8 adds Macroable before this final shape is complete. Exact macro lookup intentionally precedes wrapper-native name normalization. The normalization makes WATCH/EXEC/UNWATCH bookkeeping and pooled prohibitions honor PHP's case-insensitive method contract; PHP's case-insensitive method lookup already keeps `prepare*` and `call*` transforms working without it. RESET is deliberately absent from successful WATCH settlement because Section 13 rejects it before native dispatch. + +`RedisProxy::__call()` must independently normalize its routing name before it makes any strict comparison: + +```php +$command = strtolower($name); +``` + +Use `$command` for: + +- dedicated `subscribe` / `psubscribe` routing; +- `CONNECTION_BOUND_METHODS`; +- the `discardTransaction()` branch; +- `shouldUseSameConnection()`; +- successful `select` database tracking. + +Continue forwarding the original `$name` to `RedisConnection` and command events. This preserves exact macro names and the public command name while making proxy ownership decisions case-insensitive. Do not replace the existing fixed comparisons with a command registry or a proxy-side macro lookup. + +The one helper is justified because the failure-disposition rule is non-trivial and its name keeps `__call()` readable. Do not split its one-caller classification into smaller helpers: + +```php +protected function shouldInvalidateAfter(RedisException $exception): bool +{ + if ($this->connection->getLastError() !== $exception->getMessage()) { + return true; + } + + if (! ($this->config['sentinel']['enable'] ?? false)) { + return false; + } + + $errorCode = explode(' ', $exception->getMessage(), 2)[0]; + + return in_array( + $errorCode, + ['READONLY', 'MASTERDOWN'], + true, + ); +} +``` + +Use exact first-token/error-code matching. Do not copy Laravel's substring message registry. Do not reconnect immediately. Do not log and do not repeat the command. +Do not add a second `errorCode()` helper, command-error enum, or registry. + +Do not add a null-client guard to `shouldInvalidateAfter()`. Pooled wrappers are activated before native dispatch, and native dispatch against a null client raises `Error`, not `RedisException`. A macro that deliberately closes its held native client is an explicit extension escape hatch, not a connection-disposition invariant the framework should preserve with extra machinery. + +### Tests + +Cover: + +- a transport/protocol exception invalidates; +- an equal last-error server reply is retained; +- standalone `READONLY` and `MASTERDOWN` are retained because reconnecting the same host cannot repair them; +- Sentinel `READONLY` and `MASTERDOWN` invalidate; +- `LOADING`, `OOM`, `MISCONF`, and `CROSSSLOT` do not trigger reconnect storms; +- the original exception object remains primary; +- no command is invoked twice; +- Redis and Valkey integration confirms synchronized server-error equality where a stable command can produce it. + +Delete replay assertions. In `src/boost/docs/redis.md`, delete only the framework replay sentence. Keep native phpredis `retry_interval`, `max_retries`, and backoff documentation. + +## 3. Replace subscriber EOF framing with exact RESP2 decoding + +### Files + +- `src/redis/src/Subscriber/Connection.php` +- `src/redis/src/Subscriber/Constants.php` +- `src/redis/src/Subscriber/Exceptions/ServerException.php` (new) +- `tests/Redis/Subscriber/ConnectionTest.php` +- `tests/Redis/Subscriber/CommandBuilderTest.php` +- `tests/Redis/Fixtures/RespServer.php` (new, shared test fixture) +- `tests/Redis/Fixtures/Tls/server.crt` (copy of the established local test certificate) +- `tests/Redis/Fixtures/Tls/server.key` (copy of the established local test key) + +### Transport ownership + +`Subscriber\Connection` owns one hooked PHP stream resource. Remove `SocketFactoryInterface`, `SocketInterface`, `SocketFactory`, `SocketOption`, EOF packet settings, and the test-only socket-factory seam. + +Keep `Constants::CRLF` for `CommandBuilder`. Delete `Constants::EOF`. + +Build one endpoint from the resolved config: + +- absolute Unix host/path or canonical `unix:///...`: one `unix://...` endpoint + with no port suffix; reject relative Unix URIs; +- TLS: `tls://...`; +- normal TCP: `tcp://...`; +- when no endpoint or configuration scheme is present, a non-empty context + selects TLS exactly as phpredis does; an explicit scheme remains authoritative; +- recognize an unbracketed raw IPv6 host before URI parsing and bracket it + before adding the separate port; preserve an already-bracketed IPv6 host; +- retain an already-correct supported scheme rather than duplicating it; +- reject unsupported or conflicting endpoint shapes descriptively at this + connection boundary, including credentials, paths, queries, fragments, + unbracketed scheme-carrying IPv6, and a port embedded in the host. + +Normalize phpredis single-client context for PHP streams: + +```php +$streamOptions = $context['stream'] ?? $context['ssl'] ?? $context; +$streamContext = stream_context_create([ + 'ssl' => $streamOptions, +]); +``` + +Only add the SSL wrapper section when it is relevant. Do not pass phpredis's `['stream' => ...]` shape directly to PHP's stream wrapper. + +Open with `stream_socket_client()` and convert native `false` to `SocketException` with the endpoint and native error details. The open timeout is the configured subscriber timeout. + +### Full writes + +`send()` must loop until every byte is written: + +```php +$written = 0; +$length = strlen($data); + +while ($written < $length) { + $bytes = fwrite($this->stream, substr($data, $written)); + + if ($bytes === false || $bytes === 0) { + throw new SocketException('Failed to send data to the Redis subscriber socket.'); + } + + $written += $bytes; +} +``` + +PHP streams do not expose a source-offset argument for `fwrite()`, so slicing the remaining suffix is the direct correct shape. The invariant is a full write or a named terminal failure. + +### RESP2 decoder + +Decode exactly: + +- `+` simple string; +- `-` server error, as a new `Subscriber\Exceptions\ServerException` extending `RuntimeException`; +- `:` integer; +- `$` bulk string and `$-1` null; +- `*` array and `*-1` null. + +No RESP3 types are added. Reject any bulk or array length below `-1` as malformed instead of treating every negative value as null. + +Representative shape: + +```php +public function receive(): mixed +{ + $line = $this->readLine(); + $prefix = $line[0] ?? throw new SocketException('Received an empty Redis response.'); + $value = substr($line, 1); + + return match ($prefix) { + '+' => $value, + '-' => throw new ServerException($value), + ':' => $this->parseInteger($value), + '$' => $this->readBulk($this->parseInteger($value)), + '*' => $this->readArray($this->parseInteger($value)), + default => throw new SocketException("Unsupported Redis response type [{$prefix}]."), + }; +} +``` + +`parseInteger()` is shared by integer, bulk-length, and array-length frames. It accepts only a complete signed decimal value representable by PHP's native integer and throws `SocketException` for malformed or overflowing input; never rely on PHP's permissive string-to-integer cast. + +`readLine()`: + +- uses `fgets()`; +- treats `false` as terminal; +- requires and strips the exact trailing CRLF; +- never treats EOF as an empty valid frame. + +`readExact()`: + +- loops until the requested number of bytes has been read; +- treats `false`, EOF, and empty/no-progress reads as terminal; +- never spins on an empty read; +- reads the bulk payload plus its trailing CRLF; +- verifies that trailing CRLF before returning the payload bytes. + +Array decoding recurses only according to the wire's declared item count. This is a protocol decoder, not a generic command state machine. + +### Tests + +Replace mocked Engine socket expectations with a test-only in-process RESP server bound to port `0`. The shared fixture is justified only because Connection, CommandInvoker, and Subscriber tests use the same real transport; it must remain under `tests/Redis/Fixtures`, not production. + +Cover: + +- a large command is written completely and a closed peer fails by exception; do not add a production seam solely to force the native short-write branch; +- deliberately chunked short reads; +- a bulk payload containing CRLF; +- a bulk payload containing CRLF and NUL; +- empty bulk string and null bulk; +- integer, simple string, nested array, and null array; +- error frame becomes `ServerException`; +- truncated line, truncated bulk, missing bulk CRLF, false/EOF, and empty-no-progress reads become `SocketException`; +- malformed/overflowing integers and invalid negative bulk or array lengths become `SocketException`; +- IPv4 and IPv6 endpoint formatting; +- Unix socket operation with a `ParallelTesting::tempDir()` path; +- flat, `ssl`, and `stream` TLS context normalization through a real local TLS stream using the committed test certificate, without adding a production factory. +- schemeless host plus non-empty context reaches TLS, while an explicit TCP + endpoint remains TCP at the endpoint-formatting boundary. + +Close client streams in `finally`. `RespServer::wait()` is the terminal fixture operation and closes +the listener in its own `finally`; it also owns host/port parsing for consumers. The fixture must not +use a fixed port or shared path. + +## 4. Make subscriber command routing and topology exact + +### Files + +- `src/redis/src/Subscriber/CommandInvoker.php` +- `src/redis/src/Subscriber/Subscriber.php` +- `src/redis/src/Subscriber/Message.php` +- `src/redis/src/RedisProxy.php` +- `src/redis/src/RedisManager.php` +- `src/redis/src/RedisServiceProvider.php` +- `src/redis/src/RedisSentinelFactory.php` +- `src/redis/src/PhpRedisConnection.php` +- `tests/Redis/Subscriber/CommandInvokerTest.php` +- `tests/Redis/Subscriber/CommandInvokerCreateFailureTest.php` +- `tests/Redis/Subscriber/SubscriberTest.php` +- `tests/Redis/RedisProxyTest.php` +- `tests/Redis/RedisEventsTest.php` +- `tests/Redis/MultiExecTest.php` +- `tests/Redis/RedisPoolHeartbeatTest.php` +- `tests/Redis/RedisProxyNonCoroutineTest.php` +- `tests/Redis/RedisManagerTest.php` +- `tests/Redis/RedisServiceProviderTest.php` +- `tests/Redis/RedisSentinelFactoryTest.php` (new) +- `tests/Redis/Fixtures/RespServer.php` +- `tests/Integration/Redis/RedisSubscribeIntegrationTest.php` +- `tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php` + +### CommandInvoker failure ownership + +Retain the first receive-loop throwable: + +```php +private ?Throwable $receiveFailure = null; +``` + +When receive exits: + +1. store the throwable only when receive or routing initiated settlement; + deliberate interrupt, timeout, shutdown, and prior send-failure wakeups do + not publish their cleanup-induced read/channel exception; +2. close the result, PING, and message channels; +3. close the connection through idempotent `interrupt()`; +4. retain existing worker-shutdown watching; +5. do not let later close/reporting failure replace the receive failure. + +Keep the repository's established `while (true)` receive-loop form with an +identifier-scoped PHPStan ignore explaining that receive or routing failure is +the terminal condition. `for (;;)` would avoid the diagnostic but would be the +only alternate infinite-loop idiom in the source; a mutable flag would add a +second lifecycle authority and a per-message read. + +Foreground result and PING waits: + +- when the invoker is already interrupted, rethrow the retained receive cause + or a canonical closed-connection `SocketException` before attempting another + send; +- command acknowledgement waits use the existing subscriber timeout; +- `Subscriber::ping()` retains its existing per-call timeout argument; +- a positive timeout is bounded; +- zero means unbounded and maps to the Channel's real unbounded form, not a non-blocking poll; +- if `pop()` returns false because the receive side failed or closed, rethrow the retained cause when present; +- a foreground timeout interrupts the connection before throwing, so a late acknowledgement or PONG cannot poison the next command; +- otherwise throw the appropriate named subscriber failure. + +Idle receive remains unbounded because a valid subscription can be silent indefinitely. + +Route decoded frames by semantic values, not line counts: + +- simple `OK` and other non-PONG status replies: result channel, including AUTH; +- subscription acknowledgements: `subscribe`, `unsubscribe`, `psubscribe`, `punsubscribe`; +- messages: `message`, `pmessage`; +- PING: simple `PONG` and Pub/Sub `pong` arrays. + +Validate the arity and scalar/null types of every routed array. An unknown or malformed frame is a terminal protocol failure, not a silently ignored packet. +Every result- or PING-channel push must succeed; a closed channel throws its +matching named foreground-channel exception through the common terminal path +rather than allowing another read from the closed stream. + +### Message-channel capacity + +Delete the per-message `Timer::after()` allocation. + +Use: + +```php +if ($this->messageChannel->push($message, 30.0)) { + return; +} + +if (! $this->messageChannel->isTimeout()) { + throw new SocketException('The Redis subscriber message channel was closed.'); +} + +$exception = new SocketException(...); + +try { + $this->logger?->error(...); +} catch (Throwable) { + // Reporting does not replace the channel-capacity failure. +} + +throw $exception; +``` + +A concurrent channel close is a named terminal failure without a false “30 seconds full” log, a second read from the closed transport, or recursive interruption. Preserve worker-shutdown interruption. + +### Subscriber accounting + +`Subscriber` tracks exact prefixed channel and pattern sets. It must: + +- reject empty `subscribe()` / `psubscribe()` before sending; +- wait for one acknowledgement per explicit channel/pattern; +- for zero-argument `unsubscribe()` / `punsubscribe()`, wait for `max(1, count(current category set))`; +- update sets from successful acknowledgements; +- leave channel and pattern accounting separate; +- preserve public `prefix`, which Reverb reads; +- keep `Message::$pattern` for pattern messages. + +Do not add a generic subscription state machine or command registry. + +### Direct Subscriber credentials + +Preserve direct `Subscriber` construction while accepting the complete useful credential and endpoint shape: + +```php +public function __construct( + public string $host, + public int $port = 6379, + public string|array|null $password = null, + public float $timeout = 5.0, + public string $prefix = '', + public ?string $username = null, + public ?string $scheme = null, + public array $context = [], + protected ?StdoutLoggerInterface $logger = null, +) { + $this->connect(); +} +``` + +Use the parameter order above. It preserves the existing common host, port, password, timeout, and prefix positions, keeps the added endpoint/ACL fields explicit, and leaves the optional logger last. The semantic requirements are: + +- scalar password `"0"` is valid; +- username `"0"` is valid; +- an existing ACL credential array is forwarded without string casting; +- scalar username plus scalar password becomes two-argument AUTH; +- missing credentials do not send AUTH. + +Migrate the reflection-created Subscriber fixture in `SubscriberTest` from `password = ''` to the constructor's canonical `null` absence value. Keep explicit empty-string credentials absent as well; the fixture change must not accidentally turn the old sentinel into an AUTH argument. + +### Sentinel master resolution + +Move shuffled node attempts and master lookup into the existing `RedisSentinelFactory`: + +```php +/** + * Resolve the current master address. + * + * @return array{0: string, 1: int} + */ +public function resolveMaster(array $config): array +``` + +The factory: + +- validates each node as a non-empty string through `RedisConfig`, beside the equivalent Cluster seed validation; +- parses schemeless nodes with a temporary `tcp://` prefix but passes a bare host to phpredis so a configured TLS context can still select TLS; +- preserves an explicit node scheme because phpredis treats it as the native transport selector; +- requires bracketed IPv6 and rejects unbracketed forms that PHP can silently split into a different valid host and port; +- rejects credentials, paths, queries, and fragments instead of silently dropping unsupported endpoint components; +- applies a non-empty topology-local `sentinel.context` as phpredis's flat `ssl` option, accepting flat, `ssl`, and `stream` input shapes; +- tries every shuffled configured Sentinel node; +- preserves Sentinel auth including `"0"`; +- performs no per-node logging when a later node succeeds; +- collects node/cause details locally; +- throws one descriptive total-failure exception only after every node fails; +- returns the resolved host and integer port. + +`PhpRedisConnection` and `RedisProxy::subscriber()` both call this owner. Do not add a logger callback, resolver interface, or registry. + +### Standalone, Sentinel, and Cluster subscriber creation + +Inject `RedisSentinelFactory` into `RedisManager` through `RedisServiceProvider`, then pass that container-resolved auto-singleton into every `RedisProxy` it creates. Do not expose the PoolFactory container, resolve from global container state, or make the dependency nullable merely to preserve old test constructors. + +`RedisProxy::subscriber()`: + +- reads the real connection config from its pool; +- standalone: creates one Subscriber from the normalized host, port, scheme, context, credentials, timeout, and prefix; +- Sentinel: resolves the current master afresh, then creates the Subscriber with the master's endpoint and connection credentials; +- Cluster: briefly borrows a wrapper, activates it, reads `masters()`, releases the exact wrapper before any subscriber dial, and tries masters until a dedicated Subscriber connects using only `cluster.context`, matching the pooled Cluster client; +- if every Cluster master fails, throws one failure retaining useful endpoint causes; +- never keeps the pool wrapper for the subscriber lifetime. + +If master discovery and wrapper release both fail, keep the discovery failure primary while still exhausting release. This borrow never publishes coroutine context. + +Do not add `SSUBSCRIBE` routing. + +### Tests + +Cover: + +- simple PONG before subscription does not poison the next acknowledgement; +- array PONG after subscription; +- server error propagates to the waiting foreground command; +- receive failure closes every channel and preserves the first cause; +- acknowledgement timeout is bounded while idle receive remains unbounded; +- message-channel timeout logs once and interrupts; +- concurrent close becomes a named terminal failure without logging a capacity failure or retaining a native `TypeError`; +- command and PING after autonomous receive failure rethrow the exact retained + cause without sending; +- command and PING after deliberate close throw the canonical closed-connection + failure without sending; +- a foreground acknowledgement wait interrupted while blocked throws the + acknowledgement-channel closed failure rather than a cleanup-induced stream + error; +- closed result and PING routing channels terminate without a second receive; +- empty subscribe/psubscribe sends nothing; +- explicit and all-channel unsubscribe/punsubscribe acknowledgement counts; +- exact channel/pattern sets; +- string, array, username/password `"0"` auth; +- standalone TCP, TLS/context, IPv6, and Unix routing; +- Sentinel fresh resolution, node fallback, aggregated total failure, auth `"0"`, TLS/context, explicit TCP/TLS schemes, canonical bracketed IPv6, and rejected unsupported endpoint forms; +- Cluster master-list wrapper release and endpoint fallback; +- schemeless Cluster masters with non-empty `cluster.context` use TLS after the + discovery wrapper is released, regardless of top-level or seed schemes; +- live Redis and Valkey binary Pub/Sub payloads; +- deterministic close and worker-shutdown interruption; +- spawn failure rollback remains correct. + +## 5. Delete Reverb's subscriber-gated publish queue + +### Files + +- `src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php` +- `src/reverb/src/Protocols/Pusher/MetricsHandler.php` +- `tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php` +- `tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php` + +### Final behavior + +Delete: + +- `$queuedPublishes`; +- its docblock; +- `processQueuedPublishes()`; +- the connect-time drain call; +- `JsonException` import used only for queued payload handling; +- test-only queue exposure and queue assertions. + +`publish()` becomes direct: + +```php +public function publish(array $payload): int +{ + return (int) $this->redis->publish( + $this->channel, + json_encode($payload, JSON_THROW_ON_ERROR), + ); +} +``` + +Subscriber connection state does not gate publishing. JSON and Redis failures propagate. + +`MetricsHandler::gatherMetricsFromSubscribers()` currently stores the pending metric and registers its listener before publishing the request. If publishing throws, remove that exact metric and listener before rethrowing. A cleanup failure must not replace the publish failure. Keep this local to the failed-publication branch; do not add a generic finalizer or change the successful response/timeout lifecycle. + +Delete the obsolete “Decision 17” source comment. Rewrite the useful “Decision 16e” source and test comments as local WHY comments explaining that responses may arrive before `publish()` returns the subscriber count; do not retain references to an external decision log. + +### Tests + +Prove: + +- publish succeeds when no subscriber exists; +- subscriber reconnect state does not retain outgoing payloads; +- encoding failure propagates; +- Redis outage propagates; +- the actual Redis publish count is returned; +- `MetricsHandler` does not turn a missing subscriber into an immediate false empty result; +- a publish failure removes the pending metric and listener; +- the publish failure remains primary when listener cleanup also fails. + +No replacement queue, retry policy, disk spool, capacity key, or publisher state is added. + +## 6. Preserve limiter callback failures over release failures + +### Files + +- `src/redis/src/Limiters/ConcurrencyLimiter.php` +- `src/redis/src/Limiters/ConcurrencyLimiterBuilder.php` +- `tests/Redis/ConcurrencyLimiterTest.php` +- `tests/Redis/ConcurrencyLimiterBuilderTest.php` + +### Direct control flow + +Keep the two existing public acquisition shapes. Do not add `runWithLease()`, a trait, an acquired flag, or builder delegation through a broad timeout catch. + +At both callback sites: + +```php +$callbackException = null; + +try { + $result = $callback(); +} catch (Throwable $exception) { + $callbackException = $exception; +} + +try { + $lease->release(); +} catch (Throwable $exception) { + if ($callbackException === null) { + throw $exception; + } +} + +if ($callbackException !== null) { + throw $callbackException; +} + +return $result; +``` + +The final implementation may use a shorter direct `try/catch` shape if it: + +- always attempts release; +- suppresses release failure only when callback failure already exists; +- propagates release failure after callback success; +- does not risk an uninitialized return value; +- keeps builder acquisition failure handling scoped only to `acquire()`. + +A `LimiterTimeoutException` thrown by the user callback is not acquisition failure and must never call the builder's `$failure` callback. + +### Tests + +At both public sites, cover: + +1. callback succeeds, release succeeds; +2. callback succeeds, release fails: release failure propagates; +3. callback fails, release succeeds: callback failure propagates; +4. callback fails, release fails: callback failure remains primary; +5. builder acquisition timeout invokes `$failure`; +6. callback-thrown `LimiterTimeoutException` bypasses `$failure`. + +## 7. Port current supported PhpRedis validation and options + +### Files + +- `src/redis/src/RedisConnection.php` +- `src/redis/src/PhpRedisConnection.php` +- `src/redis/src/PhpRedisClusterConnection.php` +- `src/redis/src/RedisConfig.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/PhpRedisClusterConnectionTest.php` +- `tests/Redis/RedisConfigTest.php` +- `tests/Redis/Fixtures/PhpRedisConnectionStub.php` +- `tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php` +- `tests/Integration/Redis/RedisConnectorTest.php` + +### Host and context + +Port current Laravel behavior: + +- host must be a non-empty string; +- when host already includes a scheme and config also supplies one, schemes must match case-insensitively; +- when host has no scheme, prepend the configured scheme; +- non-empty standalone context accepts flat, `ssl`, or already-normalized `stream` input and sends `['stream' => ...]` to `Redis::connect()`; +- non-empty Cluster context accepts flat, `ssl`, or `stream` input and sends a flat context to `RedisCluster::__construct()`; +- an empty standalone or Cluster context omits the native constructor argument entirely. + +The non-empty guards are a deliberate Hypervel adaptation. Current Laravel checks only for null because its shipped config omits the context key. Hypervel merges structural empty context defaults, and its documented examples also permit explicit empty arrays. Normalizing an empty standalone context manufactures `['stream' => []]`, which phpredis interprets as a TLS request against an otherwise plain endpoint; an empty Cluster context similarly enables TLS on every seed and discovered node. Keep Laravel's normalizer bodies unchanged and adapt only the native argument guards. + +Do not add persistent connections. Pool owns socket lifetime. + +### Topology list validation + +`RedisConfig` must require every configured Cluster seed and Sentinel node to be a non-empty string. Structural config errors fail during validation; the Sentinel factory aggregates only well-formed endpoint strings that cannot be parsed, reached, or resolved. Do not repeat the element-type guard in the factory. + +### Credentials + +Use exact absence checks: + +- password `null` or `''`: no AUTH; +- password `"0"`: AUTH; +- username present and not `''`, including `"0"`, plus string password: ACL pair; +- existing array password: pass through; +- Sentinel auth `"0"`: pass through; +- Cluster username `"0"`: preserve. + +### Options and precedence + +Final supported behavior: + +- numeric native option constants remain accepted; +- shared `database.redis.options` apply first; +- connection-local nested `options` override shared options; +- a top-level connection `prefix` overrides both for that connection, matching current Laravel; +- standalone supports prefix, read timeout, scan, serializer, compression, compression level, TCP keepalive, max retries, backoff algorithm/base/cap, and conditional pack-ignore-numbers; +- standalone `name` sends `CLIENT SETNAME` after connection/auth/database setup; +- Cluster supports prefix, scan, serializer, compression, compression level, TCP keepalive, max retries, backoff settings, and `RedisCluster::OPT_SLAVE_FAILOVER`; +- Cluster does not send `CLIENT SETNAME`; +- `pack_ignore_numbers` is standalone-only and remains guarded by `defined()` because the supported 6.1 floor predates the 6.2 constant; +- delete the Hyperf-derived string key `keepalive`; only `tcp_keepalive` remains; +- delete dead `defined()` guards for serializer and compression; +- use `RedisCluster::OPT_SLAVE_FAILOVER`, never a literal fallback. + +Do not add a connector hierarchy or Predis. + +### Tests + +Reopen every current Laravel test found through the discovery commits and port the supported final cases. Merge them into existing Hypervel tests while retaining pool, transform, SafeScan, and strict-type coverage. + +Test: + +- empty host; +- matching and mismatched schemes; +- standalone and Cluster flat, `ssl`, and `stream` context normalization; +- explicit empty context uses plaintext while one non-empty context reaches TLS at each native constructor boundary; +- Sentinel-resolved masters retain the standalone empty-context rule; +- Cluster seeds and Sentinel nodes reject non-string and empty values during config validation; +- every accepted string option and numeric option; +- shared/local/top-level prefix collisions; +- standalone SETNAME and absence on Cluster; +- all username/password `"0"` paths; +- Sentinel auth `"0"`; +- TCP keepalive spelling and removal of `keepalive`; +- conditional 6.1 pack-ignore behavior; +- Cluster failover constant. + +## 8. Add exact-wrapper macros without changing event ownership + +### Files + +- `src/redis/src/RedisConnection.php` +- `src/redis/src/RedisProxy.php` +- `src/redis/composer.json` +- `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/RedisProxyTest.php` +- `tests/Redis/RedisEventsTest.php` +- `tests/Redis/PackageMetadataTest.php` + +### RedisConnection + +Add: + +```php +use Hypervel\Support\Traits\Macroable; + +use Macroable { + __call as macroCall; +} +``` + +Use the repository's actual Macroable namespace. + +Use the final `__call()` order in Section 2. Macro-first order is current Laravel behavior; wrapper-native normalization keeps WATCH/EXEC/UNWATCH bookkeeping correct and prevents mixed-case RESET or subscription calls from bypassing their pooled guards. The matching proxy normalization keeps ownership and dedicated-subscription routing correct before wrapper dispatch. + +On a held wrapper, a macro may deliberately shadow the native `reset`, `subscribe`, `psubscribe`, or `ssubscribe` guards; do not add a macro-name prohibition. `RedisProxy` continues to intercept its public dedicated `subscribe()` and `psubscribe()` methods before wrapper dispatch. + +### Registration without Redis availability + +Add explicit methods on `RedisProxy` that forward the static Macroable surface without entering `__call()` or checking out Redis: + +```php +public function macro(string $name, callable|object $macro): void +{ + RedisConnection::macro($name, $macro); +} + +public function mixin(object $mixin, bool $replace = true): void +{ + RedisConnection::mixin($mixin, $replace); +} + +public function hasMacro(string $name): bool +{ + return RedisConnection::hasMacro($name); +} + +public function flushMacros(): void +{ + RedisConnection::flushMacros(); +} +``` + +This keeps boot-time registration independent of Redis availability and adds no proxy-side work to ordinary commands. + +### Event granularity + +`RedisProxy` remains the event owner: + +- one macro call produces one outer event named for the macro; +- native commands called inside the macro do not emit separate proxy events; +- this matches existing Hypervel behavior for pipeline/transaction callbacks, held raw work, SafeScan, `flushByPattern()`, and SHA-cached helpers; +- moving events into `RedisConnection` would expose internal release `SELECT` and scan-loop traffic. + +Do not add mutable “suppress events” state or inner-event replay. + +### Static cleanup and metadata + +Add `hypervel/macroable` to the split Redis package's direct requirements. Add `RedisConnection::flushMacros()` to the authoritative `AfterEachTestSubscriber` in its alphabetical framework-cleanup position. + +Add exact facade annotations for `macro`, `mixin`, `hasMacro`, and `flushMacros`. Their signatures follow the explicit `RedisProxy` methods rather than relying on magic forwarding. + +### Tests + +Cover: + +- registration, mixin, lookup, invocation, and flush; +- every explicit proxy macro method works while Redis is unavailable and performs no pool checkout; +- macro executes on the exact checked-out wrapper; +- mixed-case `multi`, `pipeline`, `select`, and `watch` pin the exact wrapper; +- mixed-case `discard`, `subscribe`, and `psubscribe` take their dedicated proxy routes; +- mixed-case connection-bound methods remain rejected without a checkout; +- mixed-case `exec` and `unwatch` settle wrapper state, while held-wrapper RESET, SUBSCRIBE, PSUBSCRIBE, and SSUBSCRIBE prohibitions remain enforced; +- one outer success/failure event uses the macro name; +- direct native `RedisException` inside a macro uses the settled disposition rule; +- event, command, and cleanup failure precedence remains truthful; +- macro shadowing follows macro-first order; +- static cleanup prevents test leakage; +- no raw/lazy return wrapper machinery is added. + +## 9. Add boot-only event controls and remove the false extension API + +### Files + +- `src/redis/src/RedisConfig.php` +- `src/redis/src/RedisManager.php` +- `src/redis/src/RedisProxy.php` +- `src/telescope/src/Watchers/RedisWatcher.php` +- `src/sentry/src/Features/RedisFeature.php` +- `src/support/src/Facades/Redis.php` +- `tests/Redis/RedisConfigTest.php` +- `tests/Redis/RedisManagerTest.php` +- `tests/Redis/RedisServiceProviderTest.php` +- `tests/Telescope/Watchers/RedisWatcherTest.php` +- `tests/Telescope/Watchers/DisabledWatcherTest.php` +- `tests/Sentry/Features/RedisIntegrationTest.php` + +### Tri-state owner + +Add one nullable override to `RedisConfig`: + +```php +private ?bool $eventsOverride = null; +``` + +Apply it during `connectionConfig()` assembly: + +```php +if ($this->eventsOverride !== null) { + $connectionConfig['event']['enable'] = $this->eventsOverride; +} +``` + +Add exact boot-only methods to `RedisConfig`: + +```php +public function enableEvents(): void +{ + $this->eventsOverride = true; +} + +public function disableEvents(): void +{ + $this->eventsOverride = false; +} +``` + +Their docblocks warn that the worker-owned override affects every subsequently assembled connection config. Do not add a setter or a method to restore `null`; no supported requirement needs that extra state transition. `null` is the initial state that preserves explicit per-connection config. + +`RedisManager::enableEvents()` and `disableEvents()` delegate to this owner. Their docblocks must say: + +- Boot-only. +- Existing pools snapshot their config and are not retrofitted. +- Calling the method after pool creation can leave generations with different event behavior. + +Add the two manager methods to the Redis facade annotations. + +Do not mutate the user's config tree, enumerate current connection names, flush pools, touch checked-out wrappers, or add a per-command config read. + +### Telescope and Sentry + +Replace both config mutation loops with the manager API during boot. Keep their listener registration unchanged. + +Sentry's Redis parameter handling is not the Telescope formatter and does not implicitly convert objects; revalidate it but do not copy the Telescope fix into Sentry. + +Tests cover both `RedisConfig` methods, both manager delegations, the two facade annotations, null preservation, future-pool override behavior, and the fact that an already-created pool is not retrofitted. + +### Delete incompatible extension state + +Delete from `RedisManager`: + +- `$customCreators`; +- named-proxy branch in `connection()`; +- `extend()`; +- `forgetExtension()`; +- related imports, facade annotation, and tests. + +Rewrite manager release/discard/cache tests around real `RedisProxy` instances and a stubbed `PoolFactory`. Do not delete lifecycle coverage merely because its old injection seam disappears. + +### Intentional Laravel omission + +Current Laravel connector-driver `extend()` and `setDriver()` are intentionally not added. Hypervel has one phpredis pooled transport and no driver switch. Adding a connector/pool hierarchy for hypothetical clients would be overengineering. + +Macros provide conventional connection behavior extension. `withConnection(transform: false)` provides explicit raw held access. Do not claim that container binding replaces the removed named-proxy API. + +Record this closed omission at the exact README, source, and test positions specified in Section 15. + +## 10. Make Horizon use Hypervel's complete named topology + +### Files + +- `src/horizon/src/Horizon.php` +- `tests/Integration/Horizon/Feature/RedisPrefixTest.php` + +### Final behavior + +Delete the dead `database.redis.clusters..0` branch. Read only the configured named Hypervel connection: + +```php +$config = config("database.redis.{$connection}"); + +if (! is_array($config)) { + throw new Exception("Redis connection [{$connection}] has not been configured."); +} +``` + +Resolve the prefix: + +```php +$prefix = config('horizon.prefix') ?: 'horizon:'; + +if (($config['cluster']['enable'] ?? false) + && ! RedisConnection::hasHashTag($prefix)) { + $prefix = '{' . $prefix . '}'; +} + +$config['prefix'] = $prefix; +$config['options']['prefix'] = $prefix; + +config([ + 'horizon.prefix' => $prefix, + 'database.redis.horizon' => $config, +]); +``` + +Use the exact existing hash-tag helper behavior and import. Preserve: + +- nested `cluster`; +- nested `sentinel`; +- pool settings; +- credentials; +- timeout/context/options; +- every other named connection value. + +Publish the resolved prefix at both connection precedence levels. `RedisConfig` +gives the top-level connection key final precedence, so leaving the source +connection's old top-level value intact would override Horizon's nested option. + +Do not copy Laravel's top-level clusters model or its `supportsClustering()` version shim. + +### Tests + +Cover: + +- missing connection; +- standalone complete config copy; +- Cluster complete config copy; +- untagged clustered prefix becomes tagged; +- already-tagged prefix remains unchanged; +- resolved prefix is identical in `horizon.prefix` and Redis connection options; +- source top-level prefixes cannot override the resolved standalone or Cluster prefix; +- nested topology/pool values remain intact; +- Horizon multi-key operations remain Cluster-slot safe. + +## 11. Reject pooled sharded subscriptions and enforce facade metadata + +### Files + +- `src/redis/src/RedisConnection.php` +- `src/redis/src/RedisProxy.php` +- `src/support/src/Facades/Redis.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/RedisProxyTest.php` +- `tests/Redis/PackageMetadataTest.php` +- `src/redis/README.md` +- `src/boost/docs/redis.md` + +### Pooled command guard + +Extend the existing dedicated-subscription rejection to: + +- `subscribe`; +- `psubscribe`; +- `ssubscribe`. + +Remove `ssubscribe` from RedisConnection and Redis facade annotations. Add it to the facade documenter's ignored methods so reflection cannot restore it. + +Keep `unsubscribe`, `punsubscribe`, and `sunsubscribe` as current harmless native calls. Do not add ordinary or sharded Pub/Sub support on pooled wrappers. + +The dedicated Subscriber continues to support only ordinary SUBSCRIBE/PSUBSCRIBE. Do not add a sharded-subscription TODO. + +### One metadata owner + +Reflect the private `RedisProxy::CONNECTION_BOUND_METHODS` in `PackageMetadataTest` and assert it is a subset of the facade's protected ignored list. + +Extra facade ignores are valid. They include unsupported native methods such as `ssubscribe` that are not connection-bound commands. + +Update `RedisProxyTest::testConnectionBoundMethodsCannotBeCalledThroughProxy()` to derive cases from the production constant instead of maintaining a third hardcoded list. + +Add the currently missing internal methods: + +- `clearWatchState`; +- `discardTransaction`. + +Do not change production constant visibility merely for tests. + +### Intentional-difference records + +Record the pooled `ssubscribe` omission according to Section 15. Explain there that sharded Pub/Sub needs slot-routed dedicated connections and is not equivalent to ordinary Subscriber routing. + +## 12. Delete dead Sentinel compatibility and duplicate config parsing + +### Files + +- `src/redis/src/RedisSentinelFactory.php` +- `src/redis/src/RedisConfig.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/RedisProxyTest.php` +- `tests/Redis/RedisConfigTest.php` +- `tests/Redis/RedisSentinelFactoryTest.php` (new) +- `tests/Integration/Redis/RedisConnectionIntegrationTest.php` + +### Sentinel floor + +Delete: + +- `$isOlderThan6`; +- its constructor; +- `version_compare()` branch; +- six-positional-argument Sentinel construction. + +The declared supported phpredis floor is 6.1. Always use the current options-array `RedisSentinel` constructor. + +Delete the matching `< 6.0` properties, setup checks, skips, incomplete branches, and alternate constructor-signature expectations from Redis unit and integration tests. Keep the current signatures covered directly; an unsupported extension version cannot execute the suite. + +### Config normalization + +`connectionNames()` must call `parseConnectionConfiguration()` before validation, just as `connectionConfig()` does. Do not retain a second direct `ConfigurationUrlParser` flow. + +The shared parser remains responsible for: + +- URL parsing; +- `driver` to `scheme` translation for TCP/TLS; +- removal of database-style `driver`. + +Tests prove both entry points accept and reject the same normalized shapes. + +## 13. Reject pooled RESET before native dispatch + +### Files + +- `src/redis/src/RedisConnection.php` +- `src/support/src/Facades/Redis.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/RedisProxyTest.php` +- `tests/Redis/PackageMetadataTest.php` +- `tests/Integration/Redis/RedisResetIntegrationTest.php` (new) +- `src/redis/README.md` +- `src/boost/docs/redis.md` + +### Command boundary + +Macro dispatch remains first. For non-macro native dispatch, reject RESET at the start of `executeCommand()`, before: + +- `shouldTransform === false`; +- queue-mode preparation; +- native method lookup; +- WATCH settlement; +- any native call. + +Use a direct inline rejection, not the subscription message or a one-use helper: + +```php +if ($name === 'reset') { + throw new BadMethodCallException( + 'Cannot call reset() on a pooled Redis connection because it clears ' + . 'the authentication and selected database owned by the pool. ' + . 'Use Redis::discard() for MULTI, Redis::unwatch() for WATCH, ' + . 'and exec() to complete a PIPELINE.' + ); +} +``` + +Place the concise `REMOVED:` marker at this natural rejection point. The actual message must be technically exact and must not claim every RESET effect has a scoped Hypervel API. + +Do not: + +- invoke native RESET; +- mark invalid; +- reconnect; +- clear tracked WATCH state; +- add a pipeline-only special case. + +Remove RESET from the successful WATCH-terminal list. Remove RESET annotations from RedisConnection and Redis facade. Add RESET to facade-documenter ignores. + +### Deliberate escape hatches + +Macro-first dispatch means a macro named `reset` shadows the prohibition. Callback-less `Redis::pipeline()` returns native `\Redis`, so explicit `$pipeline->reset()` can still reach the phpredis fatal. These are deliberate raw/native escapes. + +Do not add: + +- a wrapper around callback-less native pipeline; +- a macro-name prohibition; +- a raw-client proxy; +- a native-state monitor. + +### Separate-process regression + +Add `tests/Integration/Redis/RedisResetIntegrationTest.php` using: + +- `InteractsWithRedis`; +- method-level `#[RunInSeparateProcess]` on the native-fatal regression; +- `protected bool $runTestsInCoroutine = false;`. + +Prove: + +1. public `Redis::pipeline()` enters native pipeline mode; +2. the next `Redis::reset()` throws the framework `BadMethodCallException`; +3. PHPUnit reports normally rather than losing a ParaTest worker; +4. the native fatal boundary is never reached. + +Unit tests prove a direct held-wrapper call: + +- does not touch the native client; +- does not clear tracked WATCH state; +- throws with the approved guidance. + +### Intentional-difference records + +Record the omission according to Section 15. State only: + +- native RESET can terminate PHP from PIPELINE mode; +- successful RESET destroys auth/database state owned by the pool; +- therefore pooled RESET is unsupported. + +## 14. Make Redis observability truthful and non-throwing + +### Files + +- `src/telescope/src/Watchers/RedisWatcher.php` +- `tests/Telescope/Watchers/RedisWatcherTest.php` +- `src/sentry/src/Features/RedisFeature.php` +- `tests/Sentry/Features/RedisIntegrationTest.php` + +### Formatter + +Use a private recursive formatter because recursion is genuine reuse and benefits from a clear name: + +```php +private function formatParameter(mixed $parameter): string +{ + if (is_array($parameter)) { + $values = []; + + foreach ($parameter as $key => $value) { + $formatted = $this->formatParameter($value); + $values[] = is_int($key) ? $formatted : "{$key} {$formatted}"; + } + + return implode(' ', $values); + } + + if ($parameter === null || is_scalar($parameter)) { + return (string) $parameter; + } + + return get_debug_type($parameter); +} +``` + +Use this for top-level parameters while preserving current command/scalar/keyed-array output. + +Nested arrays deliberately change from JSON fragments such as `["a","b"]` to recursively formatted space-delimited values such as `a b`. Add an explicit output expectation for this visible result of removing `json_encode()` so the new assertion cannot be mistaken for weakened coverage. + +Do not call: + +- `json_encode()`; +- `serialize()`; +- `__toString()`; +- `JsonSerializable::jsonSerialize()`; +- a dumper; +- user callbacks. + +Do not catch around event dispatch. Do not add recursion-depth or self-reference tracking for unsupported, unrealistic self-referential Redis parameters. + +### Batch-opener filtering + +Hypervel's `transaction()` opens native MULTI through `RedisProxy::__call('multi', [])`; it never emits an event named `transaction`. Both `multi` and `pipeline` are only outer opener events because their queued native commands and terminal EXEC run on the held native client. Recording one opener while hiding the other is misleading rather than useful command observability. + +Delete the dead `transaction` entry and make the existing filter strict and case-insensitive: + +```php +private function shouldIgnore(CommandExecuted $event): bool +{ + return in_array(strtolower($event->command), ['pipeline', 'multi'], true); +} +``` + +The concrete parameter type matches the only caller. Do not add a configurable ignore list or normalize command names in the event object itself. + +### Tests + +Cover: + +- existing scalar formatting; +- numeric and string array keys; +- nested scalar arrays; +- object; +- Closure; +- stream resource; +- a `JsonSerializable` object whose `jsonSerialize()` throws if invoked; +- nested throwing `JsonSerializable`; +- successful Redis result remains successful while Telescope records the event; +- lowercase and mixed-case `pipeline` and `multi` events are ignored; +- an ordinary mixed-case command remains recorded with its original event name. + +### Sentry database metadata + +Sentry's parameter handling and SDK handoff are distinct from Telescope's formatter and remain unchanged. Correct both Redis span paths to use the canonical normalized connection key: + +```php +'db.redis.database_index' => (int) ($config['database'] ?? 0), +``` + +The existing test fixture writes `db`, a shape no real Hypervel Redis connection uses, and therefore masks the defect. Resolve config through `$this->app->make('config')`, change the fixture key to `database`, retain the non-zero success-span assertion, and add the same non-zero assertion for a failed-command span. Do not add a fallback for the obsolete test-only key. + +## 15. Complete metadata, provenance, and user documentation + +### Files + +- `src/redis/composer.json` +- `src/redis/README.md` +- `src/boost/docs/redis.md` +- `src/support/src/Facades/Redis.php` +- `src/testing/src/PHPUnit/AfterEachTestSubscriber.php` +- `tests/Redis/PackageMetadataTest.php` +- `tests/Redis/RedisConnectionTest.php` +- `tests/Redis/RedisManagerTest.php` + +### Split metadata + +Add direct `hypervel/macroable` to `src/redis/composer.json`. + +Retain: + +- root `ext-redis: ^6.1`; +- split-package phpredis 6.1 floor advertisement; +- `hypervel/engine`, because `CommandInvoker` still directly uses Engine channels and coroutine primitives after the socket transport changes; +- every other direct dependency still imported at runtime after the final source edits. + +After all source edits, recalculate every Redis split dependency from imports. Remove only dependencies with no remaining direct runtime use. Validate the split manifest. + +### Package README + +Add: + +```md +Ported from: https://github.com/hyperf/hyperf/tree/master/src/redis +``` + +Expand `Differences From Laravel` concisely: + +- Hypervel uses phpredis-only pooled connections and connection-local nested topology; +- automatic framework command replay is not performed; +- macros are observed as one proxy operation rather than inner native events; +- connector-driver `extend()` / `setDriver()` are omitted; use macros or held raw access; +- raw pooled `subscribe`, `psubscribe`, `ssubscribe`, and `reset` are not available, with the supported alternatives; +- existing Hypervel limiter differences remain. + +Do not turn the README into an architecture document. + +### Boost Redis guide + +Match the existing task-first layout and language. Update: + +- supported connection keys: prefix, scan, name, tcp_keepalive, serializer, compression, compression_level, pack_ignore_numbers where supported, and context shapes; +- username/password `"0"` behavior only if useful to explain ACL configuration; +- native retry/backoff remains phpredis behavior; remove framework command replay; +- Subscriber standalone/TLS/Unix/Sentinel/Cluster routing and exact binary payload behavior; +- Sentinel TLS through node schemes and topology-local context, including one canonical bracketed IPv6 example; +- subscribe/unsubscribe/PING expectations only where users need them; +- macros with one-operation event granularity; +- boot-only `enableEvents()` / `disableEvents()`; +- unsupported pooled RESET and sharded subscription, with alternatives; +- Hypervel connection-local Cluster/Sentinel shape. + +Keep internal parser, channel, pool, defer, watcher, and failure-precedence mechanics out of user docs. + +### Source and test markers + +For every intentional Laravel omission, use all three repository records: + +1. README difference; +2. concise `REMOVED:` source marker at the natural insertion/rejection point; +3. concise `REMOVED:` test marker where the omission is verified. + +The closed omissions are: + +- connector-driver `extend()` / `setDriver()`; +- pooled `reset()`; +- pooled/dedicated `ssubscribe()` support. + +Current Laravel has manager extension coverage in `examples/laravel/framework/tests/Redis/RedisManagerExtensionTest.php`; place the connector-driver marker at the corresponding manager-extension position in `tests/Redis/RedisManagerTest.php`. Current Laravel has no behavioral RESET or `ssubscribe()` test to mirror, so place those markers beside the advertised-method assertions in `tests/Redis/PackageMetadataTest.php` rather than inventing an upstream test location. + +Ordinary Hypervel adaptations do not receive divergence comments. + +## 16. Remove every superseded path + +The numbered implementation sections identify every source branch, property, helper, annotation, test assumption, dependency, and documentation claim to delete. Search the whole repository for each of those exact paths after implementation; remaining hits must be deliberate `REMOVED:` records or negative regression assertions. + +Do not preserve replaced behavior through compatibility aliases, deprecated wrappers, stale comments, speculative TODOs, or parallel fallback mechanisms. + +## 17. Validation cadence + +The detailed implementation sections define the complete regression matrix. Run each changed or new test file immediately: + +```bash +./vendor/bin/phpunit --no-progress tests/Redis/RedisConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisProxyTest.php +./vendor/bin/phpunit --no-progress tests/Redis/MultiExecTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisPoolHeartbeatTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisProxyNonCoroutineTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisManagerTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisServiceProviderTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisSentinelFactoryTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisConfigTest.php +./vendor/bin/phpunit --no-progress tests/Redis/RedisEventsTest.php +./vendor/bin/phpunit --no-progress tests/Redis/PackageMetadataTest.php +./vendor/bin/phpunit --no-progress tests/Redis/PhpRedisClusterConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Redis/ConcurrencyLimiterTest.php +./vendor/bin/phpunit --no-progress tests/Redis/ConcurrencyLimiterBuilderTest.php +./vendor/bin/phpunit --no-progress tests/Redis/Subscriber/ConnectionTest.php +./vendor/bin/phpunit --no-progress tests/Redis/Subscriber/CommandBuilderTest.php +./vendor/bin/phpunit --no-progress tests/Redis/Subscriber/CommandInvokerTest.php +./vendor/bin/phpunit --no-progress tests/Redis/Subscriber/CommandInvokerCreateFailureTest.php +./vendor/bin/phpunit --no-progress tests/Redis/Subscriber/SubscriberTest.php +./vendor/bin/phpunit --no-progress tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php +./vendor/bin/phpunit --no-progress tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Horizon/Feature/RedisPrefixTest.php +./vendor/bin/phpunit --no-progress tests/Telescope/Watchers/RedisWatcherTest.php +./vendor/bin/phpunit --no-progress tests/Telescope/Watchers/DisabledWatcherTest.php +./vendor/bin/phpunit --no-progress tests/Sentry/Features/RedisIntegrationTest.php +``` + +Run new or changed integration files immediately with local Redis enabled: + +```bash +./vendor/bin/phpunit --no-progress tests/Integration/Redis/RedisConnectionIntegrationTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis/RedisConnectorTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis/RedisProxyIntegrationTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis/RedisProxyNonCoroutineIntegrationTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis/RedisSubscribeIntegrationTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis/RedisResetIntegrationTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php +``` + +Use the configured local `.env` Redis service and the assigned per-worker database from `InteractsWithRedis`. Do not hardcode database numbers. + +### Focused groups + +After file-level tests: + +```bash +./vendor/bin/phpunit --no-progress tests/Redis +./vendor/bin/phpunit --no-progress tests/Reverb/Servers/Hypervel/Scaling tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php +./vendor/bin/phpunit --no-progress tests/Integration/Redis +``` + +The existing `.github/workflows/redis.yml` already runs `tests/Integration/Redis` against Redis 8 and Valkey 9. No workflow or env-file change is needed unless implementation introduces a genuinely new external service, which this plan does not. + +### Metadata and stale-path checks + +Run: + +```bash +composer validate --strict src/redis/composer.json +git diff --check +``` + +Use repository-wide searches for: + +- `retry(` in Redis command dispatch; +- `queuedPublishes`; +- `processQueuedPublishes`; +- `Constants::EOF`; +- `SocketFactoryInterface` under Redis Subscriber; +- Redis Manager `customCreators`, `extend`, and `forgetExtension`; +- config mutation loops for `event.enable`; +- Horizon `database.redis.clusters`; +- Sentinel `isOlderThan6`; +- unsupported `< 6.0` phpredis branches under Redis tests; +- string option `keepalive`; +- advertised `reset` / `ssubscribe`; +- Telescope Redis `json_encode`; +- Telescope Redis dead `transaction` ignore; +- stale replay documentation. + +Inspect every remaining hit. + +After source, tests, metadata, and documentation are green, update `docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md` with `redis-09` through `redis-20`, `reverb-05`, `horizon-01`, `telescope-01`, `telescope-02`, and `sentry-01`, including the final decisions and evidence. Update the Redis routing and checklist state in `docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md` in the same pass. Do not mark either record complete before the implementation, full validation, self-review, and code review are complete. + +### Full gate + +After all focused tests and self-contained checks are green: + +```bash +composer fix +``` + +`composer fix` is authoritative. It runs formatting, both PHPStan configurations, the complete parallel suite, the Testbench package suite, and Testbench dogfood. Do not waste time by running fixer and PHPStan separately immediately before it. Do not replace it with package-only tests. + +Inspect every failure and skip normally. Fix source defects at their real owner; never weaken tests or add PHPStan-driven runtime branches. + +## 18. Fresh post-implementation self-review + +After `composer fix` passes, review the complete diff without trusting this plan or the implementation discussion: + +1. Re-read every changed source file in full or consecutive chunks. +2. Trace pooled ownership and failure precedence end to end: checkout, macro/native dispatch, events, stateful publication, callback release, terminal defer, task/fork cleanup, release/discard, and close. Prove no uncertain command is replayed or uncertain generation requeued. +3. Trace subscriber construction and operation end to end: topology and credentials, stream lifetime, exact RESP parsing, command routing, channel/pattern accounting, timeouts, retained causes, and deterministic close. +4. Trace the changed cross-package consumers: Reverb publish/metrics cleanup, both limiter paths, Horizon config/prefix publication, Telescope formatting/filtering, and Sentry database metadata. +5. Compare every accepted PhpRedis API, member position, test, facade annotation, and user-facing statement with current Laravel default source and documentation, then verify every approved Hypervel adaptation against the pooled architecture. +6. Recalculate split dependencies, metadata invariants, intentional-difference markers, and static test cleanup from the final code. +7. Search for every removed symbol, branch, stale comment, test assumption, and documentation claim. +8. Inspect ordinary commands and worker-retained state for new lookups, allocations, locks, context work, yields, retries, logs, network calls, reconnects, or unbounded growth. +9. Ask whether any local mechanism duplicates a lower owner, serves only PHPStan or a test seam, or solves no verified supported path; simplify it. +10. Compare README and Boost wording with nearby Hypervel docs for accurate, concise, task-first language. + +Fix straightforward omissions and rerun proportionate tests. Any newly discovered design issue goes through the focused second-opinion workflow before implementation continues. + +Then request independent code review of source, tests, docs, metadata, ledger changes, validation, API parity, event behavior, subscriber fidelity, hot paths, and overengineering. Continue until sign-off. + +## 19. Performance and retained-state assessment + +### Successful hot-path effects + +| Change | Frequency | Effect | +|---|---|---| +| Macro lookup | Every Redis wrapper command | One static array lookup; owner-approved noise-level current-Laravel capability | +| Proxy and wrapper method normalization | Every ordinary proxy-dispatched native command | Two lowercase operations on the short method name, one for proxy routing and one for wrapper state/guards; proxy-handled routes perform only the former and direct held-wrapper commands only the latter | +| Pooled `reset` / subscription guard | Wrapper dispatch name matching | A few fixed string comparisons folded into the existing prohibited-command boundary; measurement noise | +| Existing queue/watch release validation | Every pooled release | Unchanged local phpredis mode read and wrapper boolean from `redis-04`/`redis-07` | +| Existing deferred owner check | Stateful publication only | Unchanged owner-ID comparison from `redis-03` | + +No other new work belongs on a successful ordinary command: + +- no config read; +- no container resolution; +- no lock; +- no additional context operation; +- no hash or serialization; +- no allocation family; +- no yield or sleep; +- no retry; +- no log; +- no network command; +- no immediate reconnect. + +### Cold and failure paths + +| Change | Frequency | Effect | +|---|---|---| +| Failure disposition | `RedisException` only | One local last-error comparison; Sentinel errors add exact code matching | +| PhpRedis option normalization | Native connection creation | Array normalization and option application only | +| Subscriber RESP parser | Dedicated subscriber traffic | PHP parsing remains, now exact; removes line-buffer corruption and one timer coroutine per message | +| Subscriber topology | Subscriber construction | Sentinel/Cluster endpoint discovery only | +| Subscriber context-implied TLS | Subscriber construction | Two local context/scheme defaults while building the dedicated stream endpoint | +| Reverb | Publish calls | Removes an unbounded retained queue; uses the pooled publish that already existed | +| Limiter cleanup | Callback completion/failure | Direct local exception handling; same Redis release command | +| Event override | Worker boot/pool construction | One nullable property read during config assembly | +| Horizon | Boot configuration | One cluster check and optional hash-tag normalization | +| Telescope | Observed Redis command only | One lowercase operation before two fixed strict comparisons, plus bounded recursive traversal of the already-supplied parameter tree; no user code | + +### Retained worker memory + +- Existing Redis proxy and pool caches remain bounded by configured connection names. +- Existing owner-ID deferred cleanup remains one integer context slot and one closure per pool/coroutine that uses stateful commands. +- Macro registry is worker-static by Laravel design and is cleared authoritatively between tests. +- RedisConfig stores one nullable boolean override. +- Subscriber tracks only active channel and pattern names for that dedicated connection. +- CommandInvoker retains at most the first receive throwable until close. +- Reverb's unbounded queued payload list is removed. +- No error registry, connector registry, protocol state machine, live-pool registry, retry queue, or self-reference set is added. + +## 20. Completion criteria + +This work unit is complete only when: + +- every finding in the summary is implemented at its named owner while the carried Redis ownership baseline and protected Hypervel features remain intact; +- every detailed source, regression, integration, metadata, documentation, removal, and intentional-difference requirement is complete; +- no stale path, misleading claim, compatibility shim, speculative mechanism, unbounded retained state, or meaningful ordinary-command regression remains; +- the focused cadence, external Redis/Valkey coverage, metadata checks, stale-path searches, `git diff --check`, and `composer fix` pass; +- fresh self-review and independent code review sign off on correctness, Laravel API parity, coroutine ownership, failure precedence, performance, retained memory, and overengineering; +- the audit ledger and routing checklist are updated from the validated implementation, followed by the owner summary and commit approval required by the main audit workflow. From 0772a58db9401bcaf124486cf7601fdc74be415b Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:46:36 +0000 Subject: [PATCH 02/15] feat(redis): harden pooled command and topology handling Stop replaying ambiguous Redis failures and classify whether each native generation can be safely retained or must be discarded. Complete current supported phpredis configuration, ACL, context, option, prefix, Sentinel, and Cluster behavior while preserving exact pooled wrapper ownership and callback-immediate release. Add Laravel-shaped macros and boot-only event controls, normalize command routing case-insensitively, reject pool-destructive RESET and sharded subscription operations, remove the incompatible named-proxy extension API, and keep facade and static test cleanup metadata aligned. --- src/redis/composer.json | 1 + src/redis/src/PhpRedisClusterConnection.php | 25 ++- src/redis/src/PhpRedisConnection.php | 119 +++++----- src/redis/src/RedisConfig.php | 52 ++++- src/redis/src/RedisConnection.php | 84 ++++--- src/redis/src/RedisManager.php | 83 +++---- src/redis/src/RedisProxy.php | 212 +++++++++++++++--- src/redis/src/RedisSentinelFactory.php | 119 ++++++++-- src/redis/src/RedisServiceProvider.php | 3 +- src/support/src/Facades/Redis.php | 14 +- .../src/PHPUnit/AfterEachTestSubscriber.php | 1 + 11 files changed, 522 insertions(+), 191 deletions(-) diff --git a/src/redis/composer.json b/src/redis/composer.json index f05309a1a..9a4d6a74f 100644 --- a/src/redis/composer.json +++ b/src/redis/composer.json @@ -44,6 +44,7 @@ "hypervel/core": "^0.4", "hypervel/coroutine": "^0.4", "hypervel/engine": "^0.4", + "hypervel/macroable": "^0.4", "hypervel/pool": "^0.4", "hypervel/support": "^0.4" }, diff --git a/src/redis/src/PhpRedisClusterConnection.php b/src/redis/src/PhpRedisClusterConnection.php index 0bdc7cf9e..0cdaac5f4 100644 --- a/src/redis/src/PhpRedisClusterConnection.php +++ b/src/redis/src/PhpRedisClusterConnection.php @@ -137,7 +137,9 @@ protected function createRedisCluster(): RedisCluster $parameters[] = $this->config['cluster']['persistent'] ?? false; $parameters[] = $this->formatClusterPassword(); if (! empty($this->config['cluster']['context'])) { - $parameters[] = $this->config['cluster']['context']; + $parameters[] = $this->normalizeClusterContext( + $this->config['cluster']['context'] + ); } $redis = new RedisCluster(...$parameters); @@ -148,6 +150,25 @@ protected function createRedisCluster(): RedisCluster return $redis; } + /** + * Normalize the SSL context for a Redis Cluster connection. + * + * @param array $context + * @return array + */ + protected function normalizeClusterContext(array $context): array + { + if (isset($context['ssl']) && is_array($context['ssl'])) { + return $context['ssl']; + } + + if (isset($context['stream']) && is_array($context['stream'])) { + return $context['stream']; + } + + return $context; + } + /** * Format the password for the RedisCluster constructor. */ @@ -156,7 +177,7 @@ protected function formatClusterPassword(): mixed $password = $this->config['password'] ?? null; $username = $this->config['username'] ?? null; - return $username && is_string($password) + return $username !== null && $username !== '' && is_string($password) ? [$username, $password] : $password; } diff --git a/src/redis/src/PhpRedisConnection.php b/src/redis/src/PhpRedisConnection.php index bebc8608a..ad29f3d76 100644 --- a/src/redis/src/PhpRedisConnection.php +++ b/src/redis/src/PhpRedisConnection.php @@ -7,9 +7,8 @@ use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Pool\PoolInterface; use Hypervel\Pool\Exceptions\ConnectionException; -use Hypervel\Redis\Exceptions\InvalidRedisConnectionException; use Hypervel\Support\Str; -use Psr\Log\LogLevel; +use InvalidArgumentException; use Redis; use RedisException; use Throwable; @@ -48,9 +47,13 @@ public function reconnect(): bool $this->setOptions($redis); $auth = $this->config['password'] ?? null; - if (isset($auth) && $auth !== '') { + if ($auth !== null && $auth !== '') { $username = $this->config['username'] ?? null; - $redis->auth($username ? [$username, $auth] : $auth); + $redis->auth( + $username !== null && $username !== '' && is_string($auth) + ? [$username, $auth] + : $auth + ); } $database = $this->database ?? (int) ($this->config['database'] ?? 0); @@ -58,6 +61,11 @@ public function reconnect(): bool $redis->select($database); } + $name = $this->config['name'] ?? null; + if ($name !== null && $name !== '') { + $redis->client('SETNAME', $name); + } + $this->connection = $redis; $this->markReconnected(); @@ -115,7 +123,7 @@ protected function createRedis(array $config): Redis ]; if (! empty($config['context'])) { - $parameters[] = $config['context']; + $parameters[] = $this->normalizeContext($config['context']); } $redis = new Redis; @@ -133,11 +141,48 @@ protected function createRedis(array $config): Redis */ protected function formatHost(array $config): string { + $host = $config['host'] ?? null; + + if (! is_string($host) || $host === '') { + throw new InvalidArgumentException('Redis host must be a non-empty string.'); + } + + $hostScheme = parse_url($host, PHP_URL_SCHEME); + if (isset($config['scheme'])) { - return Str::start($config['host'], "{$config['scheme']}://"); + if (is_string($hostScheme)) { + if (strcasecmp($hostScheme, $config['scheme']) !== 0) { + throw new InvalidArgumentException( + 'The scheme configured in the Redis host option must match the scheme option.' + ); + } + + return $host; + } + + return Str::start($host, "{$config['scheme']}://"); + } + + return $host; + } + + /** + * Normalize the SSL context for a standalone Redis connection. + * + * @param array $context + * @return array + */ + protected function normalizeContext(array $context): array + { + if (isset($context['stream'])) { + return $context; } - return $config['host']; + if (isset($context['ssl']) && is_array($context['ssl'])) { + return ['stream' => $context['ssl']]; + } + + return ['stream' => $context]; } /** @@ -148,60 +193,20 @@ protected function formatHost(array $config): string protected function createRedisSentinel(): Redis { try { - $nodes = $this->config['sentinel']['nodes'] ?? []; - $timeout = $this->config['timeout'] ?? 0; - $persistent = $this->config['sentinel']['persistent'] ?? null; - $retryInterval = $this->config['retry_interval'] ?? 0; - $readTimeout = $this->config['sentinel']['read_timeout'] ?? 0; - $masterName = $this->config['sentinel']['master_name'] ?? ''; - $auth = $this->config['sentinel']['auth'] ?? null; - - shuffle($nodes); - - $host = null; - $port = null; - foreach ($nodes as $node) { - try { - $resolved = parse_url($node); - if (! isset($resolved['host'], $resolved['port'])) { - $this->log(sprintf('The redis sentinel node [%s] is invalid.', $node), LogLevel::ERROR); - continue; - } - - $options = [ - 'host' => $resolved['host'], - 'port' => (int) $resolved['port'], - 'connectTimeout' => $timeout, - 'persistent' => $persistent, - 'retryInterval' => $retryInterval, - 'readTimeout' => $readTimeout, - ...($auth ? ['auth' => $auth] : []), - ]; - - $sentinel = $this->container->make(RedisSentinelFactory::class)->create($options); - $masterInfo = $sentinel->getMasterAddrByName($masterName); - if (is_array($masterInfo) && count($masterInfo) >= 2) { - [$host, $port] = $masterInfo; - break; - } - } catch (Throwable $exception) { - $this->log('Redis sentinel connection failed, caused by ' . $exception->getMessage()); - continue; - } - } - - if ($host === null && $port === null) { - throw new InvalidRedisConnectionException('Connect sentinel redis server failed.'); - } + [$host, $port] = $this->container + ->make(RedisSentinelFactory::class) + ->resolveMaster($this->config); - $redis = $this->createRedis(array_filter([ + $redis = $this->createRedis([ 'scheme' => $this->config['scheme'] ?? null, 'host' => $host, 'port' => $port, - 'timeout' => $timeout, - 'retry_interval' => $retryInterval, - 'read_timeout' => $readTimeout, - ])); + 'timeout' => $this->config['timeout'] ?? 0, + 'reserved' => $this->config['reserved'] ?? null, + 'retry_interval' => $this->config['retry_interval'] ?? 0, + 'read_timeout' => $this->config['sentinel']['read_timeout'] ?? 0, + 'context' => $this->config['context'] ?? [], + ]); } catch (Throwable $exception) { throw new ConnectionException('Connection reconnect failed ' . $exception->getMessage()); } diff --git a/src/redis/src/RedisConfig.php b/src/redis/src/RedisConfig.php index 09ec21166..e60fde0d5 100644 --- a/src/redis/src/RedisConfig.php +++ b/src/redis/src/RedisConfig.php @@ -10,6 +10,11 @@ class RedisConfig { + /** + * The worker-wide event enablement override. + */ + private ?bool $eventsOverride = null; + /** * Create a new redis config helper. */ @@ -27,8 +32,6 @@ public function connectionNames(): array $redisConfig = $this->all(); $names = []; - $parser = new ConfigurationUrlParser; - foreach ($redisConfig as $name => $connectionConfig) { if (in_array($name, ['client', 'options', 'clusters'], true)) { continue; @@ -38,7 +41,10 @@ public function connectionNames(): array throw new InvalidArgumentException(sprintf('The redis connection [%s] must be an array.', $name)); } - $this->validateConnectionConfig($name, $parser->parseConfiguration($connectionConfig)); + $this->validateConnectionConfig( + $name, + $this->parseConnectionConfiguration($connectionConfig), + ); $names[] = $name; } @@ -75,9 +81,37 @@ public function connectionConfig(string $name): array $connectionConfig['options'] = array_replace($sharedOptions, $connectionOptions); + if (array_key_exists('prefix', $connectionConfig)) { + $connectionConfig['options']['prefix'] = $connectionConfig['prefix']; + } + + if ($this->eventsOverride !== null) { + $connectionConfig['event']['enable'] = $this->eventsOverride; + } + return $connectionConfig; } + /** + * Enable Redis command events. + * + * Boot-only. The worker-wide override affects every subsequently assembled connection config. + */ + public function enableEvents(): void + { + $this->eventsOverride = true; + } + + /** + * Disable Redis command events. + * + * Boot-only. The worker-wide override affects every subsequently assembled connection config. + */ + public function disableEvents(): void + { + $this->eventsOverride = false; + } + /** * Parse and normalize a Redis connection configuration. * @@ -143,6 +177,12 @@ private function validateConnectionConfig(string $name, mixed $connectionConfig) throw new InvalidArgumentException(sprintf('The redis connection [%s] cluster seeds must be a non-empty array.', $name)); } + foreach ($seeds as $seed) { + if (! is_string($seed) || $seed === '') { + throw new InvalidArgumentException(sprintf('The redis connection [%s] cluster seeds must all be non-empty strings.', $name)); + } + } + return; } @@ -154,6 +194,12 @@ private function validateConnectionConfig(string $name, mixed $connectionConfig) throw new InvalidArgumentException(sprintf('The redis connection [%s] sentinel nodes must be a non-empty array.', $name)); } + foreach ($nodes as $node) { + if (! is_string($node) || $node === '') { + throw new InvalidArgumentException(sprintf('The redis connection [%s] sentinel nodes must all be non-empty strings.', $name)); + } + } + if (! is_string($masterName) || $masterName === '') { throw new InvalidArgumentException(sprintf('The redis connection [%s] sentinel master name must be configured.', $name)); } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 1a352642a..8474c2546 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -21,6 +21,7 @@ use Hypervel\Redis\Operations\FlushByPattern; use Hypervel\Redis\Operations\SafeScan; use Hypervel\Support\Collection; +use Hypervel\Support\Traits\Macroable; use Psr\Log\LogLevel; use Redis; use RedisCluster; @@ -223,7 +224,6 @@ * @method mixed rawcommand(string $command, mixed ...$args) * @method bool|Redis rename(string $old_name, string $new_name) * @method bool|Redis renameNx(string $key_src, string $key_dst) - * @method bool|Redis reset() * @method bool|Redis restore(string $key, int $ttl, string $value, array|null $options = null) * @method mixed role() * @method false|Redis|string rpoplpush(string $srckey, string $dstkey) @@ -258,7 +258,6 @@ * @method mixed sort(string $key, array|null $options = null) * @method mixed sort_ro(string $key, array|null $options = null) * @method false|int|Redis srem(string $key, mixed $value, mixed ...$other_values) - * @method bool ssubscribe(array $channels, callable $cb) * @method false|int|Redis strlen(string $key) * @method void subscribe(array|string $channels, \Closure $callback) * @method array|bool|Redis sunsubscribe(array $channels) @@ -330,6 +329,10 @@ */ abstract class RedisConnection extends BaseConnection { + use Macroable { + __call as macroCall; + } + /** * Top-level connection config keys that should be applied through setOption. */ @@ -370,6 +373,7 @@ abstract class RedisConnection extends BaseConnection 'nodes' => [], 'persistent' => '', 'read_timeout' => 0, + 'context' => [], ], 'options' => [], 'context' => [], @@ -404,18 +408,32 @@ public function __construct(Container $container, PoolInterface $pool, array $co $this->config = array_replace_recursive($this->config, $config); } + /** + * Pass other method calls down to the underlying client. + * @param mixed $name + * @param mixed $arguments + */ public function __call($name, $arguments) { try { + if (static::hasMacro($name)) { + return $this->macroCall($name, $arguments); + } + + $name = strtolower($name); $result = $this->executeCommand($name, $arguments); } catch (RedisException $exception) { - $result = $this->retry($name, $arguments, $exception); + if ($this->shouldInvalidateAfter($exception)) { + $this->markInvalid(); + } + + throw $exception; } if ($name === 'watch' && $result !== false) { $this->watching = true; } elseif ( - in_array($name, ['exec', 'reset'], true) + $name === 'exec' || ($name === 'unwatch' && $result !== false) ) { $this->watching = false; @@ -431,7 +449,17 @@ public function __call($name, $arguments) */ private function executeCommand(string $name, array $arguments): mixed { - if (in_array($name, ['subscribe', 'psubscribe'], true)) { + // REMOVED: RESET destroys authentication and database state owned by the pool. + if ($name === 'reset') { + throw new BadMethodCallException( + 'Cannot call reset() on a pooled Redis connection because it clears ' + . 'the authentication and selected database owned by the pool. ' + . 'Use Redis::discard() for MULTI, Redis::unwatch() for WATCH, ' + . 'and exec() to complete a PIPELINE.' + ); + } + + if (in_array($name, ['subscribe', 'psubscribe', 'ssubscribe'], true)) { return $this->callSubscribe($name, $arguments); } @@ -557,6 +585,11 @@ protected function setOptions(Redis|RedisCluster $redis): void if (is_string($name)) { $name = strtolower($name); + if ($name === 'pack_ignore_numbers' + && (! $redis instanceof Redis || ! defined(Redis::class . '::OPT_PACK_IGNORE_NUMBERS'))) { + continue; + } + if ($name === 'backoff_algorithm') { $value = $this->parseBackoffAlgorithm($value); } @@ -600,11 +633,12 @@ protected function phpRedisOption(string $name): int 'prefix' => Redis::OPT_PREFIX, 'read_timeout' => Redis::OPT_READ_TIMEOUT, 'scan' => Redis::OPT_SCAN, - 'failover' => defined(Redis::class . '::OPT_SLAVE_FAILOVER') ? Redis::OPT_SLAVE_FAILOVER : 5, - 'keepalive' => Redis::OPT_TCP_KEEPALIVE, + 'failover' => RedisCluster::OPT_SLAVE_FAILOVER, + 'tcp_keepalive' => Redis::OPT_TCP_KEEPALIVE, 'compression' => Redis::OPT_COMPRESSION, 'reply_literal' => Redis::OPT_REPLY_LITERAL, 'compression_level' => Redis::OPT_COMPRESSION_LEVEL, + 'pack_ignore_numbers' => Redis::OPT_PACK_IGNORE_NUMBERS, // @phpstan-ignore classConstant.notFound (setOptions() skips this option when phpredis predates the constant) 'max_retries' => Redis::OPT_MAX_RETRIES, 'backoff_algorithm' => Redis::OPT_BACKOFF_ALGORITHM, 'backoff_base' => Redis::OPT_BACKOFF_BASE, @@ -828,22 +862,21 @@ public function isLifetimeExpired(?float $now = null): bool } /** - * Retry a redis command after reconnecting. - * - * @param array $arguments + * Determine whether a failed command left the connection unsafe to reuse. */ - protected function retry(string $name, array $arguments, RedisException $exception): mixed + protected function shouldInvalidateAfter(RedisException $exception): bool { - $this->log('Redis::__call failed, because ' . $exception->getMessage()); - - try { - $this->reconnect(); + if ($this->connection->getLastError() !== $exception->getMessage()) { + return true; + } - return $this->executeCommand($name, $arguments); - } catch (Throwable $exception) { - $this->markInvalid(); - throw $exception; + if (! ($this->config['sentinel']['enable'] ?? false)) { + return false; } + + $errorCode = explode(' ', $exception->getMessage(), 2)[0]; + + return in_array($errorCode, ['READONLY', 'MASTERDOWN'], true); } /** @@ -1264,6 +1297,9 @@ protected function callZunionstore(string $output, array $keys, array $options = return $this->connection->{$method}(...$args); } + /** + * Get the Redis scan options. + */ protected function getScanOptions(array $arguments): array { return is_array($arguments[0] ?? []) @@ -1462,7 +1498,7 @@ protected function callExecuteRaw(array $parameters): mixed } /** - * Reject subscribe/psubscribe on raw pooled connections. + * Reject subscriptions on raw pooled connections. * * Pub/sub requires a dedicated, long-lived connection that is incompatible * with connection pooling. Use the coroutine-native subscriber instead: @@ -1477,7 +1513,7 @@ protected function callSubscribe(string $name, array $arguments): never { throw new BadMethodCallException( "Cannot call {$name}() on a pooled RedisConnection. " - . 'Use Redis::subscribe(), Redis::psubscribe(), or Redis::subscriber() instead.' + . 'Use Redis::subscribe(), Redis::psubscribe(), or Redis::subscriber() for ordinary Pub/Sub.' ); } @@ -1486,8 +1522,7 @@ protected function callSubscribe(string $name, array $arguments): never */ public function serialized(): bool { - return defined('Redis::OPT_SERIALIZER') - && $this->connection->getOption(Redis::OPT_SERIALIZER) !== Redis::SERIALIZER_NONE; + return $this->connection->getOption(Redis::OPT_SERIALIZER) !== Redis::SERIALIZER_NONE; } /** @@ -1495,8 +1530,7 @@ public function serialized(): bool */ public function compressed(): bool { - return defined('Redis::OPT_COMPRESSION') - && $this->connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE; + return $this->connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE; } /** diff --git a/src/redis/src/RedisManager.php b/src/redis/src/RedisManager.php index 63a88d19c..ec64739e7 100644 --- a/src/redis/src/RedisManager.php +++ b/src/redis/src/RedisManager.php @@ -31,20 +31,14 @@ class RedisManager implements FactoryContract, ConnectionContract */ protected array $connections = []; - /** - * The registered custom connection creators. - * - * @var array - */ - protected array $customCreators = []; - /** * Create a new Redis manager instance. */ public function __construct( protected ContainerContract $app, protected PoolFactory $factory, - protected RedisConfig $config + protected RedisConfig $config, + protected RedisSentinelFactory $sentinelFactory, ) { } @@ -63,19 +57,15 @@ public function connection(UnitEnum|string|null $name = null): RedisProxy return $this->connections[$name]; } - if (isset($this->customCreators[$name])) { - return $this->connections[$name] = call_user_func( - $this->customCreators[$name], - $this->app, - $name - ); - } - // Validate the connection exists in config before creating the proxy. // Throws InvalidArgumentException if the name is not configured. $this->config->connectionConfig($name); - return $this->connections[$name] = new RedisProxy($this->factory, $name); + return $this->connections[$name] = new RedisProxy( + $this->factory, + $name, + $this->sentinelFactory, + ); } /** @@ -131,6 +121,30 @@ public function connections(): array return $this->connections; } + /** + * Enable Redis command events. + * + * Boot-only. Existing pools retain their snapshotted event configuration; + * calling this after pool creation can leave generations with different behavior. + */ + public function enableEvents(): void + { + $this->config->enableEvents(); + } + + /** + * Disable Redis command events. + * + * Boot-only. Existing pools retain their snapshotted event configuration; + * calling this after pool creation can leave generations with different behavior. + */ + public function disableEvents(): void + { + $this->config->disableEvents(); + } + + // REMOVED: Connector-driver extend()/setDriver() do not apply to Hypervel's phpredis-only pooled transport. + /** * Release connections retained by non-coroutine task execution. * @@ -181,41 +195,6 @@ protected function terminateConnections(Closure $terminate): void } } - /** - * Register a custom connection creator. - * - * The callback receives the container and connection name, and must - * return a RedisProxy instance (or subclass). - * - * Boot-only. The resolver persists in the singleton's customCreators array - * for the worker lifetime and applies to every subsequent connection. - * - * @param callable(ContainerContract, string): RedisProxy $resolver - */ - public function extend(string $name, callable $resolver): static - { - $this->customCreators[$name] = $resolver; - - // Invalidate any cached proxy so the next connection() call uses the new creator - unset($this->connections[$name]); - - return $this; - } - - /** - * Remove a custom connection creator. - * - * Boot or tests only. Mutates the singleton's customCreators and connections - * caches; concurrent coroutines may already hold a proxy reference that next - * resolution will not share. - */ - public function forgetExtension(string $name): void - { - unset($this->customCreators[$name], $this->connections[$name]); - - // Invalidate cached proxy so the next connection() call goes through normal resolution - } - /** * Register a Redis command listener with the connection. * diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index 90df46b74..6061dde28 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -54,27 +54,27 @@ class RedisProxy implements ConnectionContract 'auth', 'check', 'client', - 'clearWatchState', + 'clearwatchstate', 'close', 'connect', - 'discardTransaction', - 'getActiveConnection', - 'getConnection', - 'getCreatedAt', - 'getLastReleaseTime', - 'getLastUseTime', - 'getShouldTransform', - 'heartbeatCheck', - 'isIdleExpired', - 'isLifetimeExpired', + 'discardtransaction', + 'getactiveconnection', + 'getconnection', + 'getcreatedat', + 'getlastreleasetime', + 'getlastusetime', + 'getshouldtransform', + 'heartbeatcheck', + 'isidleexpired', + 'islifetimeexpired', 'masters', 'pconnect', 'reconnect', 'release', - 'safeScan', - 'setDatabase', - 'setOption', - 'shouldTransform', + 'safescan', + 'setdatabase', + 'setoption', + 'shouldtransform', ]; /** @@ -82,7 +82,8 @@ class RedisProxy implements ConnectionContract */ public function __construct( protected PoolFactory $factory, - protected string $poolName + protected string $poolName, + protected RedisSentinelFactory $sentinelFactory, ) { } @@ -104,6 +105,44 @@ public function isCluster(): bool return $config['cluster']['enable'] ?? false; } + /** + * Register a custom Redis connection macro. + * + * Boot-only. Macros persist for the worker lifetime and affect every Redis connection. + */ + public function macro(string $name, callable|object $macro): void + { + RedisConnection::macro($name, $macro); + } + + /** + * Mix another object into the Redis connection. + * + * Boot-only. Registered macros persist for the worker lifetime and affect every Redis connection. + */ + public function mixin(object $mixin, bool $replace = true): void + { + RedisConnection::mixin($mixin, $replace); + } + + /** + * Determine if a Redis connection macro is registered. + */ + public function hasMacro(string $name): bool + { + return RedisConnection::hasMacro($name); + } + + /** + * Flush the registered Redis connection macros. + * + * Boot or tests only. Concurrent coroutines may resolve different methods during a flush. + */ + public function flushMacros(): void + { + RedisConnection::flushMacros(); + } + /** * Scan keys matching a pattern. * @@ -151,13 +190,20 @@ public function sScan($key, $cursor, ...$arguments) return $this->__call('sScan', [$key, $cursor, ...$arguments]); } + /** + * Pass dynamic method calls to a pooled Redis connection. + * @param mixed $name + * @param mixed $arguments + */ public function __call($name, $arguments) { - if (in_array($name, ['subscribe', 'psubscribe'], true)) { - return $this->handleSubscribe($name, $arguments); // @phpstan-ignore method.void + $command = strtolower($name); + + if (in_array($command, ['subscribe', 'psubscribe'], true)) { + return $this->handleSubscribe($command, $arguments); // @phpstan-ignore method.void } - if (in_array($name, self::CONNECTION_BOUND_METHODS, true)) { + if (in_array($command, self::CONNECTION_BOUND_METHODS, true)) { throw new BadMethodCallException(sprintf( 'Redis connection method [%s] must be called on a held Redis connection. Use Redis::withConnection(...) or Redis::withPinnedConnection(...).', $name @@ -176,7 +222,7 @@ public function __call($name, $arguments) try { /** @var RedisConnection $connection */ $connection = $connection->getConnection(); - $result = $name === 'discard' + $result = $command === 'discard' ? $connection->discardTransaction() : $connection->{$name}(...$arguments); } catch (Throwable $throwable) { @@ -210,9 +256,9 @@ public function __call($name, $arguments) try { if ($hasContextConnection) { // Connection is already in context, don't release - } elseif ($commandException === null && $this->shouldUseSameConnection($name)) { + } elseif ($commandException === null && $this->shouldUseSameConnection($command)) { // On success with same-connection command: store in context for reuse - if ($name === 'select' && array_key_exists(0, $arguments)) { + if ($command === 'select' && array_key_exists(0, $arguments)) { $connection->setDatabase((int) $arguments[0]); } @@ -462,15 +508,125 @@ public function withoutSerializationOrCompression(callable $callback): mixed */ public function subscriber(): Subscriber { - $config = $this->factory->getPool($this->poolName)->getConfig(); - $options = $config['options'] ?? []; + $pool = $this->factory->getPool($this->poolName); + $config = $pool->getConfig(); + + if ($config['sentinel']['enable'] ?? false) { + [$host, $port] = $this->sentinelFactory->resolveMaster($config); + + return $this->createSubscriber( + $config, + $host, + $port, + $config['scheme'] ?? null, + $config['context'] ?? [], + ); + } + + if (! ($config['cluster']['enable'] ?? false)) { + return $this->createSubscriber( + $config, + $config['host'], + (int) $config['port'], + $config['scheme'] ?? null, + $config['context'] ?? [], + ); + } + + $connection = $pool->get(); + $discoveryException = null; + $masters = []; + + try { + if (! $connection instanceof PhpRedisClusterConnection) { + throw new InvalidRedisConnectionException( + 'The Redis Cluster pool returned an invalid connection.' + ); + } + + $connection->getConnection(); + $masters = $connection->masters(); + } catch (Throwable $exception) { + $discoveryException = $exception; + } + + try { + $connection->release(); + } catch (Throwable $exception) { + if ($discoveryException === null) { + throw $exception; + } + } + + if ($discoveryException !== null) { + throw $discoveryException; + } + + $context = $config['cluster']['context'] ?? []; + $failures = []; + + foreach ($masters as $master) { + if (! is_array($master) + || ! isset($master[0], $master[1]) + || ! is_string($master[0]) + || $master[0] === '' + || (! is_int($master[1]) && ! (is_string($master[1]) && ctype_digit($master[1])))) { + $failures[] = '[invalid master]: invalid endpoint'; + continue; + } + + try { + return $this->createSubscriber( + $config, + $master[0], + (int) $master[1], + null, + $context, + ); + } catch (Throwable $exception) { + $failures[] = sprintf( + '[%s:%d]: %s', + $master[0], + $master[1], + $exception->getMessage(), + ); + } + } + + throw new InvalidRedisConnectionException(sprintf( + 'Unable to connect a Redis subscriber to any Cluster master: %s.', + implode('; ', $failures), + )); + } + + /** + * Create a dedicated subscriber from a resolved endpoint. + * + * @param array $config + * @param array $context + */ + private function createSubscriber( + array $config, + string $host, + int $port, + ?string $scheme, + array $context, + ): Subscriber { + /** @var null|array|string $password */ + $password = $config['password'] ?? null; + + /** @var null|string $username */ + $username = $config['username'] ?? null; return new Subscriber( - host: $config['host'], - port: (int) $config['port'], - password: (string) ($config['password'] ?? ''), + host: $host, + port: $port, + password: $password, timeout: (float) ($config['timeout'] ?? 5.0), - prefix: (string) ($options['prefix'] ?? ''), + prefix: (string) (($config['options'] ?? [])['prefix'] ?? ''), + username: $username, + scheme: $scheme, + context: $context, ); } diff --git a/src/redis/src/RedisSentinelFactory.php b/src/redis/src/RedisSentinelFactory.php index 47b929090..e793d0ba7 100644 --- a/src/redis/src/RedisSentinelFactory.php +++ b/src/redis/src/RedisSentinelFactory.php @@ -4,40 +4,121 @@ namespace Hypervel\Redis; +use Hypervel\Redis\Exceptions\InvalidRedisConnectionException; use RedisSentinel; +use Throwable; class RedisSentinelFactory { - protected bool $isOlderThan6 = false; + /** + * Create a redis sentinel client instance. + * + * @param array $options + */ + public function create(array $options = []): RedisSentinel + { + // https://github.com/phpredis/phpredis/blob/develop/sentinel.md#examples-for-version-60-or-later + return new RedisSentinel($options); /* @phpstan-ignore-line */ + } /** - * Create a new Redis sentinel factory instance. + * Resolve the current master address. + * + * @param array $config + * @return array{0: string, 1: int} */ - public function __construct() + public function resolveMaster(array $config): array { - $this->isOlderThan6 = version_compare(phpversion('redis'), '6.0.0', '<'); + $sentinel = $config['sentinel'] ?? []; + $nodes = $sentinel['nodes'] ?? []; + $failures = []; + + shuffle($nodes); + + foreach ($nodes as $node) { + $hasScheme = str_contains($node, '://'); + $resolved = parse_url($hasScheme ? $node : "tcp://{$node}"); + + if (! is_array($resolved) + || ! isset($resolved['host'], $resolved['port'])) { + $failures[] = "[{$node}]: invalid node"; + continue; + } + + if (array_diff_key($resolved, ['scheme' => true, 'host' => true, 'port' => true]) !== []) { + $failures[] = "[{$node}]: unsupported node format"; + continue; + } + + if (str_contains($resolved['host'], ':') + && ! str_starts_with($resolved['host'], '[')) { + $failures[] = "[{$node}]: IPv6 node addresses must be bracketed, for example [::1]:26379"; + continue; + } + + try { + $options = [ + 'host' => $hasScheme + ? "{$resolved['scheme']}://{$resolved['host']}" + : $resolved['host'], + 'port' => (int) $resolved['port'], + 'connectTimeout' => (float) ($config['timeout'] ?? 0), + 'persistent' => $sentinel['persistent'] ?? null, + 'retryInterval' => (int) ($config['retry_interval'] ?? 0), + 'readTimeout' => (float) ($sentinel['read_timeout'] ?? 0), + ]; + $context = $sentinel['context'] ?? []; + $auth = $sentinel['auth'] ?? null; + + if ($context !== []) { + $options['ssl'] = $this->normalizeContext($context); + } + + if ($auth !== null && $auth !== '') { + $options['auth'] = $auth; + } + + $master = $this->create($options)->getMasterAddrByName( + (string) ($sentinel['master_name'] ?? '') + ); + + if (is_array($master) + && isset($master[0], $master[1]) + && is_string($master[0]) + && $master[0] !== '' + && (is_int($master[1]) || (is_string($master[1]) && ctype_digit($master[1])))) { + return [$master[0], (int) $master[1]]; + } + + $failures[] = "[{$node}]: master was not resolved"; + } catch (Throwable $exception) { + $failures[] = "[{$node}]: {$exception->getMessage()}"; + } + } + + throw new InvalidRedisConnectionException(sprintf( + 'Unable to resolve Redis master [%s] from Sentinel nodes: %s.', + $sentinel['master_name'] ?? '', + implode('; ', $failures), + )); } /** - * Create a redis sentinel client instance. + * Normalize the SSL context for a Sentinel connection. * - * @param array $options + * @param array $context + * @return array */ - public function create(array $options = []): RedisSentinel + private function normalizeContext(array $context): array { - if ($this->isOlderThan6) { - return new RedisSentinel( - $options['host'], - (int) $options['port'], - (float) $options['connectTimeout'], - $options['persistent'], - (int) $options['retryInterval'], - (float) $options['readTimeout'], - ...(isset($options['auth']) ? [$options['auth']] : []), - ); + if (isset($context['ssl']) && is_array($context['ssl'])) { + return $context['ssl']; } - // https://github.com/phpredis/phpredis/blob/develop/sentinel.md#examples-for-version-60-or-later - return new RedisSentinel($options); /* @phpstan-ignore-line */ + if (isset($context['stream']) && is_array($context['stream'])) { + return $context['stream']; + } + + return $context; } } diff --git a/src/redis/src/RedisServiceProvider.php b/src/redis/src/RedisServiceProvider.php index 0481cad24..13a835152 100644 --- a/src/redis/src/RedisServiceProvider.php +++ b/src/redis/src/RedisServiceProvider.php @@ -22,7 +22,8 @@ public function register(): void $this->app->singleton('redis', fn ($app) => new RedisManager( $app, $app->make(PoolFactory::class), - $app->make(RedisConfig::class) + $app->make(RedisConfig::class), + $app->make(RedisSentinelFactory::class), )); $this->app->bind('redis.connection', fn ($app) => $app->make('redis')->connection()); diff --git a/src/support/src/Facades/Redis.php b/src/support/src/Facades/Redis.php index b29fffa0e..56f0dfbce 100644 --- a/src/support/src/Facades/Redis.php +++ b/src/support/src/Facades/Redis.php @@ -8,8 +8,8 @@ * @method static \Hypervel\Redis\RedisProxy connection(\UnitEnum|string|null $name = null) * @method static void purge(\UnitEnum|string|null $name = null) * @method static array connections() - * @method static \Hypervel\Redis\RedisManager extend(string $name, callable $resolver) - * @method static void forgetExtension(string $name) + * @method static void enableEvents() + * @method static void disableEvents() * @method static void listen(\Closure $callback) * @method static void listenForFailures(\Closure $callback) * @method static void subscribe(array|string $channels, \Closure $callback) @@ -19,6 +19,10 @@ * @method static \Hypervel\Redis\Limiters\ConcurrencyLimiterBuilder funnel(string $name) * @method static string getName() * @method static bool isCluster() + * @method static void macro(string $name, callable|object $macro) + * @method static void mixin(object $mixin, bool $replace = true) + * @method static bool hasMacro(string $name) + * @method static void flushMacros() * @method static void scan(mixed $cursor, mixed ...$arguments) * @method static void hScan(mixed $key, mixed $cursor, mixed ...$arguments) * @method static void zScan(mixed $key, mixed $cursor, mixed ...$arguments) @@ -205,7 +209,6 @@ * @method static mixed rawcommand(string $command, mixed ...$args) * @method static (bool|\Redis) rename(string $old_name, string $new_name) * @method static (bool|\Redis) renameNx(string $key_src, string $key_dst) - * @method static (bool|\Redis) reset() * @method static (bool|\Redis) restore(string $key, int $ttl, string $value, (array|null) $options = null) * @method static mixed role() * @method static (false|\Redis|string) rpoplpush(string $srckey, string $dstkey) @@ -236,7 +239,6 @@ * @method static mixed slowlog(string $operation, int $length = 0) * @method static mixed sort(string $key, (array|null) $options = null) * @method static mixed sort_ro(string $key, (array|null) $options = null) - * @method static bool ssubscribe(array $channels, callable $cb) * @method static (false|int|\Redis) strlen(string $key) * @method static (array|bool|\Redis) sunsubscribe(array $channels) * @method static (bool|\Redis) swapdb(int $src, int $dst) @@ -316,8 +318,10 @@ protected static function ignoredFacadeDocumenterMethods(): array 'auth', 'check', 'client', + 'clearWatchState', 'close', 'connect', + 'discardTransaction', 'getActiveConnection', 'getConnection', 'getCreatedAt', @@ -331,10 +335,12 @@ protected static function ignoredFacadeDocumenterMethods(): array 'pconnect', 'reconnect', 'release', + 'reset', 'safeScan', 'setDatabase', 'setOption', 'shouldTransform', + 'ssubscribe', ]; } diff --git a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php index 4d1943b4e..0e2b969cc 100644 --- a/src/testing/src/PHPUnit/AfterEachTestSubscriber.php +++ b/src/testing/src/PHPUnit/AfterEachTestSubscriber.php @@ -243,6 +243,7 @@ protected function flushFrameworkState(): void \Hypervel\Queue\Console\WorkCommand::flushState(); \Hypervel\Queue\Queue::flushState(); \Hypervel\Queue\Worker::flushState(); + \Hypervel\Redis\RedisConnection::flushMacros(); \Hypervel\Routing\CallableDispatcher::flushState(); \Hypervel\Routing\ControllerDispatcher::flushState(); \Hypervel\Routing\ImplicitRouteBinding::flushCache(); From 85f4008d0036a9d629bf65d89d0d72c0ec697357 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:46:47 +0000 Subject: [PATCH 03/15] test(redis): cover pooled routing and phpredis parity Exercise exact wrapper ownership, failure disposition without replay, mixed-case stateful routing, callback-immediate release, macro event granularity, event overrides, and facade metadata. Cover standalone, Sentinel, and Cluster configuration; context and credential normalization; supported native options; prefix precedence; fresh topology resolution; terminal cleanup; and the separate-process RESET safety boundary against live Redis and Valkey behavior. --- .../Redis/RedisConnectionIntegrationTest.php | 16 +- .../Integration/Redis/RedisConnectorTest.php | 63 +- .../Redis/RedisResetIntegrationTest.php | 46 ++ .../PhpRedisClusterConnectionStub.php | 6 - .../Redis/Fixtures/PhpRedisConnectionStub.php | 5 +- tests/Redis/MultiExecTest.php | 7 +- tests/Redis/PackageMetadataTest.php | 52 ++ tests/Redis/PhpRedisClusterConnectionTest.php | 113 ++++ tests/Redis/RedisConfigTest.php | 110 ++++ tests/Redis/RedisConnectionTest.php | 555 ++++++++++++------ tests/Redis/RedisEventsTest.php | 7 +- tests/Redis/RedisManagerTest.php | 167 ++---- tests/Redis/RedisPoolHeartbeatTest.php | 7 +- tests/Redis/RedisProxyNonCoroutineTest.php | 7 +- tests/Redis/RedisProxyTest.php | 451 +++++++++++--- tests/Redis/RedisSentinelFactoryTest.php | 268 +++++++++ 16 files changed, 1480 insertions(+), 400 deletions(-) create mode 100644 tests/Integration/Redis/RedisResetIntegrationTest.php create mode 100644 tests/Redis/RedisSentinelFactoryTest.php diff --git a/tests/Integration/Redis/RedisConnectionIntegrationTest.php b/tests/Integration/Redis/RedisConnectionIntegrationTest.php index 0ff5a5920..974a885b9 100644 --- a/tests/Integration/Redis/RedisConnectionIntegrationTest.php +++ b/tests/Integration/Redis/RedisConnectionIntegrationTest.php @@ -13,15 +13,6 @@ class RedisConnectionIntegrationTest extends TestCase { use InteractsWithRedis; - protected bool $isOlderThan6 = false; - - protected function setUp(): void - { - parent::setUp(); - - $this->isOlderThan6 = version_compare((string) phpversion('redis'), '6.0.0', '<'); - } - public function testPhpRedisConnectSignatureAndConnection(): void { $redis = new Redis; @@ -31,12 +22,7 @@ public function testPhpRedisConnectSignatureAndConnection(): void $this->assertSame('host', $parameters[0]->getName()); $this->assertSame('port', $parameters[1]->getName()); $this->assertSame('timeout', $parameters[2]->getName()); - - if ($this->isOlderThan6) { - $this->assertSame('retry_interval', $parameters[3]->getName()); - } else { - $this->assertSame('persistent_id', $parameters[3]->getName()); - } + $this->assertSame('persistent_id', $parameters[3]->getName()); $connected = $redis->connect( env('REDIS_HOST', '127.0.0.1'), diff --git a/tests/Integration/Redis/RedisConnectorTest.php b/tests/Integration/Redis/RedisConnectorTest.php index bc1f104bd..db626bb8a 100644 --- a/tests/Integration/Redis/RedisConnectorTest.php +++ b/tests/Integration/Redis/RedisConnectorTest.php @@ -28,7 +28,7 @@ protected function defineEnvironment(ApplicationContract $app): void $app->make('config')->set('app.stdout_log.level', []); } - public function testDefaultConfiguration() + public function testDefaultConfiguration(): void { $host = $this->app->make('config')->get('database.redis.default.host'); $port = $this->app->make('config')->get('database.redis.default.port'); @@ -39,7 +39,7 @@ public function testDefaultConfiguration() }); } - public function testUrl() + public function testUrl(): void { $host = env('REDIS_HOST', '127.0.0.1'); $port = (int) env('REDIS_PORT', 6379); @@ -57,7 +57,7 @@ public function testUrl() }); } - public function testUrlWithScheme() + public function testUrlWithScheme(): void { $host = env('REDIS_HOST', '127.0.0.1'); $port = (int) env('REDIS_PORT', 6379); @@ -74,7 +74,7 @@ public function testUrlWithScheme() }); } - public function testScheme() + public function testScheme(): void { $host = env('REDIS_HOST', '127.0.0.1'); $port = (int) env('REDIS_PORT', 6379); @@ -93,7 +93,7 @@ public function testScheme() }); } - public function testPerConnectionPrefixOverridesGlobalPrefix() + public function testPerConnectionPrefixOverridesGlobalPrefix(): void { $name = $this->addTestConnection([ 'host' => env('REDIS_HOST', '127.0.0.1'), @@ -116,6 +116,59 @@ public function testPerConnectionPrefixOverridesGlobalPrefix() }); } + public function testTopLevelConnectionPrefixOverridesGlobalAndLocalPrefix(): void + { + $name = $this->addTestConnection([ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null) ?: null, + 'port' => (int) env('REDIS_PORT', 6379), + 'database' => $this->getParallelRedisDb(), + 'prefix' => 'top_level_', + 'options' => [ + 'prefix' => 'per_connection_', + ], + ]); + + $this->app->make('config')->set('database.redis.options.prefix', 'global_'); + $this->app->make('redis')->purge($name); + + $this->withClient($name, function (\Redis $client): void { + $this->assertSame('top_level_', $client->getOption(\Redis::OPT_PREFIX)); + }); + } + + public function testClientNameIsApplied(): void + { + $name = $this->addTestConnection([ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null) ?: null, + 'port' => (int) env('REDIS_PORT', 6379), + 'database' => $this->getParallelRedisDb(), + 'name' => 'hypervel-connector-test', + ]); + + $this->withClient($name, function (\Redis $client): void { + $this->assertSame('hypervel-connector-test', $client->client('GETNAME')); + }); + } + + public function testTcpKeepaliveOptionIsApplied(): void + { + $name = $this->addTestConnection([ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'password' => env('REDIS_PASSWORD', null) ?: null, + 'port' => (int) env('REDIS_PORT', 6379), + 'database' => $this->getParallelRedisDb(), + 'options' => [ + 'tcp_keepalive' => 60, + ], + ]); + + $this->withClient($name, function (\Redis $client): void { + $this->assertSame(1, $client->getOption(\Redis::OPT_TCP_KEEPALIVE)); + }); + } + /** * Execute a callback with the underlying phpredis client for a named connection. * diff --git a/tests/Integration/Redis/RedisResetIntegrationTest.php b/tests/Integration/Redis/RedisResetIntegrationTest.php new file mode 100644 index 000000000..6e5a6cc18 --- /dev/null +++ b/tests/Integration/Redis/RedisResetIntegrationTest.php @@ -0,0 +1,46 @@ +createRedisConnectionWithOptions( + 'reset_pipeline', + ['prefix' => ''], + )); + $pipeline = $redis->pipeline(); + + $this->assertInstanceOf(PhpRedis::class, $pipeline); + $this->assertSame(PhpRedis::PIPELINE, $pipeline->getMode()); + + try { + $redis->reset(); + + $this->fail('Expected pooled RESET to be rejected.'); + } catch (BadMethodCallException $exception) { + $this->assertStringContainsString( + 'Cannot call reset() on a pooled Redis connection', + $exception->getMessage(), + ); + } finally { + $this->app->make(RedisManager::class)->releaseConnections(); + } + } +} diff --git a/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php b/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php index 79a401317..9f6dcf8b7 100644 --- a/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php +++ b/tests/Redis/Fixtures/PhpRedisClusterConnectionStub.php @@ -10,7 +10,6 @@ use Mockery as m; use Redis; use RedisCluster; -use RedisException; class PhpRedisClusterConnectionStub extends PhpRedisClusterConnection { @@ -84,9 +83,4 @@ public function getConfigForTest(): array { return $this->config; } - - protected function retry(string $name, array $arguments, RedisException $exception): mixed - { - throw $exception; - } } diff --git a/tests/Redis/Fixtures/PhpRedisConnectionStub.php b/tests/Redis/Fixtures/PhpRedisConnectionStub.php index 99fae2507..fac3dd5f9 100644 --- a/tests/Redis/Fixtures/PhpRedisConnectionStub.php +++ b/tests/Redis/Fixtures/PhpRedisConnectionStub.php @@ -10,7 +10,6 @@ use Mockery as m; use Redis; use RedisCluster; -use RedisException; class PhpRedisConnectionStub extends PhpRedisConnection { @@ -85,8 +84,8 @@ public function getConfigForTest(): array return $this->config; } - protected function retry(string $name, array $arguments, RedisException $exception): mixed + public function isInvalidForTest(): bool { - throw $exception; + return $this->invalid; } } diff --git a/tests/Redis/MultiExecTest.php b/tests/Redis/MultiExecTest.php index fd73eaf0a..cb054a281 100644 --- a/tests/Redis/MultiExecTest.php +++ b/tests/Redis/MultiExecTest.php @@ -10,6 +10,7 @@ use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; +use Hypervel\Redis\RedisSentinelFactory; use Hypervel\Tests\TestCase; use Mockery as m; use Redis as PhpRedis; @@ -257,6 +258,10 @@ private function createRedis(m\MockInterface|RedisConnection $connection): Redis $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); - return new RedisProxy($poolFactory, 'default'); + return new RedisProxy( + $poolFactory, + 'default', + m::mock(RedisSentinelFactory::class), + ); } } diff --git a/tests/Redis/PackageMetadataTest.php b/tests/Redis/PackageMetadataTest.php index 1ca9065bc..a5c49af51 100644 --- a/tests/Redis/PackageMetadataTest.php +++ b/tests/Redis/PackageMetadataTest.php @@ -4,8 +4,11 @@ namespace Hypervel\Tests\Redis; +use Hypervel\Redis\RedisProxy; +use Hypervel\Support\Facades\Redis; use Hypervel\Tests\TestCase; use JsonException; +use ReflectionClass; class PackageMetadataTest extends TestCase { @@ -35,6 +38,7 @@ public function testDirectRuntimeDependenciesAreDeclared(): void 'hypervel/core', 'hypervel/coroutine', 'hypervel/engine', + 'hypervel/macroable', 'hypervel/pool', 'hypervel/support', ] as $dependency) { @@ -46,4 +50,52 @@ public function testDirectRuntimeDependenciesAreDeclared(): void $this->assertArrayNotHasKey('ext-redis', $composer['require']); $this->assertArrayHasKey('ext-redis', $composer['suggest']); } + + public function testConnectionBoundMethodsAreExcludedFromFacadeDocumentation(): void + { + $proxy = new ReflectionClass(RedisProxy::class); + $methods = $proxy->getReflectionConstant('CONNECTION_BOUND_METHODS')?->getValue(); + $this->assertIsArray($methods); + + $facade = new ReflectionClass(Redis::class); + $ignoredMethod = $facade->getMethod('ignoredFacadeDocumenterMethods'); + $ignored = array_map('strtolower', $ignoredMethod->invoke(null)); + + foreach ($methods as $method) { + $this->assertContains($method, $ignored); + } + } + + public function testFacadeDocumentsManagerAndMacroSurfaces(): void + { + $docblock = (new ReflectionClass(Redis::class))->getDocComment(); + $this->assertIsString($docblock); + + foreach ([ + 'enableEvents', + 'disableEvents', + 'macro', + 'mixin', + 'hasMacro', + 'flushMacros', + ] as $method) { + $this->assertStringContainsString(" {$method}(", $docblock); + } + } + + public function testUnsupportedNativeMethodsAreNotAdvertised(): void + { + // REMOVED: pooled RESET destroys auth/database state owned by the pool. + // REMOVED: sharded subscriptions require slot-routed dedicated connections. + $facade = new ReflectionClass(Redis::class); + $docblock = $facade->getDocComment(); + $this->assertIsString($docblock); + $ignoredMethod = $facade->getMethod('ignoredFacadeDocumenterMethods'); + $ignored = array_map('strtolower', $ignoredMethod->invoke(null)); + + foreach (['reset', 'ssubscribe'] as $method) { + $this->assertStringNotContainsString(" {$method}(", strtolower($docblock)); + $this->assertContains($method, $ignored); + } + } } diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php index 02ef41d29..8ab1c2a9e 100644 --- a/tests/Redis/PhpRedisClusterConnectionTest.php +++ b/tests/Redis/PhpRedisClusterConnectionTest.php @@ -6,13 +6,16 @@ use Hypervel\Contracts\Container\Container as ContainerContract; use Hypervel\Contracts\Pool\PoolInterface; +use Hypervel\Pool\Exceptions\ConnectionException; use Hypervel\Pool\PoolOption; use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Tests\Redis\Fixtures\FakeRedisClusterClient; use Hypervel\Tests\Redis\Fixtures\PhpRedisClusterConnectionStub; +use Hypervel\Tests\Redis\Fixtures\RespServer; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use Redis; use RedisCluster; @@ -181,6 +184,116 @@ public function formatClusterPasswordForTest(): mixed $this->assertSame(['myuser', 'mypass'], $connection->formatClusterPasswordForTest()); } + public function testFormatClusterPasswordPreservesZeroCredentials(): void + { + $connection = new class($this->getContainer(), $this->getMockedPool(), ['username' => '0', 'password' => '0']) extends PhpRedisClusterConnectionStub { + public function formatClusterPasswordForTest(): mixed + { + return $this->formatClusterPassword(); + } + }; + + $this->assertSame(['0', '0'], $connection->formatClusterPasswordForTest()); + } + + public function testNormalizeClusterContextAcceptsEverySupportedShape(): void + { + $connection = new class extends PhpRedisClusterConnectionStub { + public function normalizeClusterContextForTest(array $context): array + { + return $this->normalizeClusterContext($context); + } + }; + $options = ['verify_peer' => false, 'cafile' => '/tmp/ca.pem']; + + $this->assertSame($options, $connection->normalizeClusterContextForTest($options)); + $this->assertSame($options, $connection->normalizeClusterContextForTest(['ssl' => $options])); + $this->assertSame($options, $connection->normalizeClusterContextForTest(['stream' => $options])); + } + + #[DataProvider('clusterTransportContexts')] + public function testClusterContextSelectsTheExpectedTransport(array $context, bool $tls): void + { + $server = new RespServer( + $tls ? 'tls://127.0.0.1:0' : 'tcp://127.0.0.1:0', + $tls + ? [ + 'ssl' => [ + 'local_cert' => __DIR__ . '/Fixtures/Tls/server.crt', + 'local_pk' => __DIR__ . '/Fixtures/Tls/server.key', + 'allow_self_signed' => true, + ], + ] + : [], + ); + $bytes = null; + $failure = null; + $server->start(static function ($client) use (&$bytes): void { + $bytes = stream_get_contents($client, 2); + fwrite($client, "-ERR test endpoint is not a Redis Cluster\r\n"); + }); + $endpoint = $server->endpoint(); + $host = parse_url($endpoint, PHP_URL_HOST); + $port = parse_url($endpoint, PHP_URL_PORT); + + if (! is_string($host) || ! is_int($port)) { + throw new InvalidArgumentException("Invalid RESP test endpoint [{$endpoint}]."); + } + + try { + new PhpRedisClusterConnection( + $this->getContainer(), + $this->getMockedPool(), + [ + 'timeout' => 1.0, + 'cluster' => [ + 'enable' => true, + 'seeds' => ["{$host}:{$port}"], + 'context' => $context, + ], + ], + ); + } catch (ConnectionException $exception) { + $failure = $exception; + } finally { + $server->wait(); + } + + $this->assertNotNull($failure); + $this->assertSame('*2', $bytes); + } + + public static function clusterTransportContexts(): array + { + return [ + 'empty context uses plaintext' => [[], false], + 'non-empty context uses TLS' => [[ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], true], + ]; + } + + public function testClusterOptionsUseNativeFailoverAndTcpKeepaliveConstants(): void + { + $connection = new class($this->getContainer(), $this->getMockedPool(), ['options' => ['failover' => RedisCluster::FAILOVER_DISTRIBUTE, 'tcp_keepalive' => 30, 'pack_ignore_numbers' => true]]) extends PhpRedisClusterConnectionStub { + public function setOptionsForTest(RedisCluster $redis): void + { + $this->setOptions($redis); + } + }; + $redis = m::mock(RedisCluster::class); + $redis->expects('setOption') + ->with(RedisCluster::OPT_SLAVE_FAILOVER, RedisCluster::FAILOVER_DISTRIBUTE) + ->andReturnTrue(); + $redis->expects('setOption') + ->with(Redis::OPT_TCP_KEEPALIVE, 30) + ->andReturnTrue(); + + $connection->setOptionsForTest($redis); + } + public function testFormatClusterPasswordReturnsPlainPasswordWithoutUsername() { $connection = new class($this->getContainer(), $this->getMockedPool(), ['password' => 'mypass']) extends PhpRedisClusterConnectionStub { diff --git a/tests/Redis/RedisConfigTest.php b/tests/Redis/RedisConfigTest.php index 0f54006a7..6a1afc832 100644 --- a/tests/Redis/RedisConfigTest.php +++ b/tests/Redis/RedisConfigTest.php @@ -9,6 +9,7 @@ use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; class RedisConfigTest extends TestCase { @@ -77,6 +78,68 @@ public function testConnectionConfigMergesSharedAndConnectionOptions(): void ); } + public function testTopLevelConnectionPrefixOverridesSharedAndLocalOptions(): void + { + $config = m::mock(Repository::class); + $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => ['prefix' => 'shared:', 'serializer' => 1], + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'prefix' => 'top-level:', + 'options' => ['prefix' => 'local:', 'scan' => 1], + ], + ]); + + $connection = (new RedisConfig($config))->connectionConfig('default'); + + $this->assertSame( + [ + 'prefix' => 'top-level:', + 'serializer' => 1, + 'scan' => 1, + ], + $connection['options'], + ); + } + + public function testEventOverridePreservesConfigUntilExplicitlyChanged(): void + { + $config = m::mock(Repository::class); + $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'event' => ['enable' => true], + ], + ]); + $redisConfig = new RedisConfig($config); + + $this->assertTrue($redisConfig->connectionConfig('default')['event']['enable']); + + $redisConfig->disableEvents(); + $this->assertFalse($redisConfig->connectionConfig('default')['event']['enable']); + + $redisConfig->enableEvents(); + $this->assertTrue($redisConfig->connectionConfig('default')['event']['enable']); + } + + public function testEventOverrideCreatesEventConfigForFutureAssemblies(): void + { + $config = m::mock(Repository::class); + $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + ], + ]); + $redisConfig = new RedisConfig($config); + + $redisConfig->enableEvents(); + + $this->assertTrue($redisConfig->connectionConfig('default')['event']['enable']); + } + public function testConnectionConfigThrowsForMissingConnection(): void { $this->expectException(InvalidArgumentException::class); @@ -141,6 +204,25 @@ public function testConnectionNamesThrowsWhenClusterEnabledWithoutSeeds(): void (new RedisConfig($config))->connectionNames(); } + #[DataProvider('invalidTopologyEntries')] + public function testConnectionNamesRejectsInvalidClusterSeeds(mixed $seed): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [clustered] cluster seeds must all be non-empty strings.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'clustered' => [ + 'cluster' => [ + 'enable' => true, + 'seeds' => [$seed], + ], + ], + ]); + + (new RedisConfig($config))->connectionNames(); + } + public function testConnectionNamesAcceptsSentinelConnectionWithoutHostAndPort(): void { $config = m::mock(Repository::class); @@ -177,6 +259,34 @@ public function testConnectionNamesThrowsWhenSentinelEnabledWithoutNodes(): void (new RedisConfig($config))->connectionNames(); } + #[DataProvider('invalidTopologyEntries')] + public function testConnectionNamesRejectsInvalidSentinelNodes(mixed $node): void + { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('The redis connection [sentinel] sentinel nodes must all be non-empty strings.'); + + $config = m::mock(Repository::class); + $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'sentinel' => [ + 'sentinel' => [ + 'enable' => true, + 'nodes' => [$node], + 'master_name' => 'mymaster', + ], + ], + ]); + + (new RedisConfig($config))->connectionNames(); + } + + public static function invalidTopologyEntries(): array + { + return [ + 'non-string' => [null], + 'empty string' => [''], + ]; + } + public function testConnectionNamesThrowsWhenSentinelEnabledWithoutMasterName(): void { $this->expectException(InvalidArgumentException::class); diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 1f2df184d..c46e8229e 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -15,9 +15,12 @@ use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\RedisConnection; +use Hypervel\Redis\RedisSentinelFactory; use Hypervel\Tests\Redis\Fixtures\PhpRedisClusterConnectionStub; use Hypervel\Tests\Redis\Fixtures\PhpRedisConnectionStub; +use Hypervel\Tests\Redis\Fixtures\RespServer; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use Psr\Log\LogLevel; @@ -163,12 +166,12 @@ public function testSuccessfulTerminalCommandsClearTrackedWatchState( $pool->expects('release')->with(m::type(RedisConnection::class)); $redis = m::mock(Redis::class); $redis->expects('watch')->with('key')->andReturnTrue(); - $redis->expects($command)->andReturn($result); + $redis->expects(strtolower($command))->andReturn($result); $redis->expects('getMode')->andReturn(Redis::ATOMIC); $connection = $this->mockRedisConnection(pool: $pool); $connection->setActiveConnection($redis); - $connection->__call('watch', ['key']); + $connection->__call('WATCH', ['key']); $this->assertSame($result, $connection->__call($command, [])); $connection->release(); } @@ -176,9 +179,8 @@ public function testSuccessfulTerminalCommandsClearTrackedWatchState( public static function watchTerminalCommandProvider(): array { return [ - 'unwatch' => ['unwatch', true], - 'exec conflict' => ['exec', false], - 'reset' => ['reset', true], + 'unwatch' => ['UNWATCH', true], + 'exec conflict' => ['EXEC', false], ]; } @@ -398,6 +400,45 @@ protected function createRedis(array $config): Redis $connection->reconnect(); } + public function testSentinelResolvedMasterRetainsEmptyStandaloneContext(): void + { + $sentinelFactory = m::mock(RedisSentinelFactory::class); + $sentinelFactory->expects('resolveMaster')->andReturn(['127.0.0.1', 6380]); + $container = m::mock(ContainerContract::class); + $container->expects('make') + ->with(RedisSentinelFactory::class) + ->andReturn($sentinelFactory); + $container->shouldReceive('has')->andReturnFalse(); + $container->shouldReceive('bound')->with('events')->andReturnFalse(); + $redis = m::mock(Redis::class); + $connection = new class($container, $this->getMockedPool(), ['sentinel' => ['enable' => true, 'nodes' => ['127.0.0.1:26379'], 'master_name' => 'primary']], $redis) extends PhpRedisConnection { + private array $createdConfig = []; + + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + public function getCreatedConfig(): array + { + return $this->createdConfig; + } + + protected function createRedis(array $config): Redis + { + $this->createdConfig = $config; + + return $this->fakeRedis; + } + }; + + $this->assertSame([], $connection->getCreatedConfig()['context']); + } + public function testConnectionConfigMergesDefaults(): void { $connection = new PhpRedisConnectionStub( @@ -454,6 +495,7 @@ public function testConnectionConfigMergesDefaults(): void 'nodes' => [], 'persistent' => '', 'read_timeout' => 0, + 'context' => [], ], 'options' => [], 'context' => [ @@ -479,12 +521,95 @@ public function testConnectionConfigMergesDefaults(): void ); } - public function testClusterReconnectFailureThrowsConnectionException(): void + public function testNormalizeContextAcceptsEverySupportedShape(): void + { + $connection = new class extends PhpRedisConnectionStub { + public function normalizeContextForTest(array $context): array + { + return $this->normalizeContext($context); + } + }; + $options = ['verify_peer' => false, 'cafile' => '/tmp/ca.pem']; + + $this->assertSame(['stream' => $options], $connection->normalizeContextForTest($options)); + $this->assertSame(['stream' => $options], $connection->normalizeContextForTest(['ssl' => $options])); + $this->assertSame(['stream' => $options], $connection->normalizeContextForTest(['stream' => $options])); + } + + public function testEmptyContextKeepsStandaloneConnectionPlaintext(): void + { + $server = new RespServer; + $bytes = null; + $connection = null; + $server->start(static function ($client) use (&$bytes): void { + $bytes = stream_get_contents($client, 2); + fwrite($client, "+PONG\r\n"); + }); + [$host, $port] = $server->hostAndPort(); + + try { + $connection = new PhpRedisConnection( + $this->getContainer(), + $this->getMockedPool(), + [ + 'host' => $host, + 'port' => $port, + 'timeout' => 1.0, + 'context' => [], + ], + ); + $connection->ping(); + } finally { + $connection?->close(); + $server->wait(); + } + + $this->assertSame('*1', $bytes); + } + + public function testNonEmptyContextEnablesTlsForStandaloneConnection(): void { - if (version_compare((string) phpversion('redis'), '6.0.0', '<')) { - $this->markTestSkipped('Cluster constructor typing differs on redis extension < 6.'); + $server = new RespServer('tls://127.0.0.1:0', [ + 'ssl' => [ + 'local_cert' => __DIR__ . '/Fixtures/Tls/server.crt', + 'local_pk' => __DIR__ . '/Fixtures/Tls/server.key', + 'allow_self_signed' => true, + ], + ]); + $bytes = null; + $connection = null; + $server->start(static function ($client) use (&$bytes): void { + $bytes = stream_get_contents($client, 2); + fwrite($client, "+PONG\r\n"); + }); + [$host, $port] = $server->hostAndPort(); + + try { + $connection = new PhpRedisConnection( + $this->getContainer(), + $this->getMockedPool(), + [ + 'host' => $host, + 'port' => $port, + 'timeout' => 1.0, + 'context' => [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ], + ], + ); + $connection->ping(); + } finally { + $connection?->close(); + $server->wait(); } + $this->assertSame('*1', $bytes); + } + + public function testClusterReconnectFailureThrowsConnectionException(): void + { $this->expectException(ConnectionException::class); $this->expectExceptionMessage('Connection reconnect failed'); @@ -706,41 +831,140 @@ public function testTypeErrorsAreNotRetried(): void $connection->__call('set', ['key', 'value', 600]); } - public function testRedisExceptionIsRetried(): void + public function testMacrosCanBeRegisteredInvokedAndFlushed(): void { - $pool = $this->getMockedPool(); + RedisConnection::macro('greeting', fn (string $name) => "Hello {$name}"); + $connection = $this->mockRedisConnection(); + + $this->assertTrue(RedisConnection::hasMacro('greeting')); + $this->assertSame('Hello Taylor', $connection->__call('greeting', ['Taylor'])); + + RedisConnection::flushMacros(); + + $this->assertFalse(RedisConnection::hasMacro('greeting')); + } + + public function testMixinRegistersConnectionMacros(): void + { + RedisConnection::mixin(new class { + protected function greeting(): callable + { + return fn (string $name) => "Hello {$name}"; + } + }); + + $this->assertSame( + 'Hello Taylor', + $this->mockRedisConnection()->__call('greeting', ['Taylor']), + ); + } + + public function testMacroLookupPreservesExactNamesAndMayShadowNativeCommands(): void + { + RedisConnection::macro('CustomCommand', fn () => 'exact'); + RedisConnection::macro('reset', fn () => 'shadowed'); + $connection = $this->mockRedisConnection(); $redis = m::mock(Redis::class); + $redis->expects('customcommand')->once()->andReturn('native'); + $redis->expects('reset')->never(); + $connection->setActiveConnection($redis); - $redis->shouldReceive('get') - ->once() - ->with('foo') - ->andThrow(new RedisException('network')); - $redis->shouldReceive('get') + $this->assertSame('exact', $connection->__call('CustomCommand', [])); + $this->assertSame('native', $connection->__call('customcommand', [])); + $this->assertSame('shadowed', $connection->__call('reset', [])); + } + + public function testRedisExceptionInsideMacroUsesConnectionDispositionRule(): void + { + $exception = new RedisException('Connection lost.'); + RedisConnection::macro('failing', function () { + return $this->connection->get('key'); + }); + $connection = new PhpRedisConnectionStub( + $this->getContainer(), + $this->getMockedPool(), + ); + $redis = m::mock(Redis::class); + $redis->expects('get')->once()->with('key')->andThrow($exception); + $redis->expects('getLastError')->andReturnNull(); + $connection->setActiveConnection($redis); + + try { + $connection->__call('failing', []); + $this->fail('Expected the macro command failure to propagate.'); + } catch (RedisException $throwable) { + $this->assertSame($exception, $throwable); + } + + $this->assertTrue($connection->isInvalidForTest()); + } + + public function testTransportFailureInvalidatesWithoutReplayingCommand(): void + { + $exception = new RedisException('Connection lost.'); + $redis = m::mock(Redis::class); + $redis->expects('get') ->once() ->with('foo') - ->andReturn('bar'); + ->andThrow($exception); + $redis->expects('getLastError')->andReturnNull(); + $connection = new PhpRedisConnectionStub( + $this->getContainer(), + $this->getMockedPool(), + ); + $connection->setActiveConnection($redis); - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { - public function __construct( - ContainerContract $container, - PoolInterface $pool, - array $config, - private Redis $fakeRedis - ) { - parent::__construct($container, $pool, $config); - } + try { + $connection->__call('get', ['foo']); + $this->fail('Expected the transport failure to propagate.'); + } catch (RedisException $throwable) { + $this->assertSame($exception, $throwable); + } - protected function createRedis(array $config): Redis - { - return $this->fakeRedis; - } - }; + $this->assertTrue($connection->isInvalidForTest()); + } - $connection->shouldTransform(false); + #[DataProvider('synchronizedServerErrorDispositionProvider')] + public function testSynchronizedServerErrorDispositionDoesNotReplayCommand( + string $message, + bool $sentinel, + bool $invalid, + ): void { + $exception = new RedisException($message); + $redis = m::mock(Redis::class); + $redis->expects('set')->once()->with('key', 'value')->andThrow($exception); + $redis->expects('getLastError')->andReturn($message); + $connection = new PhpRedisConnectionStub( + $this->getContainer(), + $this->getMockedPool(), + ['sentinel' => ['enable' => $sentinel]], + ); + $connection->setActiveConnection($redis); - $result = $connection->__call('get', ['foo']); + try { + $connection->__call('set', ['key', 'value']); + $this->fail('Expected the Redis server error to propagate.'); + } catch (RedisException $throwable) { + $this->assertSame($exception, $throwable); + } - $this->assertSame('bar', $result); + $this->assertSame($invalid, $connection->isInvalidForTest()); + } + + public static function synchronizedServerErrorDispositionProvider(): array + { + return [ + 'standalone READONLY' => ['READONLY replica is read-only', false, false], + 'standalone MASTERDOWN' => ['MASTERDOWN link is down', false, false], + 'standalone LOADING' => ['LOADING data is loading', false, false], + 'standalone OOM' => ['OOM command not allowed', false, false], + 'standalone MISCONF' => ['MISCONF persistence error', false, false], + 'standalone CROSSSLOT' => ['CROSSSLOT keys do not hash to the same slot', false, false], + 'Sentinel READONLY' => ['READONLY replica is read-only', true, true], + 'Sentinel MASTERDOWN' => ['MASTERDOWN link is down', true, true], + 'Sentinel LOADING' => ['LOADING data is loading', true, false], + 'Sentinel non-exact READONLY prefix' => ['READONLY_STATE custom error', true, false], + ]; } public function testLogWritesToStdoutLogger(): void @@ -1786,92 +2010,6 @@ public function testEvalWithShaCacheClearsLastErrorBeforeEvalSha(): void $this->assertEquals('ok', $result); } - public function testRetryAppliesGetTransform(): void - { - $pool = $this->getMockedPool(); - $redis = m::mock(Redis::class); - - // First get() throws RedisException, triggering retry - $redis->shouldReceive('getMode')->andReturn(Redis::ATOMIC); - $redis->shouldReceive('get') - ->once() - ->with('missing') - ->andThrow(new RedisException('connection lost')); - - // After reconnect, get() returns false (key not found) - $redis->shouldReceive('get') - ->once() - ->with('missing') - ->andReturn(false); - - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { - public function __construct( - ContainerContract $container, - PoolInterface $pool, - array $config, - private Redis $fakeRedis - ) { - parent::__construct($container, $pool, $config); - } - - protected function createRedis(array $config): Redis - { - return $this->fakeRedis; - } - }; - - $connection->shouldTransform(true); - - // With transform enabled, retry should return null (not false) - $result = $connection->__call('get', ['missing']); - - $this->assertNull($result); - } - - public function testRetryAppliesSetnxTransform(): void - { - $pool = $this->getMockedPool(); - $redis = m::mock(Redis::class); - - $redis->shouldReceive('getMode')->andReturn(Redis::ATOMIC); - - // First setNx() throws RedisException, triggering retry - $redis->shouldReceive('setNx') - ->once() - ->with('key', 'value') - ->andThrow(new RedisException('connection lost')); - - // After reconnect, setNx() returns true (phpredis bool) - // Transform should cast to int (1) - $redis->shouldReceive('setNx') - ->once() - ->with('key', 'value') - ->andReturn(true); - - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { - public function __construct( - ContainerContract $container, - PoolInterface $pool, - array $config, - private Redis $fakeRedis - ) { - parent::__construct($container, $pool, $config); - } - - protected function createRedis(array $config): Redis - { - return $this->fakeRedis; - } - }; - - $connection->shouldTransform(true); - - // Laravel setnx returns int (1), not bool (true) - $result = $connection->__call('setnx', ['key', 'value']); - - $this->assertSame(1, $result); - } - public function testSpopWithoutCountReturnsSingleElement(): void { $connection = $this->mockRedisConnection(transform: true); @@ -2045,24 +2183,100 @@ protected function callEval(string $script, int $numberOfKeys, mixed ...$argumen $this->assertSame([], $captured['arguments']); } - public function testSubscribeThrowsOnPooledConnection(): void + #[DataProvider('unsupportedSubscriptionProvider')] + public function testSubscriptionsThrowOnPooledConnection(string $command): void { $connection = $this->mockRedisConnection(); $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('Cannot call subscribe() on a pooled RedisConnection.'); + $this->expectExceptionMessage("Cannot call {$command}() on a pooled RedisConnection."); - $connection->__call('subscribe', [['channel1'], function () {}]); + $connection->__call(strtoupper($command), [['channel1'], function () {}]); } - public function testPsubscribeThrowsOnPooledConnection(): void + public static function unsupportedSubscriptionProvider(): array { - $connection = $this->mockRedisConnection(); + return [ + ['subscribe'], + ['psubscribe'], + ['ssubscribe'], + ]; + } - $this->expectException(BadMethodCallException::class); - $this->expectExceptionMessage('Cannot call psubscribe() on a pooled RedisConnection.'); + public function testResetIsRejectedBeforeNativeDispatchAndPreservesWatchState(): void + { + $pool = $this->getMockedPool(); + $pool->expects('discard')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('watch')->with('key')->andReturnTrue(); + $redis->expects('reset')->never(); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + $connection->shouldTransform(false); - $connection->__call('psubscribe', [['channel:*'], function () {}]); + $connection->__call('WATCH', ['key']); + + try { + $connection->__call('RESET', []); + $this->fail('Expected pooled RESET to be rejected.'); + } catch (BadMethodCallException $exception) { + $this->assertSame( + 'Cannot call reset() on a pooled Redis connection because it clears ' + . 'the authentication and selected database owned by the pool. ' + . 'Use Redis::discard() for MULTI, Redis::unwatch() for WATCH, ' + . 'and exec() to complete a PIPELINE.', + $exception->getMessage(), + ); + } + + $connection->release(); + } + + #[DataProvider('hostFormattingProvider')] + public function testFormatHost( + array $config, + ?string $expected, + ?string $exceptionMessage, + ): void { + $connection = new class extends PhpRedisConnectionStub { + public function formatHostForTest(array $config): string + { + return $this->formatHost($config); + } + }; + + if ($exceptionMessage !== null) { + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage($exceptionMessage); + + $connection->formatHostForTest($config); + + return; + } + + $this->assertSame($expected, $connection->formatHostForTest($config)); + } + + public static function hostFormattingProvider(): array + { + return [ + 'empty host' => [ + ['host' => ''], + null, + 'Redis host must be a non-empty string.', + ], + 'matching scheme' => [ + ['host' => 'tls://redis.test', 'scheme' => 'TLS'], + 'tls://redis.test', + null, + ], + 'mismatched scheme' => [ + ['host' => 'tls://redis.test', 'scheme' => 'tcp'], + null, + 'must match the scheme option', + ], + ]; } public function testReconnectSetsSerializerOption(): void @@ -2115,6 +2329,35 @@ protected function createRedis(array $config): Redis }; } + public function testReconnectSetsPackIgnoreNumbersOnStandaloneConnection(): void + { + if (! defined(Redis::class . '::OPT_PACK_IGNORE_NUMBERS')) { + $this->markTestSkipped('PhpRedis does not support OPT_PACK_IGNORE_NUMBERS.'); + } + + $pool = $this->getMockedPool(); + $redis = m::mock(Redis::class); + $redis->expects('setOption') + ->with(Redis::OPT_PACK_IGNORE_NUMBERS, true) + ->andReturnTrue(); + + new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379, 'options' => ['pack_ignore_numbers' => true]], $redis) extends PhpRedisConnection { + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private Redis $fakeRedis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->fakeRedis; + } + }; + } + public function testReconnectSetsConnectionLevelPhpRedisOptions(): void { $pool = $this->getMockedPool(); @@ -2390,64 +2633,6 @@ public function testClusterTransformFiresInAtomicMode(): void $this->assertSame(1, $result); } - public function testRetryFailureMarksConnectionInvalid(): void - { - $pool = $this->getMockedPool(); - $redis = m::mock(Redis::class); - - $redis->shouldReceive('get') - ->once() - ->andThrow(new RedisException('first failure')); - - $connection = new class($this->getContainer(), $pool, ['host' => '127.0.0.1', 'port' => 6379], $redis) extends PhpRedisConnection { - private bool $constructed = false; - - public function __construct( - ContainerContract $container, - PoolInterface $pool, - array $config, - private Redis $fakeRedis, - ) { - parent::__construct($container, $pool, $config); - $this->constructed = true; - } - - protected function createRedis(array $config): Redis - { - if ($this->constructed) { - // Retry's reconnect fails - throw new RedisException('reconnect failed'); - } - - // Initial construction succeeds - return $this->fakeRedis; - } - - public function getLastUseTime(): float - { - return $this->lastUseTime; - } - - public function isInvalidForTest(): bool - { - return $this->invalid; - } - }; - - // lastUseTime should be non-zero after initial construction - $this->assertGreaterThan(0.0, $connection->getLastUseTime()); - - try { - $connection->__call('get', ['foo']); - $this->fail('Expected RedisException'); - } catch (RedisException $exception) { - $this->assertSame('reconnect failed', $exception->getMessage()); - } - - $this->assertGreaterThan(0.0, $connection->getLastUseTime()); - $this->assertTrue($connection->isInvalidForTest()); - } - public function testReconnectClearsInvalidState(): void { $pool = $this->getMockedPool(); diff --git a/tests/Redis/RedisEventsTest.php b/tests/Redis/RedisEventsTest.php index a2617b292..f1083b91e 100644 --- a/tests/Redis/RedisEventsTest.php +++ b/tests/Redis/RedisEventsTest.php @@ -16,6 +16,7 @@ use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; +use Hypervel\Redis\RedisSentinelFactory; use Hypervel\Tests\TestCase; use Mockery as m; use Redis as PhpRedis; @@ -266,7 +267,11 @@ private function createRedis(m\MockInterface|RedisConnection $connection): Redis $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); - return new RedisProxy($poolFactory, 'default'); + return new RedisProxy( + $poolFactory, + 'default', + m::mock(RedisSentinelFactory::class), + ); } private function createMockRedisConnection( diff --git a/tests/Redis/RedisManagerTest.php b/tests/Redis/RedisManagerTest.php index e5299a790..708fe67d8 100644 --- a/tests/Redis/RedisManagerTest.php +++ b/tests/Redis/RedisManagerTest.php @@ -15,6 +15,7 @@ use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisManager; use Hypervel\Redis\RedisProxy; +use Hypervel\Redis\RedisSentinelFactory; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; @@ -127,15 +128,15 @@ public function testPurgeDiscardsContextPinnedConnection(): void public function testReleaseConnectionsExhaustsProxiesAndPreservesFirstFailure(): void { $firstException = new RuntimeException('First release failed.'); - $first = m::mock(RedisProxy::class); - $first->expects('releaseContextConnection')->andThrow($firstException); - $second = m::mock(RedisProxy::class); - $second->expects('releaseContextConnection'); + $first = m::mock(PhpRedisConnection::class); + $first->expects('release')->andThrow($firstException); + $second = m::mock(PhpRedisConnection::class); + $second->expects('release'); $manager = $this->createManager(['first', 'second']); - $manager->extend('first', static fn () => $first); - $manager->extend('second', static fn () => $second); $manager->connection('first'); $manager->connection('second'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'first', $first); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'second', $second); try { $manager->releaseConnections(); @@ -147,48 +148,31 @@ public function testReleaseConnectionsExhaustsProxiesAndPreservesFirstFailure(): public function testDiscardConnectionsExhaustsEveryCreatedProxy(): void { - $first = m::mock(RedisProxy::class); - $first->expects('discardContextConnection'); - $second = m::mock(RedisProxy::class); - $second->expects('discardContextConnection'); + $first = m::mock(PhpRedisConnection::class); + $first->expects('discard'); + $second = m::mock(PhpRedisConnection::class); + $second->expects('discard'); $manager = $this->createManager(['first', 'second']); - $manager->extend('first', static fn () => $first); - $manager->extend('second', static fn () => $second); $manager->connection('first'); $manager->connection('second'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'first', $first); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'second', $second); $manager->discardConnections(); } - public function testPurgeUsesTheResolvedProxysActualPoolIdentity(): void - { - $proxy = m::mock(RedisProxy::class); - $proxy->expects('getName')->andReturn('physical'); - $proxy->expects('discardContextConnection'); - $poolFactory = m::mock(PoolFactory::class); - $poolFactory->expects('flushPool')->with('physical'); - $manager = $this->createManager(['alias'], poolFactory: $poolFactory); - $manager->extend('alias', static fn () => $proxy); - $manager->connection('alias'); - - $manager->purge('alias'); - - $this->assertSame([], $manager->connections()); - } - public function testPurgeFlushesPoolAfterDiscardFailureAndPreservesFirstFailure(): void { $discardException = new RuntimeException('Discard failed.'); - $proxy = m::mock(RedisProxy::class); - $proxy->expects('getName')->andReturn('physical'); - $proxy->expects('discardContextConnection')->andThrow($discardException); + $connection = m::mock(PhpRedisConnection::class); + $connection->expects('discard')->andThrow($discardException); $poolFactory = m::mock(PoolFactory::class); $poolFactory->expects('flushPool') - ->with('physical') + ->with('alias') ->andThrow(new RuntimeException('Flush failed.')); $manager = $this->createManager(['alias'], poolFactory: $poolFactory); - $manager->extend('alias', static fn () => $proxy); $manager->connection('alias'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'alias', $connection); try { $manager->purge('alias'); @@ -200,90 +184,48 @@ public function testPurgeFlushesPoolAfterDiscardFailureAndPreservesFirstFailure( $this->assertSame([], $manager->connections()); } - public function testExtendOverridesConnectionResolution() - { - $manager = $this->createManager(['default']); - - $custom = m::mock(RedisProxy::class); - - $manager->extend('custom', function ($app, $name) use ($custom) { - return $custom; - }); - - $this->assertSame($custom, $manager->connection('custom')); - } - - public function testExtendDoesNotAffectOtherConnections() - { - $manager = $this->createManager(['default']); - - $custom = m::mock(RedisProxy::class); - - $manager->extend('custom', function () use ($custom) { - return $custom; - }); - - $default = $manager->connection('default'); - - $this->assertInstanceOf(RedisProxy::class, $default); - $this->assertNotSame($custom, $default); - } - - public function testExtendInvalidatesCachedConnection() + public function testConnectorDriverExtensionsAreIntentionallyUnavailable(): void { + // REMOVED: Hypervel has one phpredis pooled transport rather than switchable connector drivers. $manager = $this->createManager(['default']); - // Resolve default first — caches a normal proxy - $original = $manager->connection('default'); - - // Now extend default — should invalidate the cached proxy - $custom = m::mock(RedisProxy::class); - $manager->extend('default', function () use ($custom) { - return $custom; - }); - - // Next connection() should return the custom one, not the cached original - $this->assertSame($custom, $manager->connection('default')); - $this->assertNotSame($original, $manager->connection('default')); + $this->assertFalse(method_exists($manager, 'extend')); + $this->assertFalse(method_exists($manager, 'forgetExtension')); + $this->assertFalse(method_exists($manager, 'setDriver')); } - public function testForgetExtensionRemovesCustomResolver() + public function testEnableEventsDelegatesToRedisConfigWithoutTouchingPools(): void { - $manager = $this->createManager(['default']); - - $custom = m::mock(RedisProxy::class); - $manager->extend('default', function () use ($custom) { - return $custom; - }); - - $this->assertSame($custom, $manager->connection('default')); - - $manager->forgetExtension('default'); + $app = m::mock(ContainerContract::class); + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->expects('getPool')->never(); + $config = m::mock(RedisConfig::class); + $config->expects('enableEvents'); + $manager = new RedisManager( + $app, + $poolFactory, + $config, + m::mock(RedisSentinelFactory::class), + ); - // Should go through normal resolution now - $result = $manager->connection('default'); - $this->assertNotSame($custom, $result); - $this->assertInstanceOf(RedisProxy::class, $result); + $manager->enableEvents(); } - public function testForgetExtensionInvalidatesCachedConnection() + public function testDisableEventsDelegatesToRedisConfigWithoutTouchingPools(): void { - $manager = $this->createManager(['default']); - - // Extend, resolve (caches the custom proxy) - $custom = m::mock(RedisProxy::class); - $manager->extend('default', function () use ($custom) { - return $custom; - }); - $this->assertSame($custom, $manager->connection('default')); - - // Forget — should invalidate the cached custom proxy - $manager->forgetExtension('default'); + $app = m::mock(ContainerContract::class); + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->expects('getPool')->never(); + $config = m::mock(RedisConfig::class); + $config->expects('disableEvents'); + $manager = new RedisManager( + $app, + $poolFactory, + $config, + m::mock(RedisSentinelFactory::class), + ); - // Next connection() should return a normal proxy - $result = $manager->connection('default'); - $this->assertNotSame($custom, $result); - $this->assertInstanceOf(RedisProxy::class, $result); + $manager->disableEvents(); } public function testCallDelegatesToDefaultConnection() @@ -309,7 +251,8 @@ public function testListenRegistersCommandExecutedListener() $manager = new RedisManager( $app, m::mock(PoolFactory::class), - $this->createRedisConfig(['default']) + $this->createRedisConfig(['default']), + m::mock(RedisSentinelFactory::class), ); $manager->listen(function () {}); @@ -329,7 +272,8 @@ public function testListenForFailuresRegistersCommandFailedListener() $manager = new RedisManager( $app, m::mock(PoolFactory::class), - $this->createRedisConfig(['default']) + $this->createRedisConfig(['default']), + m::mock(RedisSentinelFactory::class), ); $manager->listenForFailures(function () {}); @@ -364,7 +308,12 @@ private function createManager( $poolFactory ??= m::mock(PoolFactory::class); $config = $this->createRedisConfig($configuredConnections); - return new RedisManager($app, $poolFactory, $config); + return new RedisManager( + $app, + $poolFactory, + $config, + m::mock(RedisSentinelFactory::class), + ); } /** diff --git a/tests/Redis/RedisPoolHeartbeatTest.php b/tests/Redis/RedisPoolHeartbeatTest.php index 7af6b52ab..6d26b300e 100644 --- a/tests/Redis/RedisPoolHeartbeatTest.php +++ b/tests/Redis/RedisPoolHeartbeatTest.php @@ -16,6 +16,7 @@ use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; +use Hypervel\Redis\RedisSentinelFactory; use Hypervel\Support\ClassInvoker; use Hypervel\Tests\TestCase; use Mockery as m; @@ -505,7 +506,11 @@ protected function createProxy(RedisPool $pool): RedisProxy $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('heartbeat_test')->andReturn($pool); - return new RedisProxy($poolFactory, 'heartbeat_test'); + return new RedisProxy( + $poolFactory, + 'heartbeat_test', + m::mock(RedisSentinelFactory::class), + ); } protected function ageReleasedConnection(RedisConnection $connection): void diff --git a/tests/Redis/RedisProxyNonCoroutineTest.php b/tests/Redis/RedisProxyNonCoroutineTest.php index 2b837f5ee..91bb5200f 100644 --- a/tests/Redis/RedisProxyNonCoroutineTest.php +++ b/tests/Redis/RedisProxyNonCoroutineTest.php @@ -9,6 +9,7 @@ use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisProxy; +use Hypervel\Redis\RedisSentinelFactory; use Hypervel\Tests\TestCase; use Mockery as m; use stdClass; @@ -68,7 +69,11 @@ private function assertCommandPinsConnection( $pool->expects('get')->andReturn($connection); $factory = m::mock(PoolFactory::class); $factory->expects('getPool')->with('default')->andReturn($pool); - $redis = new RedisProxy($factory, 'default'); + $redis = new RedisProxy( + $factory, + 'default', + m::mock(RedisSentinelFactory::class), + ); $this->assertSame($result, $redis->{$command}(...$arguments)); $this->assertSame( diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index 4f5b6c699..d9ee54c2d 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -13,11 +13,16 @@ use Hypervel\Pool\PoolOption; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\Events\CommandFailed; +use Hypervel\Redis\Exceptions\InvalidRedisConnectionException; +use Hypervel\Redis\PhpRedisClusterConnection; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Redis\Pool\RedisPool; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; +use Hypervel\Redis\RedisSentinelFactory; +use Hypervel\Redis\Subscriber\CommandBuilder; +use Hypervel\Tests\Redis\Fixtures\RespServer; use Hypervel\Tests\TestCase; use Mockery as m; use Redis as PhpRedis; @@ -37,15 +42,6 @@ */ class RedisProxyTest extends TestCase { - protected bool $isOlderThan6 = false; - - protected function setUp(): void - { - parent::setUp(); - - $this->isOlderThan6 = version_compare((string) phpversion('redis'), '6.0.0', '<'); - } - protected function tearDown(): void { parent::tearDown(); @@ -66,36 +62,71 @@ public function testCommandIsProxiedToConnection(): void $this->assertSame('bar', $result); } + public function testMacroRegistrationMethodsDoNotCheckoutRedis(): void + { + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->never(); + $redis = new RedisProxy($factory, 'default', $this->sentinelFactory()); + + $redis->macro('greeting', fn (string $name) => "Hello {$name}"); + $redis->mixin(new class { + protected function farewell(): callable + { + return fn (string $name) => "Goodbye {$name}"; + } + }); + + $this->assertTrue($redis->hasMacro('greeting')); + $this->assertTrue($redis->hasMacro('farewell')); + + $redis->flushMacros(); + + $this->assertFalse($redis->hasMacro('greeting')); + $this->assertFalse($redis->hasMacro('farewell')); + } + + public function testMixedCaseSubscriptionsUseDedicatedProxyRoute(): void + { + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->never(); + $redis = new class($factory, 'default', $this->sentinelFactory()) extends RedisProxy { + public array $subscriptions = []; + + protected function handleSubscribe(string $name, array $arguments): void + { + $this->subscriptions[] = [$name, $arguments]; + } + }; + $callback = static function (): void { + }; + + $redis->__call('SUBSCRIBE', [['channel'], $callback]); + $redis->__call('PSUBSCRIBE', [['channel:*'], $callback]); + + $this->assertSame( + [ + ['subscribe', [['channel'], $callback]], + ['psubscribe', [['channel:*'], $callback]], + ], + $redis->subscriptions, + ); + } + public function testConnectionBoundMethodsCannotBeCalledThroughProxy(): void { - $redis = new RedisProxy(m::mock(PoolFactory::class), 'default'); - - foreach ([ - 'auth', - 'check', - 'client', - 'clearWatchState', - 'close', - 'connect', - 'discardTransaction', - 'getActiveConnection', - 'getConnection', - 'getCreatedAt', - 'getLastReleaseTime', - 'getLastUseTime', - 'getShouldTransform', - 'heartbeatCheck', - 'isIdleExpired', - 'isLifetimeExpired', - 'masters', - 'reconnect', - 'release', - 'safeScan', - 'setDatabase', - 'setOption', - 'shouldTransform', - 'pconnect', - ] as $method) { + $redis = new RedisProxy( + m::mock(PoolFactory::class), + 'default', + $this->sentinelFactory(), + ); + $methods = (new ReflectionClass(RedisProxy::class)) + ->getReflectionConstant('CONNECTION_BOUND_METHODS') + ?->getValue(); + $this->assertIsArray($methods); + + foreach ($methods as $method) { + $method = strtoupper($method); + try { $redis->{$method}(); $this->fail(sprintf('Method [%s] was not blocked.', $method)); @@ -108,7 +139,7 @@ public function testConnectionBoundMethodsCannotBeCalledThroughProxy(): void } } - public function testConnectionIsStoredInContextForMulti(): void + public function testMixedCaseMultiStoresConnectionInContext(): void { $multiInstance = m::mock(PhpRedis::class); @@ -119,14 +150,14 @@ public function testConnectionIsStoredInContextForMulti(): void $redis = $this->createRedis($connection); - $result = $redis->multi(); + $result = $redis->__call('MULTI', []); $this->assertSame($multiInstance, $result); // Connection should be stored in context $this->assertTrue(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } - public function testConnectionIsStoredInContextForPipeline(): void + public function testMixedCasePipelineStoresConnectionInContext(): void { $pipelineInstance = m::mock(PhpRedis::class); @@ -137,13 +168,13 @@ public function testConnectionIsStoredInContextForPipeline(): void $redis = $this->createRedis($connection); - $result = $redis->pipeline(); + $result = $redis->__call('PIPELINE', []); $this->assertSame($pipelineInstance, $result); $this->assertTrue(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } - public function testConnectionIsStoredInContextForSelect(): void + public function testMixedCaseSelectStoresConnectionInContext(): void { $connection = $this->mockConnection(); $connection->shouldReceive('select')->once()->with(1)->andReturn(true); @@ -153,7 +184,7 @@ public function testConnectionIsStoredInContextForSelect(): void $redis = $this->createRedis($connection); - $result = $redis->select(1); + $result = $redis->__call('SELECT', [1]); $this->assertTrue($result); $this->assertTrue(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); @@ -174,7 +205,7 @@ public function testConnectionIsStoredInContextForSelectZeroDatabase(): void $this->assertTrue(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } - public function testConnectionIsStoredInContextForWatch(): void + public function testMixedCaseWatchStoresConnectionInContext(): void { $connection = $this->mockConnection(); $connection->shouldReceive('watch')->once()->with('key')->andReturn(true); @@ -182,7 +213,7 @@ public function testConnectionIsStoredInContextForWatch(): void $redis = $this->createRedis($connection); - $this->assertTrue($redis->watch('key')); + $this->assertTrue($redis->__call('WATCH', ['key'])); $this->assertSame( $connection, CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default') @@ -199,7 +230,7 @@ public function testNativeDiscardDoesNotInvokePoolDiscard(): void $redis = $this->createRedis($connection); - $this->assertTrue($redis->discard()); + $this->assertTrue($redis->__call('DISCARD', [])); $this->assertSame( $connection, CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default') @@ -319,7 +350,7 @@ public function testSelectPinnedConnectionDoesNotLeakAcrossCoroutines(): void $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); - $redis = new RedisProxy($poolFactory, 'default'); + $redis = new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); $this->assertSame('db:0 name:set argument:xxxx,yyyy', $redis->set('xxxx', 'yyyy')); $this->assertTrue($redis->select(2)); @@ -354,7 +385,7 @@ public function testPinnedConnectionInOneCoroutineIsNotReusedInAnotherCoroutine( $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); - $redis = new RedisProxy($poolFactory, 'default'); + $redis = new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); $redis->multi(); $redis->set('id', '123'); @@ -960,6 +991,266 @@ public function testWithoutSerializationOrCompressionDoesNotReleaseContextConnec $this->assertTrue(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } + public function testSubscriberUsesTheCompleteStandaloneConfiguration(): void + { + $server = new RespServer; + $server->start(static function ($client): void { + fread($client, 1); + }); + $pool = m::mock(RedisPool::class); + $pool->expects('getConfig')->andReturn([ + 'host' => $server->endpoint(), + 'port' => 6379, + 'timeout' => 2.5, + 'options' => ['prefix' => 'app:'], + ]); + $pool->shouldNotReceive('get'); + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->with('default')->andReturn($pool); + $subscriber = (new RedisProxy( + $factory, + 'default', + $this->sentinelFactory(), + ))->subscriber(); + + try { + $this->assertSame($server->endpoint(), $subscriber->host); + $this->assertSame(2.5, $subscriber->timeout); + $this->assertSame('app:', $subscriber->prefix); + } finally { + $subscriber->close(); + $server->wait(); + } + } + + public function testSubscriberResolvesSentinelMasterFreshWithConnectionCredentials(): void + { + $command = CommandBuilder::build(['auth', '0', '0']); + $servers = [new RespServer, new RespServer]; + + foreach ($servers as $server) { + $server->start(function ($client) use ($command): void { + $this->readExact($client, strlen($command)); + fwrite($client, "+OK\r\n"); + fread($client, 1); + }); + } + + [$firstHost, $firstPort] = $servers[0]->hostAndPort(); + [$secondHost, $secondPort] = $servers[1]->hostAndPort(); + $config = [ + 'sentinel' => ['enable' => true], + 'username' => '0', + 'password' => '0', + 'timeout' => 1.0, + 'options' => ['prefix' => 'sentinel:'], + ]; + $pool = m::mock(RedisPool::class); + $pool->expects('getConfig')->twice()->andReturn($config); + $pool->shouldNotReceive('get'); + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->twice()->with('default')->andReturn($pool); + $sentinelFactory = m::mock(RedisSentinelFactory::class); + $sentinelFactory->expects('resolveMaster') + ->twice() + ->with($config) + ->andReturn([$firstHost, $firstPort], [$secondHost, $secondPort]); + $proxy = new RedisProxy($factory, 'default', $sentinelFactory); + $first = $proxy->subscriber(); + $second = $proxy->subscriber(); + + try { + $this->assertSame($firstPort, $first->port); + $this->assertSame($secondPort, $second->port); + $this->assertSame('sentinel:', $first->prefix); + } finally { + $first->close(); + $second->close(); + + foreach ($servers as $server) { + $server->wait(); + } + } + } + + public function testClusterSubscriberUsesClusterContextAndReleasesDiscoveryConnectionBeforeEndpointFallback(): void + { + $released = false; + $server = new RespServer; + $server->start(static function ($client) use (&$released): void { + if (! $released) { + throw new RuntimeException('Cluster discovery connection was not released before subscriber dial.'); + } + + fread($client, 1); + }); + [$host, $port] = $server->hostAndPort(); + $config = [ + 'cluster' => [ + 'enable' => true, + 'seeds' => ['tcp://127.0.0.1:1'], + 'context' => [], + ], + 'context' => ['stream' => ['verify_peer' => false]], + 'timeout' => 0.1, + 'options' => ['prefix' => 'cluster:'], + ]; + $connection = m::mock(PhpRedisClusterConnection::class); + $connection->expects('getConnection')->andReturnSelf(); + $connection->expects('masters')->andReturn([ + ['127.0.0.1', 1], + [$host, $port], + ]); + $connection->expects('release')->andReturnUsing(static function () use (&$released): void { + $released = true; + }); + $pool = m::mock(RedisPool::class); + $pool->expects('getConfig')->andReturn($config); + $pool->expects('get')->andReturn($connection); + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->with('default')->andReturn($pool); + $subscriber = (new RedisProxy( + $factory, + 'default', + $this->sentinelFactory(), + ))->subscriber(); + + try { + $this->assertTrue($released); + $this->assertSame($port, $subscriber->port); + $this->assertSame('cluster:', $subscriber->prefix); + $this->assertSame([], $subscriber->context); + } finally { + $subscriber->close(); + $server->wait(); + } + } + + public function testClusterSubscriberUsesOnlyClusterContextForMasterTransport(): void + { + $released = false; + $clientOptions = [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ]; + $server = new RespServer('tls://127.0.0.1:0', [ + 'ssl' => [ + 'local_cert' => __DIR__ . '/Fixtures/Tls/server.crt', + 'local_pk' => __DIR__ . '/Fixtures/Tls/server.key', + 'allow_self_signed' => true, + ], + ]); + $server->start(static function ($client) use (&$released): void { + if (! $released) { + throw new RuntimeException( + 'Cluster discovery connection was not released before subscriber dial.' + ); + } + + fread($client, 1); + }); + [$host, $port] = $server->hostAndPort(); + $config = [ + 'scheme' => 'tcp', + 'cluster' => [ + 'enable' => true, + 'seeds' => ['tcp://127.0.0.1:1'], + 'context' => $clientOptions, + ], + 'timeout' => 0.1, + ]; + $connection = m::mock(PhpRedisClusterConnection::class); + $connection->expects('getConnection')->andReturnSelf(); + $connection->expects('masters')->andReturn([[$host, $port]]); + $connection->expects('release')->andReturnUsing(static function () use (&$released): void { + $released = true; + }); + $pool = m::mock(RedisPool::class); + $pool->expects('getConfig')->andReturn($config); + $pool->expects('get')->andReturn($connection); + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->with('default')->andReturn($pool); + $subscriber = (new RedisProxy( + $factory, + 'default', + $this->sentinelFactory(), + ))->subscriber(); + + try { + $this->assertTrue($released); + $this->assertSame($clientOptions, $subscriber->context); + } finally { + $subscriber->close(); + $server->wait(); + } + } + + public function testClusterSubscriberAggregatesEndpointFailures(): void + { + $config = [ + 'cluster' => [ + 'enable' => true, + 'seeds' => ['tcp://127.0.0.1:1'], + ], + 'timeout' => 0.01, + ]; + $connection = m::mock(PhpRedisClusterConnection::class); + $connection->expects('getConnection')->andReturnSelf(); + $connection->expects('masters')->andReturn([ + ['127.0.0.1', 1], + ['127.0.0.1', 2], + ]); + $connection->expects('release'); + $pool = m::mock(RedisPool::class); + $pool->expects('getConfig')->andReturn($config); + $pool->expects('get')->andReturn($connection); + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->with('default')->andReturn($pool); + + try { + (new RedisProxy( + $factory, + 'default', + $this->sentinelFactory(), + ))->subscriber(); + $this->fail('Expected every Cluster subscriber endpoint to fail.'); + } catch (InvalidRedisConnectionException $exception) { + $this->assertStringContainsString('[127.0.0.1:1]', $exception->getMessage()); + $this->assertStringContainsString('[127.0.0.1:2]', $exception->getMessage()); + } + } + + public function testClusterDiscoveryFailureRemainsPrimaryOverReleaseFailure(): void + { + $discoveryException = new RuntimeException('Master discovery failed.'); + $connection = m::mock(PhpRedisClusterConnection::class); + $connection->expects('getConnection')->andReturnSelf(); + $connection->expects('masters')->andThrow($discoveryException); + $connection->expects('release')->andThrow(new RuntimeException('Release failed.')); + $pool = m::mock(RedisPool::class); + $pool->expects('getConfig')->andReturn([ + 'cluster' => [ + 'enable' => true, + 'seeds' => ['127.0.0.1:6379'], + ], + ]); + $pool->expects('get')->andReturn($connection); + $factory = m::mock(PoolFactory::class); + $factory->expects('getPool')->with('default')->andReturn($pool); + + try { + (new RedisProxy( + $factory, + 'default', + $this->sentinelFactory(), + ))->subscriber(); + $this->fail('Expected Cluster discovery to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($discoveryException, $exception); + } + } + public function testRedisClusterConstructorSignature(): void { $reflection = new ReflectionClass(RedisCluster::class); @@ -983,11 +1274,6 @@ public function testRedisClusterConstructorSignature(): void continue; } - if ($this->isOlderThan6) { - $this->assertNull($parameter->getType()); - continue; - } - if (is_array($type)) { foreach ($parameter->getType()?->getTypes() ?? [] as $namedType) { $this->assertTrue(in_array($namedType->getName(), $type, true)); @@ -1004,20 +1290,8 @@ public function testRedisSentinelConstructorSignature(): void { $reflection = new ReflectionClass(RedisSentinel::class); $method = $reflection->getMethod('__construct'); - $count = count($method->getParameters()); - - if (! $this->isOlderThan6) { - $this->assertSame(1, $count); - $this->assertSame('options', $method->getParameters()[0]->getName()); - - return; - } - - if ($count === 6) { - $this->markTestIncomplete('RedisSentinel does not support auth in this extension variant.'); - } - - $this->assertSame(7, $count); + $this->assertCount(1, $method->getParameters()); + $this->assertSame('options', $method->getParameters()[0]->getName()); } public function testShuffleNodesMaintainsNodeCount(): void @@ -1058,7 +1332,7 @@ public function testIsClusterReturnsFalseForStandardConfig() $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); - $redis = new RedisProxy($poolFactory, 'default'); + $redis = new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); $this->assertFalse($redis->isCluster()); } @@ -1074,7 +1348,7 @@ public function testIsClusterReturnsTrueForClusterConfig() $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($pool); - $proxy = new RedisProxy($poolFactory, 'cache'); + $proxy = new RedisProxy($poolFactory, 'cache', $this->sentinelFactory()); $this->assertTrue($proxy->isCluster()); } @@ -1092,7 +1366,7 @@ public function testProxyUsesSpecifiedPoolName(): void // Expect 'cache' pool to be requested, not 'default' $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($cachePool); - $proxy = new RedisProxy($poolFactory, 'cache'); + $proxy = new RedisProxy($poolFactory, 'cache', $this->sentinelFactory()); $result = $proxy->get('key'); @@ -1112,7 +1386,7 @@ public function testProxyContextKeyUsesPoolName(): void $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($pool); - $proxy = new RedisProxy($poolFactory, 'cache'); + $proxy = new RedisProxy($poolFactory, 'cache', $this->sentinelFactory()); $proxy->pipeline(); @@ -1146,7 +1420,7 @@ private function createRedis(m\MockInterface|RedisConnection $connection): Redis $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); - return new RedisProxy($poolFactory, 'default'); + return new RedisProxy($poolFactory, 'default', $this->sentinelFactory()); } /** @@ -1162,7 +1436,11 @@ private function createCountingRedis( $poolFactory = m::mock(PoolFactory::class); $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); - return new RedisProxyReleaseCountingStub($poolFactory, 'default'); + return new RedisProxyReleaseCountingStub( + $poolFactory, + 'default', + $this->sentinelFactory(), + ); } /** @@ -1198,6 +1476,33 @@ private function createMockRedisConnection( return $mockRedisConnection; } + + /** + * Read an exact number of bytes from a test stream. + * + * @param resource $stream + */ + private function readExact(mixed $stream, int $length): string + { + $value = ''; + + while (strlen($value) < $length) { + $chunk = fread($stream, $length - strlen($value)); + + if ($chunk === false || $chunk === '') { + throw new RuntimeException('Failed to read the complete test command.'); + } + + $value .= $chunk; + } + + return $value; + } + + private function sentinelFactory(): RedisSentinelFactory + { + return m::mock(RedisSentinelFactory::class); + } } class RedisProxyReleaseCountingStub extends RedisProxy diff --git a/tests/Redis/RedisSentinelFactoryTest.php b/tests/Redis/RedisSentinelFactoryTest.php new file mode 100644 index 000000000..5cefb5788 --- /dev/null +++ b/tests/Redis/RedisSentinelFactoryTest.php @@ -0,0 +1,268 @@ +expects('getMasterAddrByName') + ->with('primary') + ->andThrow(new RuntimeException('Unavailable.')); + $second = m::mock(RedisSentinel::class); + $second->expects('getMasterAddrByName') + ->with('primary') + ->andReturn(['10.0.0.1', '6380']); + $factory = new class([$first, $second]) extends RedisSentinelFactory { + public array $createdWith = []; + + public function __construct(private array $sentinels) + { + } + + public function create(array $options = []): RedisSentinel + { + $this->createdWith[] = $options; + + return array_shift($this->sentinels); + } + }; + + $master = $factory->resolveMaster([ + 'timeout' => 2.5, + 'retry_interval' => 100, + 'sentinel' => [ + 'nodes' => ['tcp://127.0.0.1:26379', 'tcp://127.0.0.2:26380'], + 'master_name' => 'primary', + 'persistent' => 'sentinel-id', + 'read_timeout' => 1.5, + 'auth' => '0', + ], + ]); + + $this->assertSame(['10.0.0.1', 6380], $master); + $this->assertCount(2, $factory->createdWith); + + foreach ($factory->createdWith as $options) { + $this->assertSame(2.5, $options['connectTimeout']); + $this->assertSame('sentinel-id', $options['persistent']); + $this->assertSame(100, $options['retryInterval']); + $this->assertSame(1.5, $options['readTimeout']); + $this->assertSame('0', $options['auth']); + } + } + + #[DataProvider('sentinelEndpoints')] + public function testResolveMasterPreservesSentinelEndpoint(string $node, string $host): void + { + $sentinel = m::mock(RedisSentinel::class); + $sentinel->expects('getMasterAddrByName') + ->with('primary') + ->andReturn(['10.0.0.1', 6380]); + $factory = new class($sentinel) extends RedisSentinelFactory { + public array $createdWith = []; + + public function __construct(private RedisSentinel $sentinel) + { + } + + public function create(array $options = []): RedisSentinel + { + $this->createdWith[] = $options; + + return $this->sentinel; + } + }; + + $factory->resolveMaster([ + 'sentinel' => [ + 'nodes' => [$node], + 'master_name' => 'primary', + ], + ]); + + $this->assertSame($host, $factory->createdWith[0]['host']); + $this->assertSame(26379, $factory->createdWith[0]['port']); + } + + public static function sentinelEndpoints(): array + { + return [ + 'bare IPv4' => ['127.0.0.1:26379', '127.0.0.1'], + 'bracketed IPv6' => ['[::1]:26379', '[::1]'], + 'explicit TCP' => ['tcp://redis.test:26379', 'tcp://redis.test'], + 'explicit TLS with IPv6' => ['tls://[::1]:26379', 'tls://[::1]'], + ]; + } + + #[DataProvider('sentinelContexts')] + public function testResolveMasterNormalizesSentinelContext(array $context, ?array $expected): void + { + $sentinel = m::mock(RedisSentinel::class); + $sentinel->expects('getMasterAddrByName')->andReturn(['10.0.0.1', 6380]); + $factory = new class($sentinel) extends RedisSentinelFactory { + public array $createdWith = []; + + public function __construct(private RedisSentinel $sentinel) + { + } + + public function create(array $options = []): RedisSentinel + { + $this->createdWith[] = $options; + + return $this->sentinel; + } + }; + + $factory->resolveMaster([ + 'sentinel' => [ + 'nodes' => ['127.0.0.1:26379'], + 'master_name' => 'primary', + 'context' => $context, + ], + ]); + + if ($expected === null) { + $this->assertArrayNotHasKey('ssl', $factory->createdWith[0]); + } else { + $this->assertSame($expected, $factory->createdWith[0]['ssl']); + } + } + + public static function sentinelContexts(): array + { + $options = ['verify_peer' => false, 'cafile' => '/tmp/ca.pem']; + + return [ + 'empty' => [[], null], + 'flat' => [$options, $options], + 'ssl' => [['ssl' => $options], $options], + 'stream' => [['stream' => $options], $options], + ]; + } + + public function testResolveMasterRejectsUnsupportedNodeComponents(): void + { + $factory = new RedisSentinelFactory; + + try { + $factory->resolveMaster([ + 'sentinel' => [ + 'nodes' => [ + 'user:password@127.0.0.1:26379', + 'tcp://127.0.0.1:26379/path', + ], + 'master_name' => 'primary', + ], + ]); + $this->fail('Expected Sentinel resolution to fail.'); + } catch (InvalidRedisConnectionException $exception) { + $this->assertStringContainsString( + '[user:password@127.0.0.1:26379]: unsupported node format', + $exception->getMessage(), + ); + $this->assertStringContainsString( + '[tcp://127.0.0.1:26379/path]: unsupported node format', + $exception->getMessage(), + ); + } + } + + public function testResolveMasterRejectsUnbracketedIpv6Nodes(): void + { + $factory = new RedisSentinelFactory; + + try { + $factory->resolveMaster([ + 'sentinel' => [ + 'nodes' => ['fe80::1:2637', '::1'], + 'master_name' => 'primary', + ], + ]); + $this->fail('Expected Sentinel resolution to fail.'); + } catch (InvalidRedisConnectionException $exception) { + $this->assertStringContainsString( + '[fe80::1:2637]: IPv6 node addresses must be bracketed, for example [::1]:26379', + $exception->getMessage(), + ); + $this->assertStringContainsString( + '[::1]: IPv6 node addresses must be bracketed, for example [::1]:26379', + $exception->getMessage(), + ); + } + } + + public function testResolveMasterAggregatesEveryNodeFailure(): void + { + $sentinel = m::mock(RedisSentinel::class); + $sentinel->expects('getMasterAddrByName') + ->with('primary') + ->andThrow(new RuntimeException('Connection refused.')); + $factory = new class($sentinel) extends RedisSentinelFactory { + public function __construct(private RedisSentinel $sentinel) + { + } + + public function create(array $options = []): RedisSentinel + { + return $this->sentinel; + } + }; + + try { + $factory->resolveMaster([ + 'sentinel' => [ + 'nodes' => ['invalid-node', 'tcp://127.0.0.1:26379'], + 'master_name' => 'primary', + ], + ]); + $this->fail('Expected Sentinel resolution to fail.'); + } catch (InvalidRedisConnectionException $exception) { + $this->assertStringContainsString('[invalid-node]: invalid node', $exception->getMessage()); + $this->assertStringContainsString( + '[tcp://127.0.0.1:26379]: Connection refused.', + $exception->getMessage(), + ); + } + } + + public function testResolveMasterRejectsMalformedMasterResponse(): void + { + $sentinel = m::mock(RedisSentinel::class); + $sentinel->expects('getMasterAddrByName')->andReturn(['', 'not-a-port']); + $factory = new class($sentinel) extends RedisSentinelFactory { + public function __construct(private RedisSentinel $sentinel) + { + } + + public function create(array $options = []): RedisSentinel + { + return $this->sentinel; + } + }; + + $this->expectException(InvalidRedisConnectionException::class); + $this->expectExceptionMessage( + '[tcp://127.0.0.1:26379]: master was not resolved' + ); + + $factory->resolveMaster([ + 'sentinel' => [ + 'nodes' => ['tcp://127.0.0.1:26379'], + 'master_name' => 'primary', + ], + ]); + } +} From 52af3b510ddb9ae6962163b1d9b2598d538e4b39 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:01 +0000 Subject: [PATCH 04/15] fix(redis): decode subscriber traffic exactly Replace CRLF-delimited Engine packet framing with one coroutine-hooked PHP stream and an exact RESP2 decoder. Handle complete writes, partial reads, binary bulk payloads, nested arrays, nulls, server errors, malformed lengths, and terminal EOF without corrupting the protocol stream or spinning. Support validated TCP, TLS, IPv4, IPv6, and Unix endpoints with real transport fixtures bound to ephemeral ports and exception-safe teardown. --- src/redis/src/Subscriber/Connection.php | 336 ++++++++++++++++-- src/redis/src/Subscriber/Constants.php | 2 - .../Subscriber/Exceptions/ServerException.php | 11 + tests/Redis/Fixtures/RespServer.php | 154 ++++++++ tests/Redis/Fixtures/Tls/server.crt | 20 ++ tests/Redis/Fixtures/Tls/server.key | 28 ++ tests/Redis/Subscriber/ConnectionTest.php | 308 +++++++++++++--- 7 files changed, 779 insertions(+), 80 deletions(-) create mode 100644 src/redis/src/Subscriber/Exceptions/ServerException.php create mode 100644 tests/Redis/Fixtures/RespServer.php create mode 100644 tests/Redis/Fixtures/Tls/server.crt create mode 100644 tests/Redis/Fixtures/Tls/server.key diff --git a/src/redis/src/Subscriber/Connection.php b/src/redis/src/Subscriber/Connection.php index 77467f5a3..338e5d72e 100644 --- a/src/redis/src/Subscriber/Connection.php +++ b/src/redis/src/Subscriber/Connection.php @@ -4,66 +4,346 @@ namespace Hypervel\Redis\Subscriber; -use Hypervel\Contracts\Engine\Socket\SocketFactoryInterface; -use Hypervel\Contracts\Engine\SocketInterface; -use Hypervel\Engine\Socket\SocketFactory; -use Hypervel\Engine\Socket\SocketOption; +use Hypervel\Redis\Subscriber\Exceptions\ServerException; use Hypervel\Redis\Subscriber\Exceptions\SocketException; class Connection { - protected SocketInterface $socket; + /** + * The subscriber stream. + * + * @var null|resource + */ + protected mixed $stream = null; protected bool $closed = false; + /** + * Create a new Redis subscriber connection. + */ public function __construct( string $host = '', int $port = 6379, float $timeout = 5.0, - ?SocketFactoryInterface $factory = null + ?string $scheme = null, + array $context = [], ) { - $options = new SocketOption($host, $port, $timeout, [ - 'open_eof_check' => true, - 'package_eof' => Constants::EOF, - ]); - $factory ??= new SocketFactory; - $this->socket = $factory->make($options); + $endpoint = $this->endpoint($host, $port, $scheme, $context); + $streamOptions = $context['stream'] ?? $context['ssl'] ?? $context; + $streamContext = stream_context_create( + $streamOptions === [] ? [] : ['ssl' => $streamOptions] + ); + $errorCode = 0; + $errorMessage = ''; + $stream = @stream_socket_client( + $endpoint, + $errorCode, + $errorMessage, + $timeout, + STREAM_CLIENT_CONNECT, + $streamContext, + ); + + if ($stream === false) { + throw new SocketException(sprintf( + 'Failed to connect to Redis subscriber endpoint [%s]: [%d] %s.', + $endpoint, + $errorCode, + $errorMessage, + )); + } + + $this->stream = $stream; } + /** + * Send a complete command to Redis. + */ public function send(string $data): bool { - $len = strlen($data); - $size = $this->socket->sendAll($data); + $written = 0; + $length = strlen($data); - if ($size === false) { - throw new SocketException('Failed to send data to the socket.'); - } + while ($written < $length) { + $bytes = @fwrite($this->stream, substr($data, $written)); + + if ($bytes === false || $bytes === 0) { + throw new SocketException( + 'Failed to send data to the Redis subscriber socket.' + ); + } - if ($len !== $size) { - throw new SocketException('The sending data is incomplete, it may be that the socket has been closed by the peer.'); + $written += $bytes; } return true; } /** - * @param float $timeout the timeout parameter is used to set the timeout rules for the recv method - * @see https://wiki.swoole.com/en/#/coroutine_client/init?id=timeout-rules - * -1: indicates no timeout - * 0: indicates no change in timeout - * > 0: represents setting a timeout timer for the corresponding number of seconds, with a maximum precision of 1 millisecond, which is a floating-point number; 0.5 represents 500 milliseconds + * Receive and decode one RESP2 value. */ - public function recv(float $timeout = -1): string|bool + public function receive(): mixed { - return $this->socket->recvPacket($timeout); + $line = $this->readLine(); + $prefix = $line[0] ?? throw new SocketException( + 'Received an empty Redis response.' + ); + $value = substr($line, 1); + + return match ($prefix) { + '+' => $value, + '-' => throw new ServerException($value), + ':' => $this->parseInteger($value), + '$' => $this->readBulk($this->parseInteger($value)), + '*' => $this->readArray($this->parseInteger($value)), + default => throw new SocketException( + "Unsupported Redis response type [{$prefix}]." + ), + }; } + /** + * Close the Redis subscriber connection. + */ public function close(): void { - if (! $this->closed && ! $this->socket->close()) { - throw new SocketException('Failed to close the socket.'); + if ($this->closed) { + return; } $this->closed = true; + $stream = $this->stream; + $this->stream = null; + + if (is_resource($stream) && ! fclose($stream)) { + throw new SocketException('Failed to close the Redis subscriber socket.'); + } + } + + /** + * Build the stream endpoint. + */ + private function endpoint( + string $host, + int $port, + ?string $scheme, + array $context, + ): string { + if ($host === '') { + throw new SocketException('Redis subscriber host must be a non-empty string.'); + } + + if (str_starts_with($host, '/')) { + if ($scheme !== null && strcasecmp($scheme, 'unix') !== 0) { + throw new SocketException( + 'A Redis Unix socket path cannot use a non-Unix scheme.' + ); + } + + return 'unix://' . $host; + } + + if (str_starts_with(strtolower($host), 'unix://')) { + if ($scheme !== null && strcasecmp($scheme, 'unix') !== 0) { + throw new SocketException( + 'A Redis Unix socket path cannot use a non-Unix scheme.' + ); + } + + $path = substr($host, strlen('unix://')); + + if (! str_starts_with($path, '/')) { + throw new SocketException( + 'A Redis Unix socket endpoint must contain an absolute path.' + ); + } + + return 'unix://' . $path; + } + + if (filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false) { + // PhpRedis treats a non-empty stream context as TLS unless an endpoint scheme overrides it. + $scheme ??= $context === [] ? 'tcp' : 'tls'; + + if (! in_array(strtolower($scheme), ['tcp', 'tls'], true)) { + throw new SocketException( + "Unsupported Redis subscriber scheme [{$scheme}]." + ); + } + + return strtolower($scheme) . "://[{$host}]:{$port}"; + } + + $hostScheme = parse_url($host, PHP_URL_SCHEME); + $endpointScheme = $scheme === null + ? ($context === [] ? 'tcp' : 'tls') + : strtolower($scheme); + + if (is_string($hostScheme)) { + $hostScheme = strtolower($hostScheme); + + if (! in_array($hostScheme, ['tcp', 'tls'], true)) { + throw new SocketException( + "Unsupported Redis subscriber scheme [{$hostScheme}]." + ); + } + + if ($scheme !== null && strcasecmp($hostScheme, $scheme) !== 0) { + throw new SocketException( + 'The scheme configured in the Redis subscriber host must match the scheme option.' + ); + } + + $endpointScheme = $hostScheme; + $endpoint = $host; + } else { + if (str_contains($host, '://')) { + throw new SocketException('The Redis subscriber endpoint is malformed.'); + } + + $endpoint = "{$endpointScheme}://{$host}:{$port}"; + } + + if (! in_array($endpointScheme, ['tcp', 'tls'], true)) { + throw new SocketException( + "Unsupported Redis subscriber scheme [{$endpointScheme}]." + ); + } + + $parts = parse_url($endpoint); + + if (! is_array($parts) + || ! isset($parts['host']) + || ! is_string($parts['host']) + || $parts['host'] === '') { + throw new SocketException('The Redis subscriber endpoint is malformed.'); + } + + if (str_contains($parts['host'], ':') + && ! str_starts_with($parts['host'], '[')) { + throw new SocketException( + 'Redis subscriber hosts containing a colon must use bracketed IPv6 addresses; pass the port separately.' + ); + } + + foreach (['user', 'pass', 'path', 'query', 'fragment'] as $component) { + if (array_key_exists($component, $parts)) { + throw new SocketException( + 'A Redis TCP or TLS subscriber endpoint cannot contain credentials, a path, a query, or a fragment.' + ); + } + } + + $endpointPort = $parts['port'] ?? $port; + + return "{$endpointScheme}://{$parts['host']}:{$endpointPort}"; + } + + /** + * Read one complete RESP line. + */ + private function readLine(): string + { + $line = fgets($this->stream); + + if ($line === false) { + throw new SocketException('Failed to read from the Redis subscriber socket.'); + } + + if (! str_ends_with($line, Constants::CRLF)) { + throw new SocketException('Received an incomplete Redis response line.'); + } + + return substr($line, 0, -strlen(Constants::CRLF)); + } + + /** + * Read an exact number of bytes. + */ + private function readExact(int $length): string + { + $result = ''; + + while (strlen($result) < $length) { + $chunk = fread($this->stream, $length - strlen($result)); + + if ($chunk === false || $chunk === '') { + throw new SocketException( + 'Failed to read the complete Redis response payload.' + ); + } + + $result .= $chunk; + } + + return $result; + } + + /** + * Parse an exact native integer. + */ + private function parseInteger(string $value): int + { + if (preg_match('/^[+-]?\d+\z/', $value) !== 1) { + throw new SocketException("Invalid Redis integer [{$value}]."); + } + + $negative = str_starts_with($value, '-'); + $digits = ltrim($value, '+-0'); + $digits = $digits === '' ? '0' : $digits; + $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX; + + if (strlen($digits) > strlen($limit) + || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { + throw new SocketException("Redis integer [{$value}] exceeds the native integer range."); + } + + return (int) $value; + } + + /** + * Read a bulk string. + */ + private function readBulk(int $length): ?string + { + if ($length === -1) { + return null; + } + + if ($length < -1 || $length > PHP_INT_MAX - 2) { + throw new SocketException("Invalid Redis bulk string length [{$length}]."); + } + + $payload = $this->readExact($length + 2); + + if (substr($payload, -2) !== Constants::CRLF) { + throw new SocketException( + 'Redis bulk string is missing its trailing CRLF.' + ); + } + + return substr($payload, 0, $length); + } + + /** + * Read an array. + */ + private function readArray(int $length): ?array + { + if ($length === -1) { + return null; + } + + if ($length < -1) { + throw new SocketException("Invalid Redis array length [{$length}]."); + } + + $values = []; + + for ($index = 0; $index < $length; ++$index) { + $values[] = $this->receive(); + } + + return $values; } } diff --git a/src/redis/src/Subscriber/Constants.php b/src/redis/src/Subscriber/Constants.php index c9ebd7a3d..4eece48bf 100644 --- a/src/redis/src/Subscriber/Constants.php +++ b/src/redis/src/Subscriber/Constants.php @@ -7,6 +7,4 @@ class Constants { public const CRLF = "\r\n"; - - public const EOF = "\r\n"; } diff --git a/src/redis/src/Subscriber/Exceptions/ServerException.php b/src/redis/src/Subscriber/Exceptions/ServerException.php new file mode 100644 index 000000000..314333390 --- /dev/null +++ b/src/redis/src/Subscriber/Exceptions/ServerException.php @@ -0,0 +1,11 @@ +server = $server; + $this->completed = new Channel(1); + } + + /** + * Get the connectable server endpoint. + */ + public function endpoint(): string + { + if (str_starts_with($this->uri, 'unix://')) { + return $this->uri; + } + + $scheme = parse_url($this->uri, PHP_URL_SCHEME) ?: 'tcp'; + $address = stream_socket_get_name($this->server, false); + + if ($address === false) { + throw new RuntimeException('Failed to resolve the RESP test server address.'); + } + + return "{$scheme}://{$address}"; + } + + /** + * Get the server host and port. + * + * @return array{0: string, 1: int} + */ + public function hostAndPort(): array + { + $endpoint = $this->endpoint(); + $host = parse_url($endpoint, PHP_URL_HOST); + $port = parse_url($endpoint, PHP_URL_PORT); + + if (! is_string($host) || ! is_int($port)) { + throw new RuntimeException("Invalid RESP test endpoint [{$endpoint}]."); + } + + return [$host, $port]; + } + + /** + * Handle one client connection in a coroutine. + * + * @param callable(resource): void $handler + */ + public function start(callable $handler): void + { + go(function () use ($handler): void { + $client = null; + + try { + $client = stream_socket_accept($this->server, 2.0); + + if ($client === false) { + throw new RuntimeException('Timed out accepting a RESP test client.'); + } + + $handler($client); + } catch (Throwable $exception) { + $this->failure = $exception; + } finally { + if (is_resource($client)) { + fclose($client); + } + + $this->completed->push(true); + } + }); + } + + /** + * Wait for the server handler to finish and close the listener. + */ + public function wait(float $timeout = 2.0): void + { + try { + if (! $this->completed->pop($timeout)) { + throw new RuntimeException('Timed out waiting for the RESP test server.'); + } + + if ($this->failure !== null) { + throw $this->failure; + } + } finally { + $this->close(); + } + } + + /** + * Close the listening stream. + */ + public function close(): void + { + $server = $this->server; + $this->server = null; + + if (is_resource($server)) { + fclose($server); + } + } +} diff --git a/tests/Redis/Fixtures/Tls/server.crt b/tests/Redis/Fixtures/Tls/server.crt new file mode 100644 index 000000000..7edf8d56d --- /dev/null +++ b/tests/Redis/Fixtures/Tls/server.crt @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDVTCCAj2gAwIBAgIUJXP90Mfiw1uFRYO6kB3pc+KWUckwDQYJKoZIhvcNAQEL +BQAwIDEeMBwGA1UEAwwVSHlwZXJ2ZWwgZ1JQQyBUZXN0IENBMB4XDTI2MDcyMDA5 +NDYxMFoXDTM2MDcxNzA5NDYxMFowFDESMBAGA1UEAwwJbG9jYWxob3N0MIIBIjAN +BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAm69MRyId5yhtM+ldgiBkveK1I+2Y +/bcd+kRMdVqm0qcHhrkAM5LpKwGmlAWe1v5shHzxNa2Zf5s3VHm4IRdtLfr68Qpu +w8tql3/CnADdp1QhkGYUB4zRkDvZWoqI+o/jUhJIQUpbZKLfyWDEAl1HdL/B4AHD +B6GTkTGRIMVxm/r8/B+WWFiwNlEJomNdKdlyJCQ72oQh4M43v1ZEWyGYIcNus8sI +MX+DqsCOTJWS2CH6Qpavpyvw7rfABz6v1TTE47eKv4QMZO/f0b1NH0c8HMQPghk/ +97I321OxI6qcNpY1yy0mOl376eqQPUHFWe1QkoTd/g8665sAnJiFkEWhfQIDAQAB +o4GSMIGPMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMBoGA1UdEQQTMBGCCWxvY2FsaG9zdIcEfwAAATAdBgNVHQ4EFgQU ++aBAgUMXJWz6lgeOGCK+eS1up00wHwYDVR0jBBgwFoAUDxIrxxnxXmgauAvvppkg +m0aC7fMwDQYJKoZIhvcNAQELBQADggEBAIrYDtYy2jSIpvwTVgD2+Vh+HHz1uqVT +7S7HPz6HPd+hNiKaeKhiGH/cogRvnT4A3qwoJA9Q+XEKT3Fg1dAqzlGHBmkKxf62 +Cn/232L0oqZlpaBWvwpIEHXnfUMvfSISEKtnh3/uk2C7v1S5SV9Ojcqy+uJj3vKX +xVcrcyml32jkEAAiA4STzbfmu13bdkHwrFt57fuzaK9LWQn8NsV/ubYg+uzpdP8v +BdURF+t80Gd/OlnJk09tZj9BEhrUxe6KJMSbaOTXIKOq/59Nt16GMIAskjYO0D1s +FCjfhT2O4DjUo/SJ9sW+zqnfDx3dgpEjQsjxoDGE1qGX0yuFCbAav7E= +-----END CERTIFICATE----- diff --git a/tests/Redis/Fixtures/Tls/server.key b/tests/Redis/Fixtures/Tls/server.key new file mode 100644 index 000000000..0d4cb35e1 --- /dev/null +++ b/tests/Redis/Fixtures/Tls/server.key @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQCbr0xHIh3nKG0z +6V2CIGS94rUj7Zj9tx36REx1WqbSpweGuQAzkukrAaaUBZ7W/myEfPE1rZl/mzdU +ebghF20t+vrxCm7Dy2qXf8KcAN2nVCGQZhQHjNGQO9laioj6j+NSEkhBSltkot/J +YMQCXUd0v8HgAcMHoZORMZEgxXGb+vz8H5ZYWLA2UQmiY10p2XIkJDvahCHgzje/ +VkRbIZghw26zywgxf4OqwI5MlZLYIfpClq+nK/Dut8AHPq/VNMTjt4q/hAxk79/R +vU0fRzwcxA+CGT/3sjfbU7Ejqpw2ljXLLSY6Xfvp6pA9QcVZ7VCShN3+DzrrmwCc +mIWQRaF9AgMBAAECggEAKz8VxEjA376GHz57IDOZaHn6cYGF1yyv4h5o0sycvLVz +TMRFPw5XQQATYtjw164TPPZsFsojcqQOSaQKNv8H8Bbg8GZCgJcYA/+Ucrt21w1y +yWbht3sxl4xYg2MqS9f+gITdl21tV9Y6rfj4WePJfq/pzi0PHSaQFGwdcWoHdhli +eHr/FzAr5D5j93ml99QmDEhBxYBH4FTsWKN64CRBIlse0ydZ81985/JEebhJ5GTY +Tkel9CIjzm9I+3m0AU21wZBRMxD4ZkWA6awNMnRw/BqLKP46oc76yoDZoTDcU+Gv +eer/jRUg6PCE0FROnLlhR7/MR7VVhr9Oqo5UyGhLzQKBgQDQ2e2lpbdcQBozbdGZ +dp9isyxyNCllp5g//qZwTk59Spo9gnmIu0odgmQwxadLI2svSoNcYvcNmg47eA+t +mkW9qgkT1TgoTnlnHuZrFQH2PlfwC+tjQEohCyKZg0PZSfdX1Bp0cQl0ZoRIXv5J +phIWCAGO3d/c/2oNvQflhP2dkwKBgQC+1LwQXOsN5hElvggTlHxHxJOjCe1ehPAZ +Ds3X9vTOyMH88r1aPkqoL+GukSSmRx4yCtIk71rQc4qMGZZCfmhTqbLs7ss+na9d +MNJtWI8ptXrwy8/KzQLrC5hR8P+REFaKcSc7iZAuRkBqQIquKCZZI2iiB1CM0Ygn +5z4sqx+urwKBgCMjJxphYRICLuZMKaFaFcKzRl0IbZaOtcy+eR4X7pihvoVuuCfK +6tNAJr8V4emAUf4o2STn+YyuSIq0zl50wBsCyngtvT76xO4WgsmtRSE6p+zY9IdE +P7SDfRS6wuWBzj1WkATbJ64PuV27raiSaiSOwERbC9jQl/UrwnJZB5pFAoGAa2Mr +scmYPOoLHEIkKWCVz40/x6/+dAI7Wt6J186RVQyEneO7ytzjBmJrjeD/ztKWm3Kb +b02CvWtHvC9p72FTNEF6/voiRcpWtQqUYBRF/CK0XG1VMbrMuZh8zx/fsbKQALhM +a6SuDlxaQ3CumfLeIatbZlLXcWc4R7xJsLlbyuECgYB62MtNupzGiSQvIXmiC0hx +KuTpI1wzjF0ENT5uYncXJNolMvbEhudEmFLDcMJYX5pXXJB1BDJTt+qTHawO8AUr +MnJpvEzd4Cfbq2lu5bNjHNiAZtwxESDcbQ0wI0S/3VsjgQVA4qEPehh6WR1bSIoG +vR6nUaA3tXUh2GzeL97l2A== +-----END PRIVATE KEY----- diff --git a/tests/Redis/Subscriber/ConnectionTest.php b/tests/Redis/Subscriber/ConnectionTest.php index ae2192f28..c99f69c0f 100644 --- a/tests/Redis/Subscriber/ConnectionTest.php +++ b/tests/Redis/Subscriber/ConnectionTest.php @@ -4,103 +4,311 @@ namespace Hypervel\Tests\Redis\Subscriber; -use Hypervel\Contracts\Engine\Socket\SocketFactoryInterface; -use Hypervel\Contracts\Engine\SocketInterface; +use Hypervel\Filesystem\Filesystem; use Hypervel\Redis\Subscriber\Connection; +use Hypervel\Redis\Subscriber\Exceptions\ServerException; use Hypervel\Redis\Subscriber\Exceptions\SocketException; +use Hypervel\Testing\ParallelTesting; +use Hypervel\Tests\Redis\Fixtures\RespServer; use Hypervel\Tests\TestCase; -use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; +use ReflectionClass; class ConnectionTest extends TestCase { - public function testSendSucceeds() + public function testSendWritesTheCompletePayload(): void { - $socket = m::mock(SocketInterface::class); - $socket->shouldReceive('sendAll')->with('hello')->once()->andReturn(5); + $payload = str_repeat('redis-command-', 100_000); + $received = ''; + $server = new RespServer; + $server->start(function ($client) use (&$received, $payload): void { + while (strlen($received) < strlen($payload)) { + $chunk = fread($client, strlen($payload) - strlen($received)); - $connection = $this->createConnection($socket); + if ($chunk === false || $chunk === '') { + break; + } - $this->assertTrue($connection->send('hello')); + $received .= $chunk; + } + }); + $connection = new Connection($server->endpoint()); + + try { + $this->assertTrue($connection->send($payload)); + $server->wait(); + $this->assertSame($payload, $received); + } finally { + $connection->close(); + } } - public function testSendThrowsWhenSendAllReturnsFalse() + public function testSendThrowsAfterPeerCloses(): void { - $socket = m::mock(SocketInterface::class); - $socket->shouldReceive('sendAll')->with('data')->once()->andReturn(false); + $server = new RespServer; + $server->start(static function (): void { + }); + $connection = new Connection($server->endpoint()); + $server->wait(); - $connection = $this->createConnection($socket); + try { + $this->expectException(SocketException::class); + $connection->send(str_repeat('x', 16 * 1024 * 1024)); + } finally { + $connection->close(); + } + } - $this->expectException(SocketException::class); - $this->expectExceptionMessage('Failed to send data to the socket.'); + #[DataProvider('responseProvider')] + public function testReceiveDecodesResp2(string $response, mixed $expected): void + { + $this->assertSame($expected, $this->receive($response)); + } + + public static function responseProvider(): array + { + return [ + 'simple string' => ["+OK\r\n", 'OK'], + 'positive integer' => [":42\r\n", 42], + 'minimum integer' => [':' . PHP_INT_MIN . "\r\n", PHP_INT_MIN], + 'bulk string' => ["$5\r\nhello\r\n", 'hello'], + 'empty bulk string' => ["$0\r\n\r\n", ''], + 'null bulk string' => ["$-1\r\n", null], + 'binary bulk string' => ["$6\r\na\r\nb\0c\r\n", "a\r\nb\0c"], + 'nested array' => ["*3\r\n+OK\r\n:2\r\n*2\r\n$1\r\na\r\n$-1\r\n", ['OK', 2, ['a', null]]], + 'null array' => ["*-1\r\n", null], + ]; + } - $connection->send('data'); + public function testReceiveHandlesChunkedShortReads(): void + { + $this->assertSame( + "a\r\nb\0c", + $this->receive(["$6\r\n", "a\r", "\nb\0", "c\r\n"]), + ); } - public function testSendThrowsWhenSendIncomplete() + public function testErrorFrameThrowsServerException(): void { - $socket = m::mock(SocketInterface::class); - // Data is 5 bytes but only 3 were sent - $socket->shouldReceive('sendAll')->with('hello')->once()->andReturn(3); + $this->expectException(ServerException::class); + $this->expectExceptionMessage('ERR command failed'); - $connection = $this->createConnection($socket); + $this->receive("-ERR command failed\r\n"); + } + #[DataProvider('malformedResponseProvider')] + public function testMalformedResponseThrowsSocketException( + string $response, + string $message, + ): void { $this->expectException(SocketException::class); - $this->expectExceptionMessage('The sending data is incomplete'); + $this->expectExceptionMessage($message); - $connection->send('hello'); + $this->receive($response); } - public function testRecvDelegatesToSocket() + public static function malformedResponseProvider(): array { - $socket = m::mock(SocketInterface::class); - $socket->shouldReceive('recvPacket')->with(-1.0)->once()->andReturn("*3\r\n"); + return [ + 'empty frame' => ["\r\n", 'empty Redis response'], + 'unknown frame' => ["?unknown\r\n", 'Unsupported Redis response type'], + 'malformed integer' => [":12x\r\n", 'Invalid Redis integer'], + 'overflowing integer' => [':' . PHP_INT_MAX . "0\r\n", 'exceeds the native integer range'], + 'invalid bulk length' => ["$-2\r\n", 'Invalid Redis bulk string length'], + 'invalid array length' => ["*-2\r\n", 'Invalid Redis array length'], + 'truncated line' => ['+OK', 'incomplete Redis response line'], + 'truncated bulk' => ["$5\r\nabc", 'complete Redis response payload'], + 'missing bulk terminator' => ["$3\r\nabcxx", 'missing its trailing CRLF'], + ]; + } - $connection = $this->createConnection($socket); + public function testEndpointFormattingSupportsIpv4Ipv6TlsAndUnix(): void + { + $this->assertSame('tcp://127.0.0.1:6379', $this->endpoint('127.0.0.1')); + $this->assertSame('tcp://[::1]:6379', $this->endpoint('::1')); + $this->assertSame('tcp://[::1]:6379', $this->endpoint('[::1]')); + $this->assertSame( + 'tls://redis.test:6379', + $this->endpoint('redis.test', context: ['verify_peer' => false]), + ); + $this->assertSame( + 'tls://[::1]:6379', + $this->endpoint('::1', context: ['verify_peer' => false]), + ); + $this->assertSame( + 'tcp://redis.test:6379', + $this->endpoint('tcp://redis.test', context: ['verify_peer' => false]), + ); + $this->assertSame('tls://[::1]:6380', $this->endpoint('::1', 6380, 'TLS')); + $this->assertSame('tls://redis.test:6380', $this->endpoint('tls://redis.test', 6380, 'TLS')); + $this->assertSame('tcp://redis.test:6380', $this->endpoint('tcp://redis.test:6380')); + $this->assertSame('tcp://[::1]:6380', $this->endpoint('tcp://[::1]:6380')); + $this->assertSame('unix:///tmp/redis.sock', $this->endpoint('/tmp/redis.sock', 0)); + $this->assertSame('unix:///tmp/redis.sock', $this->endpoint('unix:///tmp/redis.sock', 0, 'UNIX')); + } - $this->assertSame("*3\r\n", $connection->recv()); + #[DataProvider('invalidEndpointProvider')] + public function testEndpointFormattingRejectsUnsupportedShapes( + string $host, + ?string $scheme, + string $message, + ): void { + $this->expectException(SocketException::class); + $this->expectExceptionMessage($message); + + $this->endpoint($host, 6379, $scheme); } - public function testRecvPassesTimeout() + public static function invalidEndpointProvider(): array { - $socket = m::mock(SocketInterface::class); - $socket->shouldReceive('recvPacket')->with(5.0)->once()->andReturn(false); + return [ + 'empty host' => ['', null, 'non-empty string'], + 'unsupported scheme' => ['udp://redis.test', null, 'Unsupported'], + 'conflicting scheme' => ['tls://redis.test', 'tcp', 'must match'], + 'Unix path with TCP scheme' => ['/tmp/redis.sock', 'tcp', 'cannot use a non-Unix scheme'], + 'relative Unix URI' => ['unix://redis.sock', null, 'must contain an absolute path'], + 'credentials' => ['tcp://user:secret@redis.test:6379', null, 'cannot contain credentials'], + 'path' => ['tcp://redis.test/path', null, 'cannot contain credentials'], + 'query' => ['tcp://redis.test?name=value', null, 'cannot contain credentials'], + 'fragment' => ['tcp://redis.test#fragment', null, 'cannot contain credentials'], + 'schemeless path' => ['redis.test/path', null, 'cannot contain credentials'], + 'unbracketed IPv6 URI' => ['tcp://fe80::1:2637', null, 'must use bracketed IPv6 addresses'], + 'schemeless host with port' => ['redis.test:6380', null, 'pass the port separately'], + ]; + } - $connection = $this->createConnection($socket); + public function testUnixSocketOperation(): void + { + $directory = ParallelTesting::tempDir('RedisSubscriberConnectionTest'); + mkdir($directory, 0777, true); + $path = $directory . '/redis.sock'; + $server = new RespServer('unix://' . $path); + $server->start(static function ($client): void { + fwrite($client, "+PONG\r\n"); + }); + $connection = new Connection($path); - $this->assertFalse($connection->recv(5.0)); + try { + $this->assertSame('PONG', $connection->receive()); + $server->wait(); + } finally { + $connection->close(); + (new Filesystem)->deleteDirectory($directory); + } } - public function testCloseSucceeds() + public function testNonEmptyContextSelectsTlsForSchemelessEndpoint(): void { - $socket = m::mock(SocketInterface::class); - $socket->shouldReceive('close')->once()->andReturn(true); + $clientOptions = [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ]; + $server = new RespServer('tls://127.0.0.1:0', [ + 'ssl' => [ + 'local_cert' => __DIR__ . '/../Fixtures/Tls/server.crt', + 'local_pk' => __DIR__ . '/../Fixtures/Tls/server.key', + 'allow_self_signed' => true, + ], + ]); + $server->start(static function ($client): void { + fwrite($client, "+OK\r\n"); + }); + $endpoint = parse_url($server->endpoint()); + $connection = new Connection( + $endpoint['host'], + $endpoint['port'], + context: $clientOptions, + ); - $connection = $this->createConnection($socket); + try { + $this->assertSame('OK', $connection->receive()); + $server->wait(); + } finally { + $connection->close(); + } + } - $connection->close(); + public function testTlsContextShapes(): void + { + $clientOptions = [ + 'verify_peer' => false, + 'verify_peer_name' => false, + 'allow_self_signed' => true, + ]; - // Second close should not call socket->close() again - $connection->close(); + foreach ([ + 'flat' => $clientOptions, + 'ssl' => ['ssl' => $clientOptions], + 'stream' => ['stream' => $clientOptions], + ] as $context) { + $server = new RespServer('tls://127.0.0.1:0', [ + 'ssl' => [ + 'local_cert' => __DIR__ . '/../Fixtures/Tls/server.crt', + 'local_pk' => __DIR__ . '/../Fixtures/Tls/server.key', + 'allow_self_signed' => true, + ], + ]); + $server->start(static function ($client): void { + fwrite($client, "+OK\r\n"); + }); + $connection = new Connection($server->endpoint(), context: $context); + + try { + $this->assertSame('OK', $connection->receive()); + $server->wait(); + } finally { + $connection->close(); + } + } } - public function testCloseThrowsWhenSocketCloseFails() + public function testCloseIsIdempotent(): void { - $socket = m::mock(SocketInterface::class); - $socket->shouldReceive('close')->once()->andReturn(false); + $server = new RespServer; + $server->start(static function (): void { + }); + $connection = new Connection($server->endpoint()); - $connection = $this->createConnection($socket); + $connection->close(); + $connection->close(); + $server->wait(); + } - $this->expectException(SocketException::class); - $this->expectExceptionMessage('Failed to close the socket.'); + private function endpoint( + string $host, + int $port = 6379, + ?string $scheme = null, + array $context = [], + ): string { + $reflection = new ReflectionClass(Connection::class); + $connection = $reflection->newInstanceWithoutConstructor(); - $connection->close(); + return $reflection->getMethod('endpoint')->invoke( + $connection, + $host, + $port, + $scheme, + $context, + ); } - private function createConnection(SocketInterface $socket): Connection + private function receive(string|array $chunks): mixed { - $factory = m::mock(SocketFactoryInterface::class); - $factory->shouldReceive('make')->once()->andReturn($socket); + $server = new RespServer; + $server->start(static function ($client) use ($chunks): void { + foreach ((array) $chunks as $chunk) { + fwrite($client, $chunk); + usleep(1_000); + } + }); + $connection = new Connection($server->endpoint()); - return new Connection('127.0.0.1', 6379, 5.0, $factory); + try { + return $connection->receive(); + } finally { + $connection->close(); + $server->wait(); + } } } From b3c32b666428c6a62f8d368211c0f5a5fd5d814f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:09 +0000 Subject: [PATCH 05/15] fix(redis): make subscriber lifecycles deterministic Retain the first receive-loop failure, close every routing channel exactly once, bound foreground acknowledgements and pings, and prevent cleanup-induced errors from replacing the real cause. Route RESP replies semantically, track channel and pattern subscriptions independently, remove timer-per-message delivery, preserve complete credentials and topology, and make close and worker-shutdown interruption deterministic. Add unit and live-service coverage for binary Pub/Sub, acknowledgement accounting, receive failures, timeouts, channel races, credentials, and teardown. --- src/redis/src/Subscriber/CommandInvoker.php | 303 ++++++-- .../Exceptions/UnsubscribeException.php | 11 - src/redis/src/Subscriber/Subscriber.php | 115 ++- .../Subscriber/SubscriberIntegrationTest.php | 67 +- tests/Redis/Subscriber/CommandInvokerTest.php | 704 +++++++++++++----- tests/Redis/Subscriber/SubscriberTest.php | 232 ++++-- 6 files changed, 1091 insertions(+), 341 deletions(-) delete mode 100644 src/redis/src/Subscriber/Exceptions/UnsubscribeException.php diff --git a/src/redis/src/Subscriber/CommandInvoker.php b/src/redis/src/Subscriber/CommandInvoker.php index 76b68382e..0d9d1a5df 100644 --- a/src/redis/src/Subscriber/CommandInvoker.php +++ b/src/redis/src/Subscriber/CommandInvoker.php @@ -23,8 +23,18 @@ class CommandInvoker private ?int $shutdownTimerId = null; - public function __construct(protected Connection $connection, protected ?StdoutLoggerInterface $logger = null) - { + private ?Throwable $receiveFailure = null; + + private bool $interrupted = false; + + /** + * Create a new Redis subscriber command invoker. + */ + public function __construct( + protected Connection $connection, + protected ?StdoutLoggerInterface $logger = null, + protected float $timeout = 5.0, + ) { $this->resultChannel = new Channel; $this->pingChannel = new Channel; $this->messageChannel = new Channel(100); @@ -44,31 +54,81 @@ public function __construct(protected Connection $connection, protected ?StdoutL } } + /** + * Invoke a Redis subscriber command. + */ public function invoke(int|string|array|null $command, int $number): array { + if ($this->interrupted) { + throw $this->receiveFailure + ?? new SocketException('The Redis subscriber connection is closed.'); + } + try { $this->connection->send(CommandBuilder::build($command)); - } catch (Throwable $e) { - $this->interrupt(); - throw $e; + } catch (Throwable $exception) { + try { + $this->interrupt(); + } catch (Throwable) { + // The command-send failure remains primary after cleanup. + } + + throw $exception; } $result = []; for ($i = 0; $i < $number; ++$i) { - $result[] = $this->resultChannel->pop(); + $value = $this->resultChannel->pop($this->timeout > 0 ? $this->timeout : -1); + + if ($value !== false) { + $result[] = $value; + continue; + } + + if ($this->receiveFailure !== null) { + throw $this->receiveFailure; + } + + if ($this->resultChannel->isTimeout()) { + try { + $this->interrupt(); + } catch (Throwable) { + // The acknowledgement timeout remains primary after cleanup. + } + + throw new SocketException( + 'Timed out waiting for a Redis subscriber command acknowledgement.' + ); + } + + throw new SocketException( + 'The Redis subscriber command acknowledgement channel was closed.' + ); } return $result; } + /** + * Get the subscriber message channel. + */ public function channel(): Channel { return $this->messageChannel; } + /** + * Interrupt the subscriber connection. + */ public function interrupt(): bool { + if ($this->interrupted) { + return true; + } + + $this->interrupted = true; + if ($this->shutdownTimerId !== null) { $this->timer->clear($this->shutdownTimerId); $this->shutdownTimerId = null; @@ -85,101 +145,201 @@ public function interrupt(): bool return true; } + /** + * Ping the Redis subscriber connection. + */ public function ping(float $timeout = 1): string|bool { - $this->connection->send(CommandBuilder::build('ping')); - return $this->pingChannel->pop($timeout); + if ($this->interrupted) { + throw $this->receiveFailure + ?? new SocketException('The Redis subscriber connection is closed.'); + } + + try { + $this->connection->send(CommandBuilder::build('ping')); + } catch (Throwable $exception) { + try { + $this->interrupt(); + } catch (Throwable) { + // The PING send failure remains primary after cleanup. + } + + throw $exception; + } + + $result = $this->pingChannel->pop($timeout > 0 ? $timeout : -1); + + if ($result !== false) { + return $result; + } + + if ($this->receiveFailure !== null) { + throw $this->receiveFailure; + } + + if ($this->pingChannel->isTimeout()) { + try { + $this->interrupt(); + } catch (Throwable) { + // The PING timeout remains primary after cleanup. + } + + throw new SocketException('Timed out waiting for a Redis subscriber PONG response.'); + } + + throw new SocketException('The Redis subscriber PING channel was closed.'); } /** - * @throws SocketException + * Receive Redis subscriber responses. */ protected function receive(Connection $connection): void { - /** @var null|array $buffer */ - $buffer = null; - - while (true) { - $line = $connection->recv(); + try { + while (true) { // @phpstan-ignore while.alwaysTrue (receive or routing failure terminates the loop) + $this->route($connection->receive()); + } + } catch (Throwable $exception) { + if (! $this->interrupted) { + $this->receiveFailure = $exception; + } - if ($line === false || $line === '') { + try { $this->interrupt(); - break; + } catch (Throwable) { + // The terminal cause remains primary after cleanup. } + } + } - $line = substr($line, 0, -strlen(Constants::CRLF)); + /** + * Route one decoded RESP value. + */ + private function route(mixed $response): void + { + if (is_string($response)) { + if (strcasecmp($response, 'PONG') === 0) { + if (! $this->pingChannel->push('pong')) { + throw new SocketException('The Redis subscriber PING channel was closed.'); + } - if ($line === '+OK') { - $this->resultChannel->push($line); - continue; + return; } - if ($line === '*3') { - if (! empty($buffer)) { - $this->resultChannel->push($buffer); - $buffer = null; - } - $buffer[] = $line; - continue; + if (! $this->resultChannel->push($response)) { + throw new SocketException( + 'The Redis subscriber command acknowledgement channel was closed.' + ); } - $buffer[] = $line; - $type = $buffer[2] ?? false; + return; + } - if ($type === 'subscribe' && count($buffer) === 6) { - $this->resultChannel->push($buffer); - $buffer = null; - continue; - } + if (! is_array($response) || ! isset($response[0]) || ! is_string($response[0])) { + throw new SocketException('Received a malformed Redis subscriber response.'); + } - if ($type === 'unsubscribe' && count($buffer) === 6) { - $this->resultChannel->push($buffer); - $buffer = null; - continue; + $type = strtolower($response[0]); + + if (in_array($type, ['subscribe', 'unsubscribe', 'psubscribe', 'punsubscribe'], true)) { + if (count($response) !== 3 + || ( + ! is_string($response[1]) + && ! ( + in_array($type, ['unsubscribe', 'punsubscribe'], true) + && $response[1] === null + ) + ) + || ! is_int($response[2])) { + throw new SocketException( + "Received a malformed Redis {$type} acknowledgement." + ); } - if ($type === 'message' && count($buffer) === 7) { - $message = new Message(channel: $buffer[4], payload: $buffer[6]); - $timerID = $this->timer->after(30.0, function () use ($message) { - $this->logger?->error(sprintf('Message channel (%s) is 30 seconds full, disconnected', $message->channel)); - $this->interrupt(); - }); - $this->messageChannel->push($message); - $this->timer->clear($timerID); - $buffer = null; - continue; + if (! $this->resultChannel->push($response)) { + throw new SocketException( + 'The Redis subscriber command acknowledgement channel was closed.' + ); } - if ($type === 'psubscribe' && count($buffer) === 6) { - $this->resultChannel->push($buffer); - $buffer = null; - continue; + return; + } + + if ($type === 'message') { + if (count($response) !== 3 + || ! is_string($response[1]) + || ! is_string($response[2])) { + throw new SocketException('Received a malformed Redis message.'); } - if ($type === 'punsubscribe' && count($buffer) === 6) { - $this->resultChannel->push($buffer); - $buffer = null; - continue; + $this->pushMessage(new Message( + channel: $response[1], + payload: $response[2], + )); + return; + } + + if ($type === 'pmessage') { + if (count($response) !== 4 + || ! is_string($response[1]) + || ! is_string($response[2]) + || ! is_string($response[3])) { + throw new SocketException('Received a malformed Redis pattern message.'); } - if ($type === 'pmessage' && count($buffer) === 9) { - $message = new Message(pattern: $buffer[4], channel: $buffer[6], payload: $buffer[8]); - $timerID = $this->timer->after(30.0, function () use ($message) { - $this->logger?->error(sprintf('Message channel (%s) is 30 seconds full, disconnected', $message->channel)); - $this->interrupt(); - }); - $this->messageChannel->push($message); - $this->timer->clear($timerID); - $buffer = null; - continue; + $this->pushMessage(new Message( + channel: $response[2], + payload: $response[3], + pattern: $response[1], + )); + return; + } + + if ($type === 'pong') { + if (count($response) !== 2 + || (! is_string($response[1]) && $response[1] !== null)) { + throw new SocketException('Received a malformed Redis PONG response.'); } - if ($type === 'pong' && count($buffer) === 5) { - $this->pingChannel->push('pong'); - $buffer = null; - continue; + if (! $this->pingChannel->push('pong')) { + throw new SocketException('The Redis subscriber PING channel was closed.'); } + + return; + } + + throw new SocketException( + "Received an unsupported Redis subscriber response [{$response[0]}]." + ); + } + + /** + * Push a message into the bounded consumer channel. + */ + private function pushMessage(Message $message): void + { + if ($this->messageChannel->push($message, 30.0)) { + return; + } + + if (! $this->messageChannel->isTimeout()) { + throw new SocketException('The Redis subscriber message channel was closed.'); } + + $exception = new SocketException( + "Redis subscriber message channel [{$message->channel}] remained full for 30 seconds." + ); + + try { + $this->logger?->error(sprintf( + 'Message channel (%s) is 30 seconds full, disconnected', + $message->channel, + )); + } catch (Throwable) { + // Reporting must not replace the channel-capacity failure. + } + + throw $exception; } /** @@ -197,6 +357,9 @@ protected function watchForShutdown(): void }); } + /** + * Start the Redis subscriber receive loop. + */ protected function loop(): void { Coroutine::create(function (): void { diff --git a/src/redis/src/Subscriber/Exceptions/UnsubscribeException.php b/src/redis/src/Subscriber/Exceptions/UnsubscribeException.php deleted file mode 100644 index f3e15794e..000000000 --- a/src/redis/src/Subscriber/Exceptions/UnsubscribeException.php +++ /dev/null @@ -1,11 +0,0 @@ - + */ + protected array $channels = []; + + /** + * Active prefixed pattern subscriptions. + * + * @var array + */ + protected array $patterns = []; + + /** + * Create a new Redis subscriber. + * + * @throws SocketException + * @throws Throwable + */ public function __construct( public string $host, public int $port = 6379, - public string $password = '', + public string|array|null $password = null, public float $timeout = 5.0, public string $prefix = '', + public ?string $username = null, + public ?string $scheme = null, + public array $context = [], protected ?StdoutLoggerInterface $logger = null, ) { $this->connect(); } /** + * Subscribe to Redis channels. + * * @throws SocketException * @throws Throwable * @throws SubscribeException */ public function subscribe(string ...$channels): void { + if ($channels === []) { + throw new SubscribeException('At least one Redis channel is required.'); + } + $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); $result = $this->commandInvoker->invoke(['subscribe', ...$channels], count($channels)); foreach ($result as $value) { - if ($value === false) { - $this->commandInvoker->interrupt(); - throw new SubscribeException('Subscribe failed'); - } + $this->channels[$value[1]] = true; } } /** + * Unsubscribe from Redis channels. + * * @throws SocketException * @throws Throwable - * @throws UnsubscribeException */ public function unsubscribe(string ...$channels): void { $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); - $result = $this->commandInvoker->invoke(['unsubscribe', ...$channels], count($channels)); + $number = $channels === [] ? max(1, count($this->channels)) : count($channels); + $result = $this->commandInvoker->invoke(['unsubscribe', ...$channels], $number); foreach ($result as $value) { - if ($value === false) { - $this->commandInvoker->interrupt(); - throw new UnsubscribeException('Unsubscribe failed'); + if ($value[1] === null) { + $this->channels = []; + } else { + unset($this->channels[$value[1]]); } } } /** + * Subscribe to Redis channel patterns. + * * @throws SocketException * @throws Throwable * @throws SubscribeException */ public function psubscribe(string ...$channels): void { + if ($channels === []) { + throw new SubscribeException('At least one Redis channel pattern is required.'); + } + $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); $result = $this->commandInvoker->invoke(['psubscribe', ...$channels], count($channels)); foreach ($result as $value) { - if ($value === false) { - $this->commandInvoker->interrupt(); - throw new SubscribeException('Psubscribe failed'); - } + $this->patterns[$value[1]] = true; } } /** + * Unsubscribe from Redis channel patterns. + * * @throws SocketException * @throws Throwable - * @throws UnsubscribeException */ public function punsubscribe(string ...$channels): void { $channels = array_map(fn ($channel) => $this->prefix . $channel, $channels); - $result = $this->commandInvoker->invoke(['punsubscribe', ...$channels], count($channels)); + $number = $channels === [] ? max(1, count($this->patterns)) : count($channels); + $result = $this->commandInvoker->invoke(['punsubscribe', ...$channels], $number); foreach ($result as $value) { - if ($value === false) { - $this->commandInvoker->interrupt(); - throw new UnsubscribeException('Punsubscribe failed'); + if ($value[1] === null) { + $this->patterns = []; + } else { + unset($this->patterns[$value[1]]); } } } + /** + * Get the subscriber message channel. + */ public function channel(): Channel { return $this->commandInvoker->channel(); } /** + * Close the Redis subscriber. + * * @throws SocketException */ public function close(): void @@ -115,7 +154,10 @@ public function close(): void } /** + * Ping the Redis subscriber connection. + * * @throws SocketException + * @throws Throwable */ public function ping(float $timeout = 1): string|bool { @@ -123,15 +165,38 @@ public function ping(float $timeout = 1): string|bool } /** + * Connect the Redis subscriber. + * * @throws SocketException + * @throws Throwable */ protected function connect(): void { - $connection = new Connection($this->host, $this->port, $this->timeout); - $this->commandInvoker = new CommandInvoker($connection, $this->logger); - - if ($this->password !== '') { - $this->commandInvoker->invoke(['auth', $this->password], 1); + $connection = new Connection( + $this->host, + $this->port, + $this->timeout, + $this->scheme, + $this->context, + ); + $this->commandInvoker = new CommandInvoker( + $connection, + $this->logger, + $this->timeout, + ); + + if ($this->password === null || $this->password === '') { + return; } + + $credentials = is_array($this->password) + ? $this->password + : ( + $this->username !== null && $this->username !== '' + ? [$this->username, $this->password] + : [$this->password] + ); + + $this->commandInvoker->invoke(['auth', ...$credentials], 1); } } diff --git a/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php b/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php index a510a1c0c..8b56a73c5 100644 --- a/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php +++ b/tests/Integration/Redis/Subscriber/SubscriberIntegrationTest.php @@ -45,6 +45,29 @@ public function testSubscribeReceivesMessage(): void } } + public function testSubscribePreservesBinaryPayloadBytes(): void + { + $channelName = 'test_binary_' . uniqid(); + $payload = "before\r\nafter\0tail"; + $subscriber = $this->createTestSubscriber(); + + try { + $subscriber->subscribe($channelName); + + go(function () use ($channelName, $payload): void { + usleep(50_000); + $this->publishViaRawClient($channelName, $payload); + }); + + $message = $subscriber->channel()->pop(5.0); + + $this->assertNotFalse($message, 'Timed out waiting for binary message'); + $this->assertSame($payload, $message->payload); + } finally { + $subscriber->close(); + } + } + public function testSubscribeToMultipleChannels(): void { $channel1 = 'test_multi_a_' . uniqid(); @@ -188,9 +211,6 @@ public function testPingReturnsPongWhileSubscribed(): void $channelName = 'test_ping_' . uniqid(); $subscriber = $this->createTestSubscriber(); - // Must subscribe first — Redis only responds to PING with a multi-bulk - // pong in subscribe mode. In normal mode it sends +PONG which the - // RESP parser doesn't handle. try { $subscriber->subscribe($channelName); @@ -200,6 +220,40 @@ public function testPingReturnsPongWhileSubscribed(): void } } + public function testPingReturnsPongBeforeSubscribing(): void + { + $subscriber = $this->createTestSubscriber(); + + try { + $this->assertSame('pong', $subscriber->ping(5.0)); + } finally { + $subscriber->close(); + } + } + + public function testUnsubscribeAllSettlesEveryTrackedCategory(): void + { + $channel1 = 'test_unsub_all_a_' . uniqid(); + $channel2 = 'test_unsub_all_b_' . uniqid(); + $pattern1 = 'test_punsub_all_a_' . uniqid() . ':*'; + $pattern2 = 'test_punsub_all_b_' . uniqid() . ':*'; + $subscriber = $this->createTestSubscriber(); + + try { + $subscriber->subscribe($channel1, $channel2); + $subscriber->psubscribe($pattern1, $pattern2); + $subscriber->unsubscribe(); + $subscriber->punsubscribe(); + + $subscriber->subscribe($channel1); + $subscriber->unsubscribe(); + + $this->assertSame('pong', $subscriber->ping(5.0)); + } finally { + $subscriber->close(); + } + } + public function testCloseStopsReceivingMessages(): void { $channelName = 'test_close_' . uniqid(); @@ -224,10 +278,12 @@ public function testCloseStopsReceivingMessages(): void */ private function createTestSubscriber(string $prefix = ''): Subscriber { + $password = env('REDIS_PASSWORD'); + return new Subscriber( host: env('REDIS_HOST', '127.0.0.1'), port: (int) env('REDIS_PORT', 6379), - password: (string) (env('REDIS_PASSWORD', '') ?: ''), + password: is_string($password) && $password !== '' ? $password : null, timeout: 5.0, prefix: $prefix, ); @@ -246,7 +302,8 @@ private function publishViaRawClient(string $channel, string $message): void try { $auth = env('REDIS_PASSWORD'); - if ($auth) { + + if (is_string($auth) && $auth !== '') { $client->auth($auth); } diff --git a/tests/Redis/Subscriber/CommandInvokerTest.php b/tests/Redis/Subscriber/CommandInvokerTest.php index 27361e45c..fc00a9ea4 100644 --- a/tests/Redis/Subscriber/CommandInvokerTest.php +++ b/tests/Redis/Subscriber/CommandInvokerTest.php @@ -4,258 +4,540 @@ namespace Hypervel\Tests\Redis\Subscriber; +use Hypervel\Contracts\Log\StdoutLoggerInterface; use Hypervel\Coordinator\Constants; use Hypervel\Coordinator\CoordinatorManager; use Hypervel\Engine\Channel; +use Hypervel\Redis\Subscriber\CommandBuilder; use Hypervel\Redis\Subscriber\CommandInvoker; use Hypervel\Redis\Subscriber\Connection; +use Hypervel\Redis\Subscriber\Exceptions\ServerException; use Hypervel\Redis\Subscriber\Exceptions\SocketException; use Hypervel\Redis\Subscriber\Message; +use Hypervel\Tests\Redis\Fixtures\RespServer; use Hypervel\Tests\TestCase; use Mockery as m; +use ReflectionProperty; +use RuntimeException; +use stdClass; +use Throwable; + +use function Hypervel\Coroutine\go; class CommandInvokerTest extends TestCase { - public function testInvokeSendsCommandAndCollectsResults() - { - // Simulate a subscribe confirmation response: - // *3\r\n $9\r\n subscribe\r\n $3\r\n foo\r\n :1\r\n - $responses = [ - "*3\r\n", - "\$9\r\n", - "subscribe\r\n", - "\$3\r\n", - "foo\r\n", - ":1\r\n", - // After the subscribe response, return false to end the loop - false, - ]; - - $connection = $this->createMockConnection($responses); - $connection->shouldReceive('send')->once(); - $connection->shouldReceive('close')->atLeast()->once(); - - $invoker = new CommandInvoker($connection); - $result = $invoker->invoke(['subscribe', 'foo'], 1); + public function testInvokeSendsCommandAndCollectsDecodedResults(): void + { + $command = CommandBuilder::build(['subscribe', 'foo']); + $server = new RespServer; + $server->start(function ($client) use ($command): void { + $this->readExact($client, strlen($command)); + fwrite($client, "*3\r\n$9\r\nsubscribe\r\n$3\r\nfoo\r\n:1\r\n"); + fread($client, 1); + }); + $invoker = new CommandInvoker(new Connection($server->endpoint())); - $this->assertCount(1, $result); - $this->assertIsArray($result[0]); + try { + $this->assertSame( + [['subscribe', 'foo', 1]], + $invoker->invoke(['subscribe', 'foo'], 1), + ); + } finally { + $invoker->interrupt(); + $server->wait(); + } } - public function testInvokeInterruptsAndRethrowsOnSendFailure() + public function testInvokeInterruptsAndPreservesSendFailure(): void { - $connection = $this->createMockConnection([false]); - $connection->shouldReceive('send') - ->once() - ->andThrow(new SocketException('Connection lost')); - $connection->shouldReceive('close')->atLeast()->once(); - + $exception = new SocketException('Connection lost'); + $connection = new ControlledConnection($exception); $invoker = new CommandInvoker($connection); try { $invoker->invoke(['subscribe', 'foo'], 1); - $this->fail('Expected SocketException was not thrown'); - } catch (SocketException $e) { - $this->assertSame('Connection lost', $e->getMessage()); + $this->fail('Expected the send failure to be rethrown.'); + } catch (SocketException $caught) { + $this->assertSame($exception, $caught); } - // interrupt() should have closed the message channel + $this->assertTrue($connection->wasClosed()); $this->assertFalse($invoker->channel()->pop(0.01)); } - public function testChannelReturnsMessageChannel() + public function testChannelReturnsMessageChannel(): void { - $connection = $this->createMockConnection([false]); - $connection->shouldReceive('close')->atLeast()->once(); + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + + try { + $this->assertInstanceOf(Channel::class, $invoker->channel()); + } finally { + $invoker->interrupt(); + } + } + public function testInterruptIsIdempotentAndClosesAllChannels(): void + { + $connection = new ControlledConnection; $invoker = new CommandInvoker($connection); - $channel = $invoker->channel(); - $this->assertInstanceOf(\Hypervel\Engine\Channel::class, $channel); + $this->assertTrue($invoker->interrupt()); + $this->assertTrue($invoker->interrupt()); + $this->assertSame(1, $connection->closeCount); + $this->assertFalse($invoker->channel()->pop(0.01)); } - public function testInterruptClosesAllChannels() + public function testShutdownWatcherInterruptsOnWorkerExit(): void { - $connection = $this->createMockConnection([false]); - $connection->shouldReceive('close')->atLeast()->once(); + $connection = new BlockingConnection; $invoker = new CommandInvoker($connection); - // Give the background coroutine time to start and exit - usleep(10_000); + CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); - $result = $invoker->interrupt(); - $this->assertTrue($result); + usleep(50_000); - // Channel should be closed — pop returns false + $this->assertTrue($connection->wasClosed()); $this->assertFalse($invoker->channel()->pop(0.01)); } - public function testShutdownWatcherInterruptsOnWorkerExit() + public function testSimplePongDoesNotPoisonTheNextAcknowledgement(): void { - $connection = new BlockingConnection; + $ping = CommandBuilder::build('ping'); + $subscribe = CommandBuilder::build(['subscribe', 'foo']); + $server = new RespServer; + $server->start(function ($client) use ($ping, $subscribe): void { + $this->readExact($client, strlen($ping)); + fwrite($client, "+PONG\r\n"); + $this->readExact($client, strlen($subscribe)); + fwrite($client, "*3\r\n$9\r\nsubscribe\r\n$3\r\nfoo\r\n:1\r\n"); + fread($client, 1); + }); + $invoker = new CommandInvoker(new Connection($server->endpoint())); + + try { + $this->assertSame('pong', $invoker->ping()); + $this->assertSame( + [['subscribe', 'foo', 1]], + $invoker->invoke(['subscribe', 'foo'], 1), + ); + } finally { + $invoker->interrupt(); + $server->wait(); + } + } + public function testArrayPongRoutesAfterSubscription(): void + { + $connection = new ControlledConnection; $invoker = new CommandInvoker($connection); - CoordinatorManager::until(Constants::WORKER_EXIT)->resume(); + try { + $connection->pushResponse(['subscribe', 'foo', 1]); + $this->assertSame( + [['subscribe', 'foo', 1]], + $invoker->invoke(['subscribe', 'foo'], 1), + ); + + $connection->pushResponse(['pong', '']); + $this->assertSame('pong', $invoker->ping()); + } finally { + $invoker->interrupt(); + } + } - usleep(50_000); + public function testAllUnsubscribeAcknowledgementAcceptsANullChannel(): void + { + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + + try { + $connection->pushResponse(['unsubscribe', null, 0]); + + $this->assertSame( + [['unsubscribe', null, 0]], + $invoker->invoke(['unsubscribe'], 1), + ); + } finally { + $invoker->interrupt(); + } + } + + public function testSubscribeAcknowledgementRejectsANullChannel(): void + { + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $connection->pushResponse(['subscribe', null, 0]); + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('malformed Redis subscribe acknowledgement'); + + $invoker->invoke(['subscribe'], 1); + } + + public function testReceiveRoutesMessagesAndPatternMessages(): void + { + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + + try { + $connection->pushResponse(['message', 'foo', "hello\0world"]); + $connection->pushResponse(['pmessage', 'foo.*', 'foo.bar', 'data']); + + $message = $invoker->channel()->pop(1.0); + $patternMessage = $invoker->channel()->pop(1.0); + + $this->assertEquals( + new Message(channel: 'foo', payload: "hello\0world"), + $message, + ); + $this->assertEquals( + new Message(channel: 'foo.bar', payload: 'data', pattern: 'foo.*'), + $patternMessage, + ); + } finally { + $invoker->interrupt(); + } + } + + public function testServerFailurePropagatesToWaitingCommand(): void + { + $exception = new ServerException('ERR authentication failed'); + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $connection->pushResponse($exception); + + try { + $invoker->invoke(['auth', 'secret'], 1); + $this->fail('Expected the server failure to propagate.'); + } catch (ServerException $caught) { + $this->assertSame($exception, $caught); + } + } + + public function testReceiveFailureClosesEveryChannelAndRemainsPrimary(): void + { + $exception = new SocketException('truncated response'); + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $connection->pushResponse($exception); + + try { + $invoker->invoke(['subscribe', 'foo'], 1); + $this->fail('Expected the receive failure to propagate.'); + } catch (SocketException $caught) { + $this->assertSame($exception, $caught); + } $this->assertTrue($connection->wasClosed()); $this->assertFalse($invoker->channel()->pop(0.01)); } - public function testReceiveRoutesMessageToMessageChannel() - { - // Simulate: subscribe confirmation, then a message, then disconnect - $responses = [ - // Subscribe confirmation (*3 array) - "*3\r\n", - "\$9\r\n", - "subscribe\r\n", - "\$3\r\n", - "foo\r\n", - ":1\r\n", - // Message (*3 array with 'message' type — 7 lines total) - "*3\r\n", - "\$7\r\n", - "message\r\n", - "\$3\r\n", - "foo\r\n", - "\$5\r\n", - "hello\r\n", - // Disconnect - false, - ]; - - $connection = $this->createMockConnection($responses); - $connection->shouldReceive('send')->once(); - $connection->shouldReceive('close')->atLeast()->once(); + public function testInvokeAfterReceiveFailureRethrowsTheCauseWithoutSending(): void + { + $exception = new SocketException('truncated response'); + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $connection->pushResponse($exception); + $this->waitUntil(fn (): bool => $connection->wasClosed()); + try { + $invoker->invoke(['subscribe', 'foo'], 1); + $this->fail('Expected the receive failure to propagate.'); + } catch (SocketException $caught) { + $this->assertSame($exception, $caught); + } + + $this->assertSame(0, $connection->sendCount); + } + + public function testPingAfterReceiveFailureRethrowsTheCauseWithoutSending(): void + { + $exception = new SocketException('truncated response'); + $connection = new ControlledConnection; $invoker = new CommandInvoker($connection); + $connection->pushResponse($exception); + $this->waitUntil(fn (): bool => $connection->wasClosed()); - // Send subscribe to consume the confirmation - $invoker->invoke(['subscribe', 'foo'], 1); + try { + $invoker->ping(); + $this->fail('Expected the receive failure to propagate.'); + } catch (SocketException $caught) { + $this->assertSame($exception, $caught); + } - // Pop the message from the message channel - $message = $invoker->channel()->pop(1.0); - - $this->assertInstanceOf(Message::class, $message); - $this->assertSame('foo', $message->channel); - $this->assertSame('hello', $message->payload); - $this->assertNull($message->pattern); - } - - public function testReceiveRoutesPmessageToMessageChannel() - { - // Simulate: psubscribe confirmation, then a pmessage, then disconnect - $responses = [ - // Psubscribe confirmation (*3 array) - "*3\r\n", - "\$10\r\n", - "psubscribe\r\n", - "\$5\r\n", - "foo.*\r\n", - ":1\r\n", - // Pmessage (*4 array with 'pmessage' type — 9 lines total) - "*4\r\n", - "\$8\r\n", - "pmessage\r\n", - "\$5\r\n", - "foo.*\r\n", - "\$7\r\n", - "foo.bar\r\n", - "\$4\r\n", - "data\r\n", - // Disconnect - false, - ]; - - $connection = $this->createMockConnection($responses); - $connection->shouldReceive('send')->once(); - $connection->shouldReceive('close')->atLeast()->once(); + $this->assertSame(0, $connection->sendCount); + } + public function testInvokeAfterCloseThrowsANamedFailureWithoutSending(): void + { + $connection = new ControlledConnection; $invoker = new CommandInvoker($connection); + $invoker->interrupt(); - // Send psubscribe to consume the confirmation - $invoker->invoke(['psubscribe', 'foo.*'], 1); + try { + $invoker->invoke(['subscribe', 'foo'], 1); + $this->fail('Expected the closed connection failure to propagate.'); + } catch (SocketException $exception) { + $this->assertSame('The Redis subscriber connection is closed.', $exception->getMessage()); + } - // Pop the pmessage from the message channel - $message = $invoker->channel()->pop(1.0); + $this->assertSame(0, $connection->sendCount); + } - $this->assertInstanceOf(Message::class, $message); - $this->assertSame('foo.bar', $message->channel); - $this->assertSame('data', $message->payload); - $this->assertSame('foo.*', $message->pattern); + public function testPingAfterCloseThrowsANamedFailureWithoutSending(): void + { + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $invoker->interrupt(); + + try { + $invoker->ping(); + $this->fail('Expected the closed connection failure to propagate.'); + } catch (SocketException $exception) { + $this->assertSame('The Redis subscriber connection is closed.', $exception->getMessage()); + } + + $this->assertSame(0, $connection->sendCount); } - public function testReceiveRoutesPongToPingChannel() + public function testInvokeBlockedAcrossCloseThrowsTheChannelFailure(): void { - // Simulate: pong response, then disconnect - $responses = [ - // Pong (*1 array — 5 lines: *1, $4, pong, $0, empty) - // Actually looking at the code: type = buffer[2], pong check is count==5 - // So it needs: *-something, $4, pong, $0, (empty) - "*1\r\n", - "\$4\r\n", - "pong\r\n", - "\$0\r\n", - "\r\n", - // Disconnect - false, - ]; + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $result = new Channel(1); + go(function () use ($invoker, $result): void { + try { + $invoker->invoke(['subscribe', 'foo'], 1); + } catch (Throwable $exception) { + $result->push($exception); + } + }); + + $this->waitUntil(fn (): bool => $connection->sendCount === 1); + $invoker->interrupt(); + $exception = $result->pop(1.0); + + $this->assertInstanceOf(SocketException::class, $exception); + $this->assertSame( + 'The Redis subscriber command acknowledgement channel was closed.', + $exception->getMessage(), + ); + $this->assertSame(1, $connection->sendCount); + } - $connection = $this->createMockConnection($responses); - $connection->shouldReceive('send')->once(); - $connection->shouldReceive('close')->atLeast()->once(); + public function testAcknowledgementTimeoutInterruptsWhileIdleReceiveIsUnbounded(): void + { + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection, timeout: 0.01); + + usleep(20_000); + $this->assertFalse($connection->wasClosed()); + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('Timed out waiting'); + + try { + $invoker->invoke(['subscribe', 'foo'], 1); + } finally { + $this->assertTrue($connection->wasClosed()); + } + } + public function testPingTimeoutInterruptsTheConnection(): void + { + $connection = new ControlledConnection; $invoker = new CommandInvoker($connection); - // Send ping — the result should come from the ping channel - $result = $invoker->ping(1.0); + $this->expectException(SocketException::class); + $this->expectExceptionMessage('PONG'); - $this->assertSame('pong', $result); + try { + $invoker->ping(0.01); + } finally { + $this->assertTrue($connection->wasClosed()); + } } - public function testReceiveDisconnectsOnEmptyLine() + public function testZeroPingTimeoutWaitsWithoutPolling(): void { - $connection = $this->createMockConnection(['']); - $connection->shouldReceive('close')->atLeast()->once(); + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + go(function () use ($connection): void { + usleep(10_000); + $connection->pushResponse('PONG'); + }); + + try { + $this->assertSame('pong', $invoker->ping(0)); + } finally { + $invoker->interrupt(); + } + } + + public function testMessageCapacityFailureLogsOnceAndRemainsPrimaryWhenLoggingFails(): void + { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldReceive('error') + ->once() + ->andThrow(new RuntimeException('logger failed')); + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection, $logger); + $messageChannel = m::mock(Channel::class); + $messageChannel->shouldReceive('push')->once()->with(m::type(Message::class), 30.0)->andReturn(false); + $messageChannel->shouldReceive('isTimeout')->once()->andReturn(true); + $messageChannel->shouldReceive('close')->once()->andReturn(true); + (new ReflectionProperty(CommandInvoker::class, 'messageChannel')) + ->setValue($invoker, $messageChannel); + $connection->pushResponse(['message', 'foo', 'payload']); + + $this->waitUntil(fn (): bool => $connection->wasClosed()); + + try { + $invoker->invoke(['subscribe', 'foo'], 1); + $this->fail('Expected the channel-capacity failure to propagate.'); + } catch (SocketException $exception) { + $this->assertStringContainsString('remained full for 30 seconds', $exception->getMessage()); + } + } + + public function testConcurrentMessageChannelCloseIsTerminalWithoutLoggingCapacityFailure(): void + { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->shouldNotReceive('error'); + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection, $logger); + $messageChannel = $invoker->channel(); + + for ($index = 0; $index < $messageChannel->getCapacity(); ++$index) { + $messageChannel->push(new Message('buffer', (string) $index)); + } + + $result = new Channel(1); + go(function () use ($invoker, $result): void { + try { + $invoker->invoke(['subscribe', 'foo'], 1); + } catch (Throwable $exception) { + $result->push($exception); + } + }); + + $this->waitUntil(fn (): bool => $connection->sendCount === 1); + + $connection->pushResponse(['message', 'foo', 'payload']); + $this->waitUntil(fn (): bool => $connection->receiveCount === 1); + $messageChannel->close(); + + $exception = $result->pop(1.0); + + $this->assertInstanceOf(SocketException::class, $exception); + $this->assertSame( + 'The Redis subscriber message channel was closed.', + $exception->getMessage(), + ); + $this->assertTrue($connection->wasClosed()); + } + + public function testClosedResultChannelTerminatesRoutingWithoutASecondReceive(): void + { + $connection = new ControlledConnection; $invoker = new CommandInvoker($connection); + $resultChannel = (new ReflectionProperty(CommandInvoker::class, 'resultChannel')) + ->getValue($invoker); + $resultChannel->push('occupied'); + $connection->pushResponse(['subscribe', 'foo', 1]); + $this->waitUntil(fn (): bool => $connection->receiveCount === 1); + $resultChannel->close(); + $this->waitUntil(fn (): bool => $connection->wasClosed()); - // Give the background coroutine time to process - usleep(10_000); + try { + $invoker->invoke(['subscribe', 'foo'], 1); + $this->fail('Expected the result channel failure to propagate.'); + } catch (SocketException $exception) { + $this->assertSame( + 'The Redis subscriber command acknowledgement channel was closed.', + $exception->getMessage(), + ); + } - // Message channel should be closed - $this->assertFalse($invoker->channel()->pop(0.01)); + $this->assertSame(1, $connection->receiveCount); + } + + public function testClosedPingChannelTerminatesRoutingWithoutASecondReceive(): void + { + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $pingChannel = (new ReflectionProperty(CommandInvoker::class, 'pingChannel')) + ->getValue($invoker); + $pingChannel->push('occupied'); + $connection->pushResponse('PONG'); + $this->waitUntil(fn (): bool => $connection->receiveCount === 1); + $pingChannel->close(); + $this->waitUntil(fn (): bool => $connection->wasClosed()); + + try { + $invoker->ping(); + $this->fail('Expected the PING channel failure to propagate.'); + } catch (SocketException $exception) { + $this->assertSame( + 'The Redis subscriber PING channel was closed.', + $exception->getMessage(), + ); + } + + $this->assertSame(1, $connection->receiveCount); + } + + public function testMalformedResponseTerminatesTheSubscriberWithItsCause(): void + { + $connection = new ControlledConnection; + $invoker = new CommandInvoker($connection); + $connection->pushResponse(['message', 'foo', new stdClass]); + + $this->expectException(SocketException::class); + $this->expectExceptionMessage('malformed Redis message'); + + $invoker->invoke(['subscribe', 'foo'], 1); } /** - * Create a mock Connection that returns the given responses from recv(). - * - * Uses andReturnUsing with usleep before the final false response so the - * background coroutine yields, giving the test coroutine time to pop - * messages from the channel before interrupt() closes it. + * Read an exact number of bytes from a test stream. * - * @param array $responses + * @param resource $stream */ - private function createMockConnection(array $responses): Connection - { - $connection = m::mock(Connection::class); - $connection->shouldReceive('recv') - ->andReturnUsing(function () use (&$responses) { - $response = array_shift($responses); - if ($response === false || $response === null) { - // Yield before disconnecting so the test coroutine - // can pop any buffered messages from the channel. - usleep(50_000); - return false; - } - return $response; - }); - - return $connection; + private function readExact(mixed $stream, int $length): string + { + $value = ''; + + while (strlen($value) < $length) { + $chunk = fread($stream, $length - strlen($value)); + + if ($chunk === false || $chunk === '') { + throw new RuntimeException('Failed to read the complete test command.'); + } + + $value .= $chunk; + } + + return $value; + } + + /** + * Wait for a test condition. + */ + private function waitUntil(callable $condition): void + { + for ($attempt = 0; $attempt < 100; ++$attempt) { + if ($condition()) { + return; + } + + usleep(1_000); + } + + $this->fail('Timed out waiting for the test condition.'); } } @@ -270,9 +552,15 @@ public function __construct() $this->gate = new Channel(1); } - public function recv(float $timeout = -1): string|bool + public function receive(): mixed { - return $this->gate->pop($timeout); + $value = $this->gate->pop(); + + if ($value === false) { + throw new SocketException('Connection closed.'); + } + + return $value; } public function close(): void @@ -286,3 +574,69 @@ public function wasClosed(): bool return $this->wasClosed; } } + +class ControlledConnection extends Connection +{ + private readonly Channel $responses; + + private bool $wasClosed = false; + + public int $closeCount = 0; + + public int $receiveCount = 0; + + public int $sendCount = 0; + + public function __construct(private ?Throwable $sendFailure = null) + { + $this->responses = new Channel(10); + } + + public function send(string $data): bool + { + ++$this->sendCount; + + if ($this->sendFailure !== null) { + throw $this->sendFailure; + } + + return true; + } + + public function receive(): mixed + { + $response = $this->responses->pop(); + ++$this->receiveCount; + + if ($response instanceof Throwable) { + throw $response; + } + + if ($response === false) { + throw new SocketException('Connection closed.'); + } + + return $response; + } + + public function pushResponse(mixed $response): void + { + $this->responses->push($response); + } + + public function close(): void + { + if ($this->wasClosed) { + return; + } + + $this->wasClosed = true; + ++$this->closeCount; + $this->responses->close(); + } + + public function wasClosed(): bool + { + return $this->wasClosed; + } +} diff --git a/tests/Redis/Subscriber/SubscriberTest.php b/tests/Redis/Subscriber/SubscriberTest.php index a830d806d..8af52d53f 100644 --- a/tests/Redis/Subscriber/SubscriberTest.php +++ b/tests/Redis/Subscriber/SubscriberTest.php @@ -5,181 +5,211 @@ namespace Hypervel\Tests\Redis\Subscriber; use Hypervel\Engine\Channel; +use Hypervel\Redis\Subscriber\CommandBuilder; use Hypervel\Redis\Subscriber\CommandInvoker; use Hypervel\Redis\Subscriber\Exceptions\SubscribeException; -use Hypervel\Redis\Subscriber\Exceptions\UnsubscribeException; use Hypervel\Redis\Subscriber\Subscriber; +use Hypervel\Tests\Redis\Fixtures\RespServer; use Hypervel\Tests\TestCase; use Mockery as m; +use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; +use RuntimeException; class SubscriberTest extends TestCase { - public function testSubscribeDelegatesToCommandInvoker() + public function testSubscribeDelegatesToCommandInvoker(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['subscribe', 'foo', 'bar'], 2) - ->andReturn([['subscribe'], ['subscribe']]); + ->andReturn([ + ['subscribe', 'foo', 1], + ['subscribe', 'bar', 2], + ]); $subscriber = $this->createSubscriber($invoker); $subscriber->subscribe('foo', 'bar'); } - public function testSubscribePrependsPrefix() + public function testSubscribePrependsPrefix(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['subscribe', 'app:foo', 'app:bar'], 2) - ->andReturn([['subscribe'], ['subscribe']]); + ->andReturn([ + ['subscribe', 'app:foo', 1], + ['subscribe', 'app:bar', 2], + ]); $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); $subscriber->subscribe('foo', 'bar'); } - public function testSubscribeThrowsOnFailure() + public function testSubscribeRejectsAnEmptyChannelListBeforeSending(): void { $invoker = m::mock(CommandInvoker::class); - $invoker->shouldReceive('invoke') - ->once() - ->with(['subscribe', 'foo'], 1) - ->andReturn([false]); - $invoker->shouldReceive('interrupt')->once(); + $invoker->shouldNotReceive('invoke'); $subscriber = $this->createSubscriber($invoker); $this->expectException(SubscribeException::class); - $this->expectExceptionMessage('Subscribe failed'); + $this->expectExceptionMessage('At least one Redis channel is required'); - $subscriber->subscribe('foo'); + $subscriber->subscribe(); } - public function testUnsubscribeDelegatesToCommandInvoker() + public function testUnsubscribeDelegatesToCommandInvoker(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['unsubscribe', 'foo'], 1) - ->andReturn([['unsubscribe']]); + ->andReturn([['unsubscribe', 'foo', 0]]); $subscriber = $this->createSubscriber($invoker); $subscriber->unsubscribe('foo'); } - public function testUnsubscribePrependsPrefix() + public function testUnsubscribePrependsPrefix(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['unsubscribe', 'app:foo'], 1) - ->andReturn([['unsubscribe']]); + ->andReturn([['unsubscribe', 'app:foo', 0]]); $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); $subscriber->unsubscribe('foo'); } - public function testUnsubscribeThrowsOnFailure() + public function testUnsubscribeAllWaitsForEveryTrackedChannelAndKeepsPatternsSeparate(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() - ->with(['unsubscribe', 'foo'], 1) - ->andReturn([false]); - $invoker->shouldReceive('interrupt')->once(); + ->with(['subscribe', 'app:one', 'app:two'], 2) + ->andReturn([ + ['subscribe', 'app:one', 1], + ['subscribe', 'app:two', 2], + ]); + $invoker->shouldReceive('invoke') + ->once() + ->with(['psubscribe', 'app:events.*'], 1) + ->andReturn([['psubscribe', 'app:events.*', 3]]); + $invoker->shouldReceive('invoke') + ->once() + ->with(['unsubscribe'], 2) + ->andReturn([ + ['unsubscribe', 'app:one', 2], + ['unsubscribe', 'app:two', 1], + ]); + $invoker->shouldReceive('invoke') + ->once() + ->with(['unsubscribe'], 1) + ->andReturn([['unsubscribe', null, 1]]); + $invoker->shouldReceive('invoke') + ->once() + ->with(['punsubscribe'], 1) + ->andReturn([['punsubscribe', 'app:events.*', 0]]); - $subscriber = $this->createSubscriber($invoker); + $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); + $subscriber->subscribe('one', 'two'); + $subscriber->psubscribe('events.*'); + $subscriber->unsubscribe(); + $subscriber->unsubscribe(); + $subscriber->punsubscribe(); + } - $this->expectException(UnsubscribeException::class); - $this->expectExceptionMessage('Unsubscribe failed'); + public function testAllUnsubscribeWithNoTrackedChannelsWaitsForOneAcknowledgement(): void + { + $invoker = m::mock(CommandInvoker::class); + $invoker->shouldReceive('invoke') + ->once() + ->with(['unsubscribe'], 1) + ->andReturn([['unsubscribe', null, 0]]); - $subscriber->unsubscribe('foo'); + $this->createSubscriber($invoker)->unsubscribe(); } - public function testPsubscribeDelegatesToCommandInvoker() + public function testPsubscribeDelegatesToCommandInvoker(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['psubscribe', 'foo.*', 'bar.*'], 2) - ->andReturn([['psubscribe'], ['psubscribe']]); + ->andReturn([ + ['psubscribe', 'foo.*', 1], + ['psubscribe', 'bar.*', 2], + ]); $subscriber = $this->createSubscriber($invoker); $subscriber->psubscribe('foo.*', 'bar.*'); } - public function testPsubscribePrependsPrefix() + public function testPsubscribePrependsPrefix(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['psubscribe', 'app:events.*'], 1) - ->andReturn([['psubscribe']]); + ->andReturn([['psubscribe', 'app:events.*', 1]]); $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); $subscriber->psubscribe('events.*'); } - public function testPsubscribeThrowsOnFailure() + public function testPsubscribeRejectsAnEmptyPatternListBeforeSending(): void { $invoker = m::mock(CommandInvoker::class); - $invoker->shouldReceive('invoke') - ->once() - ->with(['psubscribe', 'foo.*'], 1) - ->andReturn([false]); - $invoker->shouldReceive('interrupt')->once(); + $invoker->shouldNotReceive('invoke'); $subscriber = $this->createSubscriber($invoker); $this->expectException(SubscribeException::class); - $this->expectExceptionMessage('Psubscribe failed'); + $this->expectExceptionMessage('At least one Redis channel pattern is required'); - $subscriber->psubscribe('foo.*'); + $subscriber->psubscribe(); } - public function testPunsubscribeDelegatesToCommandInvoker() + public function testPunsubscribeDelegatesToCommandInvoker(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['punsubscribe', 'foo.*'], 1) - ->andReturn([['punsubscribe']]); + ->andReturn([['punsubscribe', 'foo.*', 0]]); $subscriber = $this->createSubscriber($invoker); $subscriber->punsubscribe('foo.*'); } - public function testPunsubscribePrependsPrefix() + public function testPunsubscribePrependsPrefix(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() ->with(['punsubscribe', 'app:foo.*'], 1) - ->andReturn([['punsubscribe']]); + ->andReturn([['punsubscribe', 'app:foo.*', 0]]); $subscriber = $this->createSubscriber($invoker, prefix: 'app:'); $subscriber->punsubscribe('foo.*'); } - public function testPunsubscribeThrowsOnFailure() + public function testAllPunsubscribeWithNoTrackedPatternsWaitsForOneAcknowledgement(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('invoke') ->once() - ->with(['punsubscribe', 'foo.*'], 1) - ->andReturn([false]); - $invoker->shouldReceive('interrupt')->once(); - - $subscriber = $this->createSubscriber($invoker); + ->with(['punsubscribe'], 1) + ->andReturn([['punsubscribe', null, 0]]); - $this->expectException(UnsubscribeException::class); - $this->expectExceptionMessage('Punsubscribe failed'); - - $subscriber->punsubscribe('foo.*'); + $this->createSubscriber($invoker)->punsubscribe(); } - public function testChannelDelegatesToCommandInvoker() + public function testChannelDelegatesToCommandInvoker(): void { $channel = new Channel(1); $invoker = m::mock(CommandInvoker::class); @@ -190,7 +220,7 @@ public function testChannelDelegatesToCommandInvoker() $this->assertSame($channel, $subscriber->channel()); } - public function testCloseSetsClosedAndInterrupts() + public function testCloseSetsClosedAndInterrupts(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('interrupt')->once()->andReturn(true); @@ -204,7 +234,7 @@ public function testCloseSetsClosedAndInterrupts() $this->assertTrue($subscriber->closed); } - public function testPingDelegatesToCommandInvoker() + public function testPingDelegatesToCommandInvoker(): void { $invoker = m::mock(CommandInvoker::class); $invoker->shouldReceive('ping')->once()->with(2.5)->andReturn('pong'); @@ -214,6 +244,73 @@ public function testPingDelegatesToCommandInvoker() $this->assertSame('pong', $subscriber->ping(2.5)); } + #[DataProvider('credentialProvider')] + public function testConnectForwardsCompleteCredentialShapes( + string|array $password, + ?string $username, + array $expectedCredentials, + ): void { + $command = CommandBuilder::build(['auth', ...$expectedCredentials]); + $received = ''; + $server = new RespServer; + $server->start(function ($client) use ($command, &$received): void { + $received = $this->readExact($client, strlen($command)); + fwrite($client, "+OK\r\n"); + fread($client, 1); + }); + $subscriber = new Subscriber( + host: $server->endpoint(), + password: $password, + username: $username, + ); + + try { + $this->assertSame($command, $received); + } finally { + $subscriber->close(); + $server->wait(); + } + } + + public static function credentialProvider(): array + { + return [ + 'scalar password' => ['secret', null, ['secret']], + 'zero password' => ['0', null, ['0']], + 'credential array' => [['user', 'secret'], null, ['user', 'secret']], + 'zero credential array' => [['0', '0'], null, ['0', '0']], + 'zero username and password' => ['0', '0', ['0', '0']], + ]; + } + + #[DataProvider('absentCredentialProvider')] + public function testConnectDoesNotAuthenticateWithoutCredentials(?string $password): void + { + $received = null; + $server = new RespServer; + $server->start(static function ($client) use (&$received): void { + $received = fread($client, 1); + }); + $subscriber = new Subscriber( + host: $server->endpoint(), + password: $password, + username: 'unused', + ); + + $subscriber->close(); + $server->wait(); + + $this->assertSame('', $received); + } + + public static function absentCredentialProvider(): array + { + return [ + 'null' => [null], + 'empty string' => [''], + ]; + } + /** * Create a Subscriber with a mock CommandInvoker, bypassing the real connection. */ @@ -224,13 +321,38 @@ private function createSubscriber(CommandInvoker $invoker, string $prefix = ''): $subscriber->host = '127.0.0.1'; $subscriber->port = 6379; - $subscriber->password = ''; + $subscriber->password = null; $subscriber->timeout = 5.0; $subscriber->prefix = $prefix; + $subscriber->username = null; + $subscriber->scheme = null; + $subscriber->context = []; $subscriber->closed = false; $reflection->getProperty('commandInvoker')->setValue($subscriber, $invoker); return $subscriber; } + + /** + * Read an exact number of bytes from a test stream. + * + * @param resource $stream + */ + private function readExact(mixed $stream, int $length): string + { + $value = ''; + + while (strlen($value) < $length) { + $chunk = fread($stream, $length - strlen($value)); + + if ($chunk === false || $chunk === '') { + throw new RuntimeException('Failed to read the complete test command.'); + } + + $value .= $chunk; + } + + return $value; + } } From 9c6e12eccb65c83d047088618e5a409479fb1689 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:16 +0000 Subject: [PATCH 06/15] fix(redis): preserve limiter callback failures Always release acquired concurrency leases while keeping the callback failure primary when release also fails. Apply the same direct control flow to both public callback paths without adding a shared abstraction, and distinguish acquisition timeouts from user callbacks that happen to throw the same exception type. Cover the complete callback and release success/failure matrix at both call sites. --- src/redis/src/Limiters/ConcurrencyLimiter.php | 20 ++++++- .../Limiters/ConcurrencyLimiterBuilder.php | 21 ++++++- tests/Redis/ConcurrencyLimiterBuilderTest.php | 59 ++++++++++++++++++- tests/Redis/ConcurrencyLimiterTest.php | 48 +++++++++++++++ 4 files changed, 141 insertions(+), 7 deletions(-) diff --git a/src/redis/src/Limiters/ConcurrencyLimiter.php b/src/redis/src/Limiters/ConcurrencyLimiter.php index 773f32335..ea159c9f4 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiter.php +++ b/src/redis/src/Limiters/ConcurrencyLimiter.php @@ -94,11 +94,27 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 250 $lease = $this->acquire($timeout, $sleep); if (is_callable($callback)) { + $callbackException = null; + + try { + $result = $callback(); + } catch (Throwable $exception) { + $callbackException = $exception; + } + try { - return $callback(); - } finally { $lease->release(); + } catch (Throwable $exception) { + if ($callbackException === null) { + throw $exception; + } } + + if ($callbackException !== null) { + throw $callbackException; + } + + return $result; } return true; diff --git a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php index b4337993d..e74611e88 100644 --- a/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php +++ b/src/redis/src/Limiters/ConcurrencyLimiterBuilder.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Limiters\LimiterTimeoutException; use Hypervel\Redis\RedisProxy; use Hypervel\Support\InteractsWithTime; +use Throwable; class ConcurrencyLimiterBuilder { @@ -113,11 +114,27 @@ public function then(callable $callback, ?callable $failure = null): mixed throw $e; } + $callbackException = null; + + try { + $result = $callback(); + } catch (Throwable $exception) { + $callbackException = $exception; + } + try { - return $callback(); - } finally { $lease->release(); + } catch (Throwable $exception) { + if ($callbackException === null) { + throw $exception; + } + } + + if ($callbackException !== null) { + throw $callbackException; } + + return $result; } /** diff --git a/tests/Redis/ConcurrencyLimiterBuilderTest.php b/tests/Redis/ConcurrencyLimiterBuilderTest.php index acd1d880d..2b76b109f 100644 --- a/tests/Redis/ConcurrencyLimiterBuilderTest.php +++ b/tests/Redis/ConcurrencyLimiterBuilderTest.php @@ -10,6 +10,7 @@ use Hypervel\Redis\RedisProxy; use Hypervel\Tests\TestCase; use Mockery as m; +use RuntimeException; /** * Tests for ConcurrencyLimiterBuilder. @@ -109,6 +110,56 @@ public function testThenExecutesCallbackWhenLockAcquired(): void $this->assertSame('success', $result); } + public function testThenPropagatesReleaseFailureAfterSuccessfulCallback(): void + { + $redis = $this->mockRedis(); + $releaseException = new RuntimeException('release failed'); + + $redis->shouldReceive('eval') + ->once() + ->andReturn('test-key1'); + $redis->shouldReceive('eval') + ->once() + ->andThrow($releaseException); + + $builder = new ConcurrencyLimiterBuilder($redis, 'test-key'); + $builder->limit(5)->block(0); + + try { + $builder->then(fn (): string => 'success'); + + $this->fail('Expected the release failure to be thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($releaseException, $exception); + } + } + + public function testThenPreservesCallbackFailureWhenReleaseAlsoFails(): void + { + $redis = $this->mockRedis(); + $callbackException = new RuntimeException('callback failed'); + + $redis->shouldReceive('eval') + ->once() + ->andReturn('test-key1'); + $redis->shouldReceive('eval') + ->once() + ->andThrow(new RuntimeException('release failed')); + + $builder = new ConcurrencyLimiterBuilder($redis, 'test-key'); + $builder->limit(5)->block(0); + + try { + $builder->then(function () use ($callbackException): never { + throw $callbackException; + }); + + $this->fail('Expected the callback failure to be thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($callbackException, $exception); + } + } + public function testThenCallsFailureCallbackOnTimeout(): void { $redis = $this->mockRedis(); @@ -178,11 +229,12 @@ public function testThenDoesNotRouteCallbackTimeoutExceptionToFailureCallback(): $builder->limit(5)->block(0); $failureCalled = false; + $callbackException = new LimiterTimeoutException; try { $builder->then( - function () { - throw new LimiterTimeoutException; + function () use ($callbackException): never { + throw $callbackException; }, function () use (&$failureCalled) { $failureCalled = true; @@ -190,7 +242,8 @@ function () use (&$failureCalled) { ); $this->fail('Expected LimiterTimeoutException was not thrown.'); - } catch (LimiterTimeoutException) { + } catch (LimiterTimeoutException $exception) { + $this->assertSame($callbackException, $exception); } $this->assertFalse($failureCalled); diff --git a/tests/Redis/ConcurrencyLimiterTest.php b/tests/Redis/ConcurrencyLimiterTest.php index 4de948ca0..8a675b55d 100644 --- a/tests/Redis/ConcurrencyLimiterTest.php +++ b/tests/Redis/ConcurrencyLimiterTest.php @@ -96,6 +96,54 @@ public function testBlockReleasesLockWhenCallbackThrows(): void }); } + public function testBlockPropagatesReleaseFailureAfterSuccessfulCallback(): void + { + $redis = $this->mockRedis(); + $releaseException = new RuntimeException('release failed'); + + $redis->shouldReceive('eval') + ->once() + ->andReturn('test-lock1'); + $redis->shouldReceive('eval') + ->once() + ->andThrow($releaseException); + + $limiter = new ConcurrencyLimiter($redis, 'test-lock', 3, 60); + + try { + $limiter->block(5, fn (): string => 'callback-result'); + + $this->fail('Expected the release failure to be thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($releaseException, $exception); + } + } + + public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(): void + { + $redis = $this->mockRedis(); + $callbackException = new RuntimeException('callback failed'); + + $redis->shouldReceive('eval') + ->once() + ->andReturn('test-lock1'); + $redis->shouldReceive('eval') + ->once() + ->andThrow(new RuntimeException('release failed')); + + $limiter = new ConcurrencyLimiter($redis, 'test-lock', 3, 60); + + try { + $limiter->block(5, function () use ($callbackException): never { + throw $callbackException; + }); + + $this->fail('Expected the callback failure to be thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($callbackException, $exception); + } + } + public function testBlockThrowsTimeoutExceptionWhenCannotAcquire(): void { $redis = $this->mockRedis(); From f703ec1ad9c4989d1e2130e35f1cfe08060b7490 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:22 +0000 Subject: [PATCH 07/15] fix(reverb): publish independently of subscriber state Send scaling messages directly through the pooled Redis publisher instead of gating them on the unrelated subscriber connection and retaining an unbounded outage queue. Propagate encoding and Redis failures truthfully, return the actual publish count, and remove the exact pending metric and listener when a metrics request cannot be published while preserving the original failure. Cover disconnected subscribers, Redis outages, response races, publish counts, and cleanup failure precedence. --- .../src/Protocols/Pusher/MetricsHandler.php | 29 +++++--- .../Hypervel/Scaling/RedisPubSubProvider.php | 45 +----------- .../Protocols/Pusher/MetricsHandlerTest.php | 66 ++++++++++++++++- .../Scaling/RedisPubSubProviderTest.php | 70 +++++++++---------- 4 files changed, 116 insertions(+), 94 deletions(-) diff --git a/src/reverb/src/Protocols/Pusher/MetricsHandler.php b/src/reverb/src/Protocols/Pusher/MetricsHandler.php index 23d681585..df0c56dfc 100644 --- a/src/reverb/src/Protocols/Pusher/MetricsHandler.php +++ b/src/reverb/src/Protocols/Pusher/MetricsHandler.php @@ -11,6 +11,7 @@ use Hypervel\Reverb\Servers\Hypervel\Contracts\PubSubProvider; use Hypervel\Support\Str; use Swoole\Coroutine\Channel; +use Throwable; class MetricsHandler { @@ -141,19 +142,27 @@ protected function gatherMetricsFromSubscribers(PendingMetric $metric): array } }); - // Publish uses scalar payloads (Decision 17) - $subscriberCount = $this->pubSubProvider->publish([ - 'type' => 'metrics_request', - 'request_id' => $metric->key(), - 'app_id' => $metric->application()->id(), - 'metric_type' => $metric->type()->value, - 'options' => $metric->options(), - ]); + try { + $subscriberCount = $this->pubSubProvider->publish([ + 'type' => 'metrics_request', + 'request_id' => $metric->key(), + 'app_id' => $metric->application()->id(), + 'metric_type' => $metric->type()->value, + 'options' => $metric->options(), + ]); + } catch (Throwable $exception) { + try { + $this->stopListening($metric); + } catch (Throwable) { + // Cleanup must not replace the publish failure. + } + + throw $exception; + } $metric->setSubscriberCount($subscriberCount); - // Fix race condition (Decision 16e): check if already resolvable - // after setting subscriber count, before blocking on pop() + // Responses may arrive before publish() returns the subscriber count. if ($metric->resolvable()) { $this->stopListening($metric); diff --git a/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php b/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php index 2307b39ab..c8350af5d 100644 --- a/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php +++ b/src/reverb/src/Servers/Hypervel/Scaling/RedisPubSubProvider.php @@ -10,7 +10,6 @@ use Hypervel\Reverb\Servers\Hypervel\Contracts\PubSubIncomingMessageHandler; use Hypervel\Reverb\Servers\Hypervel\Contracts\PubSubProvider; use Hypervel\Support\Sleep; -use JsonException; use Throwable; use function Hypervel\Coroutine\go; @@ -40,13 +39,6 @@ class RedisPubSubProvider implements PubSubProvider */ protected int $retryTimer = 0; - /** - * Publishes queued while disconnected. - * - * @var list - */ - protected array $queuedPublishes = []; - /** * Create a new Redis pub/sub provider instance. */ @@ -60,9 +52,8 @@ public function __construct( /** * Connect to Redis and start subscribing. * - * Uses the injected Redis connection's subscriber() factory to create - * a Subscriber with the same host, port, password, and prefix as the - * connection used for publishing. + * Uses the injected Redis connection's subscriber() factory so the + * dedicated subscriber inherits its topology, credentials, and prefix. */ public function connect(): void { @@ -82,7 +73,6 @@ public function connect(): void $this->subscriber = $subscriber; $this->subscribedChannel = $subscribedChannel; - $this->processQueuedPublishes(); if (! $this->shouldRetry() || $this->subscriber !== $subscriber) { $this->clearSubscriber($subscriber); @@ -184,12 +174,6 @@ public function stopListening(string $event): void */ public function publish(array $payload): int { - if ($this->subscriber === null) { - $this->queuedPublishes[] = $payload; - - return 0; - } - return (int) $this->redis->publish($this->channel, json_encode($payload, JSON_THROW_ON_ERROR)); } @@ -222,31 +206,6 @@ protected function reconnect(): void $this->connect(); } - /** - * Process any publishes that were queued during disconnection. - */ - protected function processQueuedPublishes(): void - { - while ($this->queuedPublishes !== [] - && $this->shouldRetry() - && $this->subscriber !== null - ) { - $payload = $this->queuedPublishes[0]; - - try { - $encoded = json_encode($payload, JSON_THROW_ON_ERROR); - } catch (JsonException $exception) { - array_shift($this->queuedPublishes); - Log::error('Discarding invalid queued Reverb payload: ' . $exception->getMessage()); - - continue; - } - - $this->redis->publish($this->channel, $encoded); - array_shift($this->queuedPublishes); - } - } - /** * Determine whether reconnect work remains enabled. * diff --git a/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php b/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php index 4d95b847b..46472ae47 100644 --- a/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php +++ b/tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php @@ -12,6 +12,7 @@ use Hypervel\Tests\Reverb\Fixtures\FakeConnection; use Hypervel\Tests\Reverb\ReverbTestCase; use Mockery as m; +use RuntimeException; class MetricsHandlerTest extends ReverbTestCase { @@ -342,9 +343,8 @@ public function testScalingGatherChannelUsersDeduplicatesAcrossSubscribers() public function testScalingResolvesImmediatelyWhenResponsesArriveBeforePop() { - // This tests the race condition fix (Decision 16e): if all responses - // arrive during publish() (before pop()), the handler should resolve - // immediately without blocking on the coroutine channel. + // Responses may arrive during publish(), before the handler knows how + // many subscribers must respond. $app = $this->app->make(ApplicationProvider::class)->all()->first(); $handler = $this->scalingMetricsHandler([ @@ -359,4 +359,64 @@ public function testScalingResolvesImmediatelyWhenResponsesArriveBeforePop() $this->assertTrue($result['occupied']); $this->assertSame(5, $result['subscription_count']); } + + public function testScalingPublishFailureRemovesPendingMetricAndListener(): void + { + $app = $this->app->make(ApplicationProvider::class)->all()->first(); + $failure = new RuntimeException('Redis publish failed.'); + $serverManager = m::mock(ServerProviderManager::class); + $serverManager->expects('subscribesToEvents')->andReturnTrue(); + $pubSub = m::mock(PubSubProvider::class); + $pubSub->expects('on'); + $pubSub->expects('publish')->andThrow($failure); + $pubSub->expects('stopListening'); + $handler = new MetricsHandlerProbe( + $serverManager, + $this->app->make(ChannelManager::class), + $pubSub, + ); + + try { + $handler->gather($app, 'connections'); + $this->fail('Expected metrics publishing to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([], $handler->metricsForTest()); + } + + public function testScalingPublishFailureRemainsPrimaryWhenListenerCleanupFails(): void + { + $app = $this->app->make(ApplicationProvider::class)->all()->first(); + $failure = new RuntimeException('Redis publish failed.'); + $serverManager = m::mock(ServerProviderManager::class); + $serverManager->expects('subscribesToEvents')->andReturnTrue(); + $pubSub = m::mock(PubSubProvider::class); + $pubSub->expects('on'); + $pubSub->expects('publish')->andThrow($failure); + $pubSub->expects('stopListening')->andThrow(new RuntimeException('Listener cleanup failed.')); + $handler = new MetricsHandlerProbe( + $serverManager, + $this->app->make(ChannelManager::class), + $pubSub, + ); + + try { + $handler->gather($app, 'connections'); + $this->fail('Expected metrics publishing to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + + $this->assertSame([], $handler->metricsForTest()); + } +} + +class MetricsHandlerProbe extends MetricsHandler +{ + public function metricsForTest(): array + { + return $this->metrics; + } } diff --git a/tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php b/tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php index 6339ba552..695a61a73 100644 --- a/tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php +++ b/tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php @@ -4,7 +4,6 @@ namespace Hypervel\Tests\Reverb\Servers\Hypervel\Scaling; -use Hypervel\Engine\Channel; use Hypervel\Redis\RedisProxy; use Hypervel\Redis\Subscriber\Subscriber; use Hypervel\Reverb\Contracts\Logger; @@ -13,6 +12,7 @@ use Hypervel\Reverb\Servers\Hypervel\Scaling\RedisPubSubProvider; use Hypervel\Support\Sleep; use Hypervel\Tests\Reverb\ReverbTestCase; +use JsonException; use Mockery as m; use PHPUnit\Framework\Attributes\RunInSeparateProcess; use RuntimeException; @@ -93,48 +93,47 @@ function () use ($provider): void { $this->assertTrue($subscriber->closed); } - public function testQueuedPublishesDropInvalidJsonAndRetainTransientFailuresInOrder(): void + public function testPublishUsesIndependentRedisConnectionWithoutSubscriber(): void { - $firstSubscriber = $this->subscriber(); - $firstSubscriber->shouldReceive('subscribe')->once()->with('reverb'); - $firstSubscriber->shouldReceive('close')->once()->andReturnUsing(function () use ($firstSubscriber): void { - $firstSubscriber->closed = true; - }); - - $secondSubscriber = $this->subscriber(); - $secondSubscriber->shouldReceive('subscribe')->once()->with('reverb'); - $secondSubscriber->shouldReceive('channel')->once()->andReturnUsing(static function (): Channel { - $channel = new Channel(1); - $channel->close(); + $redis = m::mock(RedisProxy::class); + $redis->expects('publish')->with('reverb', '{"id":1}')->andReturn(3); + $provider = $this->provider($redis); - return $channel; - }); - $secondSubscriber->shouldReceive('close')->once()->andReturnUsing(function () use ($secondSubscriber): void { - $secondSubscriber->closed = true; - }); + $this->assertSame(3, $provider->publish(['id' => 1])); + $this->assertNull($provider->subscriberForTest()); + } + public function testPublishFailureDoesNotRetainThePayload(): void + { + $failure = new RuntimeException('redis unavailable'); $redis = m::mock(RedisProxy::class); - $redis->shouldReceive('subscriber')->twice()->andReturn($firstSubscriber, $secondSubscriber); - $redis->shouldReceive('publish') - ->once() + $redis->expects('publish') ->with('reverb', '{"id":1}') - ->andThrow(new RuntimeException('transient publish failure')); - $redis->shouldReceive('publish')->once()->with('reverb', '{"id":1}')->andReturn(1); - $redis->shouldReceive('publish')->once()->with('reverb', '{"id":2}')->andReturn(1); + ->andThrow($failure); + $redis->expects('publish') + ->with('reverb', '{"id":2}') + ->andReturn(1); $provider = $this->provider($redis); - $provider->publish(['invalid' => NAN]); - $provider->publish(['id' => 1]); - $provider->publish(['id' => 2]); - $provider->connect(); + try { + $provider->publish(['id' => 1]); + $this->fail('Expected Redis publishing to fail.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } - $this->assertSame([['id' => 1], ['id' => 2]], $provider->queuedPublishesForTest()); + $this->assertSame(1, $provider->publish(['id' => 2])); + } - $provider->connect(); + public function testPublishPropagatesEncodingFailureWithoutCallingRedis(): void + { + $redis = m::mock(RedisProxy::class); + $redis->shouldNotReceive('publish'); + $provider = $this->provider($redis); - $this->assertSame([], $provider->queuedPublishesForTest()); - $this->assertTrue($firstSubscriber->closed); - $this->assertTrue($secondSubscriber->closed); + $this->expectException(JsonException::class); + + $provider->publish(['invalid' => NAN]); } protected function provider(RedisProxy $redis): RedisPubSubProviderProbe @@ -165,11 +164,6 @@ public function subscriberForTest(): ?Subscriber return $this->subscriber; } - public function queuedPublishesForTest(): array - { - return $this->queuedPublishes; - } - protected function reconnect(): void { ++$this->reconnectCount; From 23e7988f053db19d849ded8582c6017dd400be31 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:28 +0000 Subject: [PATCH 08/15] fix(horizon): preserve complete Redis topology Copy the configured Hypervel Redis connection instead of reading Laravel's incompatible top-level cluster shape. Hash-tag clustered Horizon prefixes and publish the resolved prefix at both configuration precedence levels so RedisConfig cannot restore a stale source prefix. Verify standalone and Cluster topology, pool settings, credentials, prefix precedence, fallback behavior, and missing connections through the real normalized consumer path. --- src/horizon/src/Horizon.php | 23 +++- .../Horizon/Feature/RedisPrefixTest.php | 124 +++++++++++++++++- 2 files changed, 141 insertions(+), 6 deletions(-) diff --git a/src/horizon/src/Horizon.php b/src/horizon/src/Horizon.php index aae16b0f8..ae5def203 100644 --- a/src/horizon/src/Horizon.php +++ b/src/horizon/src/Horizon.php @@ -7,6 +7,7 @@ use Closure; use Exception; use Hypervel\Http\Request; +use Hypervel\Redis\RedisConnection; use Hypervel\Support\HtmlString; use Hypervel\Support\Js; use RuntimeException; @@ -80,15 +81,27 @@ public static function auth(Closure $callback): static */ public static function use(string $connection): void { - if (! is_null($config = config("database.redis.clusters.{$connection}.0"))) { - config(["database.redis.{$connection}" => $config]); - } elseif (is_null($config = config("database.redis.{$connection}"))) { + $config = config("database.redis.{$connection}"); + + if (! is_array($config)) { throw new Exception("Redis connection [{$connection}] has not been configured."); } - $config['options']['prefix'] = config('horizon.prefix') ?: 'horizon:'; + $prefix = config('horizon.prefix') ?: 'horizon:'; + + if (($config['cluster']['enable'] ?? false) + && ! RedisConnection::hasHashTag($prefix)) { + $prefix = '{' . $prefix . '}'; + } + + // RedisConfig gives the top-level connection prefix final precedence. + $config['prefix'] = $prefix; + $config['options']['prefix'] = $prefix; - config(['database.redis.horizon' => $config]); + config([ + 'horizon.prefix' => $prefix, + 'database.redis.horizon' => $config, + ]); } /** diff --git a/tests/Integration/Horizon/Feature/RedisPrefixTest.php b/tests/Integration/Horizon/Feature/RedisPrefixTest.php index 2016ef064..5435f93f6 100644 --- a/tests/Integration/Horizon/Feature/RedisPrefixTest.php +++ b/tests/Integration/Horizon/Feature/RedisPrefixTest.php @@ -4,17 +4,139 @@ namespace Hypervel\Tests\Integration\Horizon\Feature; +use Exception; use Hypervel\Horizon\Horizon; +use Hypervel\Redis\RedisConfig; +use Hypervel\Redis\RedisConnection; use Hypervel\Tests\Integration\Horizon\IntegrationTestCase; class RedisPrefixTest extends IntegrationTestCase { - public function testPrefixCanBeConfigured() + public function testPrefixCanBeConfigured(): void { config(['horizon.prefix' => 'custom:']); Horizon::use('default'); $this->assertSame('custom:', config('database.redis.horizon.options.prefix')); + $this->assertSame('custom:', config('horizon.prefix')); + } + + public function testUseThrowsForUnknownConnection(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessage('Redis connection [missing] has not been configured.'); + + Horizon::use('missing'); + } + + public function testStandaloneConnectionConfigurationIsPreserved(): void + { + $connection = [ + 'host' => 'redis.example.com', + 'username' => 'horizon', + 'password' => 'secret', + 'port' => 6380, + 'database' => 4, + 'timeout' => 2.5, + 'context' => ['stream' => ['verify_peer' => true]], + 'pool' => ['max_connections' => 20], + 'prefix' => 'application:', + 'options' => ['scan' => 1], + ]; + + config([ + 'database.redis.horizon-source' => $connection, + 'horizon.prefix' => 'standalone:', + ]); + + Horizon::use('horizon-source'); + + $connection['prefix'] = 'standalone:'; + $connection['options']['prefix'] = 'standalone:'; + + $this->assertSame($connection, config('database.redis.horizon')); + $this->assertSame('standalone:', config('horizon.prefix')); + $this->assertSame( + 'standalone:', + $this->app->make(RedisConfig::class)->connectionConfig('horizon')['options']['prefix'], + ); + } + + public function testClusterConnectionConfigurationAndHashTaggedPrefixArePreserved(): void + { + $connection = [ + 'username' => 'horizon', + 'password' => 'secret', + 'database' => 4, + 'cluster' => [ + 'enable' => true, + 'name' => 'horizon-cluster', + 'seeds' => ['redis-1.example.com:6379', 'redis-2.example.com:6379'], + 'context' => ['stream' => ['verify_peer' => true]], + ], + 'pool' => ['max_connections' => 20], + 'prefix' => 'application:', + 'options' => ['scan' => 1], + ]; + + config([ + 'database.redis.horizon-cluster' => $connection, + 'horizon.prefix' => 'cluster:', + ]); + + Horizon::use('horizon-cluster'); + + $connection['prefix'] = '{cluster:}'; + $connection['options']['prefix'] = '{cluster:}'; + + $this->assertSame($connection, config('database.redis.horizon')); + $this->assertSame('{cluster:}', config('horizon.prefix')); + $this->assertTrue(RedisConnection::hasHashTag(config('horizon.prefix'))); + $this->assertSame( + '{cluster:}', + $this->app->make(RedisConfig::class)->connectionConfig('horizon')['options']['prefix'], + ); + } + + public function testClusterPrefixIsNotDoubleTagged(): void + { + config([ + 'database.redis.horizon-cluster' => [ + 'cluster' => [ + 'enable' => true, + 'name' => 'horizon-cluster', + 'seeds' => ['redis.example.com:6379'], + ], + ], + 'horizon.prefix' => '{application}:horizon:', + ]); + + Horizon::use('horizon-cluster'); + + $this->assertSame('{application}:horizon:', config('horizon.prefix')); + $this->assertSame( + '{application}:horizon:', + config('database.redis.horizon.options.prefix'), + ); + } + + public function testClusterConnectionUsesHashTaggedFallbackPrefix(): void + { + config([ + 'database.redis.horizon-cluster' => [ + 'cluster' => [ + 'enable' => true, + 'name' => 'horizon-cluster', + 'seeds' => ['redis.example.com:6379'], + ], + ], + 'horizon.prefix' => '', + ]); + + Horizon::use('horizon-cluster'); + + $this->assertSame('{horizon:}', config('horizon.prefix')); + $this->assertSame('{horizon:}', config('database.redis.horizon.options.prefix')); } } From 05ccb2175d75f93d073f5abafabc56dd1a3b99f4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:36 +0000 Subject: [PATCH 09/15] fix(telescope): observe Redis commands without user code Format Redis parameters recursively using scalar values and debug types so observation cannot invoke JsonSerializable, string conversion, or other user code after a successful command. Ignore the real pipeline and MULTI opener events strictly and case-insensitively instead of filtering a dead transaction name. Cover nested parameters, objects, closures, resources, throwing serializers, mixed-case batch openers, and boot-time event disabling. --- src/telescope/src/Watchers/RedisWatcher.php | 59 +++++----- .../Watchers/DisabledWatcherTest.php | 15 ++- tests/Telescope/Watchers/RedisWatcherTest.php | 104 +++++++++++++++--- 3 files changed, 129 insertions(+), 49 deletions(-) diff --git a/src/telescope/src/Watchers/RedisWatcher.php b/src/telescope/src/Watchers/RedisWatcher.php index dad9c9bd3..81328c491 100644 --- a/src/telescope/src/Watchers/RedisWatcher.php +++ b/src/telescope/src/Watchers/RedisWatcher.php @@ -7,8 +7,7 @@ use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Foundation\Application; use Hypervel\Redis\Events\CommandExecuted; -use Hypervel\Redis\RedisConfig; -use Hypervel\Support\Collection; +use Hypervel\Redis\RedisManager; use Hypervel\Telescope\IncomingEntry; use Hypervel\Telescope\Telescope; @@ -36,18 +35,14 @@ public function register(Application $app): void * Enable Redis events. * * Boot-only. Must be called before Redis connections are created. Mutates - * process-global config and a static flag; runtime use races across - * coroutines. + * a worker-wide Redis event override and a static flag; runtime use races + * across coroutines. * * This function needs to be called before the Redis connection is created. */ public static function enableRedisEvents(Application $app): void { - $config = $app->make('config'); - $redisConfig = $app->make(RedisConfig::class); - foreach ($redisConfig->connectionNames() as $connection) { - $config->set("database.redis.{$connection}.event.enable", true); - } + $app->make(RedisManager::class)->enableEvents(); static::$eventsEnabled = true; } @@ -73,32 +68,44 @@ public function recordCommand(CommandExecuted $event): void */ private function formatCommand(string $command, array $parameters): string { - $parameters = Collection::make($parameters)->map(function ($parameter) { - if (is_array($parameter)) { - return Collection::make($parameter)->map(function ($value, $key) { - if (is_array($value)) { - return json_encode($value); - } - - return is_int($key) ? $value : "{$key} {$value}"; - })->implode(' '); + $formatted = []; + + foreach ($parameters as $parameter) { + $formatted[] = $this->formatParameter($parameter); + } + + return $command . ' ' . implode(' ', $formatted); + } + + /** + * Format one Redis command parameter. + */ + private function formatParameter(mixed $parameter): string + { + if (is_array($parameter)) { + $values = []; + + foreach ($parameter as $key => $value) { + $formatted = $this->formatParameter($value); + $values[] = is_int($key) ? $formatted : "{$key} {$formatted}"; } - return $parameter; - })->implode(' '); + return implode(' ', $values); + } + + if ($parameter === null || is_scalar($parameter)) { + return (string) $parameter; + } - return "{$command} {$parameters}"; + return get_debug_type($parameter); } /** * Determine if the event should be ignored. */ - private function shouldIgnore(mixed $event): bool + private function shouldIgnore(CommandExecuted $event): bool { - return in_array($event->command, [ - 'pipeline', - 'transaction', - ]); + return in_array(strtolower($event->command), ['pipeline', 'multi'], true); } /** diff --git a/tests/Telescope/Watchers/DisabledWatcherTest.php b/tests/Telescope/Watchers/DisabledWatcherTest.php index 3589c7c79..7d867c9c3 100644 --- a/tests/Telescope/Watchers/DisabledWatcherTest.php +++ b/tests/Telescope/Watchers/DisabledWatcherTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Telescope\Watchers; use Hypervel\Contracts\Foundation\Application as ApplicationContract; +use Hypervel\Redis\RedisConfig; use Hypervel\Telescope\Watchers\CacheWatcher; use Hypervel\Telescope\Watchers\RedisWatcher; use Hypervel\Testbench\Attributes\WithConfig; @@ -27,7 +28,7 @@ protected function defineEnvironment(ApplicationContract $app): void 'hidden' => [], ], ])] - public function testDisabledCacheWatcherDoesNotEnableCacheEvents() + public function testDisabledCacheWatcherDoesNotEnableCacheEvents(): void { $config = $this->app->make('config'); @@ -47,14 +48,16 @@ public function testDisabledCacheWatcherDoesNotEnableCacheEvents() #[WithConfig('database.redis.foo', [ 'host' => '127.0.0.1', 'port' => 6379, - 'db' => 0, + 'database' => 0, + 'event' => [ + 'enable' => false, + ], ])] - public function testDisabledRedisWatcherDoesNotEnableRedisEvents() + public function testDisabledRedisWatcherDoesNotEnableRedisEvents(): void { - $config = $this->app->make('config'); - $this->assertFalse( - $config->get('database.redis.foo.event.enable', false), + $this->app->make(RedisConfig::class) + ->connectionConfig('foo')['event']['enable'], 'Redis connection should not have events enabled when RedisWatcher is disabled.' ); } diff --git a/tests/Telescope/Watchers/RedisWatcherTest.php b/tests/Telescope/Watchers/RedisWatcherTest.php index 90731d952..67dfed3a9 100644 --- a/tests/Telescope/Watchers/RedisWatcherTest.php +++ b/tests/Telescope/Watchers/RedisWatcherTest.php @@ -8,12 +8,15 @@ use Hypervel\Contracts\Foundation\Application; use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\PhpRedisConnection; +use Hypervel\Redis\RedisConfig; use Hypervel\Telescope\EntryType; use Hypervel\Telescope\Watchers\RedisWatcher; use Hypervel\Testbench\Attributes\WithConfig; use Hypervel\Tests\Telescope\FeatureTestCase; +use JsonSerializable; use Mockery as m; use ReflectionClass; +use RuntimeException; #[WithConfig('telescope.watchers', [ RedisWatcher::class => true, @@ -21,19 +24,19 @@ #[WithConfig('database.redis.foo', [ 'host' => '127.0.0.1', 'port' => 6379, - 'db' => 0, + 'database' => 0, ])] class RedisWatcherTest extends FeatureTestCase { - public function testRegisterEnableRedisEvents() + public function testRegisterEnablesRedisEventsForFuturePools(): void { $this->assertTrue( - $this->app->make('config') - ->get('database.redis.foo.event.enable', false) + $this->app->make(RedisConfig::class) + ->connectionConfig('foo')['event']['enable'] ); } - public function testFlushStateDisablesRedisEvents() + public function testFlushStateDisablesRedisEvents(): void { RedisWatcher::enableRedisEvents($this->app); $this->assertTrue($this->eventsAreEnabled()); @@ -43,18 +46,9 @@ public function testFlushStateDisablesRedisEvents() $this->assertFalse($this->eventsAreEnabled()); } - public function testRedisWatcherRegistersEntries() + public function testRedisWatcherRegistersEntries(): void { - $connection = m::mock(PhpRedisConnection::class); - $connection->shouldReceive('getName')->andReturn('connection'); - - $this->app->make(Dispatcher::class) - ->dispatch(new CommandExecuted( - 'command', - ['foo', 'bar'], - 0.0123, - $connection, - )); + $this->dispatchRedisCommand('command', ['foo', 'bar']); $entry = $this->loadTelescopeEntries()->first(); @@ -64,7 +58,61 @@ public function testRedisWatcherRegistersEntries() $this->assertSame('0.01', $entry->content['time']); } - public function testDoesNotRegisterWhenRedisUnbound() + public function testRedisWatcherFormatsNestedScalarArrays(): void + { + $this->dispatchRedisCommand('command', [ + ['first', 'named' => 'second', 'nested' => ['third', 'fourth']], + ]); + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame( + 'command first named second nested third fourth', + $entry->content['command'], + ); + } + + public function testRedisWatcherFormatsNonScalarParametersWithoutInvokingUserCode(): void + { + $stream = fopen('php://memory', 'r+'); + $json = new RedisWatcherThrowingJsonSerializable; + + try { + $this->dispatchRedisCommand('command', [ + $json, + ['nested' => $json], + static function (): void {}, + $stream, + ]); + } finally { + fclose($stream); + } + + $entry = $this->loadTelescopeEntries()->first(); + + $this->assertSame( + 'command ' + . RedisWatcherThrowingJsonSerializable::class + . ' nested ' + . RedisWatcherThrowingJsonSerializable::class + . ' Closure resource (stream)', + $entry->content['command'], + ); + } + + public function testRedisWatcherIgnoresBatchOpenersCaseInsensitively(): void + { + $this->dispatchRedisCommand('PiPeLiNe'); + $this->dispatchRedisCommand('MuLtI'); + $this->dispatchRedisCommand('GeT', ['key']); + + $entries = $this->loadTelescopeEntries(); + + $this->assertCount(1, $entries); + $this->assertSame('GeT key', $entries->first()->content['command']); + } + + public function testDoesNotRegisterWhenRedisUnbound(): void { $app = m::mock(Application::class); @@ -82,8 +130,30 @@ public function testDoesNotRegisterWhenRedisUnbound() $watcher->register($app); } + private function dispatchRedisCommand(string $command, array $parameters = []): void + { + $connection = m::mock(PhpRedisConnection::class); + $connection->shouldReceive('getName')->andReturn('connection'); + + $this->app->make(Dispatcher::class) + ->dispatch(new CommandExecuted( + $command, + $parameters, + 0.0123, + $connection, + )); + } + private function eventsAreEnabled(): bool { return (new ReflectionClass(RedisWatcher::class))->getStaticPropertyValue('eventsEnabled'); } } + +class RedisWatcherThrowingJsonSerializable implements JsonSerializable +{ + public function jsonSerialize(): mixed + { + throw new RuntimeException('The formatter must not invoke user code.'); + } +} From b5d519947c47ec49999c3356ca49d0eb8009c1f3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:42 +0000 Subject: [PATCH 10/15] fix(sentry): report the configured Redis database Read Redis span metadata from the normalized database key used by real Hypervel connections instead of the test-only db key. Correct the masking fixture and verify that both successful and failed commands retain a non-zero configured database index. --- src/sentry/src/Features/RedisFeature.php | 12 +++----- .../Sentry/Features/RedisIntegrationTest.php | 29 ++++++++++++++----- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/src/sentry/src/Features/RedisFeature.php b/src/sentry/src/Features/RedisFeature.php index 9e156e234..8227c11c5 100644 --- a/src/sentry/src/Features/RedisFeature.php +++ b/src/sentry/src/Features/RedisFeature.php @@ -11,6 +11,7 @@ use Hypervel\Redis\Events\CommandFailed; use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Redis\RedisConfig; +use Hypervel\Redis\RedisManager; use Hypervel\Sentry\Features\Concerns\ResolvesEventOrigin; use Hypervel\Support\Str; use Sentry\SentrySdk; @@ -36,12 +37,7 @@ public function isApplicable(): bool public function onBoot(): void { - $config = $this->container->make('config'); - $redisConfig = $this->container->make(RedisConfig::class); - - foreach ($redisConfig->connectionNames() as $connection) { - $config->set("database.redis.{$connection}.event.enable", true); - } + $this->container->make(RedisManager::class)->enableEvents(); $dispatcher = $this->container->make('events'); $dispatcher->listen(CommandExecuted::class, [$this, 'handleRedisCommands']); @@ -79,7 +75,7 @@ public function handleRedisCommands(CommandExecuted $event): void 'db.system' => 'redis', 'db.statement' => $redisStatement, 'db.redis.connection' => $event->connectionName, - 'db.redis.database_index' => $config['db'] ?? 0, + 'db.redis.database_index' => (int) ($config['database'] ?? 0), 'db.redis.parameters' => $event->parameters, 'db.redis.pool.name' => $event->connectionName, 'db.redis.pool.max' => $pool->getOption()->getMaxConnections(), @@ -143,7 +139,7 @@ public function handleFailedRedisCommands(CommandFailed $event): void 'db.system' => 'redis', 'db.statement' => $redisStatement, 'db.redis.connection' => $event->connectionName, - 'db.redis.database_index' => $config['db'] ?? 0, + 'db.redis.database_index' => (int) ($config['database'] ?? 0), 'db.redis.parameters' => $event->parameters, 'db.redis.pool.name' => $event->connectionName, 'db.redis.pool.max' => $pool->getOption()->getMaxConnections(), diff --git a/tests/Sentry/Features/RedisIntegrationTest.php b/tests/Sentry/Features/RedisIntegrationTest.php index 1f0b32ac2..a3f1d6948 100644 --- a/tests/Sentry/Features/RedisIntegrationTest.php +++ b/tests/Sentry/Features/RedisIntegrationTest.php @@ -12,6 +12,7 @@ use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\Pool\PoolFactory; use Hypervel\Redis\Pool\RedisPool; +use Hypervel\Redis\RedisConfig; use Hypervel\Redis\RedisConnection; use Hypervel\Sentry\Features\RedisFeature; use Hypervel\Tests\Sentry\SentryTestCase; @@ -44,6 +45,20 @@ public function testFeatureIsApplicableWhenRedisCommandsTracingIsEnabled(): void $this->assertTrue($feature->isApplicable()); } + public function testFeatureEnablesRedisEventsForFuturePools(): void + { + $this->app->make('config')->set('database.redis.observed', [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'database' => 0, + ]); + + $this->assertTrue( + $this->app->make(RedisConfig::class) + ->connectionConfig('observed')['event']['enable'], + ); + } + public function testFeatureIsNotApplicableWhenRedisCommandsTracingIsDisabled(): void { $this->resetApplicationWithConfig([ @@ -241,12 +256,12 @@ public function testRedisFeatureWorksAfterReplacingStaleGlobalHub(): void public function testFailedRedisCommandCreatesErrorSpanWithTime(): void { - $this->setupMocks(); + $this->setupMocks('cache', 2); $transaction = $this->startTransaction(); $dispatcher = $this->app->make(Dispatcher::class); - $connection = $this->createRedisConnection('default'); + $connection = $this->createRedisConnection('cache'); $exception = new Exception('Connection refused'); $event = new CommandFailed('GET', ['test-key'], $exception, $connection, 0.005); @@ -263,9 +278,9 @@ public function testFailedRedisCommandCreatesErrorSpanWithTime(): void $spanData = $redisSpan->getData(); $this->assertEquals('redis', $spanData['db.system']); $this->assertEquals('Connection refused', $spanData['db.redis.error']); - $this->assertEquals('default', $spanData['db.redis.connection']); - $this->assertEquals(0, $spanData['db.redis.database_index']); - $this->assertEquals('default', $spanData['db.redis.pool.name']); + $this->assertEquals('cache', $spanData['db.redis.connection']); + $this->assertEquals(2, $spanData['db.redis.database_index']); + $this->assertEquals('cache', $spanData['db.redis.pool.name']); $this->assertEquals(10, $spanData['db.redis.pool.max']); $this->assertEquals(0.005, $spanData['duration']); } @@ -308,11 +323,11 @@ private function setupMocks(string $connectionName = 'default', int $database = $this->app->instance(PoolFactory::class, $poolFactory); - $config = $this->app['config']; + $config = $this->app->make('config'); $config->set("database.redis.{$connectionName}", [ 'host' => '127.0.0.1', 'port' => 6379, - 'db' => $database, + 'database' => $database, ]); } From 0a6624fd38483c38e4b2839b557b8e8b187bd628 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:51 +0000 Subject: [PATCH 11/15] docs(redis): describe pooled transport behavior Document the current phpredis options, connection-local standalone, Sentinel, and Cluster topology, exact subscriber transport, macros, and boot-only event controls. Remove the unsafe automatic replay claim and explain the deliberate pooled RESET, sharded subscription, and connector-extension differences with the supported alternatives. Keep the guide task-focused while preserving Hypervel-specific transforms, SafeScan, raw held access, and native retry and backoff behavior. --- src/boost/docs/redis.md | 71 ++++++++++++++++++++++++++++++++++++++--- src/redis/README.md | 13 ++++++-- 2 files changed, 76 insertions(+), 8 deletions(-) diff --git a/src/boost/docs/redis.md b/src/boost/docs/redis.md index 7d7b76a7a..5eed0a83d 100644 --- a/src/boost/docs/redis.md +++ b/src/boost/docs/redis.md @@ -12,6 +12,8 @@ - [Connection Pooling](#connection-pooling) - [Interacting With Redis](#interacting-with-redis) - [Using Multiple Redis Connections](#using-multiple-redis-connections) + - [Registering Redis Macros](#registering-redis-macros) + - [Redis Command Events](#redis-command-events) - [Holding a Pooled Connection](#holding-a-pooled-connection) - [Pinned Connections](#pinned-connections) - [Checking Cluster Connections](#checking-cluster-connections) @@ -128,10 +130,12 @@ By default, Redis connections will use the `tcp` scheme when connecting to your ], ``` +When no scheme is specified, a non-empty `context` configuration also selects TLS. + ### PhpRedis -Hypervel communicates with Redis using the PhpRedis extension. In addition to the default configuration options, Hypervel supports the following connection parameters: `url`, `scheme`, `host`, `username`, `password`, `port`, `database`, `timeout`, `retry_interval`, `read_timeout`, `context`, `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. +Hypervel communicates with Redis using the PhpRedis extension. In addition to the default configuration options, Hypervel supports the following connection parameters: `url`, `scheme`, `host`, `username`, `password`, `port`, `database`, `name`, `timeout`, `retry_interval`, `read_timeout`, `context`, `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap`. ```php 'default' => [ @@ -144,13 +148,16 @@ Hypervel communicates with Redis using the PhpRedis extension. In addition to th 'timeout' => 5.0, 'retry_interval' => 0, 'read_timeout' => 60, + 'name' => 'hypervel', 'context' => [ // 'stream' => ['verify_peer' => false], ], ], ``` -The `read_timeout` value is applied both when the Redis socket is opened and as the PhpRedis `Redis::OPT_READ_TIMEOUT` option. If you need to configure PhpRedis options such as a serializer, compression, or key prefix, add them to the `options` array. +The `read_timeout` value is applied both when the Redis socket is opened and as the PhpRedis `Redis::OPT_READ_TIMEOUT` option. The optional `name` value sets the client name on standalone Redis connections. + +The `context` option accepts stream options directly or nested under an `ssl` or `stream` key. If you need to configure PhpRedis options such as `prefix`, `scan`, `serializer`, `compression`, `compression_level`, `tcp_keepalive`, or `pack_ignore_numbers`, add them to the `options` array. The `pack_ignore_numbers` option requires PhpRedis 6.2 or later and applies only to standalone connections. Connection options override shared options, while a top-level connection `prefix` takes final precedence. #### Retry and Backoff Configuration @@ -172,7 +179,7 @@ The `max_retries`, `backoff_algorithm`, `backoff_base`, and `backoff_cap` option ], ``` -Hypervel also reconnects and retries a failed Redis command once when PhpRedis throws a `RedisException` from a pooled connection. +These settings control PhpRedis' native connection retry behavior. Hypervel does not replay a failed command because Redis may already have committed it before the failure became visible to the client. #### Unix Socket Connections @@ -228,6 +235,8 @@ If your application is utilizing Redis Cluster, you should define a `cluster` ar The `seeds` option should contain one or more `host:port` entries for nodes in the cluster. Redis Cluster does not support selecting logical databases, so the `database` option is ignored for clustered connections. +Cluster context accepts stream options directly or nested under an `ssl` or `stream` key. You may configure `failover` in the connection's `options` array using one of PhpRedis' `RedisCluster::FAILOVER_*` constants. + Hypervel automatically hash-tags Redis queue storage keys and Redis funnel slot keys when a clustered connection is used and the configured queue or funnel name does not already contain a valid Redis Cluster hash tag. This keeps all keys used by Hypervel's multi-key Lua scripts on the same hash slot. You may still provide your own hash tag, such as `{orders}:high`, when you need to control key placement. @@ -249,12 +258,32 @@ Redis Sentinel provides high availability for Redis by monitoring your Redis mas 'persistent' => '', 'read_timeout' => 5.0, 'auth' => env('REDIS_SENTINEL_PASSWORD'), + 'context' => [ + // 'ssl' => ['verify_peer' => false], + ], ], ], ``` When Sentinel is enabled, Hypervel asks Sentinel for the current master address and then connects to that Redis master. The `auth` value in the `sentinel` array is used to authenticate with Sentinel itself; Redis authentication still uses the connection's top-level `username` and `password` values. +Sentinel nodes may use `tcp://` or `tls://` schemes. IPv6 addresses must use brackets, including when TLS is enabled: + +```php +'sentinel' => [ + 'enable' => true, + 'master_name' => 'mymaster', + 'nodes' => ['tls://[::1]:26379'], + 'context' => [ + 'ssl' => [ + 'verify_peer' => true, + ], + ], +], +``` + +The `sentinel.context` option accepts TLS stream options directly or nested under an `ssl` or `stream` key. These options secure the Sentinel connection; the top-level connection scheme and context configure the resolved Redis master. + ### Connection Pooling @@ -345,6 +374,35 @@ You may disconnect a named Redis connection and flush its pool using the `purge` Redis::purge('cache'); ``` + +#### Registering Redis Macros + +You may register custom Redis connection methods using the `macro` method: + +```php +use Hypervel\Support\Facades\Redis; + +Redis::macro('getMany', function (array $keys): array { + return $this->mget($keys); +}); + +$values = Redis::getMany(['first', 'second']); +``` + +Macros should be registered during application boot. A macro call is recorded as one Redis command event; native commands executed by the macro are not recorded separately. + + +#### Redis Command Events + +Redis command events may be enabled or disabled during application boot: + +```php +Redis::enableEvents(); +Redis::disableEvents(); +``` + +Redis pools snapshot their event configuration when they are created, so these methods should be called before any Redis connection is used. Existing pools are not changed. + #### Holding a Pooled Connection @@ -549,6 +607,9 @@ Redis::pipeline(function (\Redis $pipe): void { > [!NOTE] > In Hypervel, the closure form of `pipeline` returns the pooled connection as soon as the pipeline is executed. Calling `Redis::pipeline()` without a closure pins a connection for the rest of the coroutine, so the closure form is preferred for application code. +> [!WARNING] +> The native `reset()` command is not available on pooled connections because it clears authentication and selected database state owned by the pool. Use `discard()` to abandon a transaction, `unwatch()` to stop watching keys, or `exec()` to complete a pipeline. + ### Advanced Helpers @@ -645,7 +706,7 @@ Route::get('/publish', function () { }); ``` -Hypervel's pooled Redis connections cannot execute raw `subscribe` or `psubscribe` commands directly because pub/sub requires a long-lived dedicated socket. The `Redis::subscribe`, `Redis::psubscribe`, and `Redis::subscriber` methods create a dedicated subscriber socket instead of using the connection pool. +Hypervel's pooled Redis connections cannot execute raw `subscribe`, `psubscribe`, or `ssubscribe` commands directly because Pub/Sub requires a long-lived dedicated socket. The `Redis::subscribe`, `Redis::psubscribe`, and `Redis::subscriber` methods create a dedicated subscriber socket instead of using the connection pool. Sharded Pub/Sub is not supported. #### Wildcard Subscriptions @@ -685,4 +746,4 @@ try { } ``` -The subscriber supports `subscribe`, `unsubscribe`, `psubscribe`, `punsubscribe`, `ping`, `channel`, and `close` methods. Messages received from pattern subscriptions include the matched pattern on the message's `pattern` property. +The subscriber supports `subscribe`, `unsubscribe`, `psubscribe`, `punsubscribe`, `ping`, `channel`, and `close` methods. It uses the selected connection's standalone, Sentinel, or Cluster topology and supports TCP, TLS, IPv4, IPv6, and Unix sockets. Message payloads are returned as the exact bytes sent by Redis, including embedded newlines and null bytes. Messages received from pattern subscriptions include the matched pattern on the message's `pattern` property. diff --git a/src/redis/README.md b/src/redis/README.md index 909292bc5..7c7efddc4 100644 --- a/src/redis/README.md +++ b/src/redis/README.md @@ -3,8 +3,15 @@ Redis for Hypervel [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/redis) -## Differences From Laravel +Ported from: https://github.com/hyperf/hyperf/tree/master/src/redis -`Redis::funnel()->acquire()` returns a caller-held concurrency lease that can be refreshed and released explicitly after work spanning multiple operations. Laravel only exposes the callback-scoped funnel API. +## Differences From Laravel -Redis funnel and throttle timeout failures throw `Hypervel\Contracts\Limiters\LimiterTimeoutException`, shared with cache funnels. Laravel uses `Illuminate\Contracts\Redis\LimiterTimeoutException` for Redis limiters. +- Hypervel uses phpredis-only pooled connections. Cluster and Sentinel settings belong to each named connection instead of Laravel's top-level cluster configuration. +- Hypervel never automatically replays a command after a Redis failure because the server may already have committed it. Native phpredis retry and backoff options remain supported. +- Redis connection macros are observed as one proxy operation; native commands run inside a macro do not emit separate command events. +- Laravel's connector-driver `extend()` and `setDriver()` APIs are intentionally omitted. Use Redis macros for custom commands or `withConnection()` for explicit access to a held connection. +- Native `subscribe()`, `psubscribe()`, and `ssubscribe()` calls are unavailable on pooled connections. Use the dedicated subscriber for ordinary channel and pattern subscriptions; sharded Pub/Sub is not supported. +- Native `reset()` is unavailable on pooled connections because it clears authentication and database state owned by the pool. Use `discard()`, `unwatch()`, or `exec()` to finish the corresponding stateful operation. +- `Redis::funnel()->acquire()` returns a caller-held concurrency lease that can be refreshed and released explicitly after work spanning multiple operations. Laravel only exposes the callback-scoped funnel API. +- Redis funnel and throttle timeout failures throw `Hypervel\Contracts\Limiters\LimiterTimeoutException`, shared with cache funnels. Laravel uses `Illuminate\Contracts\Redis\LimiterTimeoutException` for Redis limiters. From d0b81f4e5517844f730b0cc5520f1fdbe2e89e0e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 25 Jul 2026 23:47:58 +0000 Subject: [PATCH 12/15] docs(audit): close the Redis lifecycle work unit Mark Redis complete and record the verified command, subscriber, topology, consumer, metadata, performance, and retained-state findings with their final owning boundaries. Close the carried Redis and pool revalidation items, route the remaining Cache limiter and named-Redis consumer checks to their active or later audits, and make Cache the next work unit. Record the complete validation and review evidence without claiming that pending consumer audits are already complete. --- ...amework-coroutine-state-lifecycle-audit.md | 43 ++++++++++++------- ...-coroutine-state-lifecycle-audit-ledger.md | 38 +++++++++++++++- 2 files changed, 63 insertions(+), 18 deletions(-) diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md index eb3988862..d062cfd16 100644 --- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md +++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md @@ -990,8 +990,8 @@ An exceptionally large shared work unit may receive its own linked detail plan w This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md). -- **Active package or work unit:** `database` -- **Ledger entries required for the active work:** `Release cleared coordinator timers deterministically`; `Bound pool resources and connection progress deterministically`; `Normalize framework enum identifiers at string boundaries`; `Complete Foundation runtime lifecycles and safe publication`; `Complete Console command, scheduling, and generator lifecycles`; `Complete Database persistence lifecycles and current Laravel parity`. +- **Active package or work unit:** `cache` +- **Ledger entries required for the active work:** `Coordinate shared container construction and complete current contextual resolution`; `Normalize framework enum identifiers at string boundaries`; `Harden filesystem I/O, streaming, and response teardown`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`. - **Pending revalidation carried into the active work:** None. Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread. @@ -1030,13 +1030,13 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `concurrency-03` | `concurrency`, `foundation`, `testbench` | `foundation` (revalidation complete); later full `testbench` audit | `Make process concurrency transport lossless and reconstruct failures safely`; finding `concurrency-03` | | `pool-01` | `pool` | `coordinator` (revalidation complete); later full `pool` audit | `Release cleared coordinator timers deterministically`; finding `pool-01` | | `pool-02` | `pool` | later full `pool` audit | `Release cleared coordinator timers deterministically`; finding `pool-02` | -| `pool-04` | `pool`, `database`, `redis` | `database` and `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `pool-04` | -| `pool-05` | `pool` | `database` and `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `pool-05` | +| `pool-04` | `pool`, `database`, `redis` | `database` and `redis` (revalidation complete) | `Bound pool resources and connection progress deterministically`; finding `pool-04` | +| `pool-05` | `pool` | `database` and `redis` (revalidation complete) | `Bound pool resources and connection progress deterministically`; finding `pool-05` | | `database-02` | `database` | `pool` and `database` (revalidation complete) | `Bound pool resources and connection progress deterministically`; finding `database-02` | -| `redis-02` | `redis` | `pool` and `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `redis-02` | -| `pool-08` | `pool`, `redis` | `redis` (revalidation complete); later full `redis` audit | `Bound pool resources and connection progress deterministically`; finding `pool-08` | +| `redis-02` | `redis` | `pool` and `redis` (revalidation complete) | `Bound pool resources and connection progress deterministically`; finding `redis-02` | +| `pool-08` | `pool`, `redis` | `redis` (revalidation complete) | `Bound pool resources and connection progress deterministically`; finding `pool-08` | | `database-01` | `database` | `database` (revalidation complete) | `Release cleared coordinator timers deterministically`; finding `database-01` | -| `redis-01` | `redis` | `redis` (revalidation complete); later full `redis` audit | `Release cleared coordinator timers deterministically`; finding `redis-01` | +| `redis-01` | `redis` | `redis` (revalidation complete) | `Release cleared coordinator timers deterministically`; finding `redis-01` | | `di-02` | `di` | `foundation` (revalidation complete); later full `sentry` and `telescope` audits | `Correct AOP proxy generation and publication`; finding `di-02` | | `filesystem-02` | `filesystem` | `di` and `filesystem` (revalidation complete) | `Correct AOP proxy generation and publication`; finding `filesystem-02` | | `filesystem-03` | `filesystem` | `encryption`, `support`, and `filesystem` (revalidation complete) | `Harden encryption rotation, key publication, and global lifecycle state`; finding `filesystem-03` | @@ -1079,16 +1079,27 @@ Add one row only for a shared finding or changed lower-level assumption that ano | `database-04` | `database` | `console` and `database` (revalidation complete) | `Complete Console command, scheduling, and generator lifecycles`; finding `database-04` | | `reverb-04` | `reverb` | later full `reverb` audit | `Complete Console command, scheduling, and generator lifecycles`; finding `reverb-04` | | `watcher-10` | `support` | `watcher`, `foundation`, and `horizon` (revalidation complete) | `Make Watcher drivers and managed processes lifecycle-safe`; finding `watcher-10` | -| `database-05` | `core`, `database` | `redis` (revalidation complete); later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-05`; sibling finding `redis-03` | -| `database-06` | `core`, `server`, `database` | `server` and `redis` (revalidation complete); later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-06`; sibling finding `redis-05` | +| `database-05` | `core`, `database` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-05`; sibling finding `redis-03` | +| `database-06` | `core`, `server`, `database` | `server` and `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-06`; sibling finding `redis-05` | | `database-08` | `database` | `foundation`, `testing`, and `testbench` (revalidation complete); later full `testing` and `testbench` audits | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-08` | | `database-10` | `database` | `scout` and `nested-set` (revalidation complete); later full consumer audits | `Complete Database persistence lifecycles and current Laravel parity`; finding `database-10` | -| `redis-03` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-03` | -| `redis-04` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-04` | -| `redis-05` | `redis`, `core`, `server` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-05` | -| `redis-06` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-06` | -| `redis-07` | `redis` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-07` | -| `redis-08` | `redis`, `pool` | later full `redis` audit | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-08` | +| `redis-03` | `redis` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-03` | +| `redis-04` | `redis` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-04` | +| `redis-05` | `redis`, `core`, `server` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-05` | +| `redis-06` | `redis` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-06` | +| `redis-07` | `redis` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-07` | +| `redis-08` | `redis`, `pool` | `redis` (revalidation complete) | `Complete Database persistence lifecycles and current Laravel parity`; finding `redis-08` | +| `redis-09` | `redis` | `cache`; later full `cache` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-09` | +| `redis-10` | `redis` | `reverb` (revalidation complete); later full `reverb` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-10` | +| `redis-11` | `redis` | `reverb` (revalidation complete); later full `reverb` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-11` | +| `redis-12` | `redis`, `cache` | `redis` (revalidation complete); later full `cache` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-12` | +| `redis-13` | `redis` | `horizon` (revalidation complete), `cache`, `queue`, `session`, and `broadcasting`; later full consumer audits | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-13` | +| `reverb-05` | `reverb` | `redis` (revalidation complete); later full `reverb` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `reverb-05` | +| `redis-15` | `redis` | `telescope` and `sentry` (revalidation complete); later full consumer audits | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `redis-15` | +| `horizon-01` | `horizon` | `redis` (revalidation complete); later full `horizon` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `horizon-01` | +| `telescope-01` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-01` | +| `telescope-02` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-02` | +| `sentry-01` | `sentry` | `redis` (revalidation complete); later full `sentry` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `sentry-01` | ## Package checklist @@ -1158,7 +1169,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen ### Persistence, transport, and background execution - [x] `database` -- [ ] `redis` +- [x] `redis` - [ ] `cache` - [ ] `session` - [ ] `queue` diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index b44632fcd..6a2bf9bd8 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -646,7 +646,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `pool-01` | Defect | Major | High | `ConstantFrequency`'s timer callback retains the frequency and its pool, so its destructor cannot clear the timer; closing a supported custom pool leaves the timer, callback, and pool graph alive until worker exit | Add a separate clearable-frequency capability, clear it deterministically from `Pool::close()` before draining connections, and remove the ineffective destructor without changing `LowFrequencyInterface` | | `pool-02` | Maintainability | Minor | High | `KeepaliveConnection`'s timer callback retains the connection, so its destructor cannot run while cleanup is useful and only repeats the supported `close()` path after the timer is gone | Remove the dead destructor; retain `close()` as the explicit connection and heartbeat teardown boundary | | `database-01` | Maintainability | Minor | High | `DbPool`'s heartbeat callback retains the pool, making its destructor unreachable while the timer is live and redundant after `close()` or callback self-termination | Remove the dead destructor; retain `close()` and `clearHeartbeat()` as the deterministic owner path, and revalidate during the later Database audit | -| `redis-01` | Maintainability | Minor | High | `RedisPool` has the same unreachable heartbeat destructor and already clears or self-stops the timer through explicit lifecycle paths | Remove the dead destructor; retain `close()` and `clearHeartbeat()` as the deterministic owner path, and revalidate during the later Redis audit | +| `redis-01` | Maintainability | Minor | High | `RedisPool` has the same unreachable heartbeat destructor and already clears or self-stops the timer through explicit lifecycle paths | Remove the dead destructor; retain `close()` and `clearHeartbeat()` as the deterministic owner path; revalidated by the full Redis audit | - **Timer ownership boundary:** A timer entry starts with a zero coroutine-ID sentinel before spawn, publishes the real child ID after successful creation, and removes itself in the child `finally`. A separate waiting marker is set only around an actual `Coordinator::yield()` call. This lets another coroutine wake a timer parked in framework-owned waiting while ensuring `clear()` never injects cancellation into a user callback that yields. The child rechecks registration before waiting or invoking the callback, covering re-entrant creation hooks without adding a handshake or publication protocol. `clearAll()` snapshots registered IDs and delegates to the same exact-owner cleanup. - **Pool ownership boundary:** `ConstantFrequency`'s timer child retains its callback, which retains the frequency and pool, so the destructor cannot become reachable while live cleanup is still possible. `Pool::close()` is the supported terminal owner boundary: it establishes the closed state, clears only strategies implementing the additive `ClearableFrequencyInterface`, then closes the channel and drains idle connections. A custom strategy's cleanup failure is reported through the existing contained reporter and cannot abort the independent channel and connection teardown; the idempotency guard would otherwise make that partial close unrecoverable. Resource-free and custom `LowFrequencyInterface` implementations remain unchanged. Abandoning a pool without calling `close()` remains outside the supported lifecycle; breaking that reference cycle would require a disproportionate redesign and neither the rejected callback self-poll nor the old destructor would solve it. @@ -1153,7 +1153,41 @@ Append package entries in checklist order. Keep each entry compact but complete - **Performance and compatibility:** Normal Database queries gain no container lookup, context operation, lock, retry, logging, yield, or registry. Stable model construction adds one static owner-map `isset()`, and first boot alone reads the coroutine ID and may use the existing Mutex. Redis release adds one local extension-state read and one boolean; same-connection publication adds one owner-ID comparison only when publishing a pin. Per-key cast merging and indexed raw-SQL substitution remove work. SQLite normalization occurs at setup, lifecycle events are cold task/start boundaries, and exhaustive cleanup is exceptional or terminal. Public Laravel APIs remain intact; supported current APIs are restored, while internal Swoole adaptations remain at their lowest owners. - **Implementation:** Added exact task and pre-fork lifecycle events and Database/Redis listeners; replaced reflective task cleanup with exact resolver/proxy ownership; deduplicated Redis terminal defers without delaying callback release; made Redis event cleanup, WATCH, native DISCARD, mode checks, purge, and fork cleanup truthful; centralized SQLite classification and serialized shared-memory ownership; corrected RefreshDatabase and parallel/Testbench consumers; repaired transaction publication, retry, rollback, callback, disconnect, and query-log state; made first Eloquent boot coroutine-safe; ported the complete accepted current Laravel Database surface and both approved performance corrections; completed split metadata, provenance, omission markers, facade metadata, and concise user documentation; and removed every superseded listener, literal classifier, dependency, comment, and test assumption. - **Regression tests:** Deterministic coverage spans task and fork failure precedence; exact Database and Redis wrapper ownership; copied-context and callback-immediate Redis release; real MULTI/PIPELINE/WATCH/DISCARD state; full SQLite URI classification, canonical refresh, mixed RefreshDatabase ownership, and one-owner concurrency; transaction begin/commit/rollback/disconnect and manager-callback failures; retry suppression and primary-failure preservation; recursive, concurrent, failed, and post-publication model boot; all supported current Query, Eloquent, Schema, migration, connector, provider, exception, metadata, facade, documentation-facing call shapes, and external MySQL, MariaDB, PostgreSQL, SQLite, Redis, and Valkey behavior. -- **Cross-package revalidation:** The work closes carried `database-01` through `database-04`, `redis-01` and `redis-02`, `pool-04`, `pool-05`, `pool-08`, `context-04`, `support-02`, `foundation-06`, and the Database side of `database-03`. Core and Server own the new lifecycle producers; Redis remains a later full-package audit with `redis-03` through `redis-08` already fixed and recorded; Foundation, Testing, and Testbench consume the SQLite and transaction-test boundaries; Scout and NestedSet consume the corrected model-boot publication; Telescope's aggregate SQL expectation follows the corrected grammar. Focused consumer coverage and the complete gate remain green. +- **Cross-package revalidation:** The work closes carried `database-01` through `database-04`, `redis-01` and `redis-02`, `pool-04`, `pool-05`, `pool-08`, `context-04`, `support-02`, `foundation-06`, and the Database side of `database-03`. Core and Server own the new lifecycle producers; the full Redis audit revalidated `redis-03` through `redis-08`; Foundation, Testing, and Testbench consume the SQLite and transaction-test boundaries; Scout and NestedSet consume the corrected model-boot publication; Telescope's aggregate SQL expectation follows the corrected grammar. Focused consumer coverage and the complete gate remain green. - **Validation and review:** Every changed test file and affected package group passed during implementation. The final `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Database and Redis split manifests validate, `git diff --check`, package-checklist parity, broad stale-reference/classifier/dependency scans, and a fresh full-diff caller/callee, lifecycle, transaction, API, documentation, hot-path, retained-state, and overengineering self-review are complete. Independent post-implementation code review is signed off with no remaining findings. - **Laravel-facing result:** Current supported Laravel Database APIs, signatures, member ordering, tests, and task-first documentation are restored. Intentional differences are limited to Swoole/coroutine ownership, pooled-connection safety, truthful contract corrections, unsupported drivers/dynamic connections, and deliberate omission of directly deprecated forwarding. Redis's Hypervel/Hyperf-derived internals retain their public Laravel-shaped command surface while fixing lifecycle and native-state defects. - **Assessment:** Every accepted Database finding and linked Redis ownership correction is implemented at its lowest owner. The result removes reflective and duplicated cleanup, repeated defer retention, inconsistent classifiers, false shared-PDO capacity, stale manager state, and quadratic or over-broad work while adding only the owner-approved noise-level correctness checks. It contains no workaround, speculative mechanism, compatibility shim, hot-path synchronization, unresolved accepted defect, or stale superseded path. + +### Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety + +- **Architecture and inspected risk surfaces:** Redis is a Hyperf-derived, phpredis-only pooled transport with Laravel-shaped command APIs and Hypervel-specific exact-wrapper ownership. Ordinary commands borrow one wrapper per proxy operation; stateful commands pin the exact wrapper to the owning coroutine; dedicated subscribers own a separate hooked stream. The audit covered every Redis source and test file; Pool and Database lifecycle assumptions; Reverb, Horizon, Telescope, Sentry, Support facade, and Testing consumers; current Laravel source, tests, documentation, and originating pull requests; historical Hyperf behavior; phpredis and Redis protocol source; live Redis and Valkey behavior; split metadata; and every carried Redis finding. The detailed design is recorded in [`2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md`](2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md). + +| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision | +|---|---|---|---|---|---| +| `redis-09` | Defect | Critical | High | Retrying a caught Redis exception can repeat a command the server already committed | Never replay; retain synchronized server-error generations, invalidate unknown transport/protocol state and stale Sentinel-master replies | +| `redis-10` | Defect | Major | High | EOF-delimited subscriber framing corrupts CRLF-bearing bulk data, mishandles protocol errors and acknowledgements, and can strand foreground waits without their receive failure | Use one hooked stream with exact RESP2 decoding, bounded foreground waits, retained first cause, and deterministic interruption | +| `redis-11` | Defect | Major | High | Dedicated subscribers lose credentials, context/TLS, Unix, Sentinel, and Cluster topology | Resolve complete topology-local endpoints and credentials, including fresh Sentinel masters and released-before-dial Cluster discovery | +| `reverb-05` | Defect | Major | High | Publishing is incorrectly gated on subscriber state, retains an unbounded outage queue, and can publish false metrics while leaking pending metric state | Publish through the independent pooled proxy and remove exact pending metric/listener state if publication fails | +| `redis-12` | Defect and upstream defect | Major | High | Limiter release failure replaces the callback failure in both public callback paths | Attempt release after every acquired callback and preserve the callback as the primary failure | +| `redis-13` | Supported current Laravel parity and defects | Major/Minor | High | Connection validation, context normalization, ACL zero-values, options, prefix precedence, naming, and Cluster behavior are incomplete or stale | Port the supported current PhpRedis behavior into the existing pooled connection classes | +| `redis-14` | Supported current Laravel parity | Improvement | High | Redis wrappers lack current Laravel macros | Add Macroable to exact wrappers, register through explicit no-checkout proxy methods, retain one outer proxy event, and flush static macros after tests | +| `redis-15` | API/configuration correction | Major | High | Telescope and Sentry mutate shared config trees, while Hypervel's named-proxy `extend()` falsely resembles Laravel's connector-driver API | Add boot-only tri-state event controls at config assembly and remove the incompatible extension API without a compatibility shim | +| `horizon-01` | Defect and parity defect | Major | High | Horizon reads a dead cluster shape, reduces topology, and can publish a cross-slot prefix | Copy the complete named Hypervel connection, publish the resolved prefix at both precedence levels, and hash-tag Cluster prefixes | +| `redis-16` | Defect and intentional difference | Major | High | Advertised pooled `ssubscribe()` can retain a wrapper indefinitely in native sharded-subscriber mode | Reject pooled sharded subscription and remove the advertised surface rather than add unsupported slot-routing machinery | +| `redis-17` | Metadata defect | Minor | High | Proxy-bound methods and facade documentation ignores drift independently | Derive proxy test cases from the production constant and enforce its inclusion in facade ignores | +| `redis-18` | Dead compatibility and duplicate-normalization defect | Minor | High | Unsupported pre-6.1 Sentinel paths remain while config-name enumeration bypasses canonical parsing | Delete dead compatibility and route both config paths through the shared parser | +| `redis-19` | Native-boundary defect and intentional difference | Critical | High | Native RESET is process-fatal in PIPELINE mode and otherwise destroys pool-owned authentication/database state while appearing healthy | Reject pooled RESET before native dispatch and document the supported terminal alternatives | +| `redis-20` | Defect | Major | High | Mixed-case command names bypass proxy ownership, WATCH settlement, and pooled RESET/subscription guards | Preserve exact macro and event names while normalizing once at each proxy and wrapper dispatch boundary | +| `telescope-01` | Defect and upstream defect | Major | High | Redis observation can execute user conversion code and replace a successful command result with a formatter failure | Recursively format arrays and render every non-scalar leaf by debug type without invoking user code | +| `telescope-02` | Defect | Minor | High | A dead `transaction` ignore records real MULTI openers while mixed-case batch openers bypass filtering | Strictly ignore the actual `pipeline` and `multi` opener events case-insensitively | +| `sentry-01` | Defect | Major | High | Redis spans read a test-only key and report database zero for real non-zero connections | Read the normalized `database` key in success and failure spans and correct the masking fixture | + +- **Approved owner gates and intentional differences:** The owner approved rejecting pooled RESET and sharded subscription, deleting automatic framework replay and the incompatible named-proxy extension surface, changing Reverb outages to truthful publish failures, adding Laravel-shaped boot-only event controls and macros, retaining connection-local topology, and the source-proven noise-level command-name normalization and macro lookup. Connector-driver `extend()` / `setDriver()`, pooled RESET, and pooled/dedicated `ssubscribe()` are recorded at their README, source, and test locations with supported alternatives. +- **Important rejected concerns:** Do not add a retry registry, command ambiguity state machine, connector hierarchy, public transport abstraction, sharded Pub/Sub router, pool retrofit mechanism, publisher queue, generic finalizer, configurable channel policy, raw-client wrapper, recursive object serializer, or compatibility layer. Idle subscriber receive remains correctly unbounded. Cluster subscriber transport follows only `cluster.context`, matching phpredis node sockets; unsupported endpoint ambiguity is rejected rather than guessed. The identical two Cache limiter callback sites remain accepted under `redis-12` for the active Cache work; they require the same direct precedence correction, not a shared abstraction. +- **Implementation:** Command failures now determine only connection disposition and never repeat work. The subscriber uses exact RESP2 stream parsing, full I/O, semantic routing, exact channel/pattern accounting, retained receive causes, bounded foreground operations, and complete standalone/Sentinel/Cluster construction. Reverb publishes directly and cleans failed metric requests. Redis limiter cleanup preserves the primary callback failure. Current supported phpredis validation, credentials, options, macros, event controls, Horizon topology and both prefix precedence levels, facade metadata, and static cleanup are complete. Pooled RESET and sharded subscription fail before native state changes. Telescope formatting/filtering and Sentry database metadata are truthful. Dead replay, Engine EOF framing, timer-per-message delivery, named-proxy extensions, stale compatibility, duplicate config parsing, unbounded Reverb queueing, obsolete exceptions, and misleading documentation are removed. +- **Cross-package implications and revalidation:** `redis-09` is a changed lower-level command assumption for the later Cache audit. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` fixes both Redis limiter sites and routes the identical two Cache sites to the active Cache work. `redis-13` revalidated Horizon and changes prefix precedence for Cache, Queue, Session, and Broadcasting, which remain routed to their full audits. `redis-15` revalidated Telescope and Sentry boot integration. `horizon-01`, `telescope-01`, `telescope-02`, and `sentry-01` remain recorded for those packages' later full audits. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. +- **Regression tests:** Focused and live-service coverage proves no replay and exact failure disposition; immediate callback release and terminal defer ownership; real MULTI, PIPELINE, WATCH, DISCARD, RESET rejection, and mixed-case routing; byte-exact fragmented RESP, protocol failures, timeouts, close races, TLS, Unix, IPv6, Sentinel, and Cluster subscription paths; complete phpredis options and credentials; macros without checkout and one-event granularity; boot-only event overrides; Horizon topology and hash tags; direct Reverb publishing and metric cleanup; both limiter failure matrices; facade/manifest invariants; Telescope no-user-code formatting; and Sentry success/failure database metadata. +- **Performance and complexity:** Ordinary proxy commands add two lowercase operations on a short method name, one static macro-table lookup, and fixed guard comparisons; these are owner-approved measurement-noise costs with no config read, container lookup, lock, context operation, yield, retry, log, reconnect, or extra network command. Failure classification, topology, option normalization, observability, and boot overrides stay on cold or exceptional paths. Exact subscriber parsing removes one timer coroutine per message, and direct Reverb publishing removes unbounded retained outage memory. No new registry, retry queue, state machine, or unbounded worker state is retained. +- **Laravel-facing result:** Current supported phpredis options, validation, macros, manager event controls, facade metadata, tests, and task-first documentation are restored while Hypervel retains phpredis-only pooling, exact coroutine ownership, connection-local topology, transforms, SafeScan, raw held access, immediate callback release, and other advanced helpers. The deliberate differences are limited to behaviors Laravel's request-lifetime connector model cannot safely provide through a pooled Swoole connection. +- **Validation and review:** Every changed test owner, Redis integration path, and affected Reverb, Horizon, Telescope, and Sentry group passed. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split-manifest validation, `git diff --check`, stale-path and dependency scans, fresh caller/callee, protocol, lifecycle, API, documentation, hot-path, retained-memory, and overengineering self-review, and independent code review are complete with no remaining finding. +- **Assessment:** Every accepted finding in Redis and every linked consumer correction included in this work is fixed at its lowest owner while all protected Hypervel capabilities remain intact; the newly identified Cache sibling is explicitly routed to the active Cache work. The result removes unsafe replay, corrupt framing, unbounded queueing, per-message timers, false extension state, dead compatibility, and stale metadata; it adds only the approved noise-level ordinary-command work and no workaround, speculative abstraction, compatibility shim, or hot-path synchronization. From f42832162408a6089dd4853f5d31f6bb2b448154 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 26 Jul 2026 00:03:41 +0000 Subject: [PATCH 13/15] test(foundation): avoid global RNG locale assertions Verify Faker locale selection through exact provider ownership and localized output rather than seeded process-global random draws. Global Mt19937 state is shared across coroutines, so container resolution can yield while another task advances the sequence and makes an exact state-name assertion flaky. Keep the public locale behavior covered without adding RNG isolation, scheduler controls, or any framework source change. --- .../Foundation/FoundationHelpersTest.php | 23 +++++++++++++------ 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/tests/Integration/Foundation/FoundationHelpersTest.php b/tests/Integration/Foundation/FoundationHelpersTest.php index bfa7a283e..255ded2c1 100644 --- a/tests/Integration/Foundation/FoundationHelpersTest.php +++ b/tests/Integration/Foundation/FoundationHelpersTest.php @@ -5,6 +5,8 @@ namespace Hypervel\Tests\Integration\Foundation; use Exception; +use Faker\Provider\en_AU\Address as AustralianAddress; +use Faker\Provider\en_US\Address as AmericanAddress; use Hypervel\Testbench\TestCase; class FoundationHelpersTest extends TestCase @@ -64,10 +66,11 @@ public function testFakeReturnsSameInstance() public function testFakeUsesLocale() { - mt_srand(12345, MT_RAND_PHP); - - // Should fallback to en_US - $this->assertSame('Arkansas', fake()->state()); + // Process-global RNG state is not coroutine-isolated, so assert locale ownership instead of an exact draw. + $this->assertContains( + AmericanAddress::class, + array_map(static fn (object $provider): string => $provider::class, fake()->getProviders()), + ); $this->assertContains(fake('de_DE')->state(), [ 'Baden-Württemberg', 'Bayern', 'Berlin', 'Brandenburg', 'Bremen', 'Hamburg', 'Hessen', 'Mecklenburg-Vorpommern', 'Niedersachsen', 'Nordrhein-Westfalen', 'Rheinland-Pfalz', 'Saarland', 'Sachsen', 'Sachsen-Anhalt', 'Schleswig-Holstein', 'Thüringen', ]); @@ -78,9 +81,15 @@ public function testFakeUsesLocale() ]); config(['app.faker_locale' => 'en_AU']); - mt_srand(4, MT_RAND_PHP); + $faker = fake(); - // Should fallback to en_US - $this->assertSame('Australian Capital Territory', fake()->state()); + $this->assertContains( + AustralianAddress::class, + array_map(static fn (object $provider): string => $provider::class, $faker->getProviders()), + ); + $this->assertContains($faker->state(), [ + 'Australian Capital Territory', 'New South Wales', 'Northern Territory', 'Queensland', + 'South Australia', 'Tasmania', 'Victoria', 'Western Australia', + ]); } } From ac665c5f6dd05865185bb7813c22ac4bf1a6b83d Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 26 Jul 2026 01:28:17 +0000 Subject: [PATCH 14/15] fix(redis): tighten review follow-up invariants Make Horizon's shipped configuration the sole owner of its application-scoped Redis prefix default. The runtime consumer now uses the typed config getter without a second fallback, while regression coverage proves both missing and blank environment values and verifies Cluster-safe publication at every effective prefix location. Consolidate exact RESP test reads in the shared server fixture, resolve Cluster test endpoints before starting handler coroutines, and derive the subscriber message-channel timeout and diagnostics from one typed constant. Update the Redis implementation plan and lifecycle ledger to record the final boundaries. --- ...-coroutine-state-lifecycle-audit-ledger.md | 6 +- ...s-pooling-subscriber-and-laravel-parity.md | 40 ++++++++++--- src/horizon/config/horizon.php | 6 +- src/horizon/src/Horizon.php | 2 +- src/redis/src/Subscriber/CommandInvoker.php | 15 +++-- tests/Horizon/HorizonConfigTest.php | 58 +++++++++++++++++++ .../Horizon/Feature/RedisPrefixTest.php | 13 +++-- tests/Redis/Fixtures/RespServer.php | 22 +++++++ tests/Redis/PhpRedisClusterConnectionTest.php | 8 +-- tests/Redis/RedisProxyTest.php | 24 +------- tests/Redis/Subscriber/CommandInvokerTest.php | 28 +-------- tests/Redis/Subscriber/SubscriberTest.php | 25 +------- 12 files changed, 147 insertions(+), 100 deletions(-) create mode 100644 tests/Horizon/HorizonConfigTest.php diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md index 6a2bf9bd8..0f0da3bd1 100644 --- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md +++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md @@ -1172,7 +1172,7 @@ Append package entries in checklist order. Keep each entry compact but complete | `redis-13` | Supported current Laravel parity and defects | Major/Minor | High | Connection validation, context normalization, ACL zero-values, options, prefix precedence, naming, and Cluster behavior are incomplete or stale | Port the supported current PhpRedis behavior into the existing pooled connection classes | | `redis-14` | Supported current Laravel parity | Improvement | High | Redis wrappers lack current Laravel macros | Add Macroable to exact wrappers, register through explicit no-checkout proxy methods, retain one outer proxy event, and flush static macros after tests | | `redis-15` | API/configuration correction | Major | High | Telescope and Sentry mutate shared config trees, while Hypervel's named-proxy `extend()` falsely resembles Laravel's connector-driver API | Add boot-only tri-state event controls at config assembly and remove the incompatible extension API without a compatibility shim | -| `horizon-01` | Defect and parity defect | Major | High | Horizon reads a dead cluster shape, reduces topology, and can publish a cross-slot prefix | Copy the complete named Hypervel connection, publish the resolved prefix at both precedence levels, and hash-tag Cluster prefixes | +| `horizon-01` | Defect and parity defect | Major | High | Horizon reads a dead cluster shape, reduces topology, duplicates a drifted prefix default, and can publish a cross-slot prefix | Copy the complete named Hypervel connection, keep the application-scoped default in Horizon config, publish the resolved prefix at both precedence levels, and hash-tag Cluster prefixes | | `redis-16` | Defect and intentional difference | Major | High | Advertised pooled `ssubscribe()` can retain a wrapper indefinitely in native sharded-subscriber mode | Reject pooled sharded subscription and remove the advertised surface rather than add unsupported slot-routing machinery | | `redis-17` | Metadata defect | Minor | High | Proxy-bound methods and facade documentation ignores drift independently | Derive proxy test cases from the production constant and enforce its inclusion in facade ignores | | `redis-18` | Dead compatibility and duplicate-normalization defect | Minor | High | Unsupported pre-6.1 Sentinel paths remain while config-name enumeration bypasses canonical parsing | Delete dead compatibility and route both config paths through the shared parser | @@ -1184,9 +1184,9 @@ Append package entries in checklist order. Keep each entry compact but complete - **Approved owner gates and intentional differences:** The owner approved rejecting pooled RESET and sharded subscription, deleting automatic framework replay and the incompatible named-proxy extension surface, changing Reverb outages to truthful publish failures, adding Laravel-shaped boot-only event controls and macros, retaining connection-local topology, and the source-proven noise-level command-name normalization and macro lookup. Connector-driver `extend()` / `setDriver()`, pooled RESET, and pooled/dedicated `ssubscribe()` are recorded at their README, source, and test locations with supported alternatives. - **Important rejected concerns:** Do not add a retry registry, command ambiguity state machine, connector hierarchy, public transport abstraction, sharded Pub/Sub router, pool retrofit mechanism, publisher queue, generic finalizer, configurable channel policy, raw-client wrapper, recursive object serializer, or compatibility layer. Idle subscriber receive remains correctly unbounded. Cluster subscriber transport follows only `cluster.context`, matching phpredis node sockets; unsupported endpoint ambiguity is rejected rather than guessed. The identical two Cache limiter callback sites remain accepted under `redis-12` for the active Cache work; they require the same direct precedence correction, not a shared abstraction. -- **Implementation:** Command failures now determine only connection disposition and never repeat work. The subscriber uses exact RESP2 stream parsing, full I/O, semantic routing, exact channel/pattern accounting, retained receive causes, bounded foreground operations, and complete standalone/Sentinel/Cluster construction. Reverb publishes directly and cleans failed metric requests. Redis limiter cleanup preserves the primary callback failure. Current supported phpredis validation, credentials, options, macros, event controls, Horizon topology and both prefix precedence levels, facade metadata, and static cleanup are complete. Pooled RESET and sharded subscription fail before native state changes. Telescope formatting/filtering and Sentry database metadata are truthful. Dead replay, Engine EOF framing, timer-per-message delivery, named-proxy extensions, stale compatibility, duplicate config parsing, unbounded Reverb queueing, obsolete exceptions, and misleading documentation are removed. +- **Implementation:** Command failures now determine only connection disposition and never repeat work. The subscriber uses exact RESP2 stream parsing, full I/O, semantic routing, exact channel/pattern accounting, retained receive causes, bounded foreground operations, and complete standalone/Sentinel/Cluster construction. Reverb publishes directly and cleans failed metric requests. Redis limiter cleanup preserves the primary callback failure. Current supported phpredis validation, credentials, options, macros, event controls, Horizon topology, config-owned application prefix default, both prefix precedence levels, facade metadata, and static cleanup are complete. Pooled RESET and sharded subscription fail before native state changes. Telescope formatting/filtering and Sentry database metadata are truthful. Dead replay, Engine EOF framing, timer-per-message delivery, named-proxy extensions, stale compatibility, duplicate config parsing, unbounded Reverb queueing, obsolete exceptions, and misleading documentation are removed. - **Cross-package implications and revalidation:** `redis-09` is a changed lower-level command assumption for the later Cache audit. `redis-10` and `redis-11` revalidated Reverb's dedicated subscriber use; `reverb-05` remains for Reverb's full audit. `redis-12` fixes both Redis limiter sites and routes the identical two Cache sites to the active Cache work. `redis-13` revalidated Horizon and changes prefix precedence for Cache, Queue, Session, and Broadcasting, which remain routed to their full audits. `redis-15` revalidated Telescope and Sentry boot integration. `horizon-01`, `telescope-01`, `telescope-02`, and `sentry-01` remain recorded for those packages' later full audits. Every carried `redis-01` through `redis-08`, `pool-04`, `pool-05`, `pool-08`, `database-05`, `database-06`, and `support-02` assumption was revalidated. -- **Regression tests:** Focused and live-service coverage proves no replay and exact failure disposition; immediate callback release and terminal defer ownership; real MULTI, PIPELINE, WATCH, DISCARD, RESET rejection, and mixed-case routing; byte-exact fragmented RESP, protocol failures, timeouts, close races, TLS, Unix, IPv6, Sentinel, and Cluster subscription paths; complete phpredis options and credentials; macros without checkout and one-event granularity; boot-only event overrides; Horizon topology and hash tags; direct Reverb publishing and metric cleanup; both limiter failure matrices; facade/manifest invariants; Telescope no-user-code formatting; and Sentry success/failure database metadata. +- **Regression tests:** Focused and live-service coverage proves no replay and exact failure disposition; immediate callback release and terminal defer ownership; real MULTI, PIPELINE, WATCH, DISCARD, RESET rejection, and mixed-case routing; byte-exact fragmented RESP, protocol failures, timeouts, close races, TLS, Unix, IPv6, Sentinel, and Cluster subscription paths; complete phpredis options and credentials; macros without checkout and one-event granularity; boot-only event overrides; Horizon config defaults, topology, prefix publication, and hash tags; direct Reverb publishing and metric cleanup; both limiter failure matrices; facade/manifest invariants; Telescope no-user-code formatting; and Sentry success/failure database metadata. - **Performance and complexity:** Ordinary proxy commands add two lowercase operations on a short method name, one static macro-table lookup, and fixed guard comparisons; these are owner-approved measurement-noise costs with no config read, container lookup, lock, context operation, yield, retry, log, reconnect, or extra network command. Failure classification, topology, option normalization, observability, and boot overrides stay on cold or exceptional paths. Exact subscriber parsing removes one timer coroutine per message, and direct Reverb publishing removes unbounded retained outage memory. No new registry, retry queue, state machine, or unbounded worker state is retained. - **Laravel-facing result:** Current supported phpredis options, validation, macros, manager event controls, facade metadata, tests, and task-first documentation are restored while Hypervel retains phpredis-only pooling, exact coroutine ownership, connection-local topology, transforms, SafeScan, raw held access, immediate callback release, and other advanced helpers. The deliberate differences are limited to behaviors Laravel's request-lifetime connector model cannot safely provide through a pooled Swoole connection. - **Validation and review:** Every changed test owner, Redis integration path, and affected Reverb, Horizon, Telescope, and Sentry group passed. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split-manifest validation, `git diff --check`, stale-path and dependency scans, fresh caller/callee, protocol, lifecycle, API, documentation, hot-path, retained-memory, and overengineering self-review, and independent code review are complete with no remaining finding. diff --git a/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md b/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md index 12b2af7ae..679508782 100644 --- a/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md +++ b/docs/plans/2026-07-25-1811-redis-pooling-subscriber-and-laravel-parity.md @@ -64,7 +64,7 @@ Hyperf parity is not a goal. Laravel remains the public API and documentation re | `redis-13` | Supported current Laravel parity and defects | Major/Minor | Host, scheme, context, option, ACL, prefix, client-name, and Cluster failover behavior is missing, stale, or falsey-zero unsafe | Port current PhpRedis behavior into existing pooled connection classes | | `redis-14` | Supported current Laravel parity | Improvement | Redis connections lack current Laravel macros | Add Macroable to exact wrappers, register without a Redis checkout, preserve proxy event granularity, and flush static state after tests | | `redis-15` | API/configuration correction | Major | Telescope and Sentry duplicate destructive config loops, while Hypervel's `extend()` uses Laravel's name for an incompatible named-proxy API | Add boot-only manager event controls through a tri-state config override and delete the false extension API | -| `horizon-01` | Defect and parity defect | Major | `Horizon::use()` reads a dead top-level cluster shape, can reduce a Cluster to one seed, and fails to hash-tag or publish the resolved prefix | Copy the complete named Hypervel config and normalize the Cluster prefix | +| `horizon-01` | Defect and parity defect | Major | `Horizon::use()` reads a dead top-level cluster shape, can reduce a Cluster to one seed, duplicates a drifted prefix default, and fails to hash-tag or publish the resolved prefix | Copy the complete named Hypervel config, keep the application-scoped default in Horizon config, and normalize the Cluster prefix | | `redis-16` | Defect and intentional difference | Major | Advertised pooled `ssubscribe()` can capture a wrapper indefinitely in native sharded-subscriber mode | Reject it beside subscribe/psubscribe and remove its advertised surface | | `redis-17` | Metadata defect | Minor | Proxy-bound methods and generated facade exclusions have already drifted apart | Enforce one production-owner subset invariant and derive proxy test cases from the production constant | | `redis-18` | Dead compatibility and duplicate-normalization defect | Minor | Redis 6.0 source/test compatibility is unreachable at the declared 6.1 floor, and config-name validation bypasses the canonical normalizer | Delete the unsupported branches and route both config paths through one parser | @@ -617,8 +617,8 @@ Cover: endpoint remains TCP at the endpoint-formatting boundary. Close client streams in `finally`. `RespServer::wait()` is the terminal fixture operation and closes -the listener in its own `finally`; it also owns host/port parsing for consumers. The fixture must not -use a fixed port or shared path. +the listener in its own `finally`; it also owns host/port parsing and the exact test-stream reader +shared by its consumers. The fixture must not use a fixed port or shared path. ## 4. Make subscriber command routing and topology exact @@ -702,10 +702,13 @@ rather than allowing another read from the closed stream. Delete the per-message `Timer::after()` allocation. +Define one private typed constant for the 30-second message-channel push policy +and derive both timeout diagnostics from it. + Use: ```php -if ($this->messageChannel->push($message, 30.0)) { +if ($this->messageChannel->push($message, self::MESSAGE_PUSH_TIMEOUT)) { return; } @@ -1232,7 +1235,9 @@ Record this closed omission at the exact README, source, and test positions spec ### Files +- `src/horizon/config/horizon.php` - `src/horizon/src/Horizon.php` +- `tests/Horizon/HorizonConfigTest.php` (new) - `tests/Integration/Horizon/Feature/RedisPrefixTest.php` ### Final behavior @@ -1247,10 +1252,25 @@ if (! is_array($config)) { } ``` -Resolve the prefix: +Normalize missing and blank environment input in the shipped configuration file, +which is the sole default owner: + +```php +$prefix = env('HORIZON_PREFIX'); + +return [ + // ... + 'prefix' => $prefix === null || $prefix === '' + ? app_id() . '_horizon:' + : $prefix, + // ... +]; +``` + +Consume the configured prefix without a second fallback: ```php -$prefix = config('horizon.prefix') ?: 'horizon:'; +$prefix = config()->string('horizon.prefix'); if (($config['cluster']['enable'] ?? false) && ! RedisConnection::hasHashTag($prefix)) { @@ -1280,17 +1300,22 @@ gives the top-level connection key final precedence, so leaving the source connection's old top-level value intact would override Horizon's nested option. Do not copy Laravel's top-level clusters model or its `supportsClustering()` version shim. +Do not repair post-load empty config mutations in `Horizon::use()`; the shipped +config owns defaulting, and the typed getter rejects missing or malformed values. ### Tests Cover: +- missing and blank `HORIZON_PREFIX` values resolve to the application-scoped + default in the shipped config file, with exception-safe environment restoration; - missing connection; - standalone complete config copy; - Cluster complete config copy; - untagged clustered prefix becomes tagged; - already-tagged prefix remains unchanged; -- resolved prefix is identical in `horizon.prefix` and Redis connection options; +- the configured application-scoped Cluster prefix is published identically in + `horizon.prefix`, the top-level Redis connection prefix, and Redis connection options; - source top-level prefixes cannot override the resolved standalone or Cluster prefix; - nested topology/pool values remain intact; - Horizon multi-key operations remain Cluster-slot safe. @@ -1669,6 +1694,7 @@ The detailed implementation sections define the complete regression matrix. Run ./vendor/bin/phpunit --no-progress tests/Redis/Subscriber/SubscriberTest.php ./vendor/bin/phpunit --no-progress tests/Reverb/Servers/Hypervel/Scaling/RedisPubSubProviderTest.php ./vendor/bin/phpunit --no-progress tests/Reverb/Protocols/Pusher/MetricsHandlerTest.php +./vendor/bin/phpunit --no-progress tests/Horizon/HorizonConfigTest.php ./vendor/bin/phpunit --no-progress tests/Integration/Horizon/Feature/RedisPrefixTest.php ./vendor/bin/phpunit --no-progress tests/Telescope/Watchers/RedisWatcherTest.php ./vendor/bin/phpunit --no-progress tests/Telescope/Watchers/DisabledWatcherTest.php diff --git a/src/horizon/config/horizon.php b/src/horizon/config/horizon.php index a74eec314..8a95a2345 100644 --- a/src/horizon/config/horizon.php +++ b/src/horizon/config/horizon.php @@ -2,6 +2,8 @@ declare(strict_types=1); +$prefix = env('HORIZON_PREFIX'); + return [ /* |-------------------------------------------------------------------------- @@ -66,7 +68,9 @@ | */ - 'prefix' => env('HORIZON_PREFIX', app_id() . '_horizon:'), + 'prefix' => $prefix === null || $prefix === '' + ? app_id() . '_horizon:' + : $prefix, /* |-------------------------------------------------------------------------- diff --git a/src/horizon/src/Horizon.php b/src/horizon/src/Horizon.php index ae5def203..6d3ce3625 100644 --- a/src/horizon/src/Horizon.php +++ b/src/horizon/src/Horizon.php @@ -87,7 +87,7 @@ public static function use(string $connection): void throw new Exception("Redis connection [{$connection}] has not been configured."); } - $prefix = config('horizon.prefix') ?: 'horizon:'; + $prefix = config()->string('horizon.prefix'); if (($config['cluster']['enable'] ?? false) && ! RedisConnection::hasHashTag($prefix)) { diff --git a/src/redis/src/Subscriber/CommandInvoker.php b/src/redis/src/Subscriber/CommandInvoker.php index 0d9d1a5df..ef77a0f32 100644 --- a/src/redis/src/Subscriber/CommandInvoker.php +++ b/src/redis/src/Subscriber/CommandInvoker.php @@ -13,6 +13,8 @@ class CommandInvoker { + private const float MESSAGE_PUSH_TIMEOUT = 30.0; + protected Channel $resultChannel; protected Channel $messageChannel; @@ -318,7 +320,7 @@ private function route(mixed $response): void */ private function pushMessage(Message $message): void { - if ($this->messageChannel->push($message, 30.0)) { + if ($this->messageChannel->push($message, self::MESSAGE_PUSH_TIMEOUT)) { return; } @@ -326,14 +328,17 @@ private function pushMessage(Message $message): void throw new SocketException('The Redis subscriber message channel was closed.'); } - $exception = new SocketException( - "Redis subscriber message channel [{$message->channel}] remained full for 30 seconds." - ); + $exception = new SocketException(sprintf( + 'Redis subscriber message channel [%s] remained full for %s seconds.', + $message->channel, + self::MESSAGE_PUSH_TIMEOUT, + )); try { $this->logger?->error(sprintf( - 'Message channel (%s) is 30 seconds full, disconnected', + 'Message channel (%s) is %s seconds full, disconnected', $message->channel, + self::MESSAGE_PUSH_TIMEOUT, )); } catch (Throwable) { // Reporting must not replace the channel-capacity failure. diff --git a/tests/Horizon/HorizonConfigTest.php b/tests/Horizon/HorizonConfigTest.php new file mode 100644 index 000000000..cd6852289 --- /dev/null +++ b/tests/Horizon/HorizonConfigTest.php @@ -0,0 +1,58 @@ +assertSame(app_id() . '_horizon:', $config['prefix']); + + putenv("{$key}="); + $_SERVER[$key] = ''; + $_ENV[$key] = ''; + Env::flushRepository(); + + $config = require dirname(__DIR__, 2) . '/src/horizon/config/horizon.php'; + + $this->assertSame(app_id() . '_horizon:', $config['prefix']); + } finally { + $originalPutenv === false + ? putenv($key) + : putenv("{$key}={$originalPutenv}"); + + if ($originalServerExists) { + $_SERVER[$key] = $originalServer; + } else { + unset($_SERVER[$key]); + } + + if ($originalEnvExists) { + $_ENV[$key] = $originalEnv; + } else { + unset($_ENV[$key]); + } + + Env::flushRepository(); + } + } +} diff --git a/tests/Integration/Horizon/Feature/RedisPrefixTest.php b/tests/Integration/Horizon/Feature/RedisPrefixTest.php index 5435f93f6..9ae781259 100644 --- a/tests/Integration/Horizon/Feature/RedisPrefixTest.php +++ b/tests/Integration/Horizon/Feature/RedisPrefixTest.php @@ -121,8 +121,12 @@ public function testClusterPrefixIsNotDoubleTagged(): void ); } - public function testClusterConnectionUsesHashTaggedFallbackPrefix(): void + public function testClusterConnectionUsesConfiguredApplicationPrefix(): void { + $applicationId = 'application'; + $prefix = "{$applicationId}_horizon:"; + $taggedPrefix = "{{$prefix}}"; + config([ 'database.redis.horizon-cluster' => [ 'cluster' => [ @@ -131,12 +135,13 @@ public function testClusterConnectionUsesHashTaggedFallbackPrefix(): void 'seeds' => ['redis.example.com:6379'], ], ], - 'horizon.prefix' => '', + 'horizon.prefix' => $prefix, ]); Horizon::use('horizon-cluster'); - $this->assertSame('{horizon:}', config('horizon.prefix')); - $this->assertSame('{horizon:}', config('database.redis.horizon.options.prefix')); + $this->assertSame($taggedPrefix, config('horizon.prefix')); + $this->assertSame($taggedPrefix, config('database.redis.horizon.prefix')); + $this->assertSame($taggedPrefix, config('database.redis.horizon.options.prefix')); } } diff --git a/tests/Redis/Fixtures/RespServer.php b/tests/Redis/Fixtures/RespServer.php index 371526f0c..e2d7a6dec 100644 --- a/tests/Redis/Fixtures/RespServer.php +++ b/tests/Redis/Fixtures/RespServer.php @@ -91,6 +91,28 @@ public function hostAndPort(): array return [$host, $port]; } + /** + * Read an exact number of bytes from a test stream. + * + * @param resource $stream + */ + public static function readExact(mixed $stream, int $length): string + { + $value = ''; + + while (strlen($value) < $length) { + $chunk = fread($stream, $length - strlen($value)); + + if ($chunk === false || $chunk === '') { + throw new RuntimeException('Failed to read the complete test command.'); + } + + $value .= $chunk; + } + + return $value; + } + /** * Handle one client connection in a coroutine. * diff --git a/tests/Redis/PhpRedisClusterConnectionTest.php b/tests/Redis/PhpRedisClusterConnectionTest.php index 8ab1c2a9e..210d2b2a4 100644 --- a/tests/Redis/PhpRedisClusterConnectionTest.php +++ b/tests/Redis/PhpRedisClusterConnectionTest.php @@ -228,17 +228,11 @@ public function testClusterContextSelectsTheExpectedTransport(array $context, bo ); $bytes = null; $failure = null; + [$host, $port] = $server->hostAndPort(); $server->start(static function ($client) use (&$bytes): void { $bytes = stream_get_contents($client, 2); fwrite($client, "-ERR test endpoint is not a Redis Cluster\r\n"); }); - $endpoint = $server->endpoint(); - $host = parse_url($endpoint, PHP_URL_HOST); - $port = parse_url($endpoint, PHP_URL_PORT); - - if (! is_string($host) || ! is_int($port)) { - throw new InvalidArgumentException("Invalid RESP test endpoint [{$endpoint}]."); - } try { new PhpRedisClusterConnection( diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index d9ee54c2d..b7b2e5faf 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -1030,7 +1030,7 @@ public function testSubscriberResolvesSentinelMasterFreshWithConnectionCredentia foreach ($servers as $server) { $server->start(function ($client) use ($command): void { - $this->readExact($client, strlen($command)); + RespServer::readExact($client, strlen($command)); fwrite($client, "+OK\r\n"); fread($client, 1); }); @@ -1477,28 +1477,6 @@ private function createMockRedisConnection( return $mockRedisConnection; } - /** - * Read an exact number of bytes from a test stream. - * - * @param resource $stream - */ - private function readExact(mixed $stream, int $length): string - { - $value = ''; - - while (strlen($value) < $length) { - $chunk = fread($stream, $length - strlen($value)); - - if ($chunk === false || $chunk === '') { - throw new RuntimeException('Failed to read the complete test command.'); - } - - $value .= $chunk; - } - - return $value; - } - private function sentinelFactory(): RedisSentinelFactory { return m::mock(RedisSentinelFactory::class); diff --git a/tests/Redis/Subscriber/CommandInvokerTest.php b/tests/Redis/Subscriber/CommandInvokerTest.php index fc00a9ea4..2bc8563b5 100644 --- a/tests/Redis/Subscriber/CommandInvokerTest.php +++ b/tests/Redis/Subscriber/CommandInvokerTest.php @@ -31,7 +31,7 @@ public function testInvokeSendsCommandAndCollectsDecodedResults(): void $command = CommandBuilder::build(['subscribe', 'foo']); $server = new RespServer; $server->start(function ($client) use ($command): void { - $this->readExact($client, strlen($command)); + RespServer::readExact($client, strlen($command)); fwrite($client, "*3\r\n$9\r\nsubscribe\r\n$3\r\nfoo\r\n:1\r\n"); fread($client, 1); }); @@ -108,9 +108,9 @@ public function testSimplePongDoesNotPoisonTheNextAcknowledgement(): void $subscribe = CommandBuilder::build(['subscribe', 'foo']); $server = new RespServer; $server->start(function ($client) use ($ping, $subscribe): void { - $this->readExact($client, strlen($ping)); + RespServer::readExact($client, strlen($ping)); fwrite($client, "+PONG\r\n"); - $this->readExact($client, strlen($subscribe)); + RespServer::readExact($client, strlen($subscribe)); fwrite($client, "*3\r\n$9\r\nsubscribe\r\n$3\r\nfoo\r\n:1\r\n"); fread($client, 1); }); @@ -502,28 +502,6 @@ public function testMalformedResponseTerminatesTheSubscriberWithItsCause(): void $invoker->invoke(['subscribe', 'foo'], 1); } - /** - * Read an exact number of bytes from a test stream. - * - * @param resource $stream - */ - private function readExact(mixed $stream, int $length): string - { - $value = ''; - - while (strlen($value) < $length) { - $chunk = fread($stream, $length - strlen($value)); - - if ($chunk === false || $chunk === '') { - throw new RuntimeException('Failed to read the complete test command.'); - } - - $value .= $chunk; - } - - return $value; - } - /** * Wait for a test condition. */ diff --git a/tests/Redis/Subscriber/SubscriberTest.php b/tests/Redis/Subscriber/SubscriberTest.php index 8af52d53f..37b785bd3 100644 --- a/tests/Redis/Subscriber/SubscriberTest.php +++ b/tests/Redis/Subscriber/SubscriberTest.php @@ -14,7 +14,6 @@ use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; use ReflectionClass; -use RuntimeException; class SubscriberTest extends TestCase { @@ -254,7 +253,7 @@ public function testConnectForwardsCompleteCredentialShapes( $received = ''; $server = new RespServer; $server->start(function ($client) use ($command, &$received): void { - $received = $this->readExact($client, strlen($command)); + $received = RespServer::readExact($client, strlen($command)); fwrite($client, "+OK\r\n"); fread($client, 1); }); @@ -333,26 +332,4 @@ private function createSubscriber(CommandInvoker $invoker, string $prefix = ''): return $subscriber; } - - /** - * Read an exact number of bytes from a test stream. - * - * @param resource $stream - */ - private function readExact(mixed $stream, int $length): string - { - $value = ''; - - while (strlen($value) < $length) { - $chunk = fread($stream, $length - strlen($value)); - - if ($chunk === false || $chunk === '') { - throw new RuntimeException('Failed to read the complete test command.'); - } - - $value .= $chunk; - } - - return $value; - } } From 3bdceb9d01765878c36a4736bdd15fa68717afc0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:02:16 +0000 Subject: [PATCH 15/15] test(redis): cover pool event configuration snapshots Exercise the boot-only Redis event override against a live RedisPool so the documented non-retrofit boundary is enforced directly. Build the pool from a real configuration repository, change the worker-wide override, and prove the existing pool retains its captured setting while subsequent configuration assembly receives the new value. --- tests/Redis/RedisPoolTest.php | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/Redis/RedisPoolTest.php b/tests/Redis/RedisPoolTest.php index 50447bfb3..5426260b6 100644 --- a/tests/Redis/RedisPoolTest.php +++ b/tests/Redis/RedisPoolTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Redis; +use Hypervel\Config\Repository; use Hypervel\Contracts\Container\Container; use Hypervel\Contracts\Log\StdoutLoggerInterface; use Hypervel\Contracts\Pool\ConnectionInterface; @@ -64,6 +65,39 @@ public function testPoolConnectTimeoutDoesNotOverrideTheNativeRedisTimeout(): vo $this->assertSame($connectionConfig, $pool->getConfig()); } + public function testEventOverrideDoesNotRetrofitExistingPoolConfiguration(): void + { + $redisConfig = new RedisConfig(new Repository([ + 'database' => [ + 'redis' => [ + 'default' => [ + 'host' => 'redis', + 'port' => 16379, + 'database' => 0, + 'event' => ['enable' => false], + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 30, + 'connect_timeout' => 1.25, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 1, + ], + ], + ], + ], + ])); + $container = m::mock(Container::class); + $container->shouldReceive('make')->with(RedisConfig::class)->once()->andReturn($redisConfig); + $container->shouldReceive('has')->with(StdoutLoggerInterface::class)->andReturn(false); + $pool = new RedisPool($container, 'default'); + + $redisConfig->enableEvents(); + + $this->assertFalse($pool->getConfig()['event']['enable']); + $this->assertTrue($redisConfig->connectionConfig('default')['event']['enable']); + } + public function testLowFrequencyFlushClosesIdleConnections(): void { TestPoolConnection::reset();