From 5bd74f7ef0c3d8bbd98241f54edd9b45e6c8beb3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 06:27:34 +0000 Subject: [PATCH 01/22] Expand watcher consolidation TODO coverage --- docs/todo.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/todo.md b/docs/todo.md index 819c7a39e..8b9fbb264 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -48,7 +48,8 @@ - Consolidate the two find-based watcher drivers while adding deletion detection: - Keep the public `FindDriver` name, replace its rolling `find -mmin` implementation with `FindNewerDriver`'s alternating reference-file and `find -newer` design, then remove `FindNewerDriver`. Do not retain a compatibility alias or add a mode setting: the two implementations have different state and lifecycle requirements, and the older mode provides no useful capability worth exposing. - [`find -newer` is part of the POSIX `find` surface](https://pubs.opengroup.org/onlinepubs/9699919799/utilities/find.html), while `-mmin` is an extension. The current `FindDriver` also requires GNU `gfind` on macOS even though `FindNewerDriver` works with the system `find`. Hyperf [added `FindNewerDriver` specifically for macOS, Linux, and Docker compatibility](https://github.com/hyperf/hyperf/pull/3170) but retained the older driver; Hypervel does not need to carry both forward. - - Preserve the reference driver's correctness properties: create and own unique temporary reference files, record the next cutoff before scanning, advance the cutoff only after a successful scan, retain the last successful cutoff across failures, and clean up safely across stop and restart. This avoids the rolling window losing changes after a failed scan and avoids `FindDriver`'s whole-second `filemtime()` de-duplication missing another modification within the same second. Both approaches traverse the same directory tree, so the old mode has no meaningful performance advantage. An unwritable temporary directory is the only realistic boundary where the old mode could start while the reference mode cannot, and that is not a useful development environment to support with another driver. + - Preserve the reference driver's correctness properties: create and own unique temporary reference files, record the next cutoff before scanning, advance the cutoff only after a successful scan, retain the last successful cutoff across failures, and clean up safely across stop and restart. This avoids the rolling window losing changes after a failed scan, avoids changes aging out because each polling delay begins only after the preceding scan completes, and avoids `FindDriver`'s whole-second `filemtime()` de-duplication missing another modification within the same second. It also removes the `-mmin` formatting failure where a positive `scan_interval` below roughly 300 ms becomes `-0.00` and matches nothing. Cover changes made during a slow scan and very small positive scan intervals. Both approaches traverse the same directory tree, so the old mode has no meaningful performance advantage. An unwritable temporary directory is the only realistic boundary where the old mode could start while the reference mode cannot, and that is not a useful development environment to support with another driver. + - Use unambiguous NUL-delimited path output for both change detection and inventory reconciliation, with an emission strategy supported by every target system `find`; do not retain the current `-print` plus newline-splitting protocol, which corrupts valid filenames containing embedded newlines. Cover newline-containing filenames in both watched directories and explicit file targets. - Keep a lightweight inventory of matched paths, reconcile it during each successful scan, and report removed paths without hashing file contents. Cover file and directory deletion, renames, newly discovered paths, command failures, repeated lifecycle calls, stop during an active scan, and restart after cleanup. Migrate the useful `FindNewerDriver` coverage to `FindDriver` and remove tests that exist only for the discarded `-mmin` behavior. - Update `config/watcher.php` and `watcher.md` to expose only `ScanFileDriver`, `FindDriver`, and `FswatchDriver`. Add a concise "Choosing a Driver" subsection after the driver table: recommend `ScanFileDriver` as the dependency-free and most portable default, while noting that hashing every watched file costs more polling I/O as the tree grows; recommend `FswatchDriver` for large trees on native filesystems when its dependency and operating-system event delivery are suitable, since it has the lowest steady-state work; and describe `FindDriver` as the Unix polling middle ground when `fswatch` is unavailable, using file metadata rather than reading and hashing file contents. Mention that polling is the safer choice where container, virtual-machine, or network mounts do not forward filesystem events reliably. Update the table's detected-change entry after deletion reconciliation is implemented rather than documenting the future behavior early. From 6e77195ac00fcc2ab0ec7b00bdc5b3ed42142e5a Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 08:16:21 +0000 Subject: [PATCH 02/22] Document audit correctness follow-up plan --- ...-correctness-and-worker-lifetime-bounds.md | 483 ++++++++++++++++++ 1 file changed, 483 insertions(+) create mode 100644 docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md diff --git a/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md new file mode 100644 index 000000000..2cfec240b --- /dev/null +++ b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md @@ -0,0 +1,483 @@ +# Audit Correctness and Worker-Lifetime Bounds + +## Objective + +Correct the confirmed non-watcher findings from the 0.4 audit without changing Laravel-compatible APIs, adding material hot-path machinery, or documenting behavior the repository does not ship. The finished code should have truthful Redis result contracts, reentrant Redis command events, connection-owned database tracking, correct secondary Swoole settings, bounded event lookup caches, accurate scheduler timing, robust diagnostics and stream writes, and no premature Boost installation instructions. + +This plan targets branch `fix/audit-correctness-follow-up` from 0.4 commit `88190e640498`. Research references are the local Laravel checkout at `a659f095965b`, phpredis at `777f7377674a`, Swoole at `8e8c49915ca5`, and the installed PHP 8.4.23 / phpredis 6.3.0 / Swoole 6.2.2 runtime. + +## Scope decisions and invariants + +- All `FindDriver` / `FindNewerDriver` work is excluded. The complete watcher batch is already recorded under `docs/todo.md` and belongs in its own coherent PR. +- The audit's `reply_literal` explanation is false for phpredis 6.3.0. `SET ... GET` and serializer decoding are the real reasons the transformed `SET` result must be `mixed`; no `reply_literal` test or special case will be added. +- The Swoole audit is narrower than written. Most malformed settings fatal, but malformed `ssl_sni_certs` on an SSL port warns and returns `false`. Separately, untouched secondary ports inherit the first port's merged settings from Swoole, so Hypervel must explicitly configure every secondary with global plus that secondary's local settings. +- `Swoole\Server::set()` masks the primary port's recoverable `false` result by returning `true`. Hypervel will not duplicate Swoole validation or apply primary settings twice. The upstream handoff is `_tmp/swoole/pr-ideas/server-port-set-return-contract.md`. +- No Redis `EXISTS` probe will be added. With a serializer, a stored `false` and a missing key are deliberately indistinguishable through phpredis `GET`; a second command would also be racy. +- Redis listener reentrancy will reuse the connection that owns the event. It will not release the wrapper before dispatch, add a recursion guard, reserve a second pool slot, or alter event payloads. As in Laravel, nested commands emit their own events; an unconditional same-command listener therefore recurses instead of timing out on an accidental second pool checkout. +- Event cache bounds are private implementation constants. There will be no configuration, timer, request cleanup, LRU, FIFO queue, or hit-time mutation. +- Stream completion loops stop on `false` or zero progress. They will not poll readiness, sleep, spin, or add asynchronous buffering. +- Laravel port structure remains recognizable: inline JSON flags stay inline, the HTTP fake keeps its local sink branches, and the scheduler reuses the existing support trait. No shared codec, sink writer service, or clock API will be introduced. +- The only removed public-looking method is `RedisConnection::setDatabase()`: it is an undocumented Hypervel-only bookkeeping hook, absent from Laravel, all contracts, and the generated facade, and is replaced by automatic tracking at the command execution boundary. No Laravel API is removed or narrowed. + +## Finding disposition + +| Area | Disposition | +|---|---| +| Watcher misses/deletions and subsequent watcher discoveries | Deferred in full to the existing watcher TODO batch | +| Redis serializer `GET`, `ZADD INCR`, `SET GET`, and false sentinels | Fix as one native-contract pass | +| Redis command-listener pool deadlock | Fix by event-scoped reuse of the owning wrapper | +| Redis selected-database bookkeeping | Fix at `RedisConnection::__call()`; remove proxy-only setter plumbing | +| Empty Boost package advertised as working tooling | Gate installation docs; leave product implementation in TODO | +| Secondary Swoole configuration | Explicitly set every secondary and check the real false sentinel | +| Unbounded dispatcher caches | Remove redundant caches and cap the three useful caches | +| Scheduler seconds labelled as milliseconds | Reuse `InteractsWithTime` | +| Database assertion JSON failures | Use the two deliberate tolerant-encoding flags and restore Laravel's Unicode option | +| Fake HTTP sink writes/rewinds | Complete partial writes and rewind only seekable sinks | +| Log stream writes | Complete supported caller-resource writes without replaying a written prefix | + +## 1. Make transformed Redis contracts match phpredis + +### Source changes + +Edit `src/redis/src/RedisConnection.php`: + +| Wrapper | Final contract | Required behavior | +|---|---|---| +| `callGet()` | `mixed` | Return every decoded phpredis value unchanged; normalize native `false` to `null` as today. | +| `callSet()` | `mixed` | Accept `mixed $expireResolution`; preserve legacy `EX`/`PX` reshaping and native option arrays/integers. Return ordinary booleans or the previous decoded value from `SET ... GET`. | +| `callZadd()` | `false\|float\|int` | Preserve float scores from `INCR` and native failures. | +| `callMget()` | `array\|false` | Keep the empty-key fast return. Guard a whole-call `false` before mapping element-level missing values to `null`. The whole-call case is primarily Redis Cluster/transport behavior. | +| `callHmget()` | `array\|false` | Guard `false` before `array_values()`. | +| `callZrangebyscore()` / `callZrevrangebyscore()` | `array\|false` | Pass through documented native failures. | +| `callZinterstore()` / `callZunionstore()` | `false\|int` | Pass through documented native failures. | + +`prepareSet()` already distinguishes the Laravel five-argument string form from native options by checking whether the third argument is a string. Widen only the parameter that is genuinely polymorphic; retain the existing TTL and flag types. Add one concise sentence to `callSet()` explaining that `GET` returns the previous decoded value and `false` can mean that no previous value existed, not that the write failed. + +The whole-result guards must precede collection operations: + +```php +$values = $this->connection->mGet($keys); + +if ($values === false) { + return false; +} + +return array_map( + static fn (mixed $value): mixed => $value !== false ? $value : null, + $values, +); +``` + +Do not cast native values to satisfy annotations. Atomic result normalization remains separate from MULTI/PIPELINE: queueing mode must still reshape arguments but return the native `Redis`/`RedisCluster` queue object until `exec()`. + +Update the corresponding class-level `@method` annotations on `RedisConnection` (`set`, `mget`, `zinterstore`, and `zunionstore`; the other affected annotations are already broad enough), then regenerate `src/support/src/Facades/Redis.php` with the repository facade tool after source signatures settle. Inspect the generated diff so `get`, `set`, `mget`, `hmget`, `zadd`, both score-range methods, and both store methods advertise the corrected unions. Remove the completed transformed-return-type audit item from `docs/todo.md`. + +### Tests + +Extend `tests/Redis/RedisConnectionTest.php` with focused native-result doubles: + +- `GET` returns an array, object, integer, and `null` normalization for native `false` without a `TypeError`. +- `SET` accepts a native options array atomically, returns decoded scalar/array/object previous values for `GET`, preserves `true` for ordinary writes, and preserves `false` for absent/conditional results. +- The same options are reshaped correctly while queued and the queued native object is returned unchanged. +- `ZADD ... INCR` returns a float; `ZADD` and every listed wrapper preserve native `false`. +- `MGET` still maps false elements to `null`, returns `[]` without a native call for empty keys, and preserves a whole-call `false`. +- `HMGET` applies `array_values()` only to arrays. + +Extend `tests/Integration/Redis/RedisProxyIntegrationTest.php` where a configured Redis service is available: + +- Strengthen the existing PHP serializer case to round-trip arrays, objects, and integers, not only a string. +- Exercise `SET` with `['GET']` and `['GET', 'EX' => ...]`, including a decoded prior array/integer and the no-prior-value `false` result. +- Exercise `ZADD INCR` and assert its float result after the write. +- Keep unit coverage authoritative for Cluster-only whole-call `MGET === false`; do not require a Cluster service solely for that sentinel. + +The cache package remains unaffected because `StoreContext::withConnection()` defaults to `transform: false`; retain a focused assertion or source trace rather than changing cache code. + +## 2. Make Redis event dispatch reentrant without adding ordinary-command overhead + +### Ownership design + +Edit `src/redis/src/RedisProxy.php`. The existing command flow must remain: + +1. borrow or reuse a wrapper; +2. execute the command; +3. synchronously dispatch the matching command event if listeners exist; +4. permanently hand off successful stateful commands or release the wrapper; +5. preserve current exception precedence: listener failure, then command failure, then cleanup failure. + +When an event listener exists and the command did not begin with a context connection, temporarily publish the exact owned `RedisConnection` under this proxy's normal context key for dispatch only: + +```php +CoroutineContext::set($contextKey, $connection); + +try { + $dispatcher->dispatch($event); +} finally { + CoroutineContext::forget($contextKey); +} +``` + +Put this in one private event-dispatch helper used for both `CommandExecuted` and `CommandFailed`. If the command already began with a context connection, dispatch directly and leave that context untouched. Call the helper only after `hasListeners()` succeeds, so ordinary commands with no listeners gain no context operations or helper work beyond their existing guard. + +The temporary publication is not a deferred-release owner. After it is removed, the existing cleanup block remains the sole owner of release or permanent handoff. Successful outer `multi`, `pipeline`, `select`, and `watch` commands are therefore stored durably only after dispatch. If the failed wrapper was invalidated, a nested listener command calls `getConnection()` on that same wrapper and reconnects it before reuse; the outer cleanup still releases it exactly once. + +Add one short internal WHY comment at the temporary boundary: synchronous listeners must reuse the leased wrapper to avoid a reentrant pool checkout/deadlock, and nested commands deliberately retain Laravel's event/reentrancy semantics. This records the failure-mode change: an unconditional listener for the same command will recurse rather than eventually fail at the pool wait timeout. Do not add a public listener warning or recursion guard; listener code that reacts to the same command must use its normal condition to avoid recursion. + +### Tests + +Add to `tests/Integration/Redis/RedisProxyIntegrationTest.php`: + +- a named connection with `max_connections = 1` and a short test `wait_timeout`; +- a `CommandExecuted` listener filtered to the outer command/connection that performs a nested command on the same proxy; +- assertions that the nested command completes, sees the outer write, and the pool remains reusable. The old code must fail by attempting a second checkout. + +Add focused ownership tests to `tests/Redis/RedisProxyTest.php`: + +- ordinary non-stateful commands with no listeners never publish context; +- success and failure events see the exact wrapper in context and remove only a temporary publication afterward; +- pre-existing context is preserved; +- a nested command during a failure event can reconnect an invalidated wrapper, `markReconnected()` makes it valid again, and outer cleanup returns that wrapper to the pool rather than discarding it; +- listener exceptions still clean temporary context, still allow stateful outer-command handoff, and retain existing exception precedence; +- a listener during outer MULTI/PIPELINE queues commands on the caller's already-open native transaction, matching Laravel's single-connection behavior. + +Do not add `RedisManager::listen()` documentation for the MULTI/PIPELINE case: this is the normal consequence of executing a nested command on the current connection, not an unsupported or broken mode. + +## 3. Track selected databases at the connection boundary + +### Source changes + +In `RedisConnection::__call()`, record the selected database only when an atomic `SELECT` was actually applied. Atomic phpredis `select()` returns `true`; while MULTI/PIPELINE is queueing it returns the native `Redis` queue object, so the exact result check prevents an unexecuted or later-discarded selection from becoming reconnect state: + +```php +if ($name === 'select' && $result === true && array_key_exists(0, $arguments)) { + $this->database = (int) $arguments[0]; +} +``` + +Keep this beside the existing WATCH/EXEC state tracking. It covers proxy calls, calls made through `withConnection()`, and calls made through a pinned proxy. Its sole purpose is reconnect safety when the current native client is no longer available to inspect; release-time cleanup does not depend on inferred wrapper state. + +The supported MULTI/PIPELINE API deliberately returns or passes the raw phpredis object, so queued commands do not cross `RedisConnection::__call()`. Preserve that Laravel-compatible API and observe native state at the two lifecycle boundaries that need it: + +- in `PhpRedisConnection::reconnect()`, before replacing an existing connected standalone `Redis` instance, read its `getDBNum()` into `$database`; this preserves the database the old native generation actually entered, including a raw queued `SELECT` that executed, while an aborted/discarded queue reports the unchanged database; +- in `RedisConnection::release()`, retain the existing queueing/WATCH detection, CRITICAL diagnostic, and discard branch. Immediately before database restoration, if a standalone `Redis` client is disconnected, mark the wrapper invalid without calling `getDBNum()` or `select()`; otherwise compare its connected `getDBNum()` with the configured database and restore only when they differ. The normal `finally` clears tracked database/WATCH state and returns the invalid wrapper object to the pool, where the next borrower makes `check()` reject and reconnect its native generation directly to the configured database. + +Resolve the reconnect target before constructing the replacement client: + +```php +$database = $this->connection instanceof Redis && $this->connection->isConnected() + ? $this->connection->getDBNum() + : ($this->database ?? $this->config['database']); +``` + +Use that resolved value for the new client's `select()` without converting a raw queued intention into state. The `isConnected()` guard is required: phpredis's `getDBNum()` goes through `redis_sock_get_connected()`, whose connection accessor can reopen a disconnected socket, so reconnect must not trigger an unnecessary first reconnection merely to inspect it. Keep the existing tracked atomic value available as the fallback for an explicitly closed/null or disconnected native client. + +The release guard belongs after the queueing/WATCH branch. Native `getMode()` reads the stored `RedisSock` through `redis_sock_get_instance()` without opening a socket, so a disconnected wrapper that was abandoned in MULTI/PIPELINE or WATCH state must still take the existing logged discard path. Only `getDBNum()` reaches the reconnecting accessor and therefore needs the connectedness guard. On an otherwise atomic disconnected wrapper, returning the invalid wrapper object through the normal `finally` is intentional: the next checkout makes `getActiveConnection()` replace its native generation, and the cleared database fallback selects `config['database']`. Do not reconnect during cleanup or return a valid-looking client whose phpredis `dbNumber` can be replayed for the next borrower. + +On the connected-client paths above, `getDBNum()` returns phpredis's local `redis_sock->dbNumber` (measured locally at approximately 0.022 microseconds per call) and sends no Redis command. phpredis nevertheless declares `getDBNum(): int` while its disconnected C branch executes `RETURN_FALSE`; the explicit connectedness guards make that false sentinel unreachable and avoid another narrow static-analysis workaround. The release read is therefore simpler and more correct than a flag spread across exposure/reconnect/close/release paths, while adding no work to command execution. The `instanceof Redis` guard directly excludes Cluster, whose `getDBNum()` returns `false`, and safely handles a wrapper whose native client was explicitly closed. Abandoned queues retain the existing safer behavior of discarding the entire native generation before this cleanup path. + +The reconnect observation must happen before `PhpRedisConnection::reconnect()` chooses `$this->database ?? $this->config['database']` and replaces the old client. This covers native MULTI/PIPELINE chaining without speculative queue state. If the native client is already null, the last successfully applied atomic wrapper `SELECT` remains the fallback. `release()` then restores the configured database before returning a healthy wrapper to the pool. Do not add a database-dirty flag, `client()` special case, close-time synchronization, Cluster branch, or database-state lookup to ordinary command execution; release cleanup owns the connected client's local read. + +Remove all proxy-only bookkeeping: + +- delete the `select` branch that calls `$connection->setDatabase()` in `RedisProxy::__call()`; +- delete `RedisConnection::setDatabase()`; +- remove `setdatabase` from `RedisProxy::CONNECTION_BOUND_METHODS`; +- remove `setDatabase` from `Redis` facade-documenter exclusions; +- remove test mocks and direct setup calls that exist only for the setter. + +Do not add a Cluster `select` branch. `RedisCluster` has no `select`; an unsuccessful/missing native call cannot reach post-success tracking. Do not wrap the raw client or add flag-driven conditional cleanup. + +### Tests + +Rewrite setter-based cases in `tests/Redis/RedisConnectionTest.php`, `RedisProxyTest.php`, `RedisProxyNonCoroutineTest.php`, `RedisPoolHeartbeatTest.php`, and `MultiExecTest.php` to establish state through a real successful `select` call. + +Cover, with a one-slot pool or exact native-client doubles: + +- ordinary proxy `select` remains pinned and release restores the configured database; +- `withConnection(fn (RedisConnection $connection) => $connection->select(...))` restores on release; +- `withPinnedConnection(fn () => $proxy->select(...))` restores on release; +- a queued wrapper `select` is not recorded as applied before `exec()`, and release restores from the actual post-transaction state; +- raw callback-form and chaining-form MULTI/PIPELINE `select` calls restore the actual post-`exec()` database before release, including an aborted `exec()` and `discard()`; +- reconnect after a raw MULTI/PIPELINE selection preserves the old native client's actual database, not a queued intention; +- reconnect does not call `getDBNum()` or reopen an old native client when `isConnected()` is false, and instead uses the last applied atomic selection/configured fallback; +- release preserves queueing/WATCH detection, then marks an otherwise atomic disconnected standalone client invalid without `getDBNum()` or an implicit socket reopen; the subsequent borrower reconnects directly to the configured database rather than inheriting the previous phpredis `dbNumber`; +- a disconnected wrapper left in MULTI/PIPELINE or WATCH state still emits the existing CRITICAL diagnostic and is discarded instead of being requeued as an invalid wrapper; +- invalidating/reconnecting after selection reconnects to the selected database, and later release restores the configured database; +- failed `select === false` and queue-object `select` results do not change tracked state; +- selected state remains coroutine-local through each wrapper's ownership, as the existing integration isolation test requires. + +## 4. Configure every secondary Swoole port explicitly + +### Source changes + +Edit the secondary branch in `src/server/src/Server.php`: + +```php +$settings = array_replace($config->getSettings(), $server->getSettings()); + +if ($slaveServer->set($settings) === false) { // narrow PHPStan ignore: Swoole's void arginfo is wrong + throw new ServerException("Failed to configure server [{$name}]."); +} +``` + +Call `Port::set()` even when the secondary has no local settings. Swoole stores the settings applied through the primary `Server::set()` and otherwise copies that first port's complete settings into untouched secondary ports during startup. Explicitly applying `global + this secondary local` prevents first-listener protocol/TLS options from leaking while still delivering global port-level settings such as `document_root`, HTTP/2, compression, and socket buffer options. `array_replace()` preserves the existing local-over-global precedence. + +The installed IDE helper and Swoole stub declare `Port::set(): void`, but the 6.2.2 C implementation has `RETURN_FALSE` branches and falls through with `null` on success. Use only the exact PHPStan identifier reported for this comparison, with a WHY note naming the upstream contract defect. Do not widen global static-analysis configuration. + +Keep the primary `Server::set()` path unchanged. Swoole itself calls primary `Port::set()` without observing its result and returns `true`; applying settings twice or reimplementing SSL validation locally would be a fragile workaround. The upstream handoff owns that remaining native inconsistency. + +### Tests + +Extend `tests/Server/ServerTest.php` for the mocked configuration cases, and put the native contract regression in a separate `tests/Server/ServerNativeTest.php` whose `protected bool $runTestsInCoroutine = false` keeps real Swoole server construction outside `RunTestsInCoroutine` without changing the existing suite's execution mode: + +- a mocked two-port configuration proves the main server receives `global + main local`, and the secondary always receives exactly `global + secondary local`; +- explicitly assert that a main-only setting does not appear on the secondary, a secondary override wins, and a secondary with no local settings still receives global settings; +- skip the native test when `SWOOLE_SSL` is undefined; otherwise construct a real Swoole 6.2.2 HTTP primary bound to `127.0.0.1:0` and a TCP+SSL secondary also bound to `127.0.0.1:0`, so parallel workers never share a fixed port; +- set malformed `ssl_sni_certs` on the SSL secondary whose hostname value is not an array, contain the expected warning locally, and assert `ServerException("Failed to configure server [name].")` occurs before that secondary's callbacks, `ServerManager` publication, `beforeStart`, and `BeforeServerStart` event; +- do not mock `Port::set()` returning `false`: the generated `void` signature makes that double misleading and cannot validate the runtime mismatch. + +Keep the real test process-local and never start the server; object teardown releases both ephemeral listeners. Do not move it into an isolated subprocess unless direct non-coroutine construction proves nondeterministic under the repository's ParaTest runner. + +## 5. Bound event lookup caches and remove redundant wildcard caches + +### Source changes + +Edit `src/events/src/Dispatcher.php`: + +- remove `wildcardsCache` and `observerWildcardsCache`, including their declarations, registration invalidations, assignments, reads, and selective loops in `forget()`; +- have `getWildcardListeners()` and `getWildcardObservers()` return their computed arrays directly; +- retain `listenersCache`, `observersCache`, and `hasListenersCache`, because those avoid repeated listener construction/interface resolution/wildcard scans; +- add one private `10_000` entry limit shared by all three caches and one private insertion helper. + +The wildcard-only caches are write-only in steady state: `getListeners()`/`getObservers()` write the final cache on the same miss, and every later call returns that final value before consulting the wildcard cache. Removing them reduces memory and invalidation code without changing behavior. + +The insertion helper should flush only the individual cache receiving a new miss: + +```php +private function cacheEventLookup(array &$cache, string $eventName, mixed $value): mixed +{ + if (count($cache) >= self::EVENT_CACHE_LIMIT) { + $cache = []; + } + + return $cache[$eventName] = $value; +} +``` + +Bind the helper's value type explicitly so PHPStan preserves the concrete return at every call site, especially the `bool` required by `hasListeners()`: + +```php +/** + * @template TValue + * @param array $cache + * @param TValue $value + * @return TValue + */ +``` + +The native signature still takes `array &$cache` so the flushed/replaced map is returned to the caller by reference. Call the helper only after the existing `isset` hit checks and after computing a miss. The hot hit remains a single `isset`; a miss adds one `count`/comparison. Full per-cache flush is intentional: it is bounded, allocation-free eviction metadata, and lets a changed working set recache. A 10,000-entry bound avoids thrashing ordinary Eloquent model-event namespaces (roughly fifteen names per model) while bounding high-cardinality external names; a local empty-result probe placed all three maps at roughly 2 MiB per worker at the limit. + +`getListeners()` and `getObservers()` are public, so eviction is observable to callers that enumerate more than 10,000 distinct event names in one worker: an evicted lookup is recomputed and freshly prepared closures may have new object identities. Listener resolution and dispatch results remain unchanged. This is the deliberate bounded-memory contract; do not imply that the cap is invisible or preserve closure identity with a second unbounded structure. + +Listener and observer registries remain unbounded because they contain intentional boot-time registrations, not request-derived lookup names. Existing `listen()`, `observe()`, and `forget()` full invalidations remain authoritative. + +### Tests + +Add cache-boundary cases to `tests/Events/EventsDispatcherTest.php` or `CoroutineEventsTest.php` using reflection/test subclasses to seed each protected cache to the production constant. Do not dispatch 10,000 events merely to reach the boundary. + +For each retained cache: + +- a hit at the limit remains cached and does not flush; +- the next distinct miss flushes that cache and inserts the new result; +- the other two caches are untouched; +- a previously evicted event recomputes correctly; +- false `hasListeners` entries remain cache hits; +- exact, wildcard, interface, and observer resolution still return the same callbacks after eviction; +- listener/observer registration and `forget()` still invalidate all affected final caches. + +Search the repository after implementation to prove both removed wildcard cache names have no references. + +## 6. Render scheduler durations with the existing time formatter + +Edit `src/console/src/Commands/ScheduleRunCommand.php`: + +- import and use `Hypervel\Support\InteractsWithTime`; +- change the finish format placeholder from `%sms` to `%s`; +- pass `$this->runTimeForHumans($start)` instead of rounded seconds. + +`runTimeForHumans()` multiplies seconds by 1,000, renders sub-second work in milliseconds, and cascades longer durations to concise human units. It is already the framework convention in console `Task` and queue `WorkCommand`. Keep `ScheduledTaskFinished::$runtime` unchanged in seconds. + +In `tests/Console/Scheduling/ScheduleRunCommandTest.php`, use a test subclass that overrides the protected formatter with a fixed value such as `1.50s`, then run a real successful event through `runEvent()`. Assert the final line contains the value exactly once and never appends a second `ms`. This proves the command delegates and removes its old literal suffix. + +Also add deterministic shared-formatter coverage in `tests/Foundation/FoundationInteractsWithTimeTest.php`. Expose `Hypervel\Support\InteractsWithTime::runTimeForHumans()` through a tiny test fixture and pass explicit start/end values; cover a sub-second interval as milliseconds and representative values above one second through the cascading branch. These assertions exercise the trait's `* 1000` conversion directly, which the command-delegation double otherwise bypasses and which console `Task` and queue `WorkCommand` also rely on. Do not add a production clock seam, sleep, `hrtime()` refactor, or duplicate the formatter in the command. + +## 7. Make database assertion diagnostics encoding-safe + +Edit these Laravel-ported constraints: + +- `src/testing/src/Constraints/HasInDatabase.php` +- `src/testing/src/Constraints/SoftDeletedInDatabase.php` +- `src/testing/src/Constraints/NotSoftDeletedInDatabase.php` + +At all seven `json_encode()` sites, include: + +```php +JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR +``` + +For `HasInDatabase::toString($options)`, OR these flags into the caller's integer options so `JSON_PRETTY_PRINT` and other formatting survive. Restore Laravel parity in `HasInDatabase::failureDescription()` by passing `JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE`; the safety flags are added by `toString()`. Keep `JSON_UNESCAPED_UNICODE` on both existing `HasInDatabase` additional-info branches while adding the safety flags. The two soft-delete constraints retain their upstream formatting choices plus the safety flags. + +These two tolerant flags deliberately return a string for malformed UTF-8, binary bytes, non-finite floats, recursion, and depth overflow. Do not add `JSON_THROW_ON_ERROR`, a fallback based on `json_last_error_msg()`, or a shared encoder: assertion diagnostics should remain best-effort and preserve the three upstream class structures. + +Add focused coverage in `tests/Foundation/FoundationInteractsWithDatabaseTest.php` (or a small `tests/Testing/Constraints` suite if it materially simplifies direct constraint coverage): + +- all three `toString()` methods return strings containing replacement output for invalid UTF-8/binary values rather than throwing `TypeError`; +- `HasInDatabase` keeps readable unescaped Unicode in the pretty failure description; +- both `HasInDatabase` additional-info branches and both soft-delete additional-info paths remain strings when query results contain malformed bytes; +- one representative recursive/non-finite/depth case proves partial-output behavior without asserting unstable JSON error prose; +- the surrounding assertion still fails as a PHPUnit expectation with useful table/attribute diagnostics, not an encoding exception. + +## 8. Make fake HTTP sinks match real transport completion and seekability + +Edit only `PendingRequest::sinkStubHandler()` in `src/http/src/Client/PendingRequest.php`. + +Keep the string-path branch's exact byte-count check. For resource and PSR-7 sinks, calculate body length once and repeatedly write the unwritten suffix until complete. Treat `false` or zero progress before completion as failure; an empty body is already complete. On the common full-write path each branch still performs one write. + +For resources: + +- retain warning suppression around `fwrite()` and normalize failure to the current runtime exception; +- inspect `stream_get_meta_data($sink)['seekable']` and call `rewind()` only when true; +- if an attempted rewind returns `false`, throw a clear runtime exception; +- never close the caller's resource. + +For `StreamInterface`: + +- use each returned byte count to advance through the suffix; +- throw on zero progress; allow native stream exceptions to propagate; +- call `rewind()` only when `isSeekable()` is true, matching Guzzle `CurlFactory::finish()`; +- let a seekable stream's rewind exception propagate. + +Do not extract a writer class or combine PHP resources and PSR streams behind a new abstraction. The two native interfaces have different failure and seekability APIs, and the code is local to the fake handler. + +Extend `tests/Http/HttpClientTest.php`: + +- preserve existing complete path/resource/PSR cases; +- a partial PSR stream receives the exact remaining suffix until the complete body is present; +- a zero-progress PSR stream fails deterministically; +- a nonblocking resource that accepts a positive prefix and then cannot progress throws instead of silently truncating; +- nonseekable resource and PSR sinks receive the full body and are not rewound; +- seekable sinks still end at offset zero, and an actual rewind failure is propagated; +- failed fake requests remain recorded exactly as current tests require. + +## 9. Complete log records without duplicate replay + +Edit `src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php`, shared by Hypervel's `StreamHandler` and `RotatingFileHandler`. + +Format the record once, acquire the optional lock once for the logical attempt, and loop over unwritten suffixes until the full record is written. Release the lock in `finally`. `false` and zero progress are terminal for that attempt. + +Preserve the one URL reopen retry with a stricter safety condition: + +- retry only when zero bytes of the record were written, this is the first attempt, and the URL is neither null nor `php://memory`; +- after a positive prefix, never replay from byte zero because that would duplicate log content; throw instead; +- caller-supplied resources are never closed or retried through a URL; +- URL-backed blocking file/stdout streams retain their one-write normal path; +- no readiness polling or coroutine scheduling is introduced. + +Simplify inode rotation handling so closing a changed inode and opening the current URL does not consume the one write-failure retry. Close the stale stream and continue in the same first attempt instead of recursively marking the refreshed stream as already retrying. This is safe without a second inode-refresh guard because `closeStreamSafely()` clears `safeInodeUrl`, while `hasStreamInodeChanged()` can enter only when that property is non-null; opening the replacement stream establishes one new baseline rather than re-entering refresh. Preserve that invariant explicitly when restructuring the loop. This restores Monolog's intended distinction between inode refresh and write retry while reducing recursion. + +Representative loop shape: + +```php +$contents = (string) $record->formatted; +$length = strlen($contents); +$offset = 0; + +while ($offset < $length) { + $written = fwrite($stream, $offset === 0 ? $contents : substr($contents, $offset)); + + if ($written === false || $written === 0) { + break; + } + + $offset += $written; +} +``` + +Extend `tests/Log/StreamHandlerTest.php` and its local stream wrapper: + +- a wrapper that accepts small prefixes eventually records the complete formatted line exactly once; +- false/zero before any bytes causes one URL reopen and one retry, preserving the existing test; +- positive-prefix then false/zero throws, opens only once, and the stored prefix is not duplicated; +- one logical attempt with locking has one acquire/unlock pair even across multiple partial writes; +- two consecutive external inode replacements are each detected on their next write and cause exactly one reopen; a zero-progress write after the second refresh still has the independent one-time write-failure reopen available; +- a caller-owned nonblocking resource reproduces the former positive-short-write truncation and now fails on no progress; +- caller resources remain open after handler close/failure; +- `RotatingFileHandler` still writes through the shared boundary and rotates normally. + +Retain the current normalized exception context and safe open/directory behavior. + +## 10. Gate Boost documentation until Boost exists + +`src/boost` has only package metadata, a README, license, and a dependency on `hypervel/docs`; it has no autoload surface, provider, command, installer, or tools. Do not build that product in this audit PR. + +Edit `src/docs/installation.md` to remove: + +- the `Hypervel and AI` / installer table-of-contents entries; +- the entire `Hypervel and AI`, `Installing Hypervel Boost`, and custom-guidelines section; +- every `composer require hypervel/boost` and `boost:install` instruction. + +Edit `src/boost/README.md` to remove the now-invalid installation documentation link/anchor, leaving its minimal title and badge. Make `src/boost/composer.json`'s description truthful about the reserved/future package instead of claiming it already provides tools and guidelines. Update the Boost item in `docs/todo.md` to future tense: implement the installer/tools first, then add and verify the installation section. Remove its stale statement that current installation docs describe the intended workflow. + +Do not add a placeholder command, service provider, fake package test, or partial MCP/tool roster. After deletion, search all tracked Markdown outside historical plans/TODO handoffs and assert no shipped documentation mentions `boost:install` or claims the tooling exists. + +## Implementation order and verification + +Follow tests-first development within each slice and edit one file at a time as required by `AGENTS.md`: + +1. Redis result contracts and generated facade. +2. Redis event ownership and selected-database tracking. +3. Swoole secondary settings. +4. Dispatcher cache simplification/bounds. +5. Scheduler timing. +6. Testing constraint JSON diagnostics. +7. HTTP fake sink completion/seekability. +8. Log stream completion/retry behavior. +9. Boost documentation and TODO cleanup. + +For each slice, add/adjust the focused test first, observe the intended failure where practical, implement, and rerun that file. Use at least these focused commands after the slice is complete: + +```shell +composer test -- tests/Redis/RedisConnectionTest.php tests/Redis/RedisProxyTest.php tests/Redis/RedisProxyNonCoroutineTest.php tests/Redis/MultiExecTest.php tests/Redis/RedisPoolHeartbeatTest.php +composer test -- tests/Integration/Redis/RedisProxyIntegrationTest.php +composer facade "Hypervel\\Support\\Facades\\Redis" +composer facade -- --lint "Hypervel\\Support\\Facades\\Redis" +composer test -- tests/Server/ServerTest.php tests/Server/ServerNativeTest.php +composer test -- tests/Events/EventsDispatcherTest.php tests/Events/CoroutineEventsTest.php +composer test -- tests/Console/Scheduling/ScheduleRunCommandTest.php tests/Foundation/FoundationInteractsWithTimeTest.php +composer test -- tests/Foundation/FoundationInteractsWithDatabaseTest.php +composer test -- tests/Http/HttpClientTest.php +composer test -- tests/Log/StreamHandlerTest.php +composer --working-dir=src/boost validate --strict +``` + +Redis integration tests are opt-in through the copied `.env`; if the configured service is unavailable, retain deterministic unit coverage and report the skipped environmental verification rather than weakening assertions. The real Swoole test requires the repository floor, 6.2.2. + +After all focused suites pass: + +- run `rg` for removed symbols (`wildcardsCache`, `observerWildcardsCache`, `setDatabase`) and false Boost instructions; +- inspect every generated facade line and every TODO edit for stale claims; +- inspect the complete diff for accidental watcher changes, public API narrowing, broad ignores, new configuration, polling, recursion guards, or dead comments; +- run `composer fix` once at the final checkpoint. This owns formatting, both PHPStan configurations, the parallel suite, Testbench package tests, and dogfood tests; +- inspect `git status --short` to ensure Composer/vendor artifacts and temporary probes are not included. + +## Completion criteria + +- Serializer-backed Redis values and `SET ... GET` cross the facade without post-mutation `TypeError`; documented false/float results remain observable. +- A Redis command event can make a nested same-connection command with a one-slot pool, with exact context cleanup and no no-listener hot-path cost. +- Every applied atomic wrapper-level `SELECT` is tracked by the owning connection, reconnect observes the old standalone client's actual selected database, and every standalone release restores the configured database without an external setter or dirty-state machinery. +- Every secondary Swoole port receives only global plus its own settings, and a recoverable native false aborts configuration before publication. +- Dynamic event names cannot grow any lookup cache beyond 10,000 entries; removed wildcard caches leave no dead invalidation code. +- Scheduler output uses truthful human-readable units while event runtime remains seconds. +- All database constraint diagnostics return useful strings for malformed data and preserve Laravel's Unicode formatting. +- Fake HTTP sinks and log handlers either write every byte exactly once or fail deterministically; neither spins nor closes caller resources. +- Shipped docs contain no Boost installation command until the package implements it, while TODOs accurately describe remaining future work. +- No watcher implementation/config/docs/tests change in this branch, no Laravel API is broken, and the full repository quality gate passes. From 3180762043f6b9b7dfd22d7943e9eaddfdd4f9af Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:22:12 +0000 Subject: [PATCH 03/22] Clarify worker-lifetime cache key bounds Require caches retained in static properties or singleton instances to use naturally limited key sets or discard entries that can be safely recomputed. Reject size limits as a way to hide accidental growth from request-derived or user-derived keys. This keeps reviews focused on correcting the cache key design instead of capping a worker-lifetime leak. --- AGENTS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/AGENTS.md b/AGENTS.md index 540ca16c3..da02201c6 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -347,6 +347,7 @@ Decide where state lives before writing code: - **Use `Hypervel\Context\CoroutineContext` for invocation-scoped state** — anything that must not be visible to other concurrent coroutines in the same worker. Static properties and singleton fields leak across coroutines: whatever one coroutine sets becomes visible to all others in the worker. Use the established key-naming convention: `__.` value prefix, `_CONTEXT_KEY` / `_CONTEXT_KEY_PREFIX` constant suffixes, public only when other classes or tests reference the constant. Do not use `Hypervel\Support\Facades\Context` as the low-level coroutine store; it provides Laravel-style application context instead. - **Configure process-global values only during worker boot** — config is a process-global singleton, so `Config::set()` during request handling changes behavior for every concurrent request in the worker. Never mutate config for request-specific behavior; use `CoroutineContext` or middleware instead. Provider boot-time configuration is fine — it runs once per worker. - **Name static cache properties for what they store** — not with a `Cache` suffix; static properties in Swoole workers are caches by nature. Exception: matching an existing Laravel-ported pattern in the same class (e.g. `$classCastCache`, `$attributeCastCache` on `HasAttributes`). +- Any cache retained across requests in a worker—for example, in static properties or properties on singleton instances—must either have a naturally limited set of keys or deliberately discard entries that can safely be recomputed. Do not add a size limit merely to hide accidental growth from request- or user-derived keys. - **Review worker-lifetime state explicitly** — whenever a change introduces or modifies static properties/caches, singletons or other long-lived state, STOP and report the Swoole persistence impact (memory leaks, cross-request behavior) with a recommendation. - **Document worker-lifetime mutators** — when adding or touching a public method that mutates static state, singleton-held configuration, manager registries, cached drivers, global callbacks, or other worker-lifetime state, add a short warning to the method docblock if the method is intended only for boot-time configuration or tests. Use the tag-first format so humans and LLMs can recognize it quickly: - `Boot-only.` — for startup configuration methods From b260dad281c7043abbd6ba3a0fe58f34216df8f3 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:22:30 +0000 Subject: [PATCH 04/22] Correct pooled Redis command and connection contracts Preserve decoded SET GET values, floating-point ZADD INCR results, command-level false sentinels, and native queue objects across transformed Redis calls. Align the source metadata and generated facade with the supported phpredis and RedisCluster surfaces. Make command listeners reuse the wrapper already leased by the outer operation so nested listener commands cannot deadlock a one-slot pool. Keep temporary context ownership scoped to synchronous event dispatch and preserve the existing release and exception ordering. Move selected-database tracking to RedisConnection, inherit the native client database across reconnects, and restore the configured database safely on release. Normalize standalone database configuration to integers before constructing a connection, including URL-derived and Laravel-style string values. Cover atomic and queued commands, reconnect and cleanup failures, listener reentrancy, URL configuration, facade metadata, and pool ownership behavior. --- src/redis/src/PhpRedisConnection.php | 12 +- src/redis/src/RedisConfig.php | 1 + src/redis/src/RedisConnection.php | 497 ++++++++--------- src/redis/src/RedisProxy.php | 56 +- src/support/src/Facades/Redis.php | 385 +++++++------- .../Redis/RedisProxyIntegrationTest.php | 163 +++++- tests/Redis/MultiExecTest.php | 1 - tests/Redis/PackageMetadataTest.php | 8 +- tests/Redis/RedisConfigTest.php | 23 +- tests/Redis/RedisConnectionTest.php | 498 +++++++++++++++++- tests/Redis/RedisPoolHeartbeatTest.php | 10 +- tests/Redis/RedisProxyNonCoroutineTest.php | 7 +- tests/Redis/RedisProxyTest.php | 156 +++++- 13 files changed, 1336 insertions(+), 481 deletions(-) diff --git a/src/redis/src/PhpRedisConnection.php b/src/redis/src/PhpRedisConnection.php index bfbd86960..8000f7f4d 100644 --- a/src/redis/src/PhpRedisConnection.php +++ b/src/redis/src/PhpRedisConnection.php @@ -38,6 +38,10 @@ public function __construct(Container $container, PoolInterface $pool, array $co */ public function reconnect(): bool { + $database = $this->connection instanceof Redis && $this->connection->isConnected() + ? $this->connection->getDBNum() + : ($this->database ?? $this->config['database']); + $sentinel = $this->config['sentinel']['enabled'] ?? false; $redis = $sentinel @@ -56,9 +60,10 @@ public function reconnect(): bool ); } - $database = $this->database ?? $this->config['database']; - if ($database > 0) { - $redis->select($database); + if ($database > 0 && $redis->select($database) !== true) { + throw new ConnectionException( + "Failed to select Redis database [{$database}] on connection [{$this->getName()}]." + ); } $name = $this->config['name']; @@ -67,6 +72,7 @@ public function reconnect(): bool } $this->connection = $redis; + $this->database = $database; $this->markReconnected(); if ($this->config['events'] && $this->container->bound('events')) { diff --git a/src/redis/src/RedisConfig.php b/src/redis/src/RedisConfig.php index 8680f8dd6..66c0e4e24 100644 --- a/src/redis/src/RedisConfig.php +++ b/src/redis/src/RedisConfig.php @@ -76,6 +76,7 @@ public function connectionConfig(string $name): array 'database' => 0, 'name' => null, ]; + $connectionConfig['database'] = (int) $connectionConfig['database']; } $sharedOptions = $redisConfig['options']; diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index d1a4b437b..cfce7f09a 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -36,294 +36,294 @@ * Abstract base class for pooled Redis connections with Laravel-style method transformations. * * @method mixed get(string $key) Get the value of a key - * @method bool set(string $key, mixed $value, mixed $expireResolution = null, mixed $expireTTL = null, mixed $flag = null) Set the value of a key - * @method array mget(array $keys) Get the values of multiple keys - * @method bool|int|Redis setnx(string $key, mixed $value) Set key if not exists - * @method bool|int|Redis setNx(string $key, mixed $value) Set key if not exists - * @method array|false|Redis hmget(string $key, array $fields) Get hash field values - * @method bool|Redis hmset(string $key, array $fieldValues) Set hash field values - * @method bool|int|Redis hsetnx(string $hash, string $key, mixed $value) Set hash field if not exists + * @method mixed set(string $key, mixed $value, mixed $expireResolution = null, int|null $expireTTL = null, string|null $flag = null) Set the value of a key + * @method array|false|Redis|RedisCluster mget(array $keys) Get the values of multiple keys + * @method bool|int|Redis|RedisCluster setnx(string $key, mixed $value) Set key if not exists + * @method bool|int|Redis|RedisCluster setNx(string $key, mixed $value) Set key if not exists + * @method array|false|Redis|RedisCluster hmget(string $key, array $fields) Get hash field values + * @method bool|Redis|RedisCluster hmset(string $key, array $fieldValues) Set hash field values + * @method bool|int|Redis|RedisCluster hsetnx(string $hash, string $key, mixed $value) Set hash field if not exists * @method mixed hget(string $key, string $member) Get hash field value - * @method false|int|Redis hset(string $key, mixed ...$fields_and_vals) Set hash field values + * @method false|int|Redis|RedisCluster hset(string $key, mixed ...$fields_and_vals) Set hash field values * @method false|int lrem(string $key, int $count, mixed $value) Remove list elements - * @method false|int|Redis llen(string $key) Get list length - * @method null|array|false|Redis blpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking left pop from list - * @method null|array|false|Redis brpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking right pop from list + * @method false|int|Redis|RedisCluster llen(string $key) Get list length + * @method null|array|false|Redis|RedisCluster blpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking left pop from list + * @method null|array|false|Redis|RedisCluster brpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking right pop from list * @method mixed spop(string $key, int $count = 0) Remove and return random set member - * @method false|int|Redis sRem(string $key, mixed $value, mixed ...$other_values) Remove members from set - * @method false|float|int|Redis zadd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) Add members to sorted set - * @method false|int|Redis zcard(string $key) Get sorted set cardinality - * @method false|int|Redis zcount(string $key, int|string $start, int|string $end) Count sorted set members by score range - * @method array|false|Redis zrangebyscore(string $key, string $min, string $max, array $options = []) Get sorted set members by score range - * @method array|false|Redis zrevrangebyscore(string $key, string $max, string $min, array $options = []) Get sorted set members by score range (reverse) - * @method int zinterstore(string $output, array $keys, array $options = []) Intersect sorted sets - * @method int zunionstore(string $output, array $keys, array $options = []) Union sorted sets + * @method false|int|Redis|RedisCluster sRem(string $key, mixed $value, mixed ...$other_values) Remove members from set + * @method false|float|int|Redis|RedisCluster zadd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) Add members to sorted set + * @method false|int|Redis|RedisCluster zcard(string $key) Get sorted set cardinality + * @method false|int|Redis|RedisCluster zcount(string $key, int|string $start, int|string $end) Count sorted set members by score range + * @method array|false|Redis|RedisCluster zrangebyscore(string $key, float|int|string $min, float|int|string $max, array $options = []) Get sorted set members by score range + * @method array|false|Redis|RedisCluster zrevrangebyscore(string $key, float|int|string $max, float|int|string $min, array $options = []) Get sorted set members by score range (reverse) + * @method false|int|Redis|RedisCluster zinterstore(string $output, array $keys, array $options = []) Intersect sorted sets + * @method false|int|Redis|RedisCluster zunionstore(string $output, array $keys, array $options = []) Union sorted sets * @method mixed eval(string $script, int $numberOfKeys, mixed ...$arguments) Evaluate Lua script * @method mixed evalsha(string $script, int $numkeys, mixed ...$arguments) Evaluate Lua script by SHA1 * @method mixed flushdb(mixed ...$arguments) Flush database * @method mixed executeRaw(array $parameters) Execute raw Redis command * @method mixed pipeline(callable|null $callback = null) Execute commands in a pipeline - * @method array|false|Redis smembers(string $key) Get all set members - * @method false|int|Redis hdel(string $key, string $field, string ...$other_fields) Delete hash fields - * @method false|int|Redis zrem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members - * @method false|int|Redis hlen(string $key) Get number of hash fields - * @method array|false|Redis hkeys(string $key) Get all hash field names + * @method array|false|Redis|RedisCluster smembers(string $key) Get all set members + * @method false|int|Redis|RedisCluster hdel(string $key, string $field, string ...$other_fields) Delete hash fields + * @method false|int|Redis|RedisCluster zrem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members + * @method false|int|Redis|RedisCluster hlen(string $key) Get number of hash fields + * @method array|false|Redis|RedisCluster hkeys(string $key) Get all hash field names * @method string _serialize(mixed $value) Serialize a value using configured serializer * @method string _digest(mixed $value) * @method string _pack(mixed $value) * @method mixed _unpack(string $value) * @method mixed acl(string $subcmd, string ...$args) - * @method false|int|Redis append(string $key, mixed $value) + * @method false|int|Redis|RedisCluster append(string $key, mixed $value) * @method bool|Redis auth(mixed $credentials) - * @method bool|Redis bgSave() - * @method bool|Redis bgrewriteaof() - * @method array|false|Redis waitaof(int $numlocal, int $numreplicas, int $timeout) - * @method false|int|Redis bitcount(string $key, int $start = 0, int $end = -1, bool $bybit = false) - * @method false|int|Redis bitop(string $operation, string $deskey, string $srckey, string ...$other_keys) - * @method false|int|Redis bitpos(string $key, bool $bit, int $start = 0, int $end = -1, bool $bybit = false) - * @method null|array|false|Redis blPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) - * @method null|array|false|Redis brPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) - * @method false|Redis|string brpoplpush(string $src, string $dst, float|int $timeout) - * @method array|false|Redis bzPopMax(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) - * @method array|false|Redis bzPopMin(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) - * @method null|array|false|Redis bzmpop(float $timeout, array $keys, string $from, int $count = 1) - * @method null|array|false|Redis zmpop(array $keys, string $from, int $count = 1) - * @method null|array|false|Redis blmpop(float $timeout, array $keys, string $from, int $count = 1) - * @method null|array|false|Redis lmpop(array $keys, string $from, int $count = 1) + * @method bool|Redis|RedisCluster bgSave() + * @method bool|Redis|RedisCluster bgrewriteaof() + * @method array|false|Redis|RedisCluster waitaof(int $numlocal, int $numreplicas, int $timeout) + * @method false|int|Redis|RedisCluster bitcount(string $key, int $start = 0, int $end = -1, bool $bybit = false) + * @method false|int|Redis|RedisCluster bitop(string $operation, string $deskey, string $srckey, string ...$other_keys) + * @method false|int|Redis|RedisCluster bitpos(string $key, bool $bit, int $start = 0, int $end = -1, bool $bybit = false) + * @method null|array|false|Redis|RedisCluster blPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking left pop from list + * @method null|array|false|Redis|RedisCluster brPop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking right pop from list + * @method false|Redis|RedisCluster|string brpoplpush(string $src, string $dst, float|int $timeout) + * @method array|false|Redis|RedisCluster bzPopMax(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) + * @method array|false|Redis|RedisCluster bzPopMin(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) + * @method null|array|false|Redis|RedisCluster bzmpop(float $timeout, array $keys, string $from, int $count = 1) + * @method null|array|false|Redis|RedisCluster zmpop(array $keys, string $from, int $count = 1) + * @method null|array|false|Redis|RedisCluster blmpop(float $timeout, array $keys, string $from, int $count = 1) + * @method null|array|false|Redis|RedisCluster lmpop(array $keys, string $from, int $count = 1) * @method bool clearLastError() * @method mixed client(string $opt = '', mixed ...$args) * @method mixed command(string|null $opt = null, mixed ...$args) * @method mixed config(string $operation, array|string|null $key_or_settings = null, string|null $value = null) * @method bool connect(string $host, int $port = 6379, float $timeout = 0, string|null $persistent_id = null, int $retry_interval = 0, float $read_timeout = 0, array|null $context = null) - * @method bool|Redis copy(string $src, string $dst, array|null $options = null) - * @method false|int|Redis dbSize() + * @method bool|Redis|RedisCluster copy(string $src, string $dst, array|null $options = null) + * @method false|int|Redis|RedisCluster dbSize() * @method Redis|string debug(string $key) - * @method false|int|Redis decr(string $key, int $by = 1) - * @method false|int|Redis decrBy(string $key, int $value) - * @method false|int|Redis del(array|string $key, string ...$other_keys) - * @method false|int|Redis delex(string $key, array|null $options = null) - * @method false|int|Redis delifeq(string $key, mixed $value) - * @method false|Redis|string digest(string $key) - * @method false|Redis|string dump(string $key) - * @method false|Redis|string echo(string $str) + * @method false|int|Redis|RedisCluster decr(string $key, int $by = 1) + * @method false|int|Redis|RedisCluster decrBy(string $key, int $value) + * @method false|int|Redis|RedisCluster del(array|string $key, string ...$other_keys) + * @method false|int|Redis|RedisCluster delex(string $key, array|null $options = null) + * @method false|int|Redis|RedisCluster delifeq(string $key, mixed $value) + * @method false|Redis|RedisCluster|string digest(string $key) + * @method false|Redis|RedisCluster|string dump(string $key) + * @method false|Redis|RedisCluster|string echo(string $str) * @method mixed eval_ro(string $script_sha, array $args = [], int $num_keys = 0) * @method mixed evalsha_ro(string $sha1, array $args = [], int $num_keys = 0) * @method array|false|Redis exec() - * @method bool|int|Redis exists(mixed $key, mixed ...$other_keys) - * @method bool|Redis expire(string $key, int $timeout, string|null $mode = null) - * @method bool|Redis expireAt(string $key, int $timestamp, string|null $mode = null) + * @method bool|int|Redis|RedisCluster exists(mixed $key, mixed ...$other_keys) + * @method bool|Redis|RedisCluster expire(string $key, int $timeout, string|null $mode = null) + * @method bool|Redis|RedisCluster expireAt(string $key, int $timestamp, string|null $mode = null) * @method bool|Redis failover(array|null $to = null, bool $abort = false, int $timeout = 0) - * @method false|int|Redis expiretime(string $key) - * @method false|int|Redis pexpiretime(string $key) + * @method false|int|Redis|RedisCluster expiretime(string $key) + * @method false|int|Redis|RedisCluster pexpiretime(string $key) * @method mixed fcall(string $fn, array $keys = [], array $args = []) * @method mixed fcall_ro(string $fn, array $keys = [], array $args = []) - * @method bool|Redis flushAll(bool|null $sync = null) - * @method mixed flushDB(mixed ...$arguments) + * @method bool|Redis|RedisCluster flushAll(bool|null $sync = null) + * @method mixed flushDB(mixed ...$arguments) Flush database * @method array|bool|Redis|string function(string $operation, mixed ...$args) - * @method false|int|Redis geoadd(string $key, float $lng, float $lat, string $member, mixed ...$other_triples_and_options) - * @method false|float|Redis geodist(string $key, string $src, string $dst, string|null $unit = null) - * @method array|false|Redis geohash(string $key, string $member, string ...$other_members) - * @method array|false|Redis geopos(string $key, string $member, string ...$other_members) + * @method false|int|Redis|RedisCluster geoadd(string $key, float $lng, float $lat, string $member, mixed ...$other_triples_and_options) + * @method false|float|Redis|RedisCluster geodist(string $key, string $src, string $dst, string|null $unit = null) + * @method array|false|Redis|RedisCluster geohash(string $key, string $member, string ...$other_members) + * @method array|false|Redis|RedisCluster geopos(string $key, string $member, string ...$other_members) * @method mixed georadius(string $key, float $lng, float $lat, float $radius, string $unit, array $options = []) * @method mixed georadius_ro(string $key, float $lng, float $lat, float $radius, string $unit, array $options = []) * @method mixed georadiusbymember(string $key, string $member, float $radius, string $unit, array $options = []) * @method mixed georadiusbymember_ro(string $key, string $member, float $radius, string $unit, array $options = []) * @method array geosearch(string $key, array|string $position, array|int|float $shape, string $unit, array $options = []) - * @method array|false|int|Redis geosearchstore(string $dst, string $src, array|string $position, array|int|float $shape, string $unit, array $options = []) + * @method array|false|int|Redis|RedisCluster geosearchstore(string $dst, string $src, array|string $position, array|int|float $shape, string $unit, array $options = []) * @method mixed getAuth() - * @method false|int|Redis getBit(string $key, int $idx) - * @method bool|Redis|string getEx(string $key, array $options = []) + * @method false|int|Redis|RedisCluster getBit(string $key, int $idx) + * @method bool|Redis|RedisCluster|string getEx(string $key, array $options = []) * @method int getDBNum() - * @method bool|Redis|string getDel(string $key) + * @method bool|Redis|RedisCluster|string getDel(string $key) * @method string getHost() * @method null|string getLastError() * @method int getMode() * @method mixed getOption(int $option) * @method null|string getPersistentID() * @method int getPort() - * @method false|Redis|string getRange(string $key, int $start, int $end) - * @method array|false|int|Redis|string lcs(string $key1, string $key2, array|null $options = null) + * @method false|Redis|RedisCluster|string getRange(string $key, int $start, int $end) + * @method array|false|int|Redis|RedisCluster|string lcs(string $key1, string $key2, array|null $options = null) * @method float getReadTimeout() - * @method false|Redis|string getset(string $key, mixed $value) + * @method false|Redis|RedisCluster|string getset(string $key, mixed $value) * @method false|float getTimeout() * @method array getTransferredBytes() * @method void clearTransferredBytes() - * @method array|false|Redis getWithMeta(string $key) - * @method false|int|Redis hDel(string $key, string $field, string ...$other_fields) - * @method array|false|Redis hexpire(string $key, int $ttl, array $fields, string|null $mode = null) - * @method array|false|Redis hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) - * @method array|false|Redis hexpireat(string $key, int $time, array $fields, string|null $mode = null) - * @method array|false|Redis hpexpireat(string $key, int $mstime, array $fields, string|null $mode = null) - * @method array|false|Redis httl(string $key, array $fields) - * @method array|false|Redis hpttl(string $key, array $fields) - * @method array|false|Redis hexpiretime(string $key, array $fields) - * @method array|false|Redis hpexpiretime(string $key, array $fields) - * @method array|false|Redis hpersist(string $key, array $fields) - * @method bool|Redis hExists(string $key, string $field) - * @method mixed hGet(string $key, string $member) - * @method array|false|Redis hGetAll(string $key) + * @method array|false|Redis|RedisCluster getWithMeta(string $key) + * @method false|int|Redis|RedisCluster hDel(string $key, string $field, string ...$other_fields) Delete hash fields + * @method array|false|Redis|RedisCluster hexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method array|false|Redis|RedisCluster hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method array|false|Redis|RedisCluster hexpireat(string $key, int $time, array $fields, string|null $mode = null) + * @method array|false|Redis|RedisCluster hpexpireat(string $key, int $mstime, array $fields, string|null $mode = null) + * @method array|false|Redis|RedisCluster httl(string $key, array $fields) + * @method array|false|Redis|RedisCluster hpttl(string $key, array $fields) + * @method array|false|Redis|RedisCluster hexpiretime(string $key, array $fields) + * @method array|false|Redis|RedisCluster hpexpiretime(string $key, array $fields) + * @method array|false|Redis|RedisCluster hpersist(string $key, array $fields) + * @method bool|Redis|RedisCluster hExists(string $key, string $field) + * @method mixed hGet(string $key, string $member) Get hash field value + * @method array|false|Redis|RedisCluster hGetAll(string $key) * @method mixed hGetWithMeta(string $key, string $member) - * @method array|false|Redis hgetdel(string $key, array $fields) - * @method array|false|Redis hgetex(string $key, array $fields, string|array|null $expiry = null) - * @method false|int|Redis hIncrBy(string $key, string $field, int $value) - * @method false|float|Redis hIncrByFloat(string $key, string $field, float $value) - * @method array|false|Redis hKeys(string $key) - * @method false|int|Redis hLen(string $key) - * @method array|false|Redis hMget(string $key, array $fields) - * @method bool|Redis hMset(string $key, array $fieldValues) - * @method array|false|Redis|string hRandField(string $key, array|null $options = null) - * @method false|int|Redis hSet(string $key, mixed ...$fields_and_vals) - * @method bool|int|Redis hSetNx(string $hash, string $key, mixed $value) - * @method false|int|Redis hsetex(string $key, array $fields, array|null $expiry = null) - * @method false|int|Redis hStrLen(string $key, string $field) - * @method array|false|Redis hVals(string $key) - * @method false|int|Redis incr(string $key, int $by = 1) - * @method false|int|Redis incrBy(string $key, int $value) - * @method false|float|Redis incrByFloat(string $key, float $value) - * @method array|false|Redis info(string ...$sections) + * @method array|false|Redis|RedisCluster hgetdel(string $key, array $fields) + * @method array|false|Redis|RedisCluster hgetex(string $key, array $fields, string|array|null $expiry = null) + * @method false|int|Redis|RedisCluster hIncrBy(string $key, string $field, int $value) + * @method false|float|Redis|RedisCluster hIncrByFloat(string $key, string $field, float $value) + * @method array|false|Redis|RedisCluster hKeys(string $key) Get all hash field names + * @method false|int|Redis|RedisCluster hLen(string $key) Get number of hash fields + * @method array|false|Redis|RedisCluster hMget(string $key, array $fields) Get hash field values + * @method bool|Redis|RedisCluster hMset(string $key, array $fieldValues) Set hash field values + * @method array|false|Redis|RedisCluster|string hRandField(string $key, array|null $options = null) + * @method false|int|Redis|RedisCluster hSet(string $key, mixed ...$fields_and_vals) Set hash field values + * @method bool|int|Redis|RedisCluster hSetNx(string $hash, string $key, mixed $value) Set hash field if not exists + * @method false|int|Redis|RedisCluster hsetex(string $key, array $fields, array|null $expiry = null) + * @method false|int|Redis|RedisCluster hStrLen(string $key, string $field) + * @method array|false|Redis|RedisCluster hVals(string $key) + * @method false|int|Redis|RedisCluster incr(string $key, int $by = 1) + * @method false|int|Redis|RedisCluster incrBy(string $key, int $value) + * @method false|float|Redis|RedisCluster incrByFloat(string $key, float $value) + * @method array|false|Redis|RedisCluster info(string ...$sections) * @method bool isConnected() - * @method array|false|Redis keys(string $pattern) - * @method false|int|Redis lInsert(string $key, string $pos, mixed $pivot, mixed $value) - * @method false|int|Redis lLen(string $key) - * @method false|Redis|string lMove(string $src, string $dst, string $wherefrom, string $whereto) - * @method false|Redis|string blmove(string $src, string $dst, string $wherefrom, string $whereto, float $timeout) - * @method array|bool|Redis|string lPop(string $key, int $count = 0) - * @method null|array|bool|int|Redis lPos(string $key, mixed $value, array|null $options = null) - * @method false|int|Redis lPush(string $key, mixed ...$elements) - * @method false|int|Redis rPush(string $key, mixed ...$elements) - * @method false|int|Redis lPushx(string $key, mixed $value) - * @method false|int|Redis rPushx(string $key, mixed $value) - * @method bool|Redis lSet(string $key, int $index, mixed $value) + * @method array|false|Redis|RedisCluster keys(string $pattern) + * @method false|int|Redis|RedisCluster lInsert(string $key, string $pos, mixed $pivot, mixed $value) + * @method false|int|Redis|RedisCluster lLen(string $key) Get list length + * @method false|Redis|RedisCluster|string lMove(string $src, string $dst, string $wherefrom, string $whereto) + * @method false|Redis|RedisCluster|string blmove(string $src, string $dst, string $wherefrom, string $whereto, float $timeout) + * @method array|bool|Redis|RedisCluster|string lPop(string $key, int $count = 0) + * @method null|array|bool|int|Redis|RedisCluster lPos(string $key, mixed $value, array|null $options = null) + * @method false|int|Redis|RedisCluster lPush(string $key, mixed ...$elements) + * @method false|int|Redis|RedisCluster rPush(string $key, mixed ...$elements) + * @method false|int|Redis|RedisCluster lPushx(string $key, mixed $value) + * @method false|int|Redis|RedisCluster rPushx(string $key, mixed $value) + * @method bool|Redis|RedisCluster lSet(string $key, int $index, mixed $value) * @method int lastSave() * @method mixed lindex(string $key, int $index) - * @method array|false|Redis lrange(string $key, int $start, int $end) - * @method bool|Redis ltrim(string $key, int $start, int $end) + * @method array|false|Redis|RedisCluster lrange(string $key, int $start, int $end) + * @method bool|Redis|RedisCluster ltrim(string $key, int $start, int $end) * @method bool|Redis migrate(string $host, int $port, array|string $key, int $dstdb, int $timeout, bool $copy = false, bool $replace = false, mixed $credentials = null) * @method bool|Redis move(string $key, int $index) - * @method bool|Redis mset(array $key_values) - * @method false|int|Redis msetex(array $key_values, int|float|array|null $expiry = null) - * @method bool|Redis msetnx(array $key_values) - * @method bool|Redis multi(int $value = 1) - * @method false|int|Redis|string object(string $subcommand, string $key) + * @method bool|Redis|RedisCluster mset(array $key_values) + * @method false|int|Redis|RedisCluster msetex(array $key_values, int|float|array|null $expiry = null) + * @method bool|Redis|RedisCluster msetnx(array $key_values) + * @method bool|Redis|RedisCluster multi(int $value = 1) + * @method false|int|Redis|RedisCluster|string object(string $subcommand, string $key) * @method bool pconnect(string $host, int $port = 6379, float $timeout = 0, string|null $persistent_id = null, int $retry_interval = 0, float $read_timeout = 0, array|null $context = null) - * @method bool|Redis persist(string $key) + * @method bool|Redis|RedisCluster persist(string $key) * @method bool pexpire(string $key, int $timeout, string|null $mode = null) - * @method bool|Redis pexpireAt(string $key, int $timestamp, string|null $mode = null) - * @method int|Redis pfadd(string $key, array $elements) - * @method false|int|Redis pfcount(array|string $key_or_keys) - * @method bool|Redis pfmerge(string $dst, array $srckeys) - * @method bool|Redis|string ping(string|null $message = null) - * @method bool|Redis psetex(string $key, int $expire, mixed $value) - * @method false|int|Redis pttl(string $key) - * @method false|int|Redis publish(string $channel, string $message) + * @method bool|Redis|RedisCluster pexpireAt(string $key, int $timestamp, string|null $mode = null) + * @method int|Redis|RedisCluster pfadd(string $key, array $elements) + * @method false|int|Redis|RedisCluster pfcount(array|string $key_or_keys) + * @method bool|Redis|RedisCluster pfmerge(string $dst, array $srckeys) + * @method bool|Redis|RedisCluster|string ping(string|null $message = null) + * @method bool|Redis|RedisCluster psetex(string $key, int $expire, mixed $value) + * @method false|int|Redis|RedisCluster pttl(string $key) + * @method false|int|Redis|RedisCluster publish(string $channel, string $message) * @method mixed pubsub(string $command, mixed $arg = null) * @method array|bool|Redis punsubscribe(array $patterns) - * @method array|bool|Redis|string rPop(string $key, int $count = 0) - * @method false|Redis|string randomKey() + * @method array|bool|Redis|RedisCluster|string rPop(string $key, int $count = 0) + * @method false|Redis|RedisCluster|string randomKey() * @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 restore(string $key, int $ttl, string $value, array|null $options = null) + * @method bool|Redis|RedisCluster rename(string $old_name, string $new_name) + * @method bool|Redis|RedisCluster renameNx(string $key_src, string $key_dst) + * @method bool|Redis|RedisCluster restore(string $key, int $ttl, string $value, array|null $options = null) * @method mixed role() - * @method false|Redis|string rpoplpush(string $srckey, string $dstkey) - * @method false|int|Redis sAdd(string $key, mixed $value, mixed ...$other_values) + * @method false|Redis|RedisCluster|string rpoplpush(string $srckey, string $dstkey) + * @method false|int|Redis|RedisCluster sAdd(string $key, mixed $value, mixed ...$other_values) * @method int sAddArray(string $key, array $values) - * @method array|false|Redis sDiff(string $key, string ...$other_keys) - * @method false|int|Redis sDiffStore(string $dst, string $key, string ...$other_keys) - * @method array|false|Redis sInter(array|string $key, string ...$other_keys) - * @method false|int|Redis sintercard(array $keys, int $limit = -1) - * @method false|int|Redis sInterStore(array|string $key, string ...$other_keys) - * @method array|false|Redis sMembers(string $key) - * @method array|false|Redis sMisMember(string $key, string $member, string ...$other_members) - * @method bool|Redis sMove(string $src, string $dst, mixed $value) - * @method mixed sPop(string $key, int $count = 0) + * @method array|false|Redis|RedisCluster sDiff(string $key, string ...$other_keys) + * @method false|int|Redis|RedisCluster sDiffStore(string $dst, string $key, string ...$other_keys) + * @method array|false|Redis|RedisCluster sInter(array|string $key, string ...$other_keys) + * @method false|int|Redis|RedisCluster sintercard(array $keys, int $limit = -1) + * @method false|int|Redis|RedisCluster sInterStore(array|string $key, string ...$other_keys) + * @method array|false|Redis|RedisCluster sMembers(string $key) Get all set members + * @method array|false|Redis|RedisCluster sMisMember(string $key, string $member, string ...$other_members) + * @method bool|Redis|RedisCluster sMove(string $src, string $dst, mixed $value) + * @method mixed sPop(string $key, int $count = 0) Remove and return random set member * @method mixed sRandMember(string $key, int $count = 0) - * @method array|false|Redis sUnion(string $key, string ...$other_keys) - * @method false|int|Redis sUnionStore(string $dst, string $key, string ...$other_keys) - * @method bool|Redis save() - * @method false|int|Redis scard(string $key) + * @method array|false|Redis|RedisCluster sUnion(string $key, string ...$other_keys) + * @method false|int|Redis|RedisCluster sUnionStore(string $dst, string $key, string ...$other_keys) + * @method bool|Redis|RedisCluster save() + * @method false|int|Redis|RedisCluster scard(string $key) * @method mixed script(string $command, mixed ...$args) * @method bool|Redis select(int $db) * @method false|string serverName() * @method false|string serverVersion() - * @method false|int|Redis setBit(string $key, int $idx, bool $value) - * @method false|int|Redis setRange(string $key, int $index, string $value) + * @method false|int|Redis|RedisCluster setBit(string $key, int $idx, bool $value) + * @method false|int|Redis|RedisCluster setRange(string $key, int $index, string $value) * @method bool setOption(int $option, mixed $value) - * @method bool|Redis setex(string $key, int $expire, mixed $value) - * @method bool|Redis sismember(string $key, mixed $value) + * @method bool|Redis|RedisCluster setex(string $key, int $expire, mixed $value) + * @method bool|Redis|RedisCluster sismember(string $key, mixed $value) * @method bool|Redis replicaof(string|null $host = null, int $port = 6379) - * @method false|int|Redis touch(array|string $key_or_array, string ...$more_keys) + * @method false|int|Redis|RedisCluster touch(array|string $key_or_array, string ...$more_keys) * @method mixed slowlog(string $operation, int $length = 0) * @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 false|int|Redis strlen(string $key) + * @method false|int|Redis|RedisCluster srem(string $key, mixed $value, mixed ...$other_values) Remove members from set + * @method false|int|Redis|RedisCluster strlen(string $key) * @method array|bool|Redis sunsubscribe(array $channels) * @method bool|Redis swapdb(int $src, int $dst) - * @method array|Redis time() - * @method false|int|Redis ttl(string $key) - * @method false|int|Redis type(string $key) - * @method false|int|Redis unlink(array|string $key, string ...$other_keys) + * @method array|Redis|RedisCluster time() + * @method false|int|Redis|RedisCluster ttl(string $key) + * @method false|int|Redis|RedisCluster type(string $key) + * @method false|int|Redis|RedisCluster unlink(array|string $key, string ...$other_keys) * @method array|bool|Redis unsubscribe(array $channels) - * @method bool|Redis unwatch() - * @method false|int|Redis vadd(string $key, array $values, mixed $element, array|null $options = null) - * @method false|int|Redis vcard(string $key) - * @method false|int|Redis vdim(string $key) - * @method array|false|Redis vemb(string $key, mixed $member, bool $raw = false) - * @method array|false|Redis|string vgetattr(string $key, mixed $member, bool $decode = true) - * @method array|false|Redis vinfo(string $key) - * @method bool|Redis vismember(string $key, mixed $member) - * @method array|false|Redis vlinks(string $key, mixed $member, bool $withscores = false) - * @method array|false|Redis|string vrandmember(string $key, int $count = 0) - * @method array|false|Redis vrange(string $key, string $min, string $max, int $count = -1) - * @method false|int|Redis vrem(string $key, mixed $member) - * @method false|int|Redis vsetattr(string $key, mixed $member, array|string $attributes) - * @method array|false|Redis vsim(string $key, mixed $member, array|null $options = null) - * @method bool|Redis watch(array|string $key, string ...$other_keys) + * @method null|bool|Redis unwatch() + * @method false|int|Redis|RedisCluster vadd(string $key, array $values, mixed $element, array|null $options = null) + * @method false|int|Redis|RedisCluster vcard(string $key) + * @method false|int|Redis|RedisCluster vdim(string $key) + * @method array|false|Redis|RedisCluster vemb(string $key, mixed $member, bool $raw = false) + * @method array|false|Redis|RedisCluster|string vgetattr(string $key, mixed $member, bool $decode = true) + * @method array|false|Redis|RedisCluster vinfo(string $key) + * @method bool|Redis|RedisCluster vismember(string $key, mixed $member) + * @method array|false|Redis|RedisCluster vlinks(string $key, mixed $member, bool $withscores = false) + * @method array|false|Redis|RedisCluster|string vrandmember(string $key, int $count = 0) + * @method array|false|Redis|RedisCluster vrange(string $key, string $min, string $max, int $count = -1) + * @method false|int|Redis|RedisCluster vrem(string $key, mixed $member) + * @method false|int|Redis|RedisCluster vsetattr(string $key, mixed $member, array|string $attributes) + * @method array|false|Redis|RedisCluster vsim(string $key, mixed $member, array|null $options = null) + * @method bool|Redis|RedisCluster watch(array|string $key, string ...$other_keys) * @method false|int wait(int $numreplicas, int $timeout) * @method false|int xack(string $key, string $group, array $ids) - * @method false|Redis|string xadd(string $key, string $id, array $values, int $maxlen = 0, bool $approx = false, bool $nomkstream = false) - * @method array|bool|Redis xautoclaim(string $key, string $group, string $consumer, int $min_idle, string $start, int $count = -1, bool $justid = false) - * @method array|bool|Redis xclaim(string $key, string $group, string $consumer, int $min_idle, array $ids, array $options) - * @method false|int|Redis xdel(string $key, array $ids) - * @method array|false|Redis xdelex(string $key, array $ids, string|null $mode = null) + * @method false|Redis|RedisCluster|string xadd(string $key, string $id, array $values, int $maxlen = 0, bool $approx = false, bool $nomkstream = false) + * @method array|bool|Redis|RedisCluster xautoclaim(string $key, string $group, string $consumer, int $min_idle, string $start, int $count = -1, bool $justid = false) + * @method array|bool|Redis|RedisCluster xclaim(string $key, string $group, string $consumer, int $min_idle, array $ids, array $options) + * @method false|int|Redis|RedisCluster xdel(string $key, array $ids) + * @method array|false|Redis|RedisCluster xdelex(string $key, array $ids, string|null $mode = null) * @method mixed xgroup(string $operation, string|null $key = null, string|null $group = null, string|null $id_or_consumer = null, bool $mkstream = false, int $entries_read = -2) * @method mixed xinfo(string $operation, string|null $arg1 = null, string|null $arg2 = null, int $count = -1) - * @method false|int|Redis xlen(string $key) - * @method array|false|Redis xpending(string $key, string $group, string|null $start = null, string|null $end = null, int $count = -1, string|null $consumer = null) - * @method array|bool|Redis xrange(string $key, string $start, string $end, int $count = -1) - * @method array|bool|Redis xread(array $streams, int $count = -1, int $block = -1) - * @method array|bool|Redis xreadgroup(string $group, string $consumer, array $streams, int $count = 1, int $block = 1) - * @method array|bool|Redis xrevrange(string $key, string $end, string $start, int $count = -1) - * @method false|int|Redis xtrim(string $key, string $threshold, bool $approx = false, bool $minid = false, int $limit = -1) - * @method false|float|int|Redis zAdd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) - * @method false|int|Redis zCard(string $key) - * @method false|int|Redis zCount(string $key, int|string $start, int|string $end) - * @method false|float|Redis zIncrBy(string $key, float $value, mixed $member) - * @method false|int|Redis zLexCount(string $key, string $min, string $max) - * @method array|false|Redis zMscore(string $key, mixed $member, mixed ...$other_members) - * @method array|false|Redis zPopMax(string $key, int|null $count = null) - * @method array|false|Redis zPopMin(string $key, int|null $count = null) - * @method array|false|Redis zRange(string $key, string|int $start, string|int $end, array|bool|null $options = null) - * @method array|false|Redis zRangeByLex(string $key, string $min, string $max, int $offset = -1, int $count = -1) - * @method array|false|Redis zRangeByScore(string $key, string $min, string $max, array $options = []) - * @method false|int|Redis zrangestore(string $dstkey, string $srckey, string $start, string $end, array|bool|null $options = null) - * @method array|Redis|string zRandMember(string $key, array|null $options = null) - * @method false|int|Redis zRank(string $key, mixed $member) - * @method false|int|Redis zRem(mixed $key, mixed $member, mixed ...$other_members) - * @method false|int|Redis zRemRangeByLex(string $key, string $min, string $max) - * @method false|int|Redis zRemRangeByRank(string $key, int $start, int $end) - * @method false|int|Redis zRemRangeByScore(string $key, string $start, string $end) - * @method array|false|Redis zRevRange(string $key, int $start, int $end, mixed $scores = null) - * @method array|false|Redis zRevRangeByLex(string $key, string $max, string $min, int $offset = -1, int $count = -1) - * @method array|false|Redis zRevRangeByScore(string $key, string $max, string $min, array $options = []) - * @method false|int|Redis zRevRank(string $key, mixed $member) - * @method false|float|Redis zScore(string $key, mixed $member) - * @method array|false|Redis zdiff(array $keys, array|null $options = null) - * @method false|int|Redis zdiffstore(string $dst, array $keys) - * @method array|false|Redis zinter(array $keys, array|null $weights = null, array|null $options = null) - * @method false|int|Redis zintercard(array $keys, int $limit = -1) - * @method array|false|Redis zunion(array $keys, array|null $weights = null, array|null $options = null) + * @method false|int|Redis|RedisCluster xlen(string $key) + * @method array|false|Redis|RedisCluster xpending(string $key, string $group, string|null $start = null, string|null $end = null, int $count = -1, string|null $consumer = null) + * @method array|bool|Redis|RedisCluster xrange(string $key, string $start, string $end, int $count = -1) + * @method array|bool|Redis|RedisCluster xread(array $streams, int $count = -1, int $block = -1) + * @method array|bool|Redis|RedisCluster xreadgroup(string $group, string $consumer, array $streams, int $count = 1, int $block = 1) + * @method array|bool|Redis|RedisCluster xrevrange(string $key, string $end, string $start, int $count = -1) + * @method false|int|Redis|RedisCluster xtrim(string $key, string $threshold, bool $approx = false, bool $minid = false, int $limit = -1) + * @method false|float|int|Redis|RedisCluster zAdd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) Add members to sorted set + * @method false|int|Redis|RedisCluster zCard(string $key) Get sorted set cardinality + * @method false|int|Redis|RedisCluster zCount(string $key, int|string $start, int|string $end) Count sorted set members by score range + * @method false|float|Redis|RedisCluster zIncrBy(string $key, float $value, mixed $member) + * @method false|int|Redis|RedisCluster zLexCount(string $key, string $min, string $max) + * @method array|false|Redis|RedisCluster zMscore(string $key, mixed $member, mixed ...$other_members) + * @method array|false|Redis|RedisCluster zPopMax(string $key, int|null $count = null) + * @method array|false|Redis|RedisCluster zPopMin(string $key, int|null $count = null) + * @method array|false|Redis|RedisCluster zRange(string $key, string|int $start, string|int $end, array|bool|null $options = null) + * @method array|false|Redis|RedisCluster zRangeByLex(string $key, string $min, string $max, int $offset = -1, int $count = -1) + * @method array|false|Redis|RedisCluster zRangeByScore(string $key, float|int|string $min, float|int|string $max, array $options = []) Get sorted set members by score range + * @method false|int|Redis|RedisCluster zrangestore(string $dstkey, string $srckey, string $start, string $end, array|bool|null $options = null) + * @method array|Redis|RedisCluster|string zRandMember(string $key, array|null $options = null) + * @method false|int|Redis|RedisCluster zRank(string $key, mixed $member) + * @method false|int|Redis|RedisCluster zRem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members + * @method false|int|Redis|RedisCluster zRemRangeByLex(string $key, string $min, string $max) + * @method false|int|Redis|RedisCluster zRemRangeByRank(string $key, int $start, int $end) + * @method false|int|Redis|RedisCluster zRemRangeByScore(string $key, string $start, string $end) + * @method array|false|Redis|RedisCluster zRevRange(string $key, int $start, int $end, mixed $scores = null) + * @method array|false|Redis|RedisCluster zRevRangeByLex(string $key, string $max, string $min, int $offset = -1, int $count = -1) + * @method array|false|Redis|RedisCluster zRevRangeByScore(string $key, float|int|string $max, float|int|string $min, array $options = []) Get sorted set members by score range (reverse) + * @method false|int|Redis|RedisCluster zRevRank(string $key, mixed $member) + * @method false|float|Redis|RedisCluster zScore(string $key, mixed $member) + * @method array|false|Redis|RedisCluster zdiff(array $keys, array|null $options = null) + * @method false|int|Redis|RedisCluster zdiffstore(string $dst, array $keys) + * @method array|false|Redis|RedisCluster zinter(array $keys, array|null $weights = null, array|null $options = null) + * @method false|int|Redis|RedisCluster zintercard(array $keys, int $limit = -1) + * @method array|false|Redis|RedisCluster zunion(array $keys, array|null $weights = null, array|null $options = null) */ abstract class RedisConnection extends BaseConnection { @@ -411,6 +411,10 @@ public function __call($name, $arguments) $this->watching = false; } + if ($name === 'select' && $result === true && array_key_exists(0, $arguments)) { + $this->database = (int) $arguments[0]; + } + return $result; } @@ -756,16 +760,23 @@ public function release(): void } try { - // Cluster connections never select logical databases and omit this config member. - if ($this->database !== null) { + if ($this->connection instanceof Redis) { $defaultDatabase = $this->config['database']; - if ($this->database !== $defaultDatabase) { - $this->select($defaultDatabase); + if (! $this->connection->isConnected()) { + $this->markInvalid(); + } elseif ($this->connection->getDBNum() !== $defaultDatabase) { + if ($this->select($defaultDatabase) !== true) { + throw new ConnectionException( + "Failed to select Redis database [{$defaultDatabase}] on connection [{$this->getName()}]." + ); + } } } } catch (Throwable $exception) { $this->markInvalid(); + // A connected client would otherwise replay its rejected database during reconnect. + $this->close(); try { $this->log('Release connection failed, caused by ' . $exception, LogLevel::CRITICAL); @@ -807,14 +818,6 @@ public function clearWatchState(): void $this->watching = false; } - /** - * Set current redis database. - */ - public function setDatabase(?int $database): void - { - $this->database = $database; - } - /** * Determine if this connection has been idle long enough to be evicted. */ @@ -968,7 +971,7 @@ public function getShouldTransform(): bool /** * Returns the value of the given key. */ - protected function callGet(string $key): ?string + protected function callGet(string $key): mixed { $result = $this->connection->get($key); @@ -978,15 +981,22 @@ protected function callGet(string $key): ?string /** * Get the values of all the given keys. */ - protected function callMget(array $keys): array + protected function callMget(array $keys): array|false { if ($keys === []) { return []; } - return array_map(function ($value) { - return $value !== false ? $value : null; - }, $this->connection->mGet($keys)); + $values = $this->connection->mGet($keys); + + if ($values === false) { + return false; + } + + return array_map( + static fn (mixed $value): mixed => $value !== false ? $value : null, + $values, + ); } /** @@ -1007,10 +1017,11 @@ protected function prepareSet(mixed ...$arguments): array /** * Set the string value in the argument as the value of the key. */ - protected function callSet(string $key, mixed $value, ?string $expireResolution = null, ?int $expireTTL = null, ?string $flag = null): bool + protected function callSet(string $key, mixed $value, mixed $expireResolution = null, ?int $expireTTL = null, ?string $flag = null): mixed { [$method, $args] = $this->prepareSet($key, $value, $expireResolution, $expireTTL, $flag); + // SET with GET returns the previous decoded value; false may mean there was no previous value. return $this->connection->{$method}(...$args); } @@ -1038,13 +1049,17 @@ protected function prepareHmget(mixed ...$arguments): array /** * Get the value of the given hash fields. */ - protected function callHmget(string $key, mixed ...$dictionary): array + protected function callHmget(string $key, mixed ...$dictionary): array|false { [$method, $args] = $this->prepareHmget($key, ...$dictionary); - return array_values( - $this->connection->{$method}(...$args) - ); + $values = $this->connection->{$method}(...$args); + + if ($values === false) { + return false; + } + + return array_values($values); } /** @@ -1175,7 +1190,7 @@ protected function prepareZadd(mixed ...$arguments): array /** * Add one or more members to a sorted set or update its score if it already exists. */ - protected function callZadd(string $key, mixed ...$dictionary): int + protected function callZadd(string $key, mixed ...$dictionary): false|float|int { [$method, $args] = $this->prepareZadd($key, ...$dictionary); @@ -1205,7 +1220,7 @@ protected function prepareZrangebyscore(mixed ...$arguments): array /** * Return elements with score between $min and $max. */ - protected function callZrangebyscore(string $key, mixed $min, mixed $max, array $options = []): array + protected function callZrangebyscore(string $key, float|int|string $min, float|int|string $max, array $options = []): array|false { [$method, $args] = $this->prepareZrangebyscore($key, $min, $max, $options); @@ -1235,7 +1250,7 @@ protected function prepareZrevrangebyscore(mixed ...$arguments): array /** * Return elements with score between $max and $min in reverse order. */ - protected function callZrevrangebyscore(string $key, mixed $max, mixed $min, array $options = []): array + protected function callZrevrangebyscore(string $key, float|int|string $max, float|int|string $min, array $options = []): array|false { [$method, $args] = $this->prepareZrevrangebyscore($key, $max, $min, $options); @@ -1258,7 +1273,7 @@ protected function prepareZinterstore(mixed ...$arguments): array /** * Find the intersection between sets and store in a new set. */ - protected function callZinterstore(string $output, array $keys, array $options = []): int + protected function callZinterstore(string $output, array $keys, array $options = []): false|int { [$method, $args] = $this->prepareZinterstore($output, $keys, $options); @@ -1281,7 +1296,7 @@ protected function prepareZunionstore(mixed ...$arguments): array /** * Find the union between sets and store in a new set. */ - protected function callZunionstore(string $output, array $keys, array $options = []): int + protected function callZunionstore(string $output, array $keys, array $options = []): false|int { [$method, $args] = $this->prepareZunionstore($output, $keys, $options); diff --git a/src/redis/src/RedisProxy.php b/src/redis/src/RedisProxy.php index fd37ed365..86849adf4 100644 --- a/src/redis/src/RedisProxy.php +++ b/src/redis/src/RedisProxy.php @@ -8,6 +8,7 @@ use Closure; use Hypervel\Container\Container; use Hypervel\Context\CoroutineContext; +use Hypervel\Contracts\Events\Dispatcher; use Hypervel\Contracts\Redis\Connection as ConnectionContract; use Hypervel\Coroutine\Coroutine; use Hypervel\Redis\Events\CommandExecuted; @@ -75,7 +76,6 @@ class RedisProxy implements ConnectionContract 'reconnect', 'release', 'safescan', - 'setdatabase', 'setoption', 'shouldtransform', ]; @@ -232,10 +232,15 @@ public function __call($name, $arguments) $commandException = $throwable; try { - if ($connection->getEventDispatcher()?->hasListeners(CommandFailed::class)) { + $dispatcher = $connection->getEventDispatcher(); + + if ($dispatcher?->hasListeners(CommandFailed::class)) { $time = round((hrtime(true) / 1e9 - $start) * 1000, 2); - $connection->getEventDispatcher()->dispatch( - new CommandFailed($name, $arguments, $throwable, $connection, $time) + $this->dispatchCommandEvent( + $dispatcher, + new CommandFailed($name, $arguments, $throwable, $connection, $time), + $connection, + $hasContextConnection, ); } } catch (Throwable $throwable) { @@ -245,10 +250,15 @@ public function __call($name, $arguments) if ($commandException === null) { try { - if ($connection->getEventDispatcher()?->hasListeners(CommandExecuted::class)) { + $dispatcher = $connection->getEventDispatcher(); + + if ($dispatcher?->hasListeners(CommandExecuted::class)) { $time = round((hrtime(true) / 1e9 - $start) * 1000, 2); - $connection->getEventDispatcher()->dispatch( - new CommandExecuted($name, $arguments, $time, $connection) + $this->dispatchCommandEvent( + $dispatcher, + new CommandExecuted($name, $arguments, $time, $connection), + $connection, + $hasContextConnection, ); } } catch (Throwable $throwable) { @@ -261,10 +271,6 @@ public function __call($name, $arguments) // Connection is already in context, don't release } elseif ($commandException === null && $this->shouldUseSameConnection($command)) { // On success with same-connection command: store in context for reuse - if ($command === 'select' && array_key_exists(0, $arguments)) { - $connection->setDatabase((int) $arguments[0]); - } - CoroutineContext::set($this->getContextKey(), $connection); $coroutineId = Coroutine::id(); @@ -303,6 +309,34 @@ public function __call($name, $arguments) return $result; } + /** + * Dispatch a command event against its owning connection. + */ + private function dispatchCommandEvent( + Dispatcher $dispatcher, + CommandExecuted|CommandFailed $event, + RedisConnection $connection, + bool $hasContextConnection, + ): void { + if ($hasContextConnection) { + $dispatcher->dispatch($event); + + return; + } + + $contextKey = $this->getContextKey(); + + // Synchronous listeners reuse the leased wrapper instead of deadlocking on a reentrant pool checkout. + // Nested commands retain Laravel's event semantics, including recursion from an unconditional same-command listener. + CoroutineContext::set($contextKey, $connection); + + try { + $dispatcher->dispatch($event); + } finally { + CoroutineContext::forget($contextKey); + } + } + /** * Release the connection stored in coroutine context. * diff --git a/src/support/src/Facades/Redis.php b/src/support/src/Facades/Redis.php index f2dbec9e5..9eda33cb1 100644 --- a/src/support/src/Facades/Redis.php +++ b/src/support/src/Facades/Redis.php @@ -40,35 +40,35 @@ * @method static string _serialize(mixed $value) Serialize a value using configured serializer * @method static mixed _unpack(string $value) * @method static mixed acl(string $subcmd, string ...$args) - * @method static false|int|\Redis append(string $key, mixed $value) - * @method static bool|\Redis bgrewriteaof() - * @method static bool|\Redis bgSave() - * @method static false|int|\Redis bitcount(string $key, int $start = 0, int $end = -1, bool $bybit = false) - * @method static false|int|\Redis bitop(string $operation, string $deskey, string $srckey, string ...$other_keys) - * @method static false|int|\Redis bitpos(string $key, bool $bit, int $start = 0, int $end = -1, bool $bybit = false) - * @method static false|\Redis|string blmove(string $src, string $dst, string $wherefrom, string $whereto, float $timeout) - * @method static null|array|false|\Redis blmpop(float $timeout, array $keys, string $from, int $count = 1) - * @method static null|array|false|\Redis blpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking left pop from list - * @method static null|array|false|\Redis brpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking right pop from list - * @method static false|\Redis|string brpoplpush(string $src, string $dst, float|int $timeout) - * @method static null|array|false|\Redis bzmpop(float $timeout, array $keys, string $from, int $count = 1) - * @method static array|false|\Redis bzPopMax(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) - * @method static array|false|\Redis bzPopMin(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) + * @method static false|int|\Redis|\RedisCluster append(string $key, mixed $value) + * @method static bool|\Redis|\RedisCluster bgrewriteaof() + * @method static bool|\Redis|\RedisCluster bgSave() + * @method static false|int|\Redis|\RedisCluster bitcount(string $key, int $start = 0, int $end = -1, bool $bybit = false) + * @method static false|int|\Redis|\RedisCluster bitop(string $operation, string $deskey, string $srckey, string ...$other_keys) + * @method static false|int|\Redis|\RedisCluster bitpos(string $key, bool $bit, int $start = 0, int $end = -1, bool $bybit = false) + * @method static false|\Redis|\RedisCluster|string blmove(string $src, string $dst, string $wherefrom, string $whereto, float $timeout) + * @method static null|array|false|\Redis|\RedisCluster blmpop(float $timeout, array $keys, string $from, int $count = 1) + * @method static null|array|false|\Redis|\RedisCluster blpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking left pop from list + * @method static null|array|false|\Redis|\RedisCluster brpop(array|string $key_or_keys, float|int|string $timeout_or_key, mixed ...$extra_args) Blocking right pop from list + * @method static false|\Redis|\RedisCluster|string brpoplpush(string $src, string $dst, float|int $timeout) + * @method static null|array|false|\Redis|\RedisCluster bzmpop(float $timeout, array $keys, string $from, int $count = 1) + * @method static array|false|\Redis|\RedisCluster bzPopMax(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) + * @method static array|false|\Redis|\RedisCluster bzPopMin(array|string $key, int|string $timeout_or_key, mixed ...$extra_args) * @method static bool clearLastError() * @method static void clearTransferredBytes() * @method static bool compressed() * @method static mixed config(string $operation, array|string|null $key_or_settings = null, string|null $value = null) - * @method static bool|\Redis copy(string $src, string $dst, array|null $options = null) - * @method static false|int|\Redis dbSize() + * @method static bool|\Redis|\RedisCluster copy(string $src, string $dst, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster dbSize() * @method static \Redis|string debug(string $key) - * @method static false|int|\Redis decr(string $key, int $by = 1) - * @method static false|int|\Redis decrBy(string $key, int $value) - * @method static false|int|\Redis del(array|string $key, string ...$other_keys) - * @method static false|int|\Redis delex(string $key, array|null $options = null) - * @method static false|int|\Redis delifeq(string $key, mixed $value) - * @method static false|\Redis|string digest(string $key) - * @method static false|\Redis|string dump(string $key) - * @method static false|\Redis|string echo(string $str) + * @method static false|int|\Redis|\RedisCluster decr(string $key, int $by = 1) + * @method static false|int|\Redis|\RedisCluster decrBy(string $key, int $value) + * @method static false|int|\Redis|\RedisCluster del(array|string $key, string ...$other_keys) + * @method static false|int|\Redis|\RedisCluster delex(string $key, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster delifeq(string $key, mixed $value) + * @method static false|\Redis|\RedisCluster|string digest(string $key) + * @method static false|\Redis|\RedisCluster|string dump(string $key) + * @method static false|\Redis|\RedisCluster|string echo(string $str) * @method static mixed eval(string $script, int $numberOfKeys, mixed ...$arguments) Evaluate Lua script * @method static mixed eval_ro(string $script_sha, array $args = [], int $num_keys = 0) * @method static mixed evalsha(string $script, int $numkeys, mixed ...$arguments) Evaluate Lua script by SHA1 @@ -76,231 +76,231 @@ * @method static mixed evalWithShaCache(string $script, array $keys = [], array $args = []) * @method static array|false|\Redis exec() * @method static mixed executeRaw(array $parameters) Execute raw Redis command - * @method static bool|int|\Redis exists(mixed $key, mixed ...$other_keys) - * @method static bool|\Redis expire(string $key, int $timeout, string|null $mode = null) - * @method static bool|\Redis expireAt(string $key, int $timestamp, string|null $mode = null) - * @method static false|int|\Redis expiretime(string $key) + * @method static bool|int|\Redis|\RedisCluster exists(mixed $key, mixed ...$other_keys) + * @method static bool|\Redis|\RedisCluster expire(string $key, int $timeout, string|null $mode = null) + * @method static bool|\Redis|\RedisCluster expireAt(string $key, int $timestamp, string|null $mode = null) + * @method static false|int|\Redis|\RedisCluster expiretime(string $key) * @method static bool|\Redis failover(array|null $to = null, bool $abort = false, int $timeout = 0) * @method static mixed fcall(string $fn, array $keys = [], array $args = []) * @method static mixed fcall_ro(string $fn, array $keys = [], array $args = []) - * @method static bool|\Redis flushAll(bool|null $sync = null) + * @method static bool|\Redis|\RedisCluster flushAll(bool|null $sync = null) * @method static mixed flushdb(mixed ...$arguments) Flush database * @method static array|bool|\Redis|string function(string $operation, mixed ...$args) - * @method static false|int|\Redis geoadd(string $key, float $lng, float $lat, string $member, mixed ...$other_triples_and_options) - * @method static false|float|\Redis geodist(string $key, string $src, string $dst, string|null $unit = null) - * @method static array|false|\Redis geohash(string $key, string $member, string ...$other_members) - * @method static array|false|\Redis geopos(string $key, string $member, string ...$other_members) + * @method static false|int|\Redis|\RedisCluster geoadd(string $key, float $lng, float $lat, string $member, mixed ...$other_triples_and_options) + * @method static false|float|\Redis|\RedisCluster geodist(string $key, string $src, string $dst, string|null $unit = null) + * @method static array|false|\Redis|\RedisCluster geohash(string $key, string $member, string ...$other_members) + * @method static array|false|\Redis|\RedisCluster geopos(string $key, string $member, string ...$other_members) * @method static mixed georadius(string $key, float $lng, float $lat, float $radius, string $unit, array $options = []) * @method static mixed georadius_ro(string $key, float $lng, float $lat, float $radius, string $unit, array $options = []) * @method static mixed georadiusbymember(string $key, string $member, float $radius, string $unit, array $options = []) * @method static mixed georadiusbymember_ro(string $key, string $member, float $radius, string $unit, array $options = []) * @method static array geosearch(string $key, array|string $position, array|int|float $shape, string $unit, array $options = []) - * @method static array|false|int|\Redis geosearchstore(string $dst, string $src, array|string $position, array|int|float $shape, string $unit, array $options = []) + * @method static array|false|int|\Redis|\RedisCluster geosearchstore(string $dst, string $src, array|string $position, array|int|float $shape, string $unit, array $options = []) * @method static mixed get(string $key) Get the value of a key * @method static mixed getAuth() - * @method static false|int|\Redis getBit(string $key, int $idx) + * @method static false|int|\Redis|\RedisCluster getBit(string $key, int $idx) * @method static int getDBNum() - * @method static bool|\Redis|string getDel(string $key) + * @method static bool|\Redis|\RedisCluster|string getDel(string $key) * @method static \Hypervel\Contracts\Events\Dispatcher|null getEventDispatcher() - * @method static bool|\Redis|string getEx(string $key, array $options = []) + * @method static bool|\Redis|\RedisCluster|string getEx(string $key, array $options = []) * @method static string getHost() * @method static null|string getLastError() * @method static int getMode() * @method static mixed getOption(int $option) * @method static null|string getPersistentID() * @method static int getPort() - * @method static false|\Redis|string getRange(string $key, int $start, int $end) + * @method static false|\Redis|\RedisCluster|string getRange(string $key, int $start, int $end) * @method static float getReadTimeout() - * @method static false|\Redis|string getset(string $key, mixed $value) + * @method static false|\Redis|\RedisCluster|string getset(string $key, mixed $value) * @method static false|float getTimeout() * @method static array getTransferredBytes() - * @method static array|false|\Redis getWithMeta(string $key) + * @method static array|false|\Redis|\RedisCluster getWithMeta(string $key) * @method static bool hasHashTag(string $key) - * @method static false|int|\Redis hdel(string $key, string $field, string ...$other_fields) Delete hash fields - * @method static bool|\Redis hExists(string $key, string $field) - * @method static array|false|\Redis hexpire(string $key, int $ttl, array $fields, string|null $mode = null) - * @method static array|false|\Redis hexpireat(string $key, int $time, array $fields, string|null $mode = null) - * @method static array|false|\Redis hexpiretime(string $key, array $fields) + * @method static false|int|\Redis|\RedisCluster hdel(string $key, string $field, string ...$other_fields) Delete hash fields + * @method static bool|\Redis|\RedisCluster hExists(string $key, string $field) + * @method static array|false|\Redis|\RedisCluster hexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method static array|false|\Redis|\RedisCluster hexpireat(string $key, int $time, array $fields, string|null $mode = null) + * @method static array|false|\Redis|\RedisCluster hexpiretime(string $key, array $fields) * @method static mixed hget(string $key, string $member) Get hash field value - * @method static array|false|\Redis hGetAll(string $key) - * @method static array|false|\Redis hgetdel(string $key, array $fields) - * @method static array|false|\Redis hgetex(string $key, array $fields, string|array|null $expiry = null) + * @method static array|false|\Redis|\RedisCluster hGetAll(string $key) + * @method static array|false|\Redis|\RedisCluster hgetdel(string $key, array $fields) + * @method static array|false|\Redis|\RedisCluster hgetex(string $key, array $fields, string|array|null $expiry = null) * @method static mixed hGetWithMeta(string $key, string $member) - * @method static false|int|\Redis hIncrBy(string $key, string $field, int $value) - * @method static false|float|\Redis hIncrByFloat(string $key, string $field, float $value) - * @method static array|false|\Redis hkeys(string $key) Get all hash field names - * @method static false|int|\Redis hlen(string $key) Get number of hash fields - * @method static array|false|\Redis hmget(string $key, array $fields) Get hash field values - * @method static bool|\Redis hmset(string $key, array $fieldValues) Set hash field values - * @method static array|false|\Redis hpersist(string $key, array $fields) - * @method static array|false|\Redis hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) - * @method static array|false|\Redis hpexpireat(string $key, int $mstime, array $fields, string|null $mode = null) - * @method static array|false|\Redis hpexpiretime(string $key, array $fields) - * @method static array|false|\Redis hpttl(string $key, array $fields) - * @method static array|false|\Redis|string hRandField(string $key, array|null $options = null) - * @method static false|int|\Redis hset(string $key, mixed ...$fields_and_vals) Set hash field values - * @method static false|int|\Redis hsetex(string $key, array $fields, array|null $expiry = null) - * @method static bool|int|\Redis hsetnx(string $hash, string $key, mixed $value) Set hash field if not exists - * @method static false|int|\Redis hStrLen(string $key, string $field) - * @method static array|false|\Redis httl(string $key, array $fields) - * @method static array|false|\Redis hVals(string $key) - * @method static false|int|\Redis incr(string $key, int $by = 1) - * @method static false|int|\Redis incrBy(string $key, int $value) - * @method static false|float|\Redis incrByFloat(string $key, float $value) - * @method static array|false|\Redis info(string ...$sections) + * @method static false|int|\Redis|\RedisCluster hIncrBy(string $key, string $field, int $value) + * @method static false|float|\Redis|\RedisCluster hIncrByFloat(string $key, string $field, float $value) + * @method static array|false|\Redis|\RedisCluster hkeys(string $key) Get all hash field names + * @method static false|int|\Redis|\RedisCluster hlen(string $key) Get number of hash fields + * @method static array|false|\Redis|\RedisCluster hmget(string $key, array $fields) Get hash field values + * @method static bool|\Redis|\RedisCluster hmset(string $key, array $fieldValues) Set hash field values + * @method static array|false|\Redis|\RedisCluster hpersist(string $key, array $fields) + * @method static array|false|\Redis|\RedisCluster hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method static array|false|\Redis|\RedisCluster hpexpireat(string $key, int $mstime, array $fields, string|null $mode = null) + * @method static array|false|\Redis|\RedisCluster hpexpiretime(string $key, array $fields) + * @method static array|false|\Redis|\RedisCluster hpttl(string $key, array $fields) + * @method static array|false|\Redis|\RedisCluster|string hRandField(string $key, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster hset(string $key, mixed ...$fields_and_vals) Set hash field values + * @method static false|int|\Redis|\RedisCluster hsetex(string $key, array $fields, array|null $expiry = null) + * @method static bool|int|\Redis|\RedisCluster hsetnx(string $hash, string $key, mixed $value) Set hash field if not exists + * @method static false|int|\Redis|\RedisCluster hStrLen(string $key, string $field) + * @method static array|false|\Redis|\RedisCluster httl(string $key, array $fields) + * @method static array|false|\Redis|\RedisCluster hVals(string $key) + * @method static false|int|\Redis|\RedisCluster incr(string $key, int $by = 1) + * @method static false|int|\Redis|\RedisCluster incrBy(string $key, int $value) + * @method static false|float|\Redis|\RedisCluster incrByFloat(string $key, float $value) + * @method static array|false|\Redis|\RedisCluster info(string ...$sections) * @method static bool isConnected() - * @method static array|false|\Redis keys(string $pattern) + * @method static array|false|\Redis|\RedisCluster keys(string $pattern) * @method static int lastSave() - * @method static array|false|int|\Redis|string lcs(string $key1, string $key2, array|null $options = null) + * @method static array|false|int|\Redis|\RedisCluster|string lcs(string $key1, string $key2, array|null $options = null) * @method static mixed lindex(string $key, int $index) - * @method static false|int|\Redis lInsert(string $key, string $pos, mixed $pivot, mixed $value) - * @method static false|int|\Redis llen(string $key) Get list length - * @method static false|\Redis|string lMove(string $src, string $dst, string $wherefrom, string $whereto) - * @method static null|array|false|\Redis lmpop(array $keys, string $from, int $count = 1) - * @method static array|bool|\Redis|string lPop(string $key, int $count = 0) - * @method static null|array|bool|int|\Redis lPos(string $key, mixed $value, array|null $options = null) - * @method static false|int|\Redis lPush(string $key, mixed ...$elements) - * @method static false|int|\Redis lPushx(string $key, mixed $value) - * @method static array|false|\Redis lrange(string $key, int $start, int $end) + * @method static false|int|\Redis|\RedisCluster lInsert(string $key, string $pos, mixed $pivot, mixed $value) + * @method static false|int|\Redis|\RedisCluster llen(string $key) Get list length + * @method static false|\Redis|\RedisCluster|string lMove(string $src, string $dst, string $wherefrom, string $whereto) + * @method static null|array|false|\Redis|\RedisCluster lmpop(array $keys, string $from, int $count = 1) + * @method static array|bool|\Redis|\RedisCluster|string lPop(string $key, int $count = 0) + * @method static null|array|bool|int|\Redis|\RedisCluster lPos(string $key, mixed $value, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster lPush(string $key, mixed ...$elements) + * @method static false|int|\Redis|\RedisCluster lPushx(string $key, mixed $value) + * @method static array|false|\Redis|\RedisCluster lrange(string $key, int $start, int $end) * @method static false|int lrem(string $key, int $count, mixed $value) Remove list elements - * @method static bool|\Redis lSet(string $key, int $index, mixed $value) - * @method static bool|\Redis ltrim(string $key, int $start, int $end) - * @method static array mget(array $keys) Get the values of multiple keys + * @method static bool|\Redis|\RedisCluster lSet(string $key, int $index, mixed $value) + * @method static bool|\Redis|\RedisCluster ltrim(string $key, int $start, int $end) + * @method static array|false|\Redis|\RedisCluster mget(array $keys) Get the values of multiple keys * @method static bool|\Redis migrate(string $host, int $port, array|string $key, int $dstdb, int $timeout, bool $copy = false, bool $replace = false, mixed $credentials = null) * @method static bool|\Redis move(string $key, int $index) - * @method static bool|\Redis mset(array $key_values) - * @method static false|int|\Redis msetex(array $key_values, int|float|array|null $expiry = null) - * @method static bool|\Redis msetnx(array $key_values) - * @method static bool|\Redis multi(int $value = 1) - * @method static false|int|\Redis|string object(string $subcommand, string $key) + * @method static bool|\Redis|\RedisCluster mset(array $key_values) + * @method static false|int|\Redis|\RedisCluster msetex(array $key_values, int|float|array|null $expiry = null) + * @method static bool|\Redis|\RedisCluster msetnx(array $key_values) + * @method static bool|\Redis|\RedisCluster multi(int $value = 1) + * @method static false|int|\Redis|\RedisCluster|string object(string $subcommand, string $key) * @method static array pack(array $values) - * @method static bool|\Redis persist(string $key) + * @method static bool|\Redis|\RedisCluster persist(string $key) * @method static bool pexpire(string $key, int $timeout, string|null $mode = null) - * @method static bool|\Redis pexpireAt(string $key, int $timestamp, string|null $mode = null) - * @method static false|int|\Redis pexpiretime(string $key) - * @method static int|\Redis pfadd(string $key, array $elements) - * @method static false|int|\Redis pfcount(array|string $key_or_keys) - * @method static bool|\Redis pfmerge(string $dst, array $srckeys) - * @method static bool|\Redis|string ping(string|null $message = null) - * @method static bool|\Redis psetex(string $key, int $expire, mixed $value) - * @method static false|int|\Redis pttl(string $key) - * @method static false|int|\Redis publish(string $channel, string $message) + * @method static bool|\Redis|\RedisCluster pexpireAt(string $key, int $timestamp, string|null $mode = null) + * @method static false|int|\Redis|\RedisCluster pexpiretime(string $key) + * @method static int|\Redis|\RedisCluster pfadd(string $key, array $elements) + * @method static false|int|\Redis|\RedisCluster pfcount(array|string $key_or_keys) + * @method static bool|\Redis|\RedisCluster pfmerge(string $dst, array $srckeys) + * @method static bool|\Redis|\RedisCluster|string ping(string|null $message = null) + * @method static bool|\Redis|\RedisCluster psetex(string $key, int $expire, mixed $value) + * @method static false|int|\Redis|\RedisCluster pttl(string $key) + * @method static false|int|\Redis|\RedisCluster publish(string $channel, string $message) * @method static mixed pubsub(string $command, mixed $arg = null) * @method static array|bool|\Redis punsubscribe(array $patterns) - * @method static false|\Redis|string randomKey() + * @method static false|\Redis|\RedisCluster|string randomKey() * @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|\RedisCluster rename(string $old_name, string $new_name) + * @method static bool|\Redis|\RedisCluster renameNx(string $key_src, string $key_dst) * @method static bool|\Redis replicaof(string|null $host = null, int $port = 6379) - * @method static bool|\Redis restore(string $key, int $ttl, string $value, array|null $options = null) + * @method static bool|\Redis|\RedisCluster restore(string $key, int $ttl, string $value, array|null $options = null) * @method static mixed role() - * @method static array|bool|\Redis|string rPop(string $key, int $count = 0) - * @method static false|\Redis|string rpoplpush(string $srckey, string $dstkey) - * @method static false|int|\Redis rPush(string $key, mixed ...$elements) - * @method static false|int|\Redis rPushx(string $key, mixed $value) - * @method static false|int|\Redis sAdd(string $key, mixed $value, mixed ...$other_values) + * @method static array|bool|\Redis|\RedisCluster|string rPop(string $key, int $count = 0) + * @method static false|\Redis|\RedisCluster|string rpoplpush(string $srckey, string $dstkey) + * @method static false|int|\Redis|\RedisCluster rPush(string $key, mixed ...$elements) + * @method static false|int|\Redis|\RedisCluster rPushx(string $key, mixed $value) + * @method static false|int|\Redis|\RedisCluster sAdd(string $key, mixed $value, mixed ...$other_values) * @method static int sAddArray(string $key, array $values) - * @method static bool|\Redis save() - * @method static false|int|\Redis scard(string $key) + * @method static bool|\Redis|\RedisCluster save() + * @method static false|int|\Redis|\RedisCluster scard(string $key) * @method static mixed script(string $command, mixed ...$args) - * @method static array|false|\Redis sDiff(string $key, string ...$other_keys) - * @method static false|int|\Redis sDiffStore(string $dst, string $key, string ...$other_keys) + * @method static array|false|\Redis|\RedisCluster sDiff(string $key, string ...$other_keys) + * @method static false|int|\Redis|\RedisCluster sDiffStore(string $dst, string $key, string ...$other_keys) * @method static bool|\Redis select(int $db) * @method static bool serialized() * @method static false|string serverName() * @method static false|string serverVersion() - * @method static bool set(string $key, mixed $value, mixed $expireResolution = null, mixed $expireTTL = null, mixed $flag = null) Set the value of a key - * @method static false|int|\Redis setBit(string $key, int $idx, bool $value) - * @method static bool|\Redis setex(string $key, int $expire, mixed $value) - * @method static bool|int|\Redis setnx(string $key, mixed $value) Set key if not exists - * @method static false|int|\Redis setRange(string $key, int $index, string $value) - * @method static array|false|\Redis sInter(array|string $key, string ...$other_keys) - * @method static false|int|\Redis sintercard(array $keys, int $limit = -1) - * @method static false|int|\Redis sInterStore(array|string $key, string ...$other_keys) - * @method static bool|\Redis sismember(string $key, mixed $value) + * @method static mixed set(string $key, mixed $value, mixed $expireResolution = null, int|null $expireTTL = null, string|null $flag = null) Set the value of a key + * @method static false|int|\Redis|\RedisCluster setBit(string $key, int $idx, bool $value) + * @method static bool|\Redis|\RedisCluster setex(string $key, int $expire, mixed $value) + * @method static bool|int|\Redis|\RedisCluster setnx(string $key, mixed $value) Set key if not exists + * @method static false|int|\Redis|\RedisCluster setRange(string $key, int $index, string $value) + * @method static array|false|\Redis|\RedisCluster sInter(array|string $key, string ...$other_keys) + * @method static false|int|\Redis|\RedisCluster sintercard(array $keys, int $limit = -1) + * @method static false|int|\Redis|\RedisCluster sInterStore(array|string $key, string ...$other_keys) + * @method static bool|\Redis|\RedisCluster sismember(string $key, mixed $value) * @method static mixed slowlog(string $operation, int $length = 0) - * @method static array|false|\Redis smembers(string $key) Get all set members - * @method static array|false|\Redis sMisMember(string $key, string $member, string ...$other_members) - * @method static bool|\Redis sMove(string $src, string $dst, mixed $value) + * @method static array|false|\Redis|\RedisCluster smembers(string $key) Get all set members + * @method static array|false|\Redis|\RedisCluster sMisMember(string $key, string $member, string ...$other_members) + * @method static bool|\Redis|\RedisCluster sMove(string $src, string $dst, mixed $value) * @method static mixed sort(string $key, array|null $options = null) * @method static mixed sort_ro(string $key, array|null $options = null) * @method static mixed spop(string $key, int $count = 0) Remove and return random set member * @method static mixed sRandMember(string $key, int $count = 0) - * @method static false|int|\Redis sRem(string $key, mixed $value, mixed ...$other_values) Remove members from set - * @method static false|int|\Redis strlen(string $key) - * @method static array|false|\Redis sUnion(string $key, string ...$other_keys) - * @method static false|int|\Redis sUnionStore(string $dst, string $key, string ...$other_keys) + * @method static false|int|\Redis|\RedisCluster sRem(string $key, mixed $value, mixed ...$other_values) Remove members from set + * @method static false|int|\Redis|\RedisCluster strlen(string $key) + * @method static array|false|\Redis|\RedisCluster sUnion(string $key, string ...$other_keys) + * @method static false|int|\Redis|\RedisCluster sUnionStore(string $dst, string $key, string ...$other_keys) * @method static array|bool|\Redis sunsubscribe(array $channels) * @method static bool|\Redis swapdb(int $src, int $dst) - * @method static array|\Redis time() - * @method static false|int|\Redis touch(array|string $key_or_array, string ...$more_keys) - * @method static false|int|\Redis ttl(string $key) - * @method static false|int|\Redis type(string $key) - * @method static false|int|\Redis unlink(array|string $key, string ...$other_keys) + * @method static array|\Redis|\RedisCluster time() + * @method static false|int|\Redis|\RedisCluster touch(array|string $key_or_array, string ...$more_keys) + * @method static false|int|\Redis|\RedisCluster ttl(string $key) + * @method static false|int|\Redis|\RedisCluster type(string $key) + * @method static false|int|\Redis|\RedisCluster unlink(array|string $key, string ...$other_keys) * @method static array|bool|\Redis unsubscribe(array $channels) - * @method static bool|\Redis unwatch() - * @method static false|int|\Redis vadd(string $key, array $values, mixed $element, array|null $options = null) - * @method static false|int|\Redis vcard(string $key) - * @method static false|int|\Redis vdim(string $key) - * @method static array|false|\Redis vemb(string $key, mixed $member, bool $raw = false) - * @method static array|false|\Redis|string vgetattr(string $key, mixed $member, bool $decode = true) - * @method static array|false|\Redis vinfo(string $key) - * @method static bool|\Redis vismember(string $key, mixed $member) - * @method static array|false|\Redis vlinks(string $key, mixed $member, bool $withscores = false) - * @method static array|false|\Redis|string vrandmember(string $key, int $count = 0) - * @method static array|false|\Redis vrange(string $key, string $min, string $max, int $count = -1) - * @method static false|int|\Redis vrem(string $key, mixed $member) - * @method static false|int|\Redis vsetattr(string $key, mixed $member, array|string $attributes) - * @method static array|false|\Redis vsim(string $key, mixed $member, array|null $options = null) + * @method static null|bool|\Redis unwatch() + * @method static false|int|\Redis|\RedisCluster vadd(string $key, array $values, mixed $element, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster vcard(string $key) + * @method static false|int|\Redis|\RedisCluster vdim(string $key) + * @method static array|false|\Redis|\RedisCluster vemb(string $key, mixed $member, bool $raw = false) + * @method static array|false|\Redis|\RedisCluster|string vgetattr(string $key, mixed $member, bool $decode = true) + * @method static array|false|\Redis|\RedisCluster vinfo(string $key) + * @method static bool|\Redis|\RedisCluster vismember(string $key, mixed $member) + * @method static array|false|\Redis|\RedisCluster vlinks(string $key, mixed $member, bool $withscores = false) + * @method static array|false|\Redis|\RedisCluster|string vrandmember(string $key, int $count = 0) + * @method static array|false|\Redis|\RedisCluster vrange(string $key, string $min, string $max, int $count = -1) + * @method static false|int|\Redis|\RedisCluster vrem(string $key, mixed $member) + * @method static false|int|\Redis|\RedisCluster vsetattr(string $key, mixed $member, array|string $attributes) + * @method static array|false|\Redis|\RedisCluster vsim(string $key, mixed $member, array|null $options = null) * @method static false|int wait(int $numreplicas, int $timeout) - * @method static array|false|\Redis waitaof(int $numlocal, int $numreplicas, int $timeout) - * @method static bool|\Redis watch(array|string $key, string ...$other_keys) + * @method static array|false|\Redis|\RedisCluster waitaof(int $numlocal, int $numreplicas, int $timeout) + * @method static bool|\Redis|\RedisCluster watch(array|string $key, string ...$other_keys) * @method static false|int xack(string $key, string $group, array $ids) - * @method static false|\Redis|string xadd(string $key, string $id, array $values, int $maxlen = 0, bool $approx = false, bool $nomkstream = false) - * @method static array|bool|\Redis xautoclaim(string $key, string $group, string $consumer, int $min_idle, string $start, int $count = -1, bool $justid = false) - * @method static array|bool|\Redis xclaim(string $key, string $group, string $consumer, int $min_idle, array $ids, array $options) - * @method static false|int|\Redis xdel(string $key, array $ids) - * @method static array|false|\Redis xdelex(string $key, array $ids, string|null $mode = null) + * @method static false|\Redis|\RedisCluster|string xadd(string $key, string $id, array $values, int $maxlen = 0, bool $approx = false, bool $nomkstream = false) + * @method static array|bool|\Redis|\RedisCluster xautoclaim(string $key, string $group, string $consumer, int $min_idle, string $start, int $count = -1, bool $justid = false) + * @method static array|bool|\Redis|\RedisCluster xclaim(string $key, string $group, string $consumer, int $min_idle, array $ids, array $options) + * @method static false|int|\Redis|\RedisCluster xdel(string $key, array $ids) + * @method static array|false|\Redis|\RedisCluster xdelex(string $key, array $ids, string|null $mode = null) * @method static mixed xgroup(string $operation, string|null $key = null, string|null $group = null, string|null $id_or_consumer = null, bool $mkstream = false, int $entries_read = -2) * @method static mixed xinfo(string $operation, string|null $arg1 = null, string|null $arg2 = null, int $count = -1) - * @method static false|int|\Redis xlen(string $key) - * @method static array|false|\Redis xpending(string $key, string $group, string|null $start = null, string|null $end = null, int $count = -1, string|null $consumer = null) - * @method static array|bool|\Redis xrange(string $key, string $start, string $end, int $count = -1) - * @method static array|bool|\Redis xread(array $streams, int $count = -1, int $block = -1) - * @method static array|bool|\Redis xreadgroup(string $group, string $consumer, array $streams, int $count = 1, int $block = 1) - * @method static array|bool|\Redis xrevrange(string $key, string $end, string $start, int $count = -1) - * @method static false|int|\Redis xtrim(string $key, string $threshold, bool $approx = false, bool $minid = false, int $limit = -1) - * @method static false|float|int|\Redis zadd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) Add members to sorted set - * @method static false|int|\Redis zcard(string $key) Get sorted set cardinality - * @method static false|int|\Redis zcount(string $key, int|string $start, int|string $end) Count sorted set members by score range - * @method static array|false|\Redis zdiff(array $keys, array|null $options = null) - * @method static false|int|\Redis zdiffstore(string $dst, array $keys) - * @method static false|float|\Redis zIncrBy(string $key, float $value, mixed $member) - * @method static array|false|\Redis zinter(array $keys, array|null $weights = null, array|null $options = null) - * @method static false|int|\Redis zintercard(array $keys, int $limit = -1) - * @method static int zinterstore(string $output, array $keys, array $options = []) Intersect sorted sets - * @method static false|int|\Redis zLexCount(string $key, string $min, string $max) - * @method static null|array|false|\Redis zmpop(array $keys, string $from, int $count = 1) - * @method static array|false|\Redis zMscore(string $key, mixed $member, mixed ...$other_members) - * @method static array|false|\Redis zPopMax(string $key, int|null $count = null) - * @method static array|false|\Redis zPopMin(string $key, int|null $count = null) - * @method static array|\Redis|string zRandMember(string $key, array|null $options = null) - * @method static array|false|\Redis zRange(string $key, string|int $start, string|int $end, array|bool|null $options = null) - * @method static array|false|\Redis zRangeByLex(string $key, string $min, string $max, int $offset = -1, int $count = -1) - * @method static array|false|\Redis zrangebyscore(string $key, string $min, string $max, array $options = []) Get sorted set members by score range - * @method static false|int|\Redis zrangestore(string $dstkey, string $srckey, string $start, string $end, array|bool|null $options = null) - * @method static false|int|\Redis zRank(string $key, mixed $member) - * @method static false|int|\Redis zrem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members - * @method static false|int|\Redis zRemRangeByLex(string $key, string $min, string $max) - * @method static false|int|\Redis zRemRangeByRank(string $key, int $start, int $end) - * @method static false|int|\Redis zRemRangeByScore(string $key, string $start, string $end) - * @method static array|false|\Redis zRevRange(string $key, int $start, int $end, mixed $scores = null) - * @method static array|false|\Redis zRevRangeByLex(string $key, string $max, string $min, int $offset = -1, int $count = -1) - * @method static array|false|\Redis zrevrangebyscore(string $key, string $max, string $min, array $options = []) Get sorted set members by score range (reverse) - * @method static false|int|\Redis zRevRank(string $key, mixed $member) - * @method static false|float|\Redis zScore(string $key, mixed $member) - * @method static array|false|\Redis zunion(array $keys, array|null $weights = null, array|null $options = null) - * @method static int zunionstore(string $output, array $keys, array $options = []) Union sorted sets + * @method static false|int|\Redis|\RedisCluster xlen(string $key) + * @method static array|false|\Redis|\RedisCluster xpending(string $key, string $group, string|null $start = null, string|null $end = null, int $count = -1, string|null $consumer = null) + * @method static array|bool|\Redis|\RedisCluster xrange(string $key, string $start, string $end, int $count = -1) + * @method static array|bool|\Redis|\RedisCluster xread(array $streams, int $count = -1, int $block = -1) + * @method static array|bool|\Redis|\RedisCluster xreadgroup(string $group, string $consumer, array $streams, int $count = 1, int $block = 1) + * @method static array|bool|\Redis|\RedisCluster xrevrange(string $key, string $end, string $start, int $count = -1) + * @method static false|int|\Redis|\RedisCluster xtrim(string $key, string $threshold, bool $approx = false, bool $minid = false, int $limit = -1) + * @method static false|float|int|\Redis|\RedisCluster zadd(string $key, array|float $score_or_options, mixed ...$more_scores_and_mems) Add members to sorted set + * @method static false|int|\Redis|\RedisCluster zcard(string $key) Get sorted set cardinality + * @method static false|int|\Redis|\RedisCluster zcount(string $key, int|string $start, int|string $end) Count sorted set members by score range + * @method static array|false|\Redis|\RedisCluster zdiff(array $keys, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster zdiffstore(string $dst, array $keys) + * @method static false|float|\Redis|\RedisCluster zIncrBy(string $key, float $value, mixed $member) + * @method static array|false|\Redis|\RedisCluster zinter(array $keys, array|null $weights = null, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster zintercard(array $keys, int $limit = -1) + * @method static false|int|\Redis|\RedisCluster zinterstore(string $output, array $keys, array $options = []) Intersect sorted sets + * @method static false|int|\Redis|\RedisCluster zLexCount(string $key, string $min, string $max) + * @method static null|array|false|\Redis|\RedisCluster zmpop(array $keys, string $from, int $count = 1) + * @method static array|false|\Redis|\RedisCluster zMscore(string $key, mixed $member, mixed ...$other_members) + * @method static array|false|\Redis|\RedisCluster zPopMax(string $key, int|null $count = null) + * @method static array|false|\Redis|\RedisCluster zPopMin(string $key, int|null $count = null) + * @method static array|\Redis|\RedisCluster|string zRandMember(string $key, array|null $options = null) + * @method static array|false|\Redis|\RedisCluster zRange(string $key, string|int $start, string|int $end, array|bool|null $options = null) + * @method static array|false|\Redis|\RedisCluster zRangeByLex(string $key, string $min, string $max, int $offset = -1, int $count = -1) + * @method static array|false|\Redis|\RedisCluster zrangebyscore(string $key, float|int|string $min, float|int|string $max, array $options = []) Get sorted set members by score range + * @method static false|int|\Redis|\RedisCluster zrangestore(string $dstkey, string $srckey, string $start, string $end, array|bool|null $options = null) + * @method static false|int|\Redis|\RedisCluster zRank(string $key, mixed $member) + * @method static false|int|\Redis|\RedisCluster zrem(mixed $key, mixed $member, mixed ...$other_members) Remove sorted set members + * @method static false|int|\Redis|\RedisCluster zRemRangeByLex(string $key, string $min, string $max) + * @method static false|int|\Redis|\RedisCluster zRemRangeByRank(string $key, int $start, int $end) + * @method static false|int|\Redis|\RedisCluster zRemRangeByScore(string $key, string $start, string $end) + * @method static array|false|\Redis|\RedisCluster zRevRange(string $key, int $start, int $end, mixed $scores = null) + * @method static array|false|\Redis|\RedisCluster zRevRangeByLex(string $key, string $max, string $min, int $offset = -1, int $count = -1) + * @method static array|false|\Redis|\RedisCluster zrevrangebyscore(string $key, float|int|string $max, float|int|string $min, array $options = []) Get sorted set members by score range (reverse) + * @method static false|int|\Redis|\RedisCluster zRevRank(string $key, mixed $member) + * @method static false|float|\Redis|\RedisCluster zScore(string $key, mixed $member) + * @method static array|false|\Redis|\RedisCluster zunion(array $keys, array|null $weights = null, array|null $options = null) + * @method static false|int|\Redis|\RedisCluster zunionstore(string $output, array $keys, array $options = []) Union sorted sets * * @see \Hypervel\Redis\RedisManager */ @@ -341,7 +341,6 @@ protected static function ignoredFacadeDocumenterMethods(): array 'release', 'reset', 'safeScan', - 'setDatabase', 'setOption', 'shouldTransform', 'ssubscribe', diff --git a/tests/Integration/Redis/RedisProxyIntegrationTest.php b/tests/Integration/Redis/RedisProxyIntegrationTest.php index 8ef454f5e..92ebc0743 100644 --- a/tests/Integration/Redis/RedisProxyIntegrationTest.php +++ b/tests/Integration/Redis/RedisProxyIntegrationTest.php @@ -6,6 +6,7 @@ use Hypervel\Engine\Channel; use Hypervel\Foundation\Testing\Concerns\InteractsWithRedis; +use Hypervel\Redis\Events\CommandExecuted; use Hypervel\Redis\RedisConnection; use Hypervel\Redis\RedisProxy; use Hypervel\Support\Facades\Redis; @@ -52,10 +53,79 @@ public function testRedisOptionSerializer(): void $plain = Redis::connection($plainName); $serialized->flushdb(); - $serialized->set('test', 'yyy'); - $this->assertSame('yyy', $serialized->get('test')); - $this->assertSame('s:3:"yyy";', $plain->get('test')); + foreach ([['nested' => true], (object) ['name' => 'Hypervel'], 42] as $index => $value) { + $key = "test:{$index}"; + $serialized->set($key, $value); + + $this->assertEquals($value, $serialized->get($key)); + $this->assertSame(serialize($value), $plain->get($key)); + } + } + + public function testSetGetReturnsDecodedPreviousValues(): void + { + $redis = Redis::connection($this->createRedisConnectionWithOptions( + name: 'test_set_get_serializer', + options: [ + 'prefix' => '', + 'serializer' => PhpRedis::SERIALIZER_PHP, + ], + )); + $redis->flushdb(); + $key = 'set:get'; + + $this->assertFalse($redis->set($key, ['version' => 1], ['GET'])); + $this->assertSame( + ['version' => 1], + $redis->set($key, 42, ['GET', 'EX' => 60]), + ); + + $previous = (object) ['version' => 2]; + $this->assertSame(42, $redis->set($key, $previous, ['GET'])); + $this->assertEquals($previous, $redis->set($key, 'current', ['GET'])); + $this->assertSame('current', $redis->get($key)); + } + + public function testZaddIncrementReturnsFloatScore(): void + { + $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); + $redis->flushdb(); + + $this->assertSame(1.5, $redis->zadd('zadd:increment', 'INCR', 1.5, 'member')); + $this->assertSame(2.5, $redis->zadd('zadd:increment', 'INCR', 1.0, 'member')); + } + + public function testCommandListenerReusesTheOwnedConnection(): void + { + $connectionName = $this->createRedisConnectionWithOptions( + name: 'test_reentrant_listener', + options: ['prefix' => ''], + maxConnections: 1, + ); + $config = $this->app->make('config'); + $connectionConfig = $config->array("database.redis.{$connectionName}"); + $connectionConfig['events'] = true; + $connectionConfig['pool']['wait_timeout'] = 0.05; + $config->set("database.redis.{$connectionName}", $connectionConfig); + + $redis = Redis::connection($connectionName); + $redis->flushdb(); + $outerKey = 'listener:outer'; + $nestedValue = null; + + Redis::listen(function (CommandExecuted $event) use ($redis, $outerKey, &$nestedValue): void { + if ($event->connectionName === $redis->getName() + && strtolower($event->command) === 'set' + && ($event->parameters[0] ?? null) === $outerKey) { + $nestedValue = $redis->get($outerKey); + } + }); + + $this->assertTrue($redis->set($outerKey, 'written')); + $this->assertSame('written', $nestedValue); + $this->assertTrue($redis->set('listener:after', 'reusable')); + $this->assertSame('reusable', $redis->get('listener:after')); } public function testHyperLogLog(): void @@ -411,6 +481,93 @@ public function testSelectIsolationAcrossCoroutines(): void $redis->del($uniqueKey); } + public function testHeldConnectionSelectionIsRestoredAfterRelease(): void + { + if ($this->usingRedisCluster()) { + $this->markTestSkipped('Redis Cluster does not support logical databases.'); + } + + $redis = Redis::connection($this->createRedisConnectionWithOptions( + name: 'test_held_select_restore', + options: ['prefix' => ''], + maxConnections: 1, + )); + $primaryDatabase = $this->getParallelRedisDb(); + $secondaryDatabase = $this->getSecondaryRedisDb(); + + $redis->withConnection(function (RedisConnection $connection) use ($secondaryDatabase): void { + $this->assertTrue($connection->select($secondaryDatabase)); + $this->assertSame($secondaryDatabase, $connection->client()->getDBNum()); + }); + $this->assertSame($primaryDatabase, $this->nativeClient($redis)->getDBNum()); + + $redis->withPinnedConnection(function () use ($redis, $secondaryDatabase): void { + $this->assertTrue($redis->select($secondaryDatabase)); + $this->assertSame($secondaryDatabase, $this->nativeClient($redis)->getDBNum()); + }); + $this->assertSame($primaryDatabase, $this->nativeClient($redis)->getDBNum()); + } + + public function testRawPipelineAndTransactionSelectionsAreRestoredAfterExec(): void + { + if ($this->usingRedisCluster()) { + $this->markTestSkipped('Redis Cluster does not support logical databases.'); + } + + $redis = Redis::connection($this->createRedisConnectionWithOptions( + name: 'test_raw_select_restore', + options: ['prefix' => ''], + maxConnections: 1, + )); + $primaryDatabase = $this->getParallelRedisDb(); + $secondaryDatabase = $this->getSecondaryRedisDb(); + $keys = []; + + foreach (['pipeline', 'transaction'] as $method) { + $key = "raw:select:{$method}:" . uniqid(); + $keys[] = $key; + $results = $redis->{$method}(static function (PhpRedis $client) use ($secondaryDatabase, $key): void { + $client->select($secondaryDatabase); + $client->set($key, 'value'); + }); + + $this->assertSame([true, true], $results); + $this->assertSame($primaryDatabase, $this->nativeClient($redis)->getDBNum()); + } + + try { + $this->assertTrue($redis->select($secondaryDatabase)); + + foreach ($keys as $key) { + $this->assertSame('value', $redis->get($key)); + } + } finally { + $redis->del(...$keys); + $redis->select($primaryDatabase); + } + } + + public function testDiscardedRawSelectionDoesNotChangeReleaseDatabase(): void + { + if ($this->usingRedisCluster()) { + $this->markTestSkipped('Redis Cluster does not support logical databases.'); + } + + $redis = Redis::connection($this->createRedisConnectionWithOptions( + name: 'test_discarded_select', + options: ['prefix' => ''], + maxConnections: 1, + )); + $primaryDatabase = $this->getParallelRedisDb(); + $transaction = $redis->multi(); + $transaction->select($this->getSecondaryRedisDb()); + + $this->assertTrue($redis->discard()); + $redis->releaseContextConnection(); + + $this->assertSame($primaryDatabase, $this->nativeClient($redis)->getDBNum()); + } + public function testPipelineCallbackRunsCommands(): void { $redis = Redis::connection($this->createRedisConnectionWithPrefix('')); diff --git a/tests/Redis/MultiExecTest.php b/tests/Redis/MultiExecTest.php index 0fb75a8e3..c56ba9517 100644 --- a/tests/Redis/MultiExecTest.php +++ b/tests/Redis/MultiExecTest.php @@ -295,7 +295,6 @@ private function createMockConnection(m\MockInterface $phpRedis): m\MockInterfac $connection = m::mock(PhpRedisConnection::class); $connection->shouldReceive('getConnection')->andReturn($connection); $connection->shouldReceive('getEventDispatcher')->andReturnNull(); - $connection->shouldReceive('setDatabase')->andReturnNull(); $connection->shouldReceive('shouldTransform')->andReturnSelf(); // Forward method calls to the phpRedis mock diff --git a/tests/Redis/PackageMetadataTest.php b/tests/Redis/PackageMetadataTest.php index 3e1f3e7d2..196624eaf 100644 --- a/tests/Redis/PackageMetadataTest.php +++ b/tests/Redis/PackageMetadataTest.php @@ -59,17 +59,17 @@ public function testNativeRedisCommandReturnValuesAreDocumented(): void { $connectionDocblock = (new ReflectionClass(RedisConnection::class))->getDocComment(); $this->assertIsString($connectionDocblock); - $this->assertStringContainsString('@method array|false|Redis keys(string $pattern)', $connectionDocblock); + $this->assertStringContainsString('@method array|false|Redis|RedisCluster keys(string $pattern)', $connectionDocblock); $this->assertStringContainsString( - '@method false|int|Redis lInsert(string $key, string $pos, mixed $pivot, mixed $value)', + '@method false|int|Redis|RedisCluster lInsert(string $key, string $pos, mixed $pivot, mixed $value)', $connectionDocblock, ); $facadeDocblock = (new ReflectionClass(Redis::class))->getDocComment(); $this->assertIsString($facadeDocblock); - $this->assertStringContainsString('@method static array|false|\Redis keys(string $pattern)', $facadeDocblock); + $this->assertStringContainsString('@method static array|false|\Redis|\RedisCluster keys(string $pattern)', $facadeDocblock); $this->assertStringContainsString( - '@method static false|int|\Redis lInsert(string $key, string $pos, mixed $pivot, mixed $value)', + '@method static false|int|\Redis|\RedisCluster lInsert(string $key, string $pos, mixed $pivot, mixed $value)', $facadeDocblock, ); } diff --git a/tests/Redis/RedisConfigTest.php b/tests/Redis/RedisConfigTest.php index 96cd4e703..3a72372e4 100644 --- a/tests/Redis/RedisConfigTest.php +++ b/tests/Redis/RedisConfigTest.php @@ -601,7 +601,7 @@ public function testConnectionConfigParsesUrl(): void $this->assertSame(6380, $connectionConfig['port']); $this->assertSame('myuser', $connectionConfig['username']); $this->assertSame('secret', $connectionConfig['password']); - $this->assertSame('3', $connectionConfig['database']); + $this->assertSame(3, $connectionConfig['database']); } public function testConnectionConfigUrlOverridesExplicitValues(): void @@ -622,7 +622,7 @@ public function testConnectionConfigUrlOverridesExplicitValues(): void $this->assertSame('urlhost', $connectionConfig['host']); $this->assertSame(6380, $connectionConfig['port']); - $this->assertSame('2', $connectionConfig['database']); + $this->assertSame(2, $connectionConfig['database']); } public function testConnectionConfigWithoutUrlPreservesExplicitValues(): void @@ -645,6 +645,24 @@ public function testConnectionConfigWithoutUrlPreservesExplicitValues(): void $this->assertSame(0, $connectionConfig['database']); } + public function testConnectionConfigNormalizesExplicitStringDatabase(): void + { + $config = m::mock(Repository::class); + $config->shouldReceive('array')->with('database.redis')->andReturn([ + 'options' => [], + 'default' => [ + 'host' => '127.0.0.1', + 'port' => 6379, + 'database' => '4', + 'options' => [], + ], + ]); + + $connectionConfig = (new RedisConfig($config))->connectionConfig('default'); + + $this->assertSame(4, $connectionConfig['database']); + } + public function testConnectionConfigAcceptsUrlOnlyConnection(): void { $config = m::mock(Repository::class); @@ -659,6 +677,7 @@ public function testConnectionConfigAcceptsUrlOnlyConnection(): void $connection = (new RedisConfig($config))->connectionConfig('default'); $this->assertSame('127.0.0.1', $connection['host']); + $this->assertSame(0, $connection['database']); } public function testConnectionConfigThrowsWhenClusterAndSentinelBothEnabled(): void diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 9e4371963..4873ee630 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -28,6 +28,7 @@ use RedisCluster; use RedisClusterException; use RedisException; +use ReflectionProperty; use RuntimeException; use TypeError; @@ -60,6 +61,35 @@ public function testRelease(): void $this->assertFalse($connection->getShouldTransform()); } + public function testSuccessfulAtomicSelectTracksTheAppliedDatabase(): void + { + $redis = m::mock(Redis::class); + $redis->expects('select')->with(2)->andReturnTrue(); + $connection = $this->mockRedisConnection(); + $connection->setActiveConnection($redis); + + $this->assertTrue($connection->__call('SELECT', [2])); + $this->assertSame( + 2, + (new ReflectionProperty(RedisConnection::class, 'database'))->getValue($connection), + ); + } + + public function testFailedAndQueuedSelectResultsAreNotTrackedAsApplied(): void + { + $redis = m::mock(Redis::class); + $redis->expects('select')->with(2)->andReturnFalse(); + $redis->expects('select')->with(3)->andReturnSelf(); + $connection = $this->mockRedisConnection(); + $connection->setActiveConnection($redis); + $database = new ReflectionProperty(RedisConnection::class, 'database'); + + $this->assertFalse($connection->__call('select', [2])); + $this->assertNull($database->getValue($connection)); + $this->assertSame($redis, $connection->__call('select', [3])); + $this->assertNull($database->getValue($connection)); + } + public function testReleaseResetsDatabaseToConfiguredDefault(): void { $pool = $this->getMockedPool(); @@ -68,7 +98,10 @@ public function testReleaseResetsDatabaseToConfiguredDefault(): void $redis = m::mock(Redis::class); $redis->shouldReceive('select')->once()->with(1)->andReturn(true); $redis->shouldReceive('select')->once()->with(1)->andReturn(true); + $redis->expects('select')->with(2)->andReturnTrue(); $redis->shouldReceive('getMode')->once()->andReturn(Redis::ATOMIC); + $redis->expects('isConnected')->andReturnTrue(); + $redis->expects('getDBNum')->andReturn(2); $redis->shouldReceive('setOption')->andReturnTrue(); @@ -88,10 +121,46 @@ protected function createRedis(array $config): Redis } }; - $connection->setDatabase(2); + $connection->__call('select', [2]); $connection->release(); } + public function testReleaseRestoresTheNativeDatabaseWithoutTrackedSelection(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $redis->expects('isConnected')->andReturnTrue(); + $redis->expects('getDBNum')->andReturn(2); + $redis->expects('select')->with(0)->andReturnTrue(); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->release(); + + $this->assertNull( + (new ReflectionProperty(RedisConnection::class, 'database'))->getValue($connection), + ); + } + + public function testReleaseInvalidatesDisconnectedStandaloneConnectionWithoutInspectingItsDatabase(): void + { + $pool = $this->getMockedPool(); + $pool->expects('release')->with(m::type(RedisConnection::class)); + $redis = m::mock(Redis::class); + $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $redis->expects('isConnected')->andReturnFalse(); + $redis->shouldNotReceive('getDBNum'); + $redis->shouldNotReceive('select'); + $connection = $this->mockRedisConnection(pool: $pool); + $connection->setActiveConnection($redis); + + $connection->release(); + + $this->assertTrue($connection->isInvalidForTest()); + } + public function testReleaseDiscardsAConnectionInMultiMode(): void { $pool = $this->getMockedPool(); @@ -212,6 +281,7 @@ public function testReconnectBeginsWithNoTrackedWatchState(): void $redis = m::mock(Redis::class); $redis->expects('watch')->with('key')->andReturnTrue(); $redis->expects('getMode')->andReturn(Redis::ATOMIC); + $redis->expects('isConnected')->twice()->andReturnFalse(); $redis->shouldReceive('setOption')->andReturnTrue(); $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), $redis) extends PhpRedisConnection { @@ -255,10 +325,23 @@ public function testReleaseChecksNativeModeBeforeRestoringDatabase(): void $pool = $this->getMockedPool(); $pool->expects('release')->with(m::type(RedisConnection::class)); $redis = m::mock(Redis::class); + $redis->expects('select') + ->with(2) + ->globally() + ->ordered() + ->andReturnTrue(); $redis->expects('getMode') ->globally() ->ordered() ->andReturn(Redis::ATOMIC); + $redis->expects('isConnected') + ->globally() + ->ordered() + ->andReturnTrue(); + $redis->expects('getDBNum') + ->globally() + ->ordered() + ->andReturn(2); $redis->expects('select') ->with(0) ->globally() @@ -266,7 +349,7 @@ public function testReleaseChecksNativeModeBeforeRestoringDatabase(): void ->andReturnTrue(); $connection = $this->mockRedisConnection(pool: $pool, options: ['database' => 0]); $connection->setActiveConnection($redis); - $connection->setDatabase(2); + $connection->__call('select', [2]); $connection->release(); } @@ -290,25 +373,77 @@ public function isInvalidForTest(): bool $this->assertTrue($connection->isInvalidForTest()); } - public function testDatabaseRestoreFailureInvalidatesAndReleasesConnection(): void + #[DataProvider('databaseRestoreFailureProvider')] + public function testDatabaseRestoreFailureClosesTheNativeGenerationBeforeReconnect(string $failureMode): void { $pool = $this->getMockedPool(); + $pool->shouldReceive('getName')->andReturn('default'); $pool->expects('release')->with(m::type(RedisConnection::class)); - $redis = m::mock(Redis::class); - $redis->expects('getMode')->andReturn(Redis::ATOMIC); - $redis->expects('select')->with(0)->andThrow(new RuntimeException('Select failed.')); - $connection = new class($this->getContainer(), $pool, $this->standaloneConfig()) extends PhpRedisConnectionStub { + $logger = m::mock(StdoutLoggerInterface::class); + $logger->expects('log')->with( + LogLevel::CRITICAL, + m::on(static fn (string $message): bool => str_starts_with($message, 'Release connection failed, caused by ')), + ); + $container = $this->getContainer(); + $container->instance(StdoutLoggerInterface::class, $logger); + $oldRedis = m::mock(Redis::class); + $newRedis = m::mock(Redis::class); + $this->expectDefaultConnectionOptions($oldRedis); + $this->expectDefaultConnectionOptions($newRedis); + $oldRedis->expects('select')->with(2)->andReturnTrue(); + $oldRedis->expects('getMode')->andReturn(Redis::ATOMIC); + $oldRedis->expects('isConnected')->andReturnTrue(); + $oldRedis->expects('getDBNum')->once()->andReturn(2); + $restore = $oldRedis->expects('select')->with(0); + + if ($failureMode === 'false') { + $restore->andReturnFalse(); + } else { + $restore->andThrow(new RuntimeException('Select failed.')); + } + + $oldRedis->expects('close')->andReturnTrue(); + $newRedis->shouldNotReceive('select'); + $connection = new class($container, $pool, $this->standaloneConfig(), [$oldRedis, $newRedis]) extends PhpRedisConnection { + /** + * @param Redis[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return array_shift($this->clients); + } + public function isInvalidForTest(): bool { return $this->invalid; } }; - $connection->setActiveConnection($redis); - $connection->setDatabase(2); + $connection->__call('select', [2]); $connection->release(); $this->assertTrue($connection->isInvalidForTest()); + $this->assertNull($connection->client()); + $this->assertSame($connection, $connection->getActiveConnection()); + $this->assertSame($newRedis, $connection->client()); + $this->assertFalse($connection->isInvalidForTest()); + } + + public static function databaseRestoreFailureProvider(): array + { + return [ + 'false result' => ['false'], + 'exception' => ['exception'], + ]; } public function testReportingFailureCannotPreventQueueingModeDiscard(): void @@ -354,7 +489,9 @@ public function testReconnectUsesCurrentDatabaseWhenSet(): void { $pool = $this->getMockedPool(); $redis = m::mock(Redis::class); - $redis->shouldReceive('select')->once()->with(2)->andReturn(true); + $redis->shouldReceive('select')->twice()->with(2)->andReturn(true); + $redis->expects('isConnected')->andReturnTrue(); + $redis->expects('getDBNum')->andReturn(2); $redis->shouldReceive('setOption')->andReturnTrue(); @@ -374,8 +511,159 @@ protected function createRedis(array $config): Redis } }; - $connection->setDatabase(2); + $connection->__call('select', [2]); + $connection->reconnect(); + } + + public function testReconnectCarriesTheConnectedNativeClientsActualDatabaseAcrossAReplacement(): void + { + $pool = $this->getMockedPool(); + $oldRedis = m::mock(Redis::class); + $newRedis = m::mock(Redis::class); + $secondNewRedis = m::mock(Redis::class); + $this->expectDefaultConnectionOptions($oldRedis); + $this->expectDefaultConnectionOptions($newRedis); + $this->expectDefaultConnectionOptions($secondNewRedis); + $oldRedis->expects('isConnected')->andReturnTrue(); + $oldRedis->expects('getDBNum')->andReturn(2); + $newRedis->expects('select')->with(2)->andReturnTrue(); + $newRedis->expects('isConnected')->andReturnFalse(); + $newRedis->shouldNotReceive('getDBNum'); + $secondNewRedis->expects('select')->with(2)->andReturnTrue(); + + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), [$oldRedis, $newRedis, $secondNewRedis]) extends PhpRedisConnection { + /** + * @param Redis[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return array_shift($this->clients); + } + }; + $connection->reconnect(); + $connection->reconnect(); + + $this->assertSame($secondNewRedis, $connection->client()); + } + + public function testReconnectRejectsARefusedDatabaseBeforePublishingTheNewClient(): void + { + $pool = $this->getMockedPool(); + $pool->expects('getName')->andReturn('default'); + $oldRedis = m::mock(Redis::class); + $newRedis = m::mock(Redis::class); + $this->expectDefaultConnectionOptions($oldRedis); + $this->expectDefaultConnectionOptions($newRedis); + $oldRedis->expects('select')->with(2)->andReturnTrue(); + $oldRedis->expects('isConnected')->andReturnTrue(); + $oldRedis->expects('getDBNum')->andReturn(2); + $newRedis->expects('select')->with(2)->andReturnFalse(); + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(['database' => 2]), [$oldRedis, $newRedis]) extends PhpRedisConnection { + /** + * @param Redis[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return array_shift($this->clients); + } + + public function isInvalidForTest(): bool + { + return $this->invalid; + } + }; + $connection->invalidate(); + $exception = null; + + try { + $connection->reconnect(); + } catch (ConnectionException $exception) { + } + + $this->assertInstanceOf(ConnectionException::class, $exception); + $this->assertSame('Failed to select Redis database [2] on connection [default].', $exception->getMessage()); + $this->assertSame($oldRedis, $connection->client()); + $this->assertTrue($connection->isInvalidForTest()); + } + + public function testReconnectToDatabaseZeroDoesNotIssueSelect(): void + { + $redis = m::mock(Redis::class); + $this->expectDefaultConnectionOptions($redis); + $redis->shouldNotReceive('select'); + $connection = new class($this->getContainer(), $this->getMockedPool(), $this->standaloneConfig(), $redis) extends PhpRedisConnection { + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private Redis $redis, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return $this->redis; + } + }; + + $this->assertSame($redis, $connection->client()); + } + + public function testReconnectDoesNotInspectADisconnectedClientAndUsesTrackedSelection(): void + { + $pool = $this->getMockedPool(); + $oldRedis = m::mock(Redis::class); + $newRedis = m::mock(Redis::class); + $this->expectDefaultConnectionOptions($oldRedis); + $this->expectDefaultConnectionOptions($newRedis); + $oldRedis->expects('select')->with(2)->andReturnTrue(); + $oldRedis->expects('isConnected')->andReturnFalse(); + $oldRedis->shouldNotReceive('getDBNum'); + $newRedis->expects('select')->with(2)->andReturnTrue(); + + $connection = new class($this->getContainer(), $pool, $this->standaloneConfig(), [$oldRedis, $newRedis]) extends PhpRedisConnection { + /** + * @param Redis[] $clients + */ + public function __construct( + ContainerContract $container, + PoolInterface $pool, + array $config, + private array $clients, + ) { + parent::__construct($container, $pool, $config); + } + + protected function createRedis(array $config): Redis + { + return array_shift($this->clients); + } + }; + + $this->assertTrue($connection->__call('select', [2])); + $connection->reconnect(); + + $this->assertSame($newRedis, $connection->client()); } public function testSentinelResolvedMasterUsesStandaloneDataConnectionSettings(): void @@ -582,6 +870,24 @@ public function testQueueingModeReshapesSetArgumentsButPreservesRawQueuedReturn( $this->assertSame($redis, $result); } + public function testQueueingModePreservesNativeSetOptionsAndRawQueuedReturn(): void + { + $connection = $this->mockRedisConnection(transform: true); + $redis = m::mock(Redis::class); + + $redis->shouldReceive('getMode')->once()->andReturn(Redis::MULTI); + $redis->shouldReceive('set') + ->once() + ->with('key', 'value', ['GET', 'EX' => 600]) + ->andReturnSelf(); + + $connection->setActiveConnection($redis); + + $result = $connection->__call('set', ['key', 'value', ['GET', 'EX' => 600]]); + + $this->assertSame($redis, $result); + } + public function testQueueingModeReshapesHmsetArgumentsButPreservesRawQueuedReturn(): void { $connection = $this->mockRedisConnection(transform: true); @@ -741,6 +1047,10 @@ public function testTypeErrorsAreNotRetried(): void $redis = m::mock(Redis::class); $redis->shouldReceive('getMode')->once()->andReturn(Redis::ATOMIC); + $redis->shouldReceive('set') + ->once() + ->with('key', 'value', 600) + ->andThrow(new TypeError('Invalid native Redis argument.')); $connection->setActiveConnection($redis); $this->expectException(TypeError::class); @@ -963,6 +1273,23 @@ public function testCallGet(): void $this->assertEquals($value, $result); } + public function testGetPreservesDecodedValues(): void + { + $connection = $this->mockRedisConnection(transform: true); + $object = (object) ['name' => 'Hypervel']; + + foreach ([['nested' => true], $object, 42] as $index => $value) { + $key = "key-{$index}"; + $connection->getConnection() + ->shouldReceive('get') + ->with($key) + ->once() + ->andReturn($value); + + $this->assertSame($value, $connection->__call('get', [$key])); + } + } + public function testMget(): void { $connection = $this->mockRedisConnection(transform: true); @@ -978,6 +1305,19 @@ public function testMget(): void $this->assertEquals(['value1', null, 'value3'], $result); } + public function testMgetPreservesWholeCallFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('mGet') + ->with(['key1', 'key2']) + ->once() + ->andReturnFalse(); + + $this->assertFalse($connection->__call('mget', [['key1', 'key2']])); + } + public function testMgetReturnsAnEmptyArrayWithoutCallingRedisForEmptyKeys(): void { $connection = $this->mockRedisConnection(transform: true); @@ -1001,6 +1341,57 @@ public function testSet(): void $this->assertTrue($result); } + public function testSetAcceptsNativeOptionsAndPreservesDecodedPreviousValues(): void + { + foreach ([['version' => 1], 42, (object) ['version' => 1]] as $previous) { + $server = new RespServer; + $serialized = serialize($previous); + $server->start(static function ($client) use ($serialized): void { + $argumentCount = (int) substr((string) fgets($client), 1); + + for ($index = 0; $index < $argumentCount; ++$index) { + $length = (int) substr((string) fgets($client), 1); + RespServer::readExact($client, $length + 2); + } + + fwrite($client, '$' . strlen($serialized) . "\r\n{$serialized}\r\n"); + }); + [$host, $port] = $server->hostAndPort(); + $redis = new Redis; + + try { + $this->assertTrue($redis->connect($host, $port)); + $this->assertTrue($redis->setOption(Redis::OPT_SERIALIZER, Redis::SERIALIZER_PHP)); + $connection = $this->mockRedisConnection(transform: true); + $connection->setActiveConnection($redis); + + $this->assertEquals( + $previous, + $connection->__call('set', ['key', ['version' => 2], ['GET', 'EX' => 3600]]), + ); + } finally { + $redis->close(); + $server->wait(); + } + } + } + + public function testSetPreservesBooleanNativeResults(): void + { + $connection = $this->mockRedisConnection(transform: true); + + foreach ([true, false] as $index => $result) { + $key = "key-{$index}"; + $connection->getConnection() + ->shouldReceive('set') + ->with($key, 'value', ['GET']) + ->once() + ->andReturn($result); + + $this->assertSame($result, $connection->__call('set', [$key, 'value', ['GET']])); + } + } + public function testSetnxAcceptsNonStringValues(): void { $connection = $this->mockRedisConnection(transform: true); @@ -1046,6 +1437,19 @@ public function testHmgetMultipleArgs(): void $this->assertEquals(['value1', 'value2'], $result); } + public function testHmgetPreservesWholeCallFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('hMGet') + ->with('hash', ['field1', 'field2']) + ->once() + ->andReturnFalse(); + + $this->assertFalse($connection->__call('hmget', ['hash', ['field1', 'field2']])); + } + public function testHmset(): void { $connection = $this->mockRedisConnection(transform: true); @@ -1298,6 +1702,25 @@ public function testZaddWithOptions(): void $this->assertEquals(2, $result); } + public function testZaddPreservesIncrementScoreAndFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zAdd') + ->with('sortedset', ['INCR'], 1.5, 'member') + ->once() + ->andReturn(2.5); + $connection->getConnection() + ->shouldReceive('zAdd') + ->with('sortedset', ['XX'], 1.5, 'missing') + ->once() + ->andReturnFalse(); + + $this->assertSame(2.5, $connection->__call('zadd', ['sortedset', 'INCR', 1.5, 'member'])); + $this->assertFalse($connection->__call('zadd', ['sortedset', 'XX', 1.5, 'missing'])); + } + public function testZaddWithArray(): void { $connection = $this->mockRedisConnection(transform: true); @@ -1328,6 +1751,19 @@ public function testZrangebyscoreWithOptions(): void $this->assertEquals(['member1', 'member2'], $result); } + public function testZrangebyscorePreservesFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zRangeByScore') + ->with('sortedset', '1', '5', []) + ->once() + ->andReturnFalse(); + + $this->assertFalse($connection->__call('zrangebyscore', ['sortedset', '1', '5'])); + } + public function testFlushdbAsync(): void { $connection = $this->mockRedisConnection(transform: true); @@ -1388,6 +1824,32 @@ public function testZinterstoreWithOptions(): void $this->assertEquals(3, $result); } + public function testZinterstorePreservesFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zinterstore') + ->with('output', ['set1', 'set2'], null, 'sum') + ->once() + ->andReturnFalse(); + + $this->assertFalse($connection->__call('zinterstore', ['output', ['set1', 'set2']])); + } + + public function testZunionstorePreservesFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zunionstore') + ->with('output', ['set1', 'set2'], null, 'sum') + ->once() + ->andReturnFalse(); + + $this->assertFalse($connection->__call('zunionstore', ['output', ['set1', 'set2']])); + } + public function testZunionstoreSimple(): void { $connection = $this->mockRedisConnection(); @@ -1511,6 +1973,19 @@ public function testZrevrangebyscoreWithLimitOption(): void $this->assertEquals(['member2', 'member1'], $result); } + public function testZrevrangebyscorePreservesFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zRevRangeByScore') + ->with('zset', '+inf', '-inf', []) + ->once() + ->andReturnFalse(); + + $this->assertFalse($connection->__call('zrevrangebyscore', ['zset', '+inf', '-inf'])); + } + public function testZinterstoreDefaultsAggregate(): void { $connection = $this->mockRedisConnection(transform: true); @@ -2609,6 +3084,7 @@ public function testReconnectClearsInvalidState(): void $pool = $this->getMockedPool(); $redis = m::mock(Redis::class); $redis->shouldReceive('select')->andReturn(true); + $redis->expects('isConnected')->andReturnFalse(); $redis->shouldReceive('setOption')->andReturnTrue(); diff --git a/tests/Redis/RedisPoolHeartbeatTest.php b/tests/Redis/RedisPoolHeartbeatTest.php index 9a20af1ca..4e2b04f18 100644 --- a/tests/Redis/RedisPoolHeartbeatTest.php +++ b/tests/Redis/RedisPoolHeartbeatTest.php @@ -356,9 +356,12 @@ public function testReleaseResetFailureReturnsInvalidConnectionToPool(): void $this->assertInstanceOf(HeartbeatRedisConnection::class, $connection); $redis = m::mock(Redis::class); + $redis->expects('select')->once()->with(2)->andReturnTrue(); + $redis->expects('isConnected')->andReturnTrue(); + $redis->expects('getDBNum')->andReturn(2); $redis->shouldReceive('select')->once()->with(0)->andThrow(new RuntimeException('select failed')); $connection->setNativeClientForTest($redis); - $connection->setDatabase(2); + $connection->__call('select', [2]); $connection->release(); @@ -577,7 +580,10 @@ public function __construct(Container $container, PoolInterface $pool, array $co public function reconnect(): bool { - $this->connection = m::mock(Redis::class)->shouldIgnoreMissing(); + $redis = m::mock(Redis::class)->shouldIgnoreMissing(); + $redis->shouldReceive('isConnected')->andReturnTrue(); + $redis->shouldReceive('getDBNum')->andReturn($this->config['database']); + $this->connection = $redis; ++$this->reconnectCount; $this->markReconnected(); diff --git a/tests/Redis/RedisProxyNonCoroutineTest.php b/tests/Redis/RedisProxyNonCoroutineTest.php index 91bb5200f..6c758e7ca 100644 --- a/tests/Redis/RedisProxyNonCoroutineTest.php +++ b/tests/Redis/RedisProxyNonCoroutineTest.php @@ -37,7 +37,7 @@ public function testPipelinePinsConnectionUntilTerminalCleanup(): void public function testSelectPinsConnectionUntilTerminalCleanup(): void { - $this->assertCommandPinsConnection('select', [2], true, 2); + $this->assertCommandPinsConnection('select', [2], true); } public function testWatchPinsConnectionUntilTerminalCleanup(): void @@ -52,7 +52,6 @@ private function assertCommandPinsConnection( string $command, array $arguments, mixed $result, - ?int $selectedDatabase = null, ): void { $connection = m::mock(PhpRedisConnection::class); $connection->shouldReceive('shouldTransform')->andReturnSelf(); @@ -61,10 +60,6 @@ private function assertCommandPinsConnection( $connection->expects($command)->with(...$arguments)->andReturn($result); $connection->expects('release'); - if ($selectedDatabase !== null) { - $connection->expects('setDatabase')->with($selectedDatabase); - } - $pool = m::mock(RedisPool::class); $pool->expects('get')->andReturn($connection); $factory = m::mock(PoolFactory::class); diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php index d66782e9c..c800ed7bb 100644 --- a/tests/Redis/RedisProxyTest.php +++ b/tests/Redis/RedisProxyTest.php @@ -178,7 +178,6 @@ public function testMixedCaseSelectStoresConnectionInContext(): void { $connection = $this->mockConnection(); $connection->shouldReceive('select')->once()->with(1)->andReturn(true); - $connection->shouldReceive('setDatabase')->once()->with(1); // Connection is released via defer() at end of coroutine $connection->shouldReceive('release')->once(); @@ -194,7 +193,6 @@ public function testConnectionIsStoredInContextForSelectZeroDatabase(): void { $connection = $this->mockConnection(); $connection->shouldReceive('select')->once()->with(0)->andReturn(true); - $connection->shouldReceive('setDatabase')->once()->with(0); $connection->shouldReceive('release')->once(); $redis = $this->createRedis($connection); @@ -331,7 +329,6 @@ public function testSelectPinnedConnectionDoesNotLeakAcrossCoroutines(): void $selectedConnection = $this->mockConnection(); $selectedConnection->shouldReceive('select')->once()->with(2)->andReturn(true); - $selectedConnection->shouldReceive('setDatabase')->once()->with(2); $selectedConnection->shouldReceive('get')->once()->with('xxxx')->andReturn('db:2 name:get argument:xxxx'); $selectedConnection->shouldReceive('release')->once(); @@ -522,6 +519,69 @@ public function testEventDispatchedOnSuccess(): void $redis->get('key'); } + public function testSuccessEventTemporarilyPublishesTheOwnedConnection(): void + { + $dispatcher = m::mock(Dispatcher::class); + $connection = $this->createMockRedisConnection('get', 'value', eventDispatcher: $dispatcher); + $dispatcher->expects('hasListeners')->with(CommandExecuted::class)->andReturnTrue(); + $dispatcher->expects('dispatch') + ->with(m::type(CommandExecuted::class)) + ->andReturnUsing(function () use ($connection): void { + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default'), + ); + }); + $connection->expects('release'); + + $this->assertSame('value', $this->createRedis($connection)->get('key')); + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); + } + + public function testSuccessEventPreservesAPreExistingContextConnection(): void + { + $dispatcher = m::mock(Dispatcher::class); + $connection = $this->createMockRedisConnection('get', 'value', eventDispatcher: $dispatcher); + $dispatcher->expects('hasListeners')->with(CommandExecuted::class)->andReturnTrue(); + $dispatcher->expects('dispatch') + ->with(m::type(CommandExecuted::class)) + ->andReturnUsing(function () use ($connection): void { + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default'), + ); + }); + $connection->shouldNotReceive('release'); + CoroutineContext::set(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default', $connection); + + $this->assertSame('value', $this->createRedis($connection)->get('key')); + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default'), + ); + } + + public function testSuccessEventCanRunANestedCommandOnTheOwnedConnection(): void + { + $dispatcher = m::mock(Dispatcher::class); + $connection = $this->createMockRedisConnection('get', 'outer', eventDispatcher: $dispatcher); + $connection->expects('set')->with('nested', 'value')->andReturnTrue(); + $connection->expects('release'); + $dispatcher->expects('hasListeners') + ->twice() + ->with(CommandExecuted::class) + ->andReturn(true, false); + $redis = $this->createRedis($connection); + $dispatcher->expects('dispatch') + ->with(m::type(CommandExecuted::class)) + ->andReturnUsing(function () use ($redis): void { + $this->assertTrue($redis->set('nested', 'value')); + }); + + $this->assertSame('outer', $redis->get('outer')); + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); + } + public function testEventDispatchedOnErrorWithExceptionInfo(): void { $expectedException = new Exception('Redis error'); @@ -556,6 +616,83 @@ public function testEventDispatchedOnErrorWithExceptionInfo(): void } } + public function testFailureEventTemporarilyPublishesTheOwnedConnection(): void + { + $commandException = new RuntimeException('Command failed.'); + $dispatcher = m::mock(Dispatcher::class); + $connection = $this->createMockRedisConnection('get', exception: $commandException, eventDispatcher: $dispatcher); + $dispatcher->expects('hasListeners')->with(CommandFailed::class)->andReturnTrue(); + $dispatcher->expects('dispatch') + ->with(m::type(CommandFailed::class)) + ->andReturnUsing(function () use ($connection): void { + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default'), + ); + }); + $connection->expects('release'); + + try { + $this->createRedis($connection)->get('key'); + $this->fail('Expected the command failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($commandException, $throwable); + } + + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); + } + + public function testFailureEventCanRunANestedCommandOnTheOwnedConnection(): void + { + $commandException = new RuntimeException('Command failed.'); + $dispatcher = m::mock(Dispatcher::class); + $connection = $this->createMockRedisConnection('get', exception: $commandException, eventDispatcher: $dispatcher); + $connection->expects('set')->with('nested', 'recovered')->andReturnTrue(); + $connection->expects('release'); + $dispatcher->expects('hasListeners')->with(CommandFailed::class)->andReturnTrue(); + $dispatcher->expects('hasListeners')->with(CommandExecuted::class)->andReturnFalse(); + $redis = $this->createRedis($connection); + $dispatcher->expects('dispatch') + ->with(m::type(CommandFailed::class)) + ->andReturnUsing(function () use ($redis): void { + $this->assertTrue($redis->set('nested', 'recovered')); + }); + + try { + $redis->get('outer'); + $this->fail('Expected the outer command failure to propagate.'); + } catch (RuntimeException $throwable) { + $this->assertSame($commandException, $throwable); + } + + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); + } + + public function testMultiListenerQueuesNestedCommandsOnTheOwnedTransaction(): void + { + $transaction = m::mock(PhpRedis::class); + $dispatcher = m::mock(Dispatcher::class); + $connection = $this->createMockRedisConnection('multi', $transaction, eventDispatcher: $dispatcher); + $connection->expects('set')->with('nested', 'queued')->andReturn($transaction); + $connection->expects('release'); + $dispatcher->expects('hasListeners') + ->twice() + ->with(CommandExecuted::class) + ->andReturn(true, false); + $redis = $this->createRedis($connection); + $dispatcher->expects('dispatch') + ->with(m::type(CommandExecuted::class)) + ->andReturnUsing(function () use ($redis, $transaction): void { + $this->assertSame($transaction, $redis->set('nested', 'queued')); + }); + + $this->assertSame($transaction, $redis->multi()); + $this->assertSame( + $connection, + CoroutineContext::get(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default'), + ); + } + public function testThrowingSuccessListenerStillReleasesOrdinaryConnection(): void { $eventException = new RuntimeException('Success listener failed.'); @@ -571,6 +708,8 @@ public function testThrowingSuccessListenerStillReleasesOrdinaryConnection(): vo } catch (RuntimeException $throwable) { $this->assertSame($eventException, $throwable); } + + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } public function testThrowingSuccessListenerDoesNotSkipSameConnectionHandoff(): void @@ -613,6 +752,8 @@ public function testThrowingFailureListenerStillReleasesAndReplacesCommandFailur } catch (RuntimeException $throwable) { $this->assertSame($eventException, $throwable); } + + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); } public function testCommandFailureRemainsPrimaryOverCleanupFailure(): void @@ -713,7 +854,14 @@ public function testCallbackPipelineDoesNotClearWatchState(): void public function testRegularCommandDoesNotStoreConnectionInContext(): void { - $mockRedisConnection = $this->createMockRedisConnection(); + $mockRedisConnection = $this->mockConnection(); + $mockRedisConnection->expects('get') + ->with('key') + ->andReturnUsing(function (): string { + $this->assertFalse(CoroutineContext::has(RedisProxy::CONNECTION_CONTEXT_PREFIX . 'default')); + + return 'value'; + }); $mockRedisConnection->shouldReceive('release')->once(); $redis = $this->createRedis($mockRedisConnection); From ac6c23dc245c41b81f2e5059c0413f7703836e59 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:22:48 +0000 Subject: [PATCH 05/22] Configure secondary Swoole ports explicitly Apply global server settings plus each secondary listener's local overrides directly to every Swoole port. This prevents primary-only protocol and TLS settings from leaking through Swoole's implicit first-port inheritance while retaining shared port-level configuration. Treat the native false return from Port::set() as a configuration failure before callbacks or server publication. Cover merged settings with mocks and exercise the real recoverable failure in an isolated child process so Swoole's native server lifecycle cannot contaminate the PHPUnit worker. --- src/server/src/Server.php | 6 +- tests/Server/ServerNativeTest.php | 121 ++++++++++++++++++++++++++++++ tests/Server/ServerTest.php | 60 +++++++++++++++ 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 tests/Server/ServerNativeTest.php diff --git a/src/server/src/Server.php b/src/server/src/Server.php index 0d2681cd3..3edc5bb87 100644 --- a/src/server/src/Server.php +++ b/src/server/src/Server.php @@ -100,7 +100,11 @@ protected function initServers(ServerConfig $config): void if ($slaveServer === false) { throw new ServerException("Failed to listen on server port [{$host}:{$port}]."); } - $server->getSettings() && $slaveServer->set(array_replace($config->getSettings(), $server->getSettings())); + $settings = array_replace($config->getSettings(), $server->getSettings()); + // Swoole declares this method void, but malformed SNI settings warn and return false. + if ($slaveServer->set($settings) === false) { + throw new ServerException("Failed to configure server [{$name}]."); + } $this->registerSwooleEvents($slaveServer, $callbacks, $name); ServerManager::add($name, [$type, $slaveServer]); } diff --git a/tests/Server/ServerNativeTest.php b/tests/Server/ServerNativeTest.php new file mode 100644 index 000000000..95c2f5d8d --- /dev/null +++ b/tests/Server/ServerNativeTest.php @@ -0,0 +1,121 @@ +markTestSkipped('Swoole was not compiled with SSL support.'); + } + + $events = []; + $dispatcher = m::mock(Dispatcher::class); + $dispatcher->expects('dispatch')->twice()->andReturnUsing( + static function (object $event) use (&$events): null { + $events[] = $event; + + return null; + }, + ); + $container = m::mock(Container::class); + $container->shouldNotReceive('has'); + $server = new ServerNativeTestServer( + $container, + m::mock(LoggerInterface::class), + $dispatcher, + ); + $warnings = []; + + set_error_handler(static function (int $severity, string $message) use (&$warnings): bool { + $warnings[] = $message; + + return str_contains($message, 'invalid SNI_cert setting'); + }); + + try { + $server->init(new ServerConfig([ + 'servers' => [ + [ + 'name' => 'http', + 'type' => ServerInterface::SERVER_HTTP, + 'host' => '127.0.0.1', + 'port' => 0, + ], + [ + 'name' => 'tls', + 'type' => ServerInterface::SERVER_BASE, + 'host' => '127.0.0.1', + 'port' => 0, + 'sock_type' => SWOOLE_SOCK_TCP | SWOOLE_SSL, + 'settings' => [ + 'ssl_sni_certs' => ['example.test' => 'not-an-array'], + ], + 'callbacks' => [ + Event::ON_RECEIVE => static function (): void { + }, + Event::ON_BEFORE_START => [ServerNativeBeforeStartCallback::class, 'handle'], + ], + ], + ], + ])); + + $exception = null; + } catch (ServerException $exception) { + } finally { + restore_error_handler(); + } + + $this->assertInstanceOf(ServerException::class, $exception); + $this->assertSame('Failed to configure server [tls].', $exception->getMessage()); + $this->assertTrue((bool) array_filter( + $warnings, + static fn (string $warning): bool => str_contains($warning, 'invalid SNI_cert setting'), + )); + $this->assertSame( + [BeforeMainServerStart::class, BeforeServerStart::class], + array_map(static fn (object $event): string => $event::class, $events), + ); + $this->assertFalse(ServerManager::has('tls')); + $this->assertNull($server->getServer()->ports[1]->getCallback(Event::ON_RECEIVE)); + } +} + +class ServerNativeTestServer extends Server +{ + protected function defaultCallbacks(): array + { + return []; + } +} + +class ServerNativeBeforeStartCallback +{ + public function handle(): void + { + } +} diff --git a/tests/Server/ServerTest.php b/tests/Server/ServerTest.php index 4c8270ad4..0971e01e4 100644 --- a/tests/Server/ServerTest.php +++ b/tests/Server/ServerTest.php @@ -274,6 +274,66 @@ public function testMainServerSettingsFailureStopsConfiguration(): void ])); } + public function testEverySecondaryServerReceivesGlobalAndOnlyItsOwnSettings(): void + { + $mainPort = m::mock(SwoolePort::class); + $firstSecondary = m::mock(SwoolePort::class); + $secondSecondary = m::mock(SwoolePort::class); + $nativeServer = m::mock(SwooleServer::class); + $nativeServer->ports = [$mainPort]; + $nativeServer->expects('set')->with([ + 'socket_buffer_size' => 2048, + 'http_compression' => false, + 'open_http2_protocol' => true, + ])->andReturnTrue(); + $nativeServer->expects('addlistener') + ->with('127.0.0.1', 8001, SWOOLE_SOCK_TCP) + ->andReturn($firstSecondary); + $firstSecondary->expects('set')->with([ + 'socket_buffer_size' => 4096, + 'http_compression' => false, + 'open_http_protocol' => false, + ]); + $nativeServer->expects('addlistener') + ->with('127.0.0.1', 8002, SWOOLE_SOCK_TCP) + ->andReturn($secondSecondary); + $secondSecondary->expects('set')->with([ + 'socket_buffer_size' => 2048, + 'http_compression' => false, + ]); + $server = $this->server(m::mock(Container::class)); + $server->createWith($nativeServer); + + $server->init(new ServerConfig([ + 'settings' => [ + 'socket_buffer_size' => 2048, + 'http_compression' => false, + ], + 'servers' => [ + [ + 'name' => 'http', + 'host' => '127.0.0.1', + 'port' => 8000, + 'settings' => ['open_http2_protocol' => true], + ], + [ + 'name' => 'grpc', + 'host' => '127.0.0.1', + 'port' => 8001, + 'settings' => [ + 'socket_buffer_size' => 4096, + 'open_http_protocol' => false, + ], + ], + [ + 'name' => 'metrics', + 'host' => '127.0.0.1', + 'port' => 8002, + ], + ], + ])); + } + public function testListenerCreationFailureStopsConfiguration(): void { $mainPort = m::mock(SwoolePort::class); From 2ec7af8ae320729d68bbe6d07a61da1b46040061 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:22:57 +0000 Subject: [PATCH 06/22] Bound event preparation by registrations Replace Dispatcher caches keyed by arbitrary runtime event names with lazily prepared buckets keyed only by finite listener and observer registrations. Assemble exact, wildcard, and interface handlers for each dispatch without retaining the dispatched name. Preserve listener ordering, observer behavior, lazy extension-point timing, raw listener access, and interface autoload behavior while invalidating only the registration bucket that changed. Add structural no-growth coverage together with exact, wildcard, interface, subscriber, queued listener, observer, and coroutine behavior tests. --- src/events/src/Dispatcher.php | 198 ++++++++++--------- tests/Events/CoroutineEventsTest.php | 102 ++++------ tests/Events/EventsDispatcherTest.php | 275 ++++++++++++++++---------- 3 files changed, 320 insertions(+), 255 deletions(-) diff --git a/src/events/src/Dispatcher.php b/src/events/src/Dispatcher.php index 33832e19d..fb856e420 100755 --- a/src/events/src/Dispatcher.php +++ b/src/events/src/Dispatcher.php @@ -101,27 +101,18 @@ class Dispatcher implements DispatcherContract protected array $interfaceListeners = []; /** - * The cached wildcard listeners. + * The prepared event listeners keyed by registered event name. * * @var array> */ - protected array $wildcardsCache = []; + protected array $preparedListeners = []; /** - * The cached prepared listeners. - */ - protected array $listenersCache = []; - - /** - * The cached hasListeners results. - * - * Avoids repeated wildcard scanning in hasListeners() when called - * from hot paths like Router event guards. Cleared whenever the - * listener set changes (listen, forget, wildcard registration). + * The prepared wildcard listeners keyed by registered pattern. * - * @var array + * @var array> */ - protected array $hasListenersCache = []; + protected array $preparedWildcardListeners = []; /** * The registered event observers. @@ -138,18 +129,18 @@ class Dispatcher implements DispatcherContract protected array $observerWildcards = []; /** - * The cached wildcard observers. + * The prepared event observers keyed by registered event name. * * @var array> */ - protected array $observerWildcardsCache = []; + protected array $preparedObservers = []; /** - * The cached prepared observers. + * The prepared wildcard observers keyed by registered pattern. * * @var array> */ - protected array $observersCache = []; + protected array $preparedWildcardObservers = []; /** * The queue resolver instance. @@ -215,6 +206,7 @@ public function listen(array|Closure|QueuedClosure|string $events, array|object| $this->setupWildcardListen($event, $listener); } else { $this->listeners[$event][] = $listener; + unset($this->preparedListeners[$event]); // Track interface keys so hasListeners() and getListeners() know // whether interface resolution is worth entering. This autoloads @@ -225,9 +217,6 @@ public function listen(array|Closure|QueuedClosure|string $events, array|object| } } } - - $this->listenersCache = []; - $this->hasListenersCache = []; } /** @@ -236,10 +225,7 @@ public function listen(array|Closure|QueuedClosure|string $events, array|object| protected function setupWildcardListen(string $event, array|object|string $listener): void { $this->wildcards[$event][] = $listener; - - $this->wildcardsCache = []; - $this->listenersCache = []; - $this->hasListenersCache = []; + unset($this->preparedWildcardListeners[$event]); } /** @@ -264,11 +250,9 @@ public function observe(array|string $events, array|object|string $observer): vo $this->setupWildcardObserver($event, $observer); } else { $this->observers[$event][] = $observer; + unset($this->preparedObservers[$event]); } } - - $this->observersCache = []; - $this->observerWildcardsCache = []; } /** @@ -277,9 +261,7 @@ public function observe(array|string $events, array|object|string $observer): vo protected function setupWildcardObserver(string $event, array|object|string $observer): void { $this->observerWildcards[$event][] = $observer; - - $this->observerWildcardsCache = []; - $this->observersCache = []; + unset($this->preparedWildcardObservers[$event]); } /** @@ -287,11 +269,7 @@ protected function setupWildcardObserver(string $event, array|object|string $obs */ public function hasListeners(string $eventName): bool { - if (isset($this->hasListenersCache[$eventName])) { - return $this->hasListenersCache[$eventName]; - } - - return $this->hasListenersCache[$eventName] = isset($this->listeners[$eventName]) + return isset($this->listeners[$eventName]) || isset($this->wildcards[$eventName]) || $this->hasWildcardListeners($eventName) || $this->hasInterfaceListeners($eventName); @@ -320,8 +298,9 @@ protected function hasInterfaceListeners(string $eventName): bool return false; } - foreach (class_implements($eventName) as $interface) { - if (isset($this->listeners[$interface])) { + // The guard only needs a boolean, while resolution keeps class_implements() to preserve Laravel's listener order. + foreach ($this->interfaceListeners as $interface => $registered) { + if (is_a($eventName, $interface, true)) { return true; } } @@ -581,20 +560,16 @@ protected function broadcastEvent(ShouldBroadcast $event): void */ public function getListeners(string $eventName): array { - if (isset($this->listenersCache[$eventName])) { - return $this->listenersCache[$eventName]; - } + $listeners = $this->prepareListeners($eventName); + $wildcardListeners = $this->getWildcardListeners($eventName); - $listeners = array_merge( - $this->prepareListeners($eventName), - $this->wildcardsCache[$eventName] ?? $this->getWildcardListeners($eventName) - ); + if ($wildcardListeners !== []) { + array_push($listeners, ...$wildcardListeners); + } - $listeners = $this->shouldResolveInterfaceListeners($eventName) + return $this->shouldResolveInterfaceListeners($eventName) ? $this->addInterfaceListeners($eventName, $listeners) : $listeners; - - return $this->listenersCache[$eventName] = $listeners; } /** @@ -606,13 +581,29 @@ protected function getWildcardListeners(string $eventName): array foreach ($this->wildcards as $key => $listeners) { if (Str::is($key, $eventName)) { - foreach ($listeners as $listener) { - $wildcards[] = $this->makeListener($listener, true); - } + array_push($wildcards, ...$this->prepareWildcardListeners($key)); } } - return $this->wildcardsCache[$eventName] = $wildcards; + return $wildcards; + } + + /** + * Prepare the listeners for a registered wildcard pattern. + * + * @return Closure[] + */ + protected function prepareWildcardListeners(string $eventName): array + { + if (! isset($this->preparedWildcardListeners[$eventName])) { + $this->preparedWildcardListeners[$eventName] = []; + + foreach ($this->wildcards[$eventName] as $listener) { + $this->preparedWildcardListeners[$eventName][] = $this->makeListener($listener, true); + } + } + + return $this->preparedWildcardListeners[$eventName]; } /** @@ -621,10 +612,8 @@ protected function getWildcardListeners(string $eventName): array protected function addInterfaceListeners(string $eventName, array $listeners = []): array { foreach (class_implements($eventName) as $interface) { - if (isset($this->listeners[$interface])) { - foreach ($this->prepareListeners($interface) as $names) { - $listeners = array_merge($listeners, (array) $names); - } + if (isset($this->interfaceListeners[$interface])) { + array_push($listeners, ...$this->prepareListeners($interface)); } } @@ -638,13 +627,19 @@ protected function addInterfaceListeners(string $eventName, array $listeners = [ */ protected function prepareListeners(string $eventName): array { - $listeners = []; + if (! isset($this->listeners[$eventName])) { + return []; + } + + if (! isset($this->preparedListeners[$eventName])) { + $this->preparedListeners[$eventName] = []; - foreach ($this->listeners[$eventName] ?? [] as $listener) { - $listeners[] = $this->makeListener($listener); + foreach ($this->listeners[$eventName] as $listener) { + $this->preparedListeners[$eventName][] = $this->makeListener($listener); + } } - return $listeners; + return $this->preparedListeners[$eventName]; } /** @@ -652,16 +647,14 @@ protected function prepareListeners(string $eventName): array */ public function getObservers(string $eventName): array { - if (isset($this->observersCache[$eventName])) { - return $this->observersCache[$eventName]; - } + $observers = $this->prepareObservers($eventName); + $wildcardObservers = $this->getWildcardObservers($eventName); - $observers = array_merge( - $this->prepareObservers($eventName), - $this->observerWildcardsCache[$eventName] ?? $this->getWildcardObservers($eventName) - ); + if ($wildcardObservers !== []) { + array_push($observers, ...$wildcardObservers); + } - return $this->observersCache[$eventName] = $observers; + return $observers; } /** @@ -671,13 +664,19 @@ public function getObservers(string $eventName): array */ protected function prepareObservers(string $eventName): array { - $observers = []; + if (! isset($this->observers[$eventName])) { + return []; + } + + if (! isset($this->preparedObservers[$eventName])) { + $this->preparedObservers[$eventName] = []; - foreach ($this->observers[$eventName] ?? [] as $observer) { - $observers[] = $this->makeObserver($observer); + foreach ($this->observers[$eventName] as $observer) { + $this->preparedObservers[$eventName][] = $this->makeObserver($observer); + } } - return $observers; + return $this->preparedObservers[$eventName]; } /** @@ -689,13 +688,29 @@ protected function getWildcardObservers(string $eventName): array foreach ($this->observerWildcards as $key => $observers) { if (Str::is($key, $eventName)) { - foreach ($observers as $observer) { - $wildcards[] = $this->makeObserver($observer); - } + array_push($wildcards, ...$this->prepareWildcardObservers($key)); } } - return $this->observerWildcardsCache[$eventName] = $wildcards; + return $wildcards; + } + + /** + * Prepare the observers for a registered wildcard pattern. + * + * @return Closure[] + */ + protected function prepareWildcardObservers(string $eventName): array + { + if (! isset($this->preparedWildcardObservers[$eventName])) { + $this->preparedWildcardObservers[$eventName] = []; + + foreach ($this->observerWildcards[$eventName] as $observer) { + $this->preparedWildcardObservers[$eventName][] = $this->makeObserver($observer); + } + } + + return $this->preparedWildcardObservers[$eventName]; } /** @@ -1003,26 +1018,21 @@ protected function propagateListenerOptions(mixed $listener, CallQueuedListener public function forget(string $event): void { if (str_contains($event, '*')) { - unset($this->wildcards[$event], $this->observerWildcards[$event]); + unset( + $this->wildcards[$event], + $this->observerWildcards[$event], + $this->preparedWildcardListeners[$event], + $this->preparedWildcardObservers[$event], + ); } else { - unset($this->listeners[$event], $this->observers[$event], $this->interfaceListeners[$event]); - } - - foreach ($this->wildcardsCache as $key => $listeners) { - if (Str::is($event, $key)) { - unset($this->wildcardsCache[$key]); - } - } - - foreach ($this->observerWildcardsCache as $key => $observers) { - if (Str::is($event, $key)) { - unset($this->observerWildcardsCache[$key]); - } + unset( + $this->listeners[$event], + $this->observers[$event], + $this->interfaceListeners[$event], + $this->preparedListeners[$event], + $this->preparedObservers[$event], + ); } - - $this->listenersCache = []; - $this->hasListenersCache = []; - $this->observersCache = []; } /** diff --git a/tests/Events/CoroutineEventsTest.php b/tests/Events/CoroutineEventsTest.php index f702f4cd4..7fd8991b6 100644 --- a/tests/Events/CoroutineEventsTest.php +++ b/tests/Events/CoroutineEventsTest.php @@ -4,10 +4,10 @@ namespace Hypervel\Tests\Events\CoroutineEventsTest; +use Closure; use Hypervel\Context\CoroutineContext; use Hypervel\Events\Dispatcher; use Hypervel\Tests\TestCase; -use ReflectionClass; use RuntimeException; use function Hypervel\Coroutine\parallel; @@ -256,106 +256,88 @@ public function testNestedDeferRestoresOuterStateAfterInnerCompletes() $this->assertContains('inner', $dispatched); } - public function testListenersCacheIsPopulatedOnFirstGetListenersCall() + public function testPreparedListenersAreSharedAcrossCoroutines(): void { - $dispatcher = new Dispatcher; + $dispatcher = new CoroutinePreparationCountingDispatcher; $dispatcher->listen('test-event', function () { return 'listener-1'; }); - // Access the protected listenersCache via reflection - $reflection = new ReflectionClass($dispatcher); - $cacheProperty = $reflection->getProperty('listenersCache'); - - // Cache should be empty before getListeners() - $this->assertEmpty($cacheProperty->getValue($dispatcher)); - - // First call should populate the cache - $listeners = $dispatcher->getListeners('test-event'); - $this->assertNotEmpty($listeners); - - $cache = $cacheProperty->getValue($dispatcher); - $this->assertArrayHasKey('test-event', $cache); - $this->assertCount(count($listeners), $cache['test-event']); + [$first, $second] = parallel([ + static fn (): array => $dispatcher->getListeners('test-event'), + static fn (): array => $dispatcher->getListeners('test-event'), + ]); - // Second call should return same result from cache - $listeners2 = $dispatcher->getListeners('test-event'); - $this->assertSame($listeners, $listeners2); + $this->assertSame($first[0], $second[0]); + $this->assertSame(1, $dispatcher->listenerPreparationCount); } - public function testListenersCacheIsInvalidatedOnListen() + public function testPreparedListenerBucketIsInvalidatedOnListen(): void { - $dispatcher = new Dispatcher; + $dispatcher = new CoroutinePreparationCountingDispatcher; $dispatcher->listen('test-event', function () { return 'listener-1'; }); - $reflection = new ReflectionClass($dispatcher); - $cacheProperty = $reflection->getProperty('listenersCache'); - - // Populate the cache $dispatcher->getListeners('test-event'); - $this->assertNotEmpty($cacheProperty->getValue($dispatcher)); + $this->assertSame(1, $dispatcher->listenerPreparationCount); - // Adding a new listener should invalidate the cache $dispatcher->listen('test-event', function () { return 'listener-2'; }); - $this->assertEmpty($cacheProperty->getValue($dispatcher)); - - // New call should include both listeners $listeners = $dispatcher->getListeners('test-event'); $this->assertCount(2, $listeners); + $this->assertSame(3, $dispatcher->listenerPreparationCount); } - public function testListenersCacheIsInvalidatedOnForget() + public function testPreparedListenerBucketIsRemovedOnForget(): void { - $dispatcher = new Dispatcher; + $dispatcher = new CoroutinePreparationCountingDispatcher; $dispatcher->listen('test-event', function () { return 'listener-1'; }); - $reflection = new ReflectionClass($dispatcher); - $cacheProperty = $reflection->getProperty('listenersCache'); - - // Populate the cache $dispatcher->getListeners('test-event'); - $this->assertNotEmpty($cacheProperty->getValue($dispatcher)); - - // Forgetting the event should invalidate the cache $dispatcher->forget('test-event'); - $this->assertEmpty($cacheProperty->getValue($dispatcher)); - - // New call should return empty listeners - $listeners = $dispatcher->getListeners('test-event'); - $this->assertEmpty($listeners); + $this->assertSame([], $dispatcher->getListeners('test-event')); + $this->assertSame(1, $dispatcher->listenerPreparationCount); } - public function testListenersCacheIsInvalidatedOnWildcardListen() + public function testPreparedWildcardListenerBucketIsInvalidatedOnListen(): void { - $dispatcher = new Dispatcher; - $dispatcher->listen('test-event', function () { + $dispatcher = new CoroutinePreparationCountingDispatcher; + $dispatcher->listen('test-*', function () { return 'listener-1'; }); - $reflection = new ReflectionClass($dispatcher); - $cacheProperty = $reflection->getProperty('listenersCache'); - - // Populate the cache - $dispatcher->getListeners('test-event'); - $this->assertNotEmpty($cacheProperty->getValue($dispatcher)); + $first = $dispatcher->getListeners('test-first'); + $this->assertSame(1, $dispatcher->listenerPreparationCount); - // Adding a wildcard listener should invalidate the cache $dispatcher->listen('test-*', function () { - return 'wildcard'; + return 'listener-2'; }); - $this->assertEmpty($cacheProperty->getValue($dispatcher)); + $second = $dispatcher->getListeners('test-second'); + $third = $dispatcher->getListeners('test-third'); - // New call should include original + wildcard listener - $listeners = $dispatcher->getListeners('test-event'); - $this->assertCount(2, $listeners); + $this->assertCount(1, $first); + $this->assertCount(2, $second); + $this->assertSame($second[0], $third[0]); + $this->assertSame($second[1], $third[1]); + $this->assertSame(3, $dispatcher->listenerPreparationCount); + } +} + +class CoroutinePreparationCountingDispatcher extends Dispatcher +{ + public int $listenerPreparationCount = 0; + + public function makeListener(array|object|string $listener, bool $wildcard = false): Closure + { + ++$this->listenerPreparationCount; + + return parent::makeListener($listener, $wildcard); } } diff --git a/tests/Events/EventsDispatcherTest.php b/tests/Events/EventsDispatcherTest.php index 19ac3bb21..3cbdfe10f 100755 --- a/tests/Events/EventsDispatcherTest.php +++ b/tests/Events/EventsDispatcherTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Events\EventsDispatcherTest; +use Closure; use Error; use Exception; use Hypervel\Container\Container; @@ -22,7 +23,7 @@ use Hypervel\Tests\Events\Fixtures\UnlistenedStringEvent; use Hypervel\Tests\TestCase; use Mockery as m; -use ReflectionProperty; +use ReflectionClass; use RuntimeException; class EventsDispatcherTest extends TestCase @@ -419,7 +420,7 @@ public function testWildcardListenersWithResponses() $this->assertEquals(['regular', 'wildcard'], $response); } - public function testWildcardListenersCacheFlushing() + public function testAddingWildcardListenerChangesResolvedListeners() { unset($_SERVER['__event.test']); $d = new Dispatcher; @@ -462,7 +463,7 @@ public function testWildcardListenersCanBeRemoved() $this->assertFalse(isset($_SERVER['__event.test'])); } - public function testWildcardCacheIsClearedWhenListenersAreRemoved() + public function testForgettingWildcardRemovesResolvedListeners() { unset($_SERVER['__event.test']); @@ -569,7 +570,7 @@ public function testStringDispatchedEventClassesDoNotAutoloadWithoutInterfaceLis $this->assertFalse(class_exists(UnlistenedStringEvent::class, false)); } - public function testHasListenersCacheIsClearedWhenInterfaceListenerIsAddedOrForgotten(): void + public function testHasListenersReflectsInterfaceListenerRegistrationAndRemoval(): void { $d = new Dispatcher; @@ -643,126 +644,38 @@ public function testTargetedWildcardIsNotPassiveForHasListeners() $this->assertFalse($d->hasListeners('Other\Event')); } - public function testHasListenersCachesFalseResult(): void + public function testHasListenersReflectsExactListenerRegistrationAndRemoval(): void { $d = new Dispatcher; - // First call — uncached, scans listeners and wildcards. - $this->assertFalse($d->hasListeners('nonexistent')); - - // Second call — should hit cache and still return false. - $this->assertFalse($d->hasListeners('nonexistent')); - - // Verify the cache is populated by reading the protected property. - $cache = (new ReflectionProperty($d, 'hasListenersCache'))->getValue($d); - $this->assertArrayHasKey('nonexistent', $cache); - $this->assertFalse($cache['nonexistent']); - } - - public function testHasListenersCachesTrueResult(): void - { - $d = new Dispatcher; - $d->listen('foo', function () {}); - - // First call — uncached. - $this->assertTrue($d->hasListeners('foo')); - - // Second call — should hit cache. - $this->assertTrue($d->hasListeners('foo')); - - $cache = (new ReflectionProperty($d, 'hasListenersCache'))->getValue($d); - $this->assertArrayHasKey('foo', $cache); - $this->assertTrue($cache['foo']); - } - - public function testHasListenersCacheIsClearedWhenListenerIsAdded(): void - { - $d = new Dispatcher; - - // Populate cache with false. $this->assertFalse($d->hasListeners('bar')); - - // Adding a listener should clear the cache. $d->listen('bar', function () {}); - - // Now should return true (not the stale cached false). $this->assertTrue($d->hasListeners('bar')); + $d->forget('bar'); + $this->assertFalse($d->hasListeners('bar')); } - public function testHasListenersCacheIsClearedWhenWildcardIsAdded(): void - { - $d = new Dispatcher; - - // Populate cache with false for a specific event. - $this->assertFalse($d->hasListeners('foo.bar')); - - // Adding a wildcard that matches should clear the cache. - $d->listen('foo.*', function () {}); - - // Now should return true. - $this->assertTrue($d->hasListeners('foo.bar')); - } - - public function testHasListenersCacheIsClearedOnForget(): void + public function testHasListenersReflectsWildcardListenerRegistrationAndRemoval(): void { $d = new Dispatcher; - $d->listen('baz', function () {}); - - // Populate cache with true. - $this->assertTrue($d->hasListeners('baz')); - - // Forgetting should clear the cache. - $d->forget('baz'); - // Now should return false (not the stale cached true). - $this->assertFalse($d->hasListeners('baz')); - } - - public function testHasListenersCacheIsClearedOnForgetWildcard(): void - { - $d = new Dispatcher; + $this->assertFalse($d->hasListeners('ns.event')); $d->listen('ns.*', function () {}); - - // Populate cache. $this->assertTrue($d->hasListeners('ns.event')); - - // Forget the wildcard. $d->forget('ns.*'); - - // Should no longer have listeners. $this->assertFalse($d->hasListeners('ns.event')); } - public function testHasListenersCacheWithWildcardMatch(): void + public function testHasListenersChecksEachEventNameIndependently(): void { $d = new Dispatcher; $d->listen('app.*', function () {}); + $d->listen('exists', function () {}); - // First call — wildcard scan finds the match, caches true. - $this->assertTrue($d->hasListeners('app.started')); - - // Second call — hits cache, no wildcard rescan. $this->assertTrue($d->hasListeners('app.started')); - - // Different event under same wildcard — separate cache entry. $this->assertTrue($d->hasListeners('app.stopped')); - - $cache = (new ReflectionProperty($d, 'hasListenersCache'))->getValue($d); - $this->assertArrayHasKey('app.started', $cache); - $this->assertArrayHasKey('app.stopped', $cache); - } - - public function testHasListenersCacheIsIndependentPerEventName(): void - { - $d = new Dispatcher; - $d->listen('exists', function () {}); - $this->assertTrue($d->hasListeners('exists')); $this->assertFalse($d->hasListeners('does_not_exist')); - - $cache = (new ReflectionProperty($d, 'hasListenersCache'))->getValue($d); - $this->assertTrue($cache['exists']); - $this->assertFalse($cache['does_not_exist']); } public function testEventPassedFirstToWildcards() @@ -899,6 +812,112 @@ public function testGetListeners() $this->assertCount(3, $listeners); } + public function testRegisteredHandlerBucketsArePreparedLazilyOnceAndReused(): void + { + $dispatcher = new PreparationCountingDispatcher; + $dispatcher->listen('exact.event', static function (): void {}); + $dispatcher->listen('orders.*', static function (): void {}); + $dispatcher->listen(SomeEventInterface::class, static function (): void {}); + $dispatcher->observe('exact.observer', static function (): void {}); + $dispatcher->observe('metrics.*', static function (): void {}); + + $this->assertSame(0, $dispatcher->listenerPreparationCount); + $this->assertSame(0, $dispatcher->observerPreparationCount); + + $exactListeners = $dispatcher->getListeners('exact.event'); + $this->assertSame($exactListeners, $dispatcher->getListeners('exact.event')); + $firstWildcardListeners = $dispatcher->getListeners('orders.created'); + $secondWildcardListeners = $dispatcher->getListeners('orders.shipped'); + $this->assertSame($firstWildcardListeners[0], $secondWildcardListeners[0]); + $interfaceListeners = $dispatcher->getListeners(AnotherEvent::class); + $this->assertSame($interfaceListeners, $dispatcher->getListeners(AnotherEvent::class)); + + $exactObservers = $dispatcher->getObservers('exact.observer'); + $this->assertSame($exactObservers, $dispatcher->getObservers('exact.observer')); + $firstWildcardObservers = $dispatcher->getObservers('metrics.first'); + $secondWildcardObservers = $dispatcher->getObservers('metrics.second'); + $this->assertSame($firstWildcardObservers[0], $secondWildcardObservers[0]); + + $this->assertSame(3, $dispatcher->listenerPreparationCount); + $this->assertSame(2, $dispatcher->observerPreparationCount); + } + + public function testListenersAndObserversRetainTheirResolutionOrder(): void + { + $dispatcher = new Dispatcher; + $order = []; + $dispatcher->listen(AnotherEvent::class, static function () use (&$order): void { + $order[] = 'exact-listener'; + }); + $dispatcher->listen(__NAMESPACE__ . '\*', static function () use (&$order): void { + $order[] = 'wildcard-listener'; + }); + $dispatcher->listen(SomeEventInterface::class, static function () use (&$order): void { + $order[] = 'interface-listener'; + }); + $dispatcher->observe(AnotherEvent::class, static function () use (&$order): void { + $order[] = 'exact-observer'; + }); + $dispatcher->observe(__NAMESPACE__ . '\*', static function () use (&$order): void { + $order[] = 'wildcard-observer'; + }); + + $dispatcher->dispatch(new AnotherEvent); + + $this->assertSame([ + 'exact-listener', + 'wildcard-listener', + 'interface-listener', + 'exact-observer', + 'wildcard-observer', + ], $order); + } + + public function testInterfaceListenersFollowDirectImplementsClauseOrder(): void + { + $dispatcher = new Dispatcher; + $order = []; + $dispatcher->listen(SecondOrderedEventInterface::class, static function () use (&$order): void { + $order[] = 'second'; + }); + $dispatcher->listen(FirstOrderedEventInterface::class, static function () use (&$order): void { + $order[] = 'first'; + }); + + $dispatcher->dispatch(new OrderedInterfaceEvent); + + $this->assertSame(['first', 'second'], $order); + } + + public function testRuntimeEventNamesDoNotGrowDispatcherState(): void + { + $dispatcher = new Dispatcher; + $dispatcher->listen('fixed.event', static function (): void {}); + $dispatcher->listen('dynamic.*', static function (): void {}); + $dispatcher->listen(SomeEventInterface::class, static function (): void {}); + $dispatcher->observe('fixed.observer', static function (): void {}); + $dispatcher->observe('observed.*', static function (): void {}); + + $dispatcher->getListeners('fixed.event'); + $dispatcher->getListeners('dynamic.warm'); + $dispatcher->getListeners(AnotherEvent::class); + $dispatcher->getObservers('fixed.observer'); + $dispatcher->getObservers('observed.warm'); + + $state = $this->arrayPropertyEntryCounts($dispatcher); + + for ($index = 0; $index < 200; ++$index) { + $dispatcher->hasListeners("dynamic.{$index}"); + $dispatcher->getListeners("dynamic.{$index}"); + $dispatcher->getListeners("unmatched.{$index}"); + $dispatcher->getListeners("Dynamic\\Missing\\Event{$index}"); + $dispatcher->dispatch("dynamic.{$index}"); + $dispatcher->dispatch("observed.{$index}"); + } + + $this->assertSame($state, $this->arrayPropertyEntryCounts($dispatcher)); + } + public function testListenersObjectsCreationOrder() { $_SERVER['__event.test'] = []; @@ -1336,7 +1355,7 @@ public function testGetRawListenersExcludesObservers() $this->assertArrayNotHasKey('bar', $d2->getRawListeners()); } - public function testObserverCacheIsInvalidatedOnNewObserver() + public function testAddingObserverChangesResolvedObservers() { $d = new Dispatcher; $first = false; @@ -1360,7 +1379,7 @@ public function testObserverCacheIsInvalidatedOnNewObserver() $this->assertTrue($second); } - public function testHasListenersCacheNotAffectedByObservers() + public function testObserversDoNotAffectHasListeners() { $d = new Dispatcher; @@ -1453,6 +1472,48 @@ public function testClosureListenerRegistersEveryTypeFromAFirstParameterUnion(): $this->assertSame([$first, $second], $events); } + + private function arrayPropertyEntryCounts(Dispatcher $dispatcher): array + { + $counts = []; + + foreach ((new ReflectionClass($dispatcher))->getProperties() as $property) { + if ($property->isStatic() || ! $property->isInitialized($dispatcher)) { + continue; + } + + $value = $property->getValue($dispatcher); + + if (is_array($value)) { + $counts[$property->getName()] = count($value, COUNT_RECURSIVE); + } + } + + ksort($counts); + + return $counts; + } +} + +class PreparationCountingDispatcher extends Dispatcher +{ + public int $listenerPreparationCount = 0; + + public int $observerPreparationCount = 0; + + public function makeListener(array|object|string $listener, bool $wildcard = false): Closure + { + ++$this->listenerPreparationCount; + + return parent::makeListener($listener, $wildcard); + } + + protected function makeObserver(array|object|string $observer): Closure + { + ++$this->observerPreparationCount; + + return parent::makeObserver($observer); + } } class TestListenerLean @@ -1504,6 +1565,18 @@ class AnotherEvent implements SomeEventInterface { } +interface FirstOrderedEventInterface +{ +} + +interface SecondOrderedEventInterface +{ +} + +class OrderedInterfaceEvent implements FirstOrderedEventInterface, SecondOrderedEventInterface +{ +} + class TestEventListener { public function handle($foo, $bar) From 56b3d89b767ffbab4209140491920fa163dc8872 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:23:07 +0000 Subject: [PATCH 07/22] Reclaim expired worker array cache records Make array-store reads, increments, and touch operations share one expiry-aware value path, and prevent touch from reviving an expired item. Centralize exact lock reads so both array stores remove an expired physical lock at the inclusive expiry boundary. Have WorkerArrayStore inspect a fixed number of value and lock records on each requested write. The rotating cursor reclaims abandoned expired entries without work proportional to store size, while live and permanent records retain their documented worker lifetime. Document worker-local visibility and lock scope, and cover expiry, serialization, counters, locks, pointer rotation, arbitrary deletion, flushes, and bounded maintenance. --- src/cache/src/AbstractArrayStore.php | 120 +++++++++-- src/cache/src/ArrayStore.php | 10 - src/cache/src/WorkerArrayStore.php | 95 ++++++++- src/docs/cache.md | 4 + tests/Cache/CacheArrayStoreTest.php | 24 ++- tests/Cache/CacheWorkerArrayStoreTest.php | 240 ++++++++++++++++++++++ 6 files changed, 457 insertions(+), 36 deletions(-) diff --git a/src/cache/src/AbstractArrayStore.php b/src/cache/src/AbstractArrayStore.php index b3c99627a..114556c0f 100644 --- a/src/cache/src/AbstractArrayStore.php +++ b/src/cache/src/AbstractArrayStore.php @@ -79,15 +79,7 @@ public function get(string $key): mixed return null; } - $expiresAt = $item['expiresAt']; - - if ($expiresAt !== 0.0 && (now()->getPreciseTimestamp(3) / 1000) >= $expiresAt) { - $this->forget($key); - - return null; - } - - return $this->serializesValues ? $this->unserialize($item['value']) : $item['value']; + return $this->valueFromItem($key, $item); } /** @@ -95,9 +87,12 @@ public function get(string $key): mixed */ public function put(string $key, mixed $value, int $seconds): bool { + $expiresAt = $this->calculateExpiration($seconds); + + $this->reclaimExpiredRecords(); $this->putCacheItem($key, [ 'value' => $this->serializesValues ? serialize($value) : $value, - 'expiresAt' => $this->calculateExpiration($seconds), + 'expiresAt' => $expiresAt, ]); return true; @@ -109,12 +104,20 @@ public function put(string $key, mixed $value, int $seconds): bool public function increment(string $key, int $value = 1): int { // When backed by WorkerArrayStore, this read/modify/write path is shared across coroutines; keep it non-yielding. - if (! is_null($existing = $this->get($key))) { + $item = $this->getCacheItem($key); + $existing = null; + $currentTimestamp = null; + + if ($item !== null) { + $currentTimestamp = $item['expiresAt'] === 0.0 ? null : $this->currentPreciseTimestamp(); + $existing = $this->valueFromItem($key, $item, $currentTimestamp); + } + + if ($existing !== null) { $incremented = ((int) $existing) + $value; - /** @var array{value: mixed, expiresAt: float} $item */ - $item = $this->getCacheItem($key); $item['value'] = $this->serializesValues ? serialize($incremented) : $incremented; + $this->reclaimExpiredRecords($currentTimestamp); $this->putCacheItem($key, $item); return $incremented; @@ -152,7 +155,20 @@ public function touch(string $key, int $seconds): bool return false; } + $currentTimestamp = null; + + if ($item['expiresAt'] !== 0.0) { + $currentTimestamp = $this->currentPreciseTimestamp(); + + if ($this->isCacheItemExpired($item, $currentTimestamp)) { + $this->forget($key); + + return false; + } + } + $item['expiresAt'] = $this->calculateExpiration($seconds); + $this->reclaimExpiredRecords($currentTimestamp); $this->putCacheItem($key, $item); return true; @@ -237,7 +253,20 @@ public function hasSeparateLockStore(): bool * * @return null|array{owner: ?string, expiresAt: ?CarbonImmutable} */ - abstract public function getLockRecord(string $name): ?array; + public function getLockRecord(string $name): ?array + { + $record = $this->getLockRecords()[$name] ?? null; + + // Permanent locks need no clock read. + if ($record !== null && $record['expiresAt'] !== null + && $this->isLockRecordExpired($record['expiresAt'], CarbonImmutable::now())) { + $this->forgetLockRecord($name); + + return null; + } + + return $record; + } /** * Store the lock record for the given name. @@ -256,6 +285,13 @@ abstract public function forgetLockRecord(string $name): void; */ abstract public function clearLockRecords(): void; + /** + * Get all lock records. + * + * @return array + */ + abstract protected function getLockRecords(): array; + /** * Get the cached item for the given key. * @@ -287,6 +323,52 @@ abstract protected function clearCacheItems(): void; */ abstract protected function getCacheItems(): array; + /** + * Perform maintenance before writing one record. + */ + protected function reclaimExpiredRecords(?float $currentTimestamp = null): void + { + } + + /** + * Retrieve and decode a cached item after applying its expiration. + * + * @param array{value: mixed, expiresAt: float} $item + */ + protected function valueFromItem(string $key, array $item, ?float $currentTimestamp = null): mixed + { + if ($item['expiresAt'] !== 0.0 + && $this->isCacheItemExpired($item, $currentTimestamp ?? $this->currentPreciseTimestamp())) { + $this->forget($key); + + return null; + } + + return $this->serializesValues ? $this->unserialize($item['value']) : $item['value']; + } + + /** + * Determine if a cached item has expired. + * + * Callers must exclude permanent items whose expiration is 0.0. + * + * @param array{value: mixed, expiresAt: float} $item + */ + protected function isCacheItemExpired(array $item, float $currentTimestamp): bool + { + return $currentTimestamp >= $item['expiresAt']; + } + + /** + * Determine if a lock record has expired. + * + * The inclusive boundary treats a lock as expired at its expiry instant. + */ + protected function isLockRecordExpired(CarbonImmutable $expiresAt, CarbonImmutable $currentTime): bool + { + return $expiresAt <= $currentTime; + } + /** * Get the expiration time of the key. */ @@ -300,7 +382,15 @@ protected function calculateExpiration(int $seconds): float */ protected function toTimestamp(int $seconds): float { - return $seconds > 0 ? (now()->getPreciseTimestamp(3) / 1000) + $seconds : 0; + return $seconds > 0 ? $this->currentPreciseTimestamp() + $seconds : 0; + } + + /** + * Get the current UNIX timestamp with millisecond precision. + */ + protected function currentPreciseTimestamp(): float + { + return now()->getPreciseTimestamp(3) / 1000; } /** diff --git a/src/cache/src/ArrayStore.php b/src/cache/src/ArrayStore.php index 2361c69e3..4d4f88306 100644 --- a/src/cache/src/ArrayStore.php +++ b/src/cache/src/ArrayStore.php @@ -109,16 +109,6 @@ protected function getCacheItems(): array return CoroutineContext::get($this->storageContextKey, []); } - /** - * Get the lock record for the given name. - * - * @return null|array{owner: ?string, expiresAt: ?CarbonImmutable} - */ - public function getLockRecord(string $name): ?array - { - return $this->getLockRecords()[$name] ?? null; - } - /** * Store the lock record for the given name. * diff --git a/src/cache/src/WorkerArrayStore.php b/src/cache/src/WorkerArrayStore.php index bcd6c566d..c861dd91f 100644 --- a/src/cache/src/WorkerArrayStore.php +++ b/src/cache/src/WorkerArrayStore.php @@ -8,6 +8,11 @@ class WorkerArrayStore extends AbstractArrayStore { + /** + * The maximum records reclaimed from each map per write. + */ + private const int RECLAMATION_LIMIT = 8; + /** * The array of stored values. * @@ -74,16 +79,6 @@ protected function getCacheItems(): array return $this->storage; } - /** - * Get the lock record for the given name. - * - * @return null|array{owner: ?string, expiresAt: ?CarbonImmutable} - */ - public function getLockRecord(string $name): ?array - { - return $this->locks[$name] ?? null; - } - /** * Store the lock record for the given name. * @@ -91,6 +86,7 @@ public function getLockRecord(string $name): ?array */ public function putLockRecord(string $name, array $record): void { + $this->reclaimExpiredRecords(); $this->locks[$name] = $record; } @@ -109,4 +105,83 @@ public function clearLockRecords(): void { $this->locks = []; } + + /** + * Get all lock records. + * + * @return array + */ + protected function getLockRecords(): array + { + return $this->locks; + } + + /** + * Reclaim a fixed number of expired value and lock records. + */ + protected function reclaimExpiredRecords(?float $currentTimestamp = null): void + { + if ($this->storage === [] && $this->locks === []) { + return; + } + + $currentTime = $this->locks === [] ? null : CarbonImmutable::now(); + + if ($this->storage !== []) { + $currentTimestamp ??= $currentTime !== null + ? $currentTime->getPreciseTimestamp(3) / 1000 + : $this->currentPreciseTimestamp(); + + $this->reclaimExpiredValues($currentTimestamp); + } + + if ($currentTime !== null) { + $this->reclaimExpiredLocks($currentTime); + } + } + + /** + * Reclaim expired values within the per-write limit. + */ + private function reclaimExpiredValues(float $currentTimestamp): void + { + $limit = min(self::RECLAMATION_LIMIT, count($this->storage)); + + for ($index = 0; $index < $limit && $this->storage !== []; ++$index) { + if (key($this->storage) === null) { + reset($this->storage); + } + + $key = key($this->storage); + $item = $this->storage[$key]; + next($this->storage); + + if ($item['expiresAt'] !== 0.0 && $this->isCacheItemExpired($item, $currentTimestamp)) { + unset($this->storage[$key]); + } + } + } + + /** + * Reclaim expired locks within the per-write limit. + */ + private function reclaimExpiredLocks(CarbonImmutable $currentTime): void + { + $limit = min(self::RECLAMATION_LIMIT, count($this->locks)); + + for ($index = 0; $index < $limit && $this->locks !== []; ++$index) { + if (key($this->locks) === null) { + reset($this->locks); + } + + $key = key($this->locks); + $record = $this->locks[$key]; + next($this->locks); + + if ($record['expiresAt'] !== null + && $this->isLockRecordExpired($record['expiresAt'], $currentTime)) { + unset($this->locks[$key]); + } + } + } } diff --git a/src/docs/cache.md b/src/docs/cache.md index f291d1c2a..921f4b51d 100644 --- a/src/docs/cache.md +++ b/src/docs/cache.md @@ -63,6 +63,10 @@ Hypervel implements this with coroutine context. Child coroutines start with a f The `worker-array` store keeps values for the lifetime of the current worker process. Values are shared by all requests, jobs, tasks, and coroutines handled by that worker. They are not shared across worker processes, servers, or restarts. +The same boundary applies to `worker-array` locks: they coordinate work only within one worker. When a lock must span workers, use a store shared by those workers, such as Swoole within one application node or Redis across servers. + +Expired values and locks are removed when accessed, and later mutations also reclaim expired records that are no longer accessed. Values stored forever and locks without an expiration remain until they are explicitly removed, flushed, or the worker exits. + Use `array` for request-local test and scratch data. Use `worker-array` only when worker-local persistence is the intended behavior: ```php diff --git a/tests/Cache/CacheArrayStoreTest.php b/tests/Cache/CacheArrayStoreTest.php index c4f19755d..3b67367ba 100644 --- a/tests/Cache/CacheArrayStoreTest.php +++ b/tests/Cache/CacheArrayStoreTest.php @@ -82,6 +82,19 @@ public function testTouchExtendsTtl(): void $this->assertSame('value', $store->get('key')); } + public function testTouchDoesNotReviveAnExpiredItem(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + + $store = new ArrayStore; + $store->put('key', 'value', 10); + + CarbonImmutable::setTestNow($now->addSeconds(10)); + + $this->assertFalse($store->touch('key', 60)); + $this->assertArrayNotHasKey('key', $store->all(false)); + } + public function testStoreItemForeverProperlyStoresInArray(): void { $mock = $this->getMockBuilder(ArrayStore::class)->onlyMethods(['put'])->getMock(); @@ -232,7 +245,7 @@ public function testExpiredLockIsNotLockedOrOwned(): void { CarbonImmutable::setTestNow($now = CarbonImmutable::now()); - $store = new ArrayStore; + $store = new InspectableArrayStore; $lock = $store->lock('foo', 10); $lock->acquire(); @@ -244,6 +257,7 @@ public function testExpiredLockIsNotLockedOrOwned(): void $this->assertFalse($lock->isLocked()); $this->assertFalse($lock->isOwnedByCurrentProcess()); $this->assertNull($lock->getRemainingLifetime()); + $this->assertSame([], $store->lockRecords()); } public function testLockExpirationLowerBoundary(): void @@ -687,3 +701,11 @@ public function testGetRemainingLifetimeReturnsNullWhenExpired(): void $this->assertNull($lock->getRemainingLifetime()); } } + +class InspectableArrayStore extends ArrayStore +{ + public function lockRecords(): array + { + return $this->getLockRecords(); + } +} diff --git a/tests/Cache/CacheWorkerArrayStoreTest.php b/tests/Cache/CacheWorkerArrayStoreTest.php index 449f8c753..5e5ee67fc 100644 --- a/tests/Cache/CacheWorkerArrayStoreTest.php +++ b/tests/Cache/CacheWorkerArrayStoreTest.php @@ -110,6 +110,19 @@ public function testTouchExtendsTtl(): void $this->assertSame('value', $store->get('key')); } + public function testTouchDoesNotReviveAnExpiredItem(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + + $store = new WorkerArrayStore; + $store->put('key', 'value', 10); + + CarbonImmutable::setTestNow($now->addSeconds(10)); + + $this->assertFalse($store->touch('key', 60)); + $this->assertArrayNotHasKey('key', $store->all(false)); + } + public function testLocksCanBeRestoredRefreshedAndMeasured(): void { CarbonImmutable::setTestNow(CarbonImmutable::now()); @@ -129,6 +142,196 @@ public function testLocksCanBeRestoredRefreshedAndMeasured(): void $this->assertSame(30.0, $restoredLock->getRemainingLifetime()); } + public function testExactExpiredLockReadRemovesThePhysicalRecord(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + + $store = new InspectableWorkerArrayStore; + $lock = $store->lock('expired', 10); + $this->assertTrue($lock->acquire()); + + CarbonImmutable::setTestNow($now->addSeconds(10)); + + $this->assertNull($store->getLockRecord('expired')); + $this->assertSame([], $store->lockRecords()); + $this->assertTrue($lock->acquire()); + } + + public function testUnrelatedWriteReclaimsExpiredRecordsAndPreservesLiveRecords(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + $currentTimestamp = $now->getPreciseTimestamp(3) / 1000; + $store = new InspectableWorkerArrayStore; + $store->seedRecords( + [ + 'expired' => ['value' => 'expired', 'expiresAt' => $currentTimestamp - 1], + 'live' => ['value' => 'live', 'expiresAt' => $currentTimestamp + 60], + 'forever' => ['value' => 'forever', 'expiresAt' => 0.0], + ], + [ + 'expired-lock' => ['owner' => 'expired', 'expiresAt' => $now->subSecond()], + 'live-lock' => ['owner' => 'live', 'expiresAt' => $now->addMinute()], + 'permanent-lock' => ['owner' => 'permanent', 'expiresAt' => null], + ], + ); + + $store->forever('trigger', 'written'); + + $this->assertSame(['live', 'forever', 'trigger'], array_keys($store->storedValues())); + $this->assertSame(['live-lock', 'permanent-lock'], array_keys($store->lockRecords())); + } + + public function testExistingRecordMutationsAdvanceReclamation(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + $currentTimestamp = $now->getPreciseTimestamp(3) / 1000; + $expiredValue = ['value' => 'expired', 'expiresAt' => $currentTimestamp - 1]; + + $incrementStore = new InspectableWorkerArrayStore; + $incrementStore->seedRecords([ + 'expired' => $expiredValue, + 'counter' => ['value' => 1, 'expiresAt' => $currentTimestamp + 60], + ], []); + + $this->assertSame(2, $incrementStore->increment('counter')); + $this->assertSame(['counter'], array_keys($incrementStore->storedValues())); + + $touchStore = new InspectableWorkerArrayStore; + $touchStore->seedRecords([ + 'expired' => $expiredValue, + 'target' => ['value' => 'target', 'expiresAt' => $currentTimestamp + 60], + ], []); + + $this->assertTrue($touchStore->touch('target', 120)); + $this->assertSame(['target'], array_keys($touchStore->storedValues())); + + $lockStore = new InspectableWorkerArrayStore; + $lockStore->seedRecords( + ['expired' => $expiredValue], + [ + 'target' => ['owner' => 'owner', 'expiresAt' => $now->addMinute()], + 'expired' => ['owner' => 'expired', 'expiresAt' => $now->subSecond()], + ], + ); + + $this->assertTrue($lockStore->restoreLock('target', 'owner')->refresh(120)); + $this->assertSame([], $lockStore->storedValues()); + $this->assertSame(['target'], array_keys($lockStore->lockRecords())); + } + + public function testOneWriteReclaimsOnlyTheFixedRecordBudgetFromEachMap(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + $expiredValues = []; + $expiredLocks = []; + + for ($index = 0; $index < 32; ++$index) { + $expiredValues["value-{$index}"] = [ + 'value' => $index, + 'expiresAt' => ($now->getPreciseTimestamp(3) / 1000) - 1, + ]; + $expiredLocks["lock-{$index}"] = [ + 'owner' => (string) $index, + 'expiresAt' => $now->subSecond(), + ]; + } + + $store = new InspectableWorkerArrayStore; + $store->seedRecords($expiredValues, $expiredLocks); + + $store->put('trigger', 'written', 60); + + $this->assertCount(25, $store->storedValues()); + $this->assertCount(24, $store->lockRecords()); + } + + public function testStorageCursorWrapsAfterCurrentDeletionAndAppendAtEnd(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + $expiredValues = []; + + for ($index = 0; $index < 32; ++$index) { + $expiredValues["expired-{$index}"] = [ + 'value' => $index, + 'expiresAt' => ($now->getPreciseTimestamp(3) / 1000) - 1, + ]; + } + + $store = new InspectableWorkerArrayStore; + $store->seedRecords($expiredValues, []); + $store->positionStorageAt(24); + + $copy = $store->all(false); + unset($copy['expired-0']); + $this->assertCount(32, $store->storedValues()); + + $this->assertTrue($store->forget('expired-24')); + + for ($index = 0; $index < 5; ++$index) { + $store->put("live-{$index}", $index, 60); + } + + $this->assertSame( + ['live-0', 'live-1', 'live-2', 'live-3', 'live-4'], + array_keys($store->storedValues()), + ); + } + + public function testExpiredLockReadAtTheCursorDoesNotBreakRotation(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + $expiredLocks = []; + + for ($index = 0; $index < 16; ++$index) { + $expiredLocks["expired-{$index}"] = [ + 'owner' => (string) $index, + 'expiresAt' => $now->subSecond(), + ]; + } + + $store = new InspectableWorkerArrayStore; + $store->seedRecords([], $expiredLocks); + $store->positionLocksAt(4); + + $this->assertNull($store->getLockRecord('expired-4')); + + for ($index = 0; $index < 3; ++$index) { + $this->assertTrue($store->lock("live-{$index}", 60)->acquire()); + } + + $this->assertSame( + ['live-0', 'live-1', 'live-2'], + array_keys($store->lockRecords()), + ); + } + + public function testFlushesResetMaintenanceAfterPointersHaveAdvanced(): void + { + CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + $store = new InspectableWorkerArrayStore; + $store->seedRecords( + [ + 'expired' => [ + 'value' => 'expired', + 'expiresAt' => ($now->getPreciseTimestamp(3) / 1000) - 1, + ], + ], + [ + 'expired' => ['owner' => 'expired', 'expiresAt' => $now->subSecond()], + ], + ); + $store->positionStorageAt(0); + $store->positionLocksAt(0); + + $this->assertTrue($store->flush()); + $this->assertTrue($store->flushLocks()); + $this->assertTrue($store->put('value', 'live', 60)); + $this->assertTrue($store->lock('lock', 60)->acquire()); + + $this->assertSame(['value'], array_keys($store->storedValues())); + $this->assertSame(['lock'], array_keys($store->lockRecords())); + } + public function testFlushClearsValuesButNotLocks(): void { $store = new WorkerArrayStore; @@ -217,3 +420,40 @@ public function testWorkerArrayLocksAreSharedAcrossCoroutines(): void $this->assertFalse($results['contender']); } } + +class InspectableWorkerArrayStore extends WorkerArrayStore +{ + public function seedRecords(array $storage, array $locks): void + { + $this->storage = $storage; + $this->locks = $locks; + } + + public function storedValues(): array + { + return $this->storage; + } + + public function lockRecords(): array + { + return $this->locks; + } + + public function positionStorageAt(int $offset): void + { + reset($this->storage); + + for ($index = 0; $index < $offset; ++$index) { + next($this->storage); + } + } + + public function positionLocksAt(int $offset): void + { + reset($this->locks); + + for ($index = 0; $index < $offset; ++$index) { + next($this->locks); + } + } +} From 93985f084614671fe8d43afa186105f956b7d1d4 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:23:24 +0000 Subject: [PATCH 08/22] Preserve whole-second expiration deadlines Round future whole-second deadlines upward so a requested cache TTL, lock lifetime, queue delay, or visibility timeout never expires or becomes runnable before its target instant. Keep zero, past, and immediate values on their existing floor behavior. Route database cache and lock expiries through the shared conversion, ceil database reservation markers, and preserve exact absolute file and storage deadlines when incrementing cached values. Rename the internal fixed-width header helper to make its timestamp unit explicit. Cover fractional clocks, integer and interval delays, absolute dates, queue reservation recovery, file locks, funnel leases, permanent entries, and Laravel-shaped payload overrides. --- src/cache/src/DatabaseLock.php | 2 +- src/cache/src/DatabaseStore.php | 10 +- src/cache/src/FileStore.php | 81 +++++++---- src/cache/src/StorageStore.php | 48 +++++-- src/queue/src/Jobs/DatabaseJobRecord.php | 6 +- src/support/src/InteractsWithTime.php | 12 +- tests/Cache/CacheDatabaseLockTest.php | 31 +++- tests/Cache/CacheDatabaseStoreTest.php | 63 ++++++-- tests/Cache/CacheFileStoreTest.php | 134 +++++++++++++++++- tests/Cache/CacheStorageStoreTest.php | 83 +++++++++++ .../FoundationInteractsWithTimeTest.php | 57 ++++++++ .../Integration/Cache/CacheFunnelTestCase.php | 25 ++-- tests/Integration/Cache/FileCacheLockTest.php | 6 +- .../Queue/Redis/RedisQueueTest.php | 39 ++++- .../QueueDatabaseQueueIntegrationTest.php | 35 +++++ tests/Queue/QueueDatabaseQueueUnitTest.php | 33 ++++- tests/Queue/QueueRedisQueueTest.php | 65 ++++++--- 17 files changed, 609 insertions(+), 121 deletions(-) diff --git a/src/cache/src/DatabaseLock.php b/src/cache/src/DatabaseLock.php index 85341b3ac..5725801ab 100644 --- a/src/cache/src/DatabaseLock.php +++ b/src/cache/src/DatabaseLock.php @@ -182,7 +182,7 @@ protected function expiresAt(?int $seconds = null): int $lockTimeout = $seconds > 0 ? $seconds : $this->defaultTimeoutInSeconds; - return $this->currentTime() + $lockTimeout; + return $this->availableAt($lockTimeout); } /** diff --git a/src/cache/src/DatabaseStore.php b/src/cache/src/DatabaseStore.php index 5e8fb399e..979d60250 100644 --- a/src/cache/src/DatabaseStore.php +++ b/src/cache/src/DatabaseStore.php @@ -176,7 +176,7 @@ public function putMany(array $values, int $seconds): bool $serializedValues = []; - $expiration = $this->getTime() + $seconds; + $expiration = $this->availableAt($seconds); foreach ($values as $key => $value) { $serializedValues[] = [ @@ -203,7 +203,7 @@ public function add(string $key, mixed $value, int $seconds): bool $key = $this->prefix . $key; $value = $this->serialize($value); - $expiration = $this->getTime() + $seconds; + $expiration = $this->availableAt($seconds); return $this->table()->insertOrIgnore(compact('key', 'value', 'expiration')) > 0; } @@ -316,10 +316,12 @@ public function restoreLock(string $name, string $owner): DatabaseLock */ public function touch(string $key, int $seconds): bool { + $now = $this->getTime(); + return (bool) $this->table() ->where('key', '=', $this->getPrefix() . $key) - ->where('expiration', '>', $now = $this->getTime()) - ->update(['expiration' => $now + $seconds]); + ->where('expiration', '>', $now) + ->update(['expiration' => $this->availableAt($seconds)]); } /** diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php index 33ebffc10..cfa4cf22c 100644 --- a/src/cache/src/FileStore.php +++ b/src/cache/src/FileStore.php @@ -84,21 +84,7 @@ public function get(string $key): mixed */ public function put(string $key, mixed $value, int $seconds): bool { - $this->ensureCacheDirectoryExists($path = $this->path($key)); - - $result = $this->files->put( - $path, - $this->expirationHeader($seconds) . serialize($value), - true - ); - - if ($result !== false && $result > 0) { - $this->ensurePermissionsAreCorrect($path); - - return true; - } - - return false; + return $this->putWithExpiresAt($key, $value, $this->expiration($seconds)); } /** @@ -122,7 +108,7 @@ public function add(string $key, mixed $value, int $seconds): bool if (empty($expire) || $this->currentTime() >= $expire) { $file->truncate() - ->write($this->expirationHeader($seconds) . serialize($value)) + ->write($this->expiresAtHeader($this->expiration($seconds)) . serialize($value)) ->close(); $this->ensurePermissionsAreCorrect($path); @@ -170,7 +156,7 @@ public function refreshIfOwned(string $key, string $expectedOwner, int $seconds) } $file->truncate() - ->write($this->expirationHeader($seconds) . serialize($expectedOwner)) + ->write($this->expiresAtHeader($this->expiration($seconds)) . serialize($expectedOwner)) ->close(); $this->ensurePermissionsAreCorrect($path); @@ -204,9 +190,16 @@ public function remainingSeconds(string $key): ?float public function increment(string $key, int $value = 1): int { $raw = $this->getPayload($key); + $expiresAt = $raw['expiresAt'] ?? null; + + return tap(((int) $raw['data']) + $value, function (int $newValue) use ($key, $raw, $expiresAt): void { + if ($expiresAt === null) { + $this->put($key, $newValue, $raw['time'] ?? 0); + + return; + } - return tap(((int) $raw['data']) + $value, function ($newValue) use ($key, $raw) { - $this->put($key, $newValue, $raw['time'] ?? 0); + $this->putWithExpiresAt($key, $newValue, $expiresAt); }); } @@ -418,8 +411,32 @@ protected function ensurePermissionsAreCorrect(string $path): void $this->files->chmod($path, $this->filePermission); } + /** + * Store an item with an absolute expiration timestamp. + */ + protected function putWithExpiresAt(string $key, mixed $value, int $expiresAt): bool + { + $this->ensureCacheDirectoryExists($path = $this->path($key)); + + $result = $this->files->put( + $path, + $this->expiresAtHeader($expiresAt) . serialize($value), + true + ); + + if ($result !== false && $result > 0) { + $this->ensurePermissionsAreCorrect($path); + + return true; + } + + return false; + } + /** * Retrieve an item and expiry time from the cache by key. + * + * @return array{data: mixed, time: ?int, expiresAt: ?int} */ protected function getPayload(string $key): array { @@ -429,19 +446,21 @@ protected function getPayload(string $key): array // just return null. Otherwise, we'll get the contents of the file and get // the expiration UNIX timestamps from the start of the file's contents. try { - $expire = (int) substr( + $expiresAt = (int) substr( $contents = $this->files->get($path, true), 0, 10 ); - } catch (Exception $e) { + } catch (Exception) { return $this->emptyPayload(); } // If the current time is greater than expiration timestamps we will delete // the file and return null. This helps clean up the old files and keeps // this directory much cleaner for us as old files aren't hanging out. - if ($this->currentTime() >= $expire) { + $currentTime = $this->currentTime(); + + if ($currentTime >= $expiresAt) { $this->forget($key); return $this->emptyPayload(); @@ -449,18 +468,16 @@ protected function getPayload(string $key): array try { $data = $this->unserialize(substr($contents, 10)); - } catch (Exception $e) { + } catch (Exception) { $this->forget($key); return $this->emptyPayload(); } - // Next, we'll extract the number of seconds that are remaining for a cache - // so that we can properly retain the time for things like the increment - // operation that may be performed on this cache on a later operation. - $time = $expire - $this->currentTime(); + // Keep Laravel's remaining duration for subclasses; internal rewrites use the exact deadline. + $time = $expiresAt - $currentTime; - return compact('data', 'time'); + return compact('data', 'time', 'expiresAt'); } /** @@ -481,10 +498,12 @@ protected function unserialize(string $value): mixed /** * Get a default empty payload for the cache. + * + * @return array{data: mixed, time: ?int, expiresAt: ?int} */ protected function emptyPayload(): array { - return ['data' => null, 'time' => null]; + return ['data' => null, 'time' => null, 'expiresAt' => null]; } /** @@ -510,9 +529,9 @@ protected function expiration(int $seconds): int /** * Get the fixed-width expiration header for a cache item. */ - protected function expirationHeader(int $seconds): string + protected function expiresAtHeader(int $expiresAt): string { - return sprintf('%010d', $this->expiration($seconds)); + return sprintf('%010d', $expiresAt); } /** diff --git a/src/cache/src/StorageStore.php b/src/cache/src/StorageStore.php index 77806f04b..11af1ece6 100644 --- a/src/cache/src/StorageStore.php +++ b/src/cache/src/StorageStore.php @@ -74,10 +74,7 @@ public function get(string $key): mixed */ public function put(string $key, mixed $value, int $seconds): bool { - return $this->disk->put( - $this->path($key), - $this->expirationHeader($seconds) . serialize($value) - ) !== false; + return $this->putWithExpiresAt($key, $value, $this->expiration($seconds)); } /** @@ -98,9 +95,16 @@ public function add(string $key, mixed $value, int $seconds): bool public function increment(string $key, int $value = 1): int { $raw = $this->getPayload($key); + $expiresAt = $raw['expiresAt'] ?? null; - return tap(((int) $raw['data']) + $value, function (int $newValue) use ($key, $raw): void { - $this->put($key, $newValue, $raw['time'] ?? 0); + return tap(((int) $raw['data']) + $value, function (int $newValue) use ($key, $raw, $expiresAt): void { + if ($expiresAt === null) { + $this->put($key, $newValue, $raw['time'] ?? 0); + + return; + } + + $this->putWithExpiresAt($key, $newValue, $expiresAt); }); } @@ -163,8 +167,21 @@ public function flush(): bool && $this->disk->makeDirectory($this->directory); } + /** + * Store an item with an absolute expiration timestamp. + */ + protected function putWithExpiresAt(string $key, mixed $value, int $expiresAt): bool + { + return $this->disk->put( + $this->path($key), + $this->expiresAtHeader($expiresAt) . serialize($value) + ) !== false; + } + /** * Retrieve an item and expiry time from the cache by key. + * + * @return array{data: mixed, time: ?int, expiresAt: ?int} */ protected function getPayload(string $key): array { @@ -175,11 +192,13 @@ protected function getPayload(string $key): array return $this->emptyPayload(); } - $expire = (int) substr($contents, 0, 10); + $expiresAt = (int) substr($contents, 0, 10); } catch (Exception) { return $this->emptyPayload(); } - if ($this->currentTime() >= $expire) { + $currentTime = $this->currentTime(); + + if ($currentTime >= $expiresAt) { $this->forget($key); return $this->emptyPayload(); @@ -193,9 +212,10 @@ protected function getPayload(string $key): array return $this->emptyPayload(); } - $time = $expire - $this->currentTime(); + // Keep Laravel's remaining duration for subclasses; internal rewrites use the exact deadline. + $time = $expiresAt - $currentTime; - return ['data' => $data, 'time' => $time]; + return compact('data', 'time', 'expiresAt'); } /** @@ -216,10 +236,12 @@ protected function unserialize(string $value): mixed /** * Get a default empty payload for the cache. + * + * @return array{data: mixed, time: ?int, expiresAt: ?int} */ protected function emptyPayload(): array { - return ['data' => null, 'time' => null]; + return ['data' => null, 'time' => null, 'expiresAt' => null]; } /** @@ -245,9 +267,9 @@ protected function expiration(int $seconds): int /** * Get the fixed-width expiration header for a cache item. */ - protected function expirationHeader(int $seconds): string + protected function expiresAtHeader(int $expiresAt): string { - return sprintf('%010d', $this->expiration($seconds)); + return sprintf('%010d', $expiresAt); } /** diff --git a/src/queue/src/Jobs/DatabaseJobRecord.php b/src/queue/src/Jobs/DatabaseJobRecord.php index 076cabe52..853aeb95a 100644 --- a/src/queue/src/Jobs/DatabaseJobRecord.php +++ b/src/queue/src/Jobs/DatabaseJobRecord.php @@ -4,7 +4,7 @@ namespace Hypervel\Queue\Jobs; -use Hypervel\Support\InteractsWithTime; +use Hypervel\Support\CarbonImmutable; use stdClass; /** @@ -14,8 +14,6 @@ */ class DatabaseJobRecord { - use InteractsWithTime; - /** * Create a new job record instance. */ @@ -39,7 +37,7 @@ public function increment(): int */ public function touch(): int { - $this->record->reserved_at = $this->currentTime(); + $this->record->reserved_at = CarbonImmutable::now()->ceilSecond()->getTimestamp(); return $this->record->reserved_at; } diff --git a/src/support/src/InteractsWithTime.php b/src/support/src/InteractsWithTime.php index 0d4f36bb9..1df47df84 100644 --- a/src/support/src/InteractsWithTime.php +++ b/src/support/src/InteractsWithTime.php @@ -30,9 +30,15 @@ protected function availableAt(DateInterval|DateTimeInterface|int|null $delay = { $delay = $this->parseDateInterval($delay); - return $delay instanceof DateTimeInterface - ? $delay->getTimestamp() - : Date::now()->addSeconds($delay)->getTimestamp(); + $now = Date::now(); + + $target = $delay instanceof DateTimeInterface + ? Date::instance($delay) + : $now->addSeconds($delay); + + return $target > $now + ? $target->ceilSecond()->getTimestamp() + : $target->getTimestamp(); } /** diff --git a/tests/Cache/CacheDatabaseLockTest.php b/tests/Cache/CacheDatabaseLockTest.php index de287a22f..6231f5b29 100644 --- a/tests/Cache/CacheDatabaseLockTest.php +++ b/tests/Cache/CacheDatabaseLockTest.php @@ -32,6 +32,29 @@ public function testLockCanBeAcquired(): void $this->assertTrue($lock->acquire()); } + public function testFractionalSecondAcquireAndRefreshNeverExpireBeforeRequestedDeadline(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + [$lock, $table] = $this->getLock(seconds: 1); + $owner = $lock->owner(); + + $table->shouldReceive('insert')->once()->with([ + 'key' => 'foo', + 'owner' => $owner, + 'expiration' => 1002, + ])->andReturn(true); + + $this->assertTrue($lock->acquire()); + + $table->shouldReceive('where')->once()->with('key', 'foo')->andReturn($table); + $table->shouldReceive('where')->once()->with('owner', $owner)->andReturn($table); + $table->shouldReceive('where')->once()->with('expiration', '>', 1000)->andReturn($table); + $table->shouldReceive('update')->once()->with(['expiration' => 1002])->andReturn(1); + + $this->assertTrue($lock->refresh()); + } + public function testLockCanBeAcquiredIfAlreadyOwnedBySameOwner(): void { [$lock, $table] = $this->getLock(); @@ -163,7 +186,7 @@ public function testLockCanBeForceReleased(): void public function testLockWithDefaultTimeout(): void { - CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + CarbonImmutable::setTestNow($now = CarbonImmutable::now()->startOfSecond()); [$lock, $table] = $this->getLock(seconds: 0); @@ -186,7 +209,7 @@ public function testLockImplementsRefreshableLock(): void public function testRefreshExtendsLockExpiration(): void { - CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + CarbonImmutable::setTestNow($now = CarbonImmutable::now()->startOfSecond()); [$lock, $table] = $this->getLock(); $owner = $lock->owner(); @@ -204,7 +227,7 @@ public function testRefreshExtendsLockExpiration(): void public function testRefreshWithCustomTtl(): void { - CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + CarbonImmutable::setTestNow($now = CarbonImmutable::now()->startOfSecond()); [$lock, $table] = $this->getLock(); $owner = $lock->owner(); @@ -235,7 +258,7 @@ public function testRefreshReturnsFalseWhenNotOwned(): void public function testRefreshOnDefaultTimeoutLockReappliesDefaultTimeout(): void { - CarbonImmutable::setTestNow($now = CarbonImmutable::now()); + CarbonImmutable::setTestNow($now = CarbonImmutable::now()->startOfSecond()); [$lock, $table] = $this->getLock(seconds: 0); $owner = $lock->owner(); diff --git a/tests/Cache/CacheDatabaseStoreTest.php b/tests/Cache/CacheDatabaseStoreTest.php index 36c69cafb..ca32641d3 100644 --- a/tests/Cache/CacheDatabaseStoreTest.php +++ b/tests/Cache/CacheDatabaseStoreTest.php @@ -194,9 +194,9 @@ public function testItemsCanBeStored() public function testValueIsUpserted() { - $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); - [$table] = $this->mockTable($store); - $store->expects($this->once())->method('getTime')->willReturn(1); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(1)); + + [$store, $table] = $this->getStore(); $table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 61]], 'key')->andReturnTrue(); $result = $store->put('foo', 'bar', 60); @@ -205,9 +205,9 @@ public function testValueIsUpserted() public function testValueIsUpsertedOnPostgres() { - $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getPostgresMocks())->getMock(); - [$table] = $this->mockTable($store); - $store->expects($this->once())->method('getTime')->willReturn(1); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(1)); + + [$store, $table] = $this->getPostgresStore(); $table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => base64_encode(serialize("\0")), 'expiration' => 61]], 'key')->andReturn(1); $result = $store->put('foo', "\0", 60); @@ -216,9 +216,9 @@ public function testValueIsUpsertedOnPostgres() public function testValueIsUpsertedOnSqlite() { - $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getSqliteMocks())->getMock(); - [$table] = $this->mockTable($store); - $store->expects($this->once())->method('getTime')->willReturn(1); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(1)); + + [$store, $table] = $this->getSqliteStore(); $table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => base64_encode(serialize("\0")), 'expiration' => 61]], 'key')->andReturn(1); $result = $store->put('foo', "\0", 60); @@ -255,10 +255,9 @@ public function testPutManyReturnsTrueForEmptyInputWithoutUpserting(): void public function testPutManyReturnsTrueWhenUpsertAffectsNoRows(): void { - $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); - [$table] = $this->mockTable($store); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(1)); - $store->expects($this->once())->method('getTime')->willReturn(1); + [$store, $table] = $this->getStore(); $table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 61]], 'key')->andReturn(0); $this->assertTrue($store->putMany(['foo' => 'bar'], 60)); @@ -266,10 +265,9 @@ public function testPutManyReturnsTrueWhenUpsertAffectsNoRows(): void public function testPutReturnsTrueWhenDelegatedUpsertAffectsNoRows(): void { - $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); - [$table] = $this->mockTable($store); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(1)); - $store->expects($this->once())->method('getTime')->willReturn(1); + [$store, $table] = $this->getStore(); $table->shouldReceive('upsert')->once()->with([['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 61]], 'key')->andReturn(0); $this->assertTrue($store->put('foo', 'bar', 60)); @@ -294,6 +292,35 @@ public function testAddOnlyAddsIfKeyDoesntExist() $this->assertTrue($store->add('foo', 'bar', 10)); } + public function testFractionalSecondWritesNeverExpireBeforeRequestedDeadline(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + [$store, $table] = $this->getStore(); + + $table->shouldReceive('upsert')->once()->with([ + ['key' => 'prefixfoo', 'value' => serialize('bar'), 'expiration' => 1002], + ], 'key')->andReturn(1); + + $this->assertTrue($store->put('foo', 'bar', 1)); + + $table->shouldReceive('whereIn')->once()->with('key', ['prefixnew'])->andReturn($table); + $table->shouldReceive('get')->once()->andReturn(new Collection); + $table->shouldReceive('insertOrIgnore')->once()->with([ + 'key' => 'prefixnew', + 'value' => serialize('value'), + 'expiration' => 1002, + ])->andReturn(1); + + $this->assertTrue($store->add('new', 'value', 1)); + + $table->shouldReceive('where')->once()->with('key', '=', 'prefixtouched')->andReturn($table); + $table->shouldReceive('where')->once()->with('expiration', '>', 1000)->andReturn($table); + $table->shouldReceive('update')->once()->with(['expiration' => 1002])->andReturn(1); + + $this->assertTrue($store->touch('touched', 1)); + } + public function testAddReturnsFalseIfKeyExists() { [$store, $table] = $this->getStore(); @@ -374,6 +401,8 @@ public function testDecrementReturnsCorrectValues() public function testTouchExtendsTtl() { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(0)); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getMocks())->getMock(); [$table] = $this->mockTable($store); @@ -386,6 +415,8 @@ public function testTouchExtendsTtl() public function testTouchExtendsTtlOnPostgres() { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(0)); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getPostgresMocks())->getMock(); [$table] = $this->mockTable($store); @@ -398,6 +429,8 @@ public function testTouchExtendsTtlOnPostgres() public function testTouchExtendsTtlOnSqlite() { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(0)); + $store = $this->getMockBuilder(DatabaseStore::class)->onlyMethods(['getTime'])->setConstructorArgs($this->getSqliteMocks())->getMock(); [$table] = $this->mockTable($store); diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php index 3d1d6981e..d6a2db892 100644 --- a/tests/Cache/CacheFileStoreTest.php +++ b/tests/Cache/CacheFileStoreTest.php @@ -207,6 +207,138 @@ public function testGetPayloadReadsZeroPaddedTimestampsCorrectly(): void $this->assertSame('Hello World', (new FileStore($files, __DIR__))->get('foo')); } + public function testFractionalSecondWritesPreserveTheRequestedValueAndLockLifetime(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $tempDir = ParallelTesting::tempDir('CacheFileStoreTest-fractional-expiry'); + (new Filesystem)->deleteDirectory($tempDir); + mkdir($tempDir, 0777, true); + + try { + $store = new FileStore(new Filesystem, $tempDir); + $lock = $store->lock('boundary', 1, 'owner'); + + $this->assertTrue($store->put('foo', 'bar', 1)); + $this->assertTrue($lock->acquire()); + $this->assertStringStartsWith('0000001002', file_get_contents($store->path('foo'))); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1001.000000')); + + $this->assertSame('bar', $store->get('foo')); + $this->assertTrue($lock->isOwnedByCurrentProcess()); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1002.000000')); + + $this->assertNull($store->get('foo')); + $this->assertFalse($lock->isOwnedByCurrentProcess()); + } finally { + (new Filesystem)->deleteDirectory($tempDir); + } + } + + public function testIncrementPreservesAbsoluteExpiryAtFractionalSecond(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $tempDir = ParallelTesting::tempDir('CacheFileStoreTest-fractional-increment'); + (new Filesystem)->deleteDirectory($tempDir); + mkdir($tempDir, 0777, true); + + try { + $store = new FileStore(new Filesystem, $tempDir); + + $this->assertTrue($store->put('counter', 1, 1)); + $this->assertSame(2, $store->increment('counter')); + $this->assertStringStartsWith('0000001002', file_get_contents($store->path('counter'))); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1001.000000')); + + $this->assertSame(2, $store->get('counter')); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1002.000000')); + + $this->assertNull($store->get('counter')); + } finally { + (new Filesystem)->deleteDirectory($tempDir); + } + } + + public function testIncrementPreservesForeverExpiryAtFractionalSecond(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $tempDir = ParallelTesting::tempDir('CacheFileStoreTest-fractional-forever-increment'); + (new Filesystem)->deleteDirectory($tempDir); + mkdir($tempDir, 0777, true); + + try { + $store = new FileStore(new Filesystem, $tempDir); + + $this->assertTrue($store->forever('counter', 1)); + $this->assertSame(2, $store->increment('counter')); + $this->assertStringStartsWith('9999999999', file_get_contents($store->path('counter'))); + $this->assertSame(2, $store->get('counter')); + } finally { + (new Filesystem)->deleteDirectory($tempDir); + } + } + + public function testIncrementSupportsLaravelShapedPayloadOverrides(): void + { + $store = new class(new Filesystem, __DIR__) extends FileStore { + public ?int $writtenDuration = null; + + public ?int $writtenExpiresAt = null; + + public function put(string $key, mixed $value, int $seconds): bool + { + $this->writtenDuration = $seconds; + + return true; + } + + protected function getPayload(string $key): array + { + return ['data' => 1, 'time' => 30]; + } + + protected function putWithExpiresAt(string $key, mixed $value, int $expiresAt): bool + { + $this->writtenExpiresAt = $expiresAt; + + return true; + } + }; + + $this->assertSame(2, $store->increment('counter')); + $this->assertSame(30, $store->writtenDuration); + $this->assertNull($store->writtenExpiresAt); + } + + public function testPayloadRetainsLaravelRemainingTimeAlongsideAbsoluteExpiry(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(1000)); + $tempDir = ParallelTesting::tempDir('CacheFileStoreTest-payload-shape'); + (new Filesystem)->deleteDirectory($tempDir); + mkdir($tempDir, 0777, true); + + try { + $store = new class(new Filesystem, $tempDir) extends FileStore { + public function payload(string $key): array + { + return $this->getPayload($key); + } + }; + + $this->assertTrue($store->put('key', 'value', 30)); + $this->assertSame([ + 'data' => 'value', + 'time' => 30, + 'expiresAt' => 1030, + ], $store->payload('key')); + } finally { + (new Filesystem)->deleteDirectory($tempDir); + } + } + public function testTouchExtendsTtl() { $files = $this->mockFilesystem(); @@ -227,7 +359,7 @@ public function testTouchExtendsTtl() $store->expects($this->once()) ->method('getPayload') ->with($key) - ->willReturn(['data' => $content, 'expiration' => $now->addSeconds($ttl)->getTimestamp()]); + ->willReturn(['data' => $content, 'expiresAt' => $now->addSeconds($ttl)->getTimestamp()]); $files->expects($this->once()) ->method('put') ->with( diff --git a/tests/Cache/CacheStorageStoreTest.php b/tests/Cache/CacheStorageStoreTest.php index 53b741ca0..6c825403d 100644 --- a/tests/Cache/CacheStorageStoreTest.php +++ b/tests/Cache/CacheStorageStoreTest.php @@ -113,6 +113,89 @@ public function testIncrementAndDecrementRetainExpiration(): void $this->assertNull($store->get('foo')); } + public function testIncrementPreservesAbsoluteExpiryAtFractionalSecond(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + $disk = new ArrayFilesystem; + $store = new StorageStore($disk, 'cache'); + + $this->assertTrue($store->put('counter', 1, 1)); + $this->assertSame(2, $store->increment('counter')); + $this->assertStringStartsWith('0000001002', (string) $disk->get($store->path('counter'))); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1001.000000')); + + $this->assertSame(2, $store->get('counter')); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1002.000000')); + + $this->assertNull($store->get('counter')); + } + + public function testIncrementPreservesForeverExpiryAtFractionalSecond(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + $disk = new ArrayFilesystem; + $store = new StorageStore($disk, 'cache'); + + $this->assertTrue($store->forever('counter', 1)); + $this->assertSame(2, $store->increment('counter')); + $this->assertStringStartsWith('9999999999', (string) $disk->get($store->path('counter'))); + $this->assertSame(2, $store->get('counter')); + } + + public function testIncrementSupportsLaravelShapedPayloadOverrides(): void + { + $store = new class(new ArrayFilesystem, 'cache') extends StorageStore { + public ?int $writtenDuration = null; + + public ?int $writtenExpiresAt = null; + + public function put(string $key, mixed $value, int $seconds): bool + { + $this->writtenDuration = $seconds; + + return true; + } + + protected function getPayload(string $key): array + { + return ['data' => 1, 'time' => 30]; + } + + protected function putWithExpiresAt(string $key, mixed $value, int $expiresAt): bool + { + $this->writtenExpiresAt = $expiresAt; + + return true; + } + }; + + $this->assertSame(2, $store->increment('counter')); + $this->assertSame(30, $store->writtenDuration); + $this->assertNull($store->writtenExpiresAt); + } + + public function testPayloadRetainsLaravelRemainingTimeAlongsideAbsoluteExpiry(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(1000)); + $store = new class(new ArrayFilesystem, 'cache') extends StorageStore { + public function payload(string $key): array + { + return $this->getPayload($key); + } + }; + + $this->assertTrue($store->put('key', 'value', 30)); + $this->assertSame([ + 'data' => 'value', + 'time' => 30, + 'expiresAt' => 1030, + ], $store->payload('key')); + } + public function testTouchUpdatesExpiration(): void { CarbonImmutable::setTestNow($now = CarbonImmutable::now()); diff --git a/tests/Foundation/FoundationInteractsWithTimeTest.php b/tests/Foundation/FoundationInteractsWithTimeTest.php index 096121064..9a561a4ef 100644 --- a/tests/Foundation/FoundationInteractsWithTimeTest.php +++ b/tests/Foundation/FoundationInteractsWithTimeTest.php @@ -4,10 +4,12 @@ namespace Hypervel\Tests\Foundation; +use DateInterval; use Hypervel\Foundation\Testing\Concerns\InteractsWithTime; use Hypervel\Support\Carbon; use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Facades\Date; +use Hypervel\Support\InteractsWithTime as SupportInteractsWithTime; use Hypervel\Tests\TestCase; use RuntimeException; @@ -110,4 +112,59 @@ public function testFreezeTimeRestoresRealTimeWhenTheCallbackThrows(): void $this->assertFalse(Carbon::hasTestNow()); } + + public function testSupportRuntimeFormatterUsesMillisecondsBelowOneSecond(): void + { + $formatter = new SupportInteractsWithTimeTestFixture; + + $this->assertSame('125.00ms', $formatter->runTimeForHumans(10.0, 10.125)); + } + + public function testSupportRuntimeFormatterCascadesLongerDurations(): void + { + $formatter = new SupportInteractsWithTimeTestFixture; + + $this->assertSame('1m 5s', $formatter->runTimeForHumans(10.0, 75.0)); + } + + public function testFutureIntegerDeadlinesRoundUpWithoutDelayingImmediateOrPastValues(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $formatter = new SupportInteractsWithTimeTestFixture; + + $this->assertSame(1002, $formatter->availableAt(1)); + $this->assertSame(1000, $formatter->availableAt()); + $this->assertSame(1000, $formatter->availableAt(0)); + $this->assertSame(999, $formatter->availableAt(-1)); + } + + public function testFutureIntervalDeadlinesRoundUpWithoutDelayingZeroOrInvertedIntervals(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $formatter = new SupportInteractsWithTimeTestFixture; + $invertedInterval = new DateInterval('PT1S'); + $invertedInterval->invert = 1; + + $this->assertSame(1002, $formatter->availableAt(new DateInterval('PT1S'))); + $this->assertSame(1000, $formatter->availableAt(new DateInterval('PT0S'))); + $this->assertSame(999, $formatter->availableAt($invertedInterval)); + } + + public function testFutureAbsoluteDeadlinesRoundUpWhilePastAndWholeSecondValuesRemainExact(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $formatter = new SupportInteractsWithTimeTestFixture; + + $this->assertSame(1002, $formatter->availableAt(CarbonImmutable::createFromTimestampUTC('1001.100000'))); + $this->assertSame(999, $formatter->availableAt(CarbonImmutable::createFromTimestampUTC('999.900000'))); + $this->assertSame(1002, $formatter->availableAt(CarbonImmutable::createFromTimestampUTC('1002.000000'))); + } +} + +class SupportInteractsWithTimeTestFixture +{ + use SupportInteractsWithTime { + availableAt as public; + runTimeForHumans as public; + } } diff --git a/tests/Integration/Cache/CacheFunnelTestCase.php b/tests/Integration/Cache/CacheFunnelTestCase.php index 079270978..32025d719 100644 --- a/tests/Integration/Cache/CacheFunnelTestCase.php +++ b/tests/Integration/Cache/CacheFunnelTestCase.php @@ -201,7 +201,7 @@ public function testLeakedFunnelLeaseIsReclaimedAfterReleaseAfter(): void ->block(0) ->acquire(); - $this->expectException(LimiterTimeoutException::class); + $exception = null; try { $this->cache()->funnel('lease-reclaim') @@ -209,17 +209,22 @@ public function testLeakedFunnelLeaseIsReclaimedAfterReleaseAfter(): void ->releaseAfter(1) ->block(0) ->acquire(); - } finally { - usleep(1_200_000); + } catch (LimiterTimeoutException $caught) { + $exception = $caught; + } - $lease = $this->cache()->funnel('lease-reclaim') - ->limit(1) - ->releaseAfter(1) - ->block(0) - ->acquire(); + $this->assertInstanceOf(LimiterTimeoutException::class, $exception); - $this->assertTrue($lease->release()); - } + // A ceiled one-second expiry can remain live for almost two seconds. + usleep(2_200_000); + + $lease = $this->cache()->funnel('lease-reclaim') + ->limit(1) + ->releaseAfter(60) + ->block(0) + ->acquire(); + + $this->assertTrue($lease->release()); } public function testFunnelLeaseRefreshExtendsLifetime(): void diff --git a/tests/Integration/Cache/FileCacheLockTest.php b/tests/Integration/Cache/FileCacheLockTest.php index 0dde090a5..e99b92474 100644 --- a/tests/Integration/Cache/FileCacheLockTest.php +++ b/tests/Integration/Cache/FileCacheLockTest.php @@ -186,7 +186,7 @@ public function testLockCannotBeRefreshedByAnotherOwner(): void public function testLockRefreshWithDefaultSeconds(): void { - $this->freezeTime(); + $this->freezeSecond(); $lock = Cache::lock('foo', 10); $this->assertTrue($lock->get()); @@ -201,7 +201,7 @@ public function testLockRefreshWithDefaultSeconds(): void public function testRefreshReturnsFalseAfterExpiry(): void { - $this->freezeTime(); + $this->freezeSecond(); $lock = Cache::lock('foo', 10); $this->assertTrue($lock->get()); @@ -232,7 +232,7 @@ public function testRefreshWithExplicitZeroThrowsException(): void public function testGetRemainingLifetimeReturnsSeconds(): void { - $this->freezeTime(); + $this->freezeSecond(); $lock = Cache::lock('foo', 10); diff --git a/tests/Integration/Queue/Redis/RedisQueueTest.php b/tests/Integration/Queue/Redis/RedisQueueTest.php index cd8ea5a69..024b2a005 100644 --- a/tests/Integration/Queue/Redis/RedisQueueTest.php +++ b/tests/Integration/Queue/Redis/RedisQueueTest.php @@ -63,6 +63,37 @@ public function testExpiredJobsArePopped(): void $this->assertSame(3, $this->redisConnection()->zcard("{$redisKey}:reserved")); } + public function testFractionalDelayedAndReservedJobsDoNotMigrateEarly(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $default = $this->defaultQueueName(); + $this->setQueue($default, retryAfter: 1); + $job = new RedisQueueIntegrationTestJob(10); + + $this->queue->later(1, $job); + + $redisKey = $this->getQueueRedisKey($default); + $delayed = $this->redisConnection()->zrangebyscore("{$redisKey}:delayed", -INF, INF, ['withscores' => true]); + $this->assertSame(1002.0, (float) reset($delayed)); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1001.900000')); + + $this->assertNull($this->queue->pop()); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1002.000000')); + + $reservedJob = $this->queue->pop(); + $this->assertInstanceOf(RedisJob::class, $reservedJob); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1002.900000')); + + $this->assertNull($this->queue->pop()); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1003.000000')); + + $this->assertInstanceOf(RedisJob::class, $this->queue->pop()); + } + public function testPopProperlyPopsJobOffOfRedis(): void { $default = $this->defaultQueueName(); @@ -89,7 +120,7 @@ public function testPopProperlyPopsJobOffOfRedis(): void $reservedJob = array_key_first($result); $score = (int) $result[$reservedJob]; $this->assertLessThanOrEqual($score, $before + 60); - $this->assertGreaterThanOrEqual($score, $after + 60); + $this->assertGreaterThanOrEqual($score, $after + 61); $this->assertEquals($job, unserialize(json_decode($reservedJob)->data->command)); } @@ -192,7 +223,7 @@ public function testPopProperlyPopsDelayedJobOffOfRedis(): void $reservedJob = array_key_first($result); $score = (int) $result[$reservedJob]; $this->assertLessThanOrEqual($score, $before + 60); - $this->assertGreaterThanOrEqual($score, $after + 60); + $this->assertGreaterThanOrEqual($score, $after + 61); $this->assertEquals($job, unserialize(json_decode($reservedJob)->data->command)); } @@ -325,7 +356,7 @@ public function testExpireJobsWhenExpireSet(): void $reservedJob = array_key_first($result); $score = (int) $result[$reservedJob]; $this->assertLessThanOrEqual($score, $before + 30); - $this->assertGreaterThanOrEqual($score, $after + 30); + $this->assertGreaterThanOrEqual($score, $after + 31); $this->assertEquals($job, unserialize(json_decode($reservedJob)->data->command)); } @@ -352,7 +383,7 @@ public function testRelease(): void $score = (int) $results[$payload]; $this->assertGreaterThanOrEqual($before + 1000, $score); - $this->assertLessThanOrEqual($after + 1000, $score); + $this->assertLessThanOrEqual($after + 1001, $score); $decoded = json_decode($payload); diff --git a/tests/Queue/QueueDatabaseQueueIntegrationTest.php b/tests/Queue/QueueDatabaseQueueIntegrationTest.php index 151054142..1bb9d625f 100644 --- a/tests/Queue/QueueDatabaseQueueIntegrationTest.php +++ b/tests/Queue/QueueDatabaseQueueIntegrationTest.php @@ -194,6 +194,41 @@ public function testThatReservedJobsAreNotPopped(): void $this->assertNull($poppedJob); } + public function testReservedJobIsNotReclaimedBeforeRetryAfterAtFractionalSecond(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + $queue = new DatabaseQueue( + $this->app->make('db'), + null, + 'jobs', + retryAfter: 1, + ); + $queue->setConnectionName('default'); + $queue->setContainer($this->app); + + $this->connection()->table('jobs')->insert([ + 'id' => 1, + 'queue' => 'fractional', + 'payload' => 'mock_payload', + 'attempts' => 0, + 'reserved_at' => null, + 'available_at' => 1000, + 'created_at' => 1000, + ]); + + $this->assertNotNull($queue->pop('fractional')); + $this->assertSame(1001, $this->connection()->table('jobs')->find(1)->reserved_at); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1001.900000')); + + $this->assertNull($queue->pop('fractional')); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1002.000000')); + + $this->assertNotNull($queue->pop('fractional')); + } + public function testJobPayloadIsAvailableOnEvents() { $jobQueueingEvent = null; diff --git a/tests/Queue/QueueDatabaseQueueUnitTest.php b/tests/Queue/QueueDatabaseQueueUnitTest.php index 08f993c9e..70686bf06 100644 --- a/tests/Queue/QueueDatabaseQueueUnitTest.php +++ b/tests/Queue/QueueDatabaseQueueUnitTest.php @@ -21,6 +21,7 @@ use Hypervel\Queue\Events\JobQueueing; use Hypervel\Queue\Events\JobQueueingFailed; use Hypervel\Queue\InvalidPayloadException; +use Hypervel\Queue\Jobs\DatabaseJobRecord; use Hypervel\Queue\Jobs\InspectedJob; use Hypervel\Queue\Queue; use Hypervel\Support\CarbonImmutable; @@ -107,10 +108,11 @@ public static function pushJobsDataProvider() ]; } - public function testDelayedPushProperlyPushesJobOntoDatabase(): void + #[DataProvider('delayedJobDeadlineProvider')] + public function testDelayedPushNeverRunsBeforeRequestedDeadline(DateInterval|DateTimeInterface|int $delay): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); $now = CarbonImmutable::now(); - CarbonImmutable::setTestNow($now); $uuid = Str::uuid(); @@ -121,7 +123,7 @@ public function testDelayedPushProperlyPushesJobOntoDatabase(): void connection: null, table: 'table', default: 'default', - currentTime: 1732502704, + currentTime: 1000, ); $queue->setContainer($container = m::spy(Container::class)); $connection = m::mock(ConnectionInterface::class); @@ -130,19 +132,28 @@ public function testDelayedPushProperlyPushesJobOntoDatabase(): void $query->shouldReceive('insertGetId')->once()->andReturnUsing(function ($array) use ($uuid, $now) { $this->assertSame('default', $array['queue']); - $this->assertSame(json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'createdAt' => $now->getTimestamp(), 'delay' => 10]), $array['payload']); + $this->assertSame(json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'createdAt' => $now->getTimestamp(), 'delay' => 1]), $array['payload']); $this->assertEquals(0, $array['attempts']); $this->assertNull($array['reserved_at']); - $this->assertIsInt($array['available_at']); + $this->assertSame(1002, $array['available_at']); return 1; }); - $queue->later(10, 'foo', ['data']); + $queue->later($delay, 'foo', ['data']); $container->shouldHaveReceived('bound')->with('events')->twice(); } + public static function delayedJobDeadlineProvider(): array + { + return [ + 'integer' => [1], + 'interval' => [new DateInterval('PT1S')], + 'absolute date' => [CarbonImmutable::createFromTimestampUTC('1001.100000')], + ]; + } + public function testPushIncludesBatchIdInPayloadForBatchableJob() { $uuid = Str::uuid()->toString(); @@ -472,6 +483,16 @@ public function testBuildDatabaseRecordWithPayloadAtTheEnd() $this->assertArrayHasKey('payload', array_slice($record, -1, 1, true)); } + public function testReservedTimestampRoundsUpAtFractionalSecond(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $record = (object) ['attempts' => 0, 'reserved_at' => null]; + $job = new DatabaseJobRecord($record); + + $this->assertSame(1001, $job->touch()); + $this->assertSame(1001, $record->reserved_at); + } + public function testPendingJobs(): void { [$queue, $query] = $this->createInspectionQueue(); diff --git a/tests/Queue/QueueRedisQueueTest.php b/tests/Queue/QueueRedisQueueTest.php index b3684735c..93b711f6c 100644 --- a/tests/Queue/QueueRedisQueueTest.php +++ b/tests/Queue/QueueRedisQueueTest.php @@ -244,15 +244,14 @@ public function testPushProperlyPushesJobOntoRedisWithTwoCustomPayloadHook(): vo public function testDelayedPushProperlyPushesJobOntoRedis(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); $now = CarbonImmutable::now(); - CarbonImmutable::setTestNow($now); $uuid = $this->mockUuid(); - $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['availableAt', 'getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->setContainer($container = m::spy(Container::class)); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->expects($this->once())->method('availableAt')->with(1)->willReturn(2); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldReceive('isCluster')->once()->andReturn(false); @@ -260,7 +259,7 @@ public function testDelayedPushProperlyPushesJobOntoRedis(): void LuaScripts::later(), 1, 'queues:default:delayed', - 2, + 1002, json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'createdAt' => $now->getTimestamp(), 'id' => 'foo', 'attempts' => 0, 'delay' => 1]) ); $redis->shouldReceive('connection')->twice()->andReturn($redisProxy); @@ -272,16 +271,15 @@ public function testDelayedPushProperlyPushesJobOntoRedis(): void public function testDelayedPushWithDateTimeProperlyPushesJobOntoRedis(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); $now = CarbonImmutable::now(); - CarbonImmutable::setTestNow($now); $uuid = $this->mockUuid(); - $date = CarbonImmutable::now()->addSeconds(5); - $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['availableAt', 'getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); + $date = CarbonImmutable::createFromTimestampUTC('1001.100000'); + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); $queue->setContainer($container = m::spy(Container::class)); $queue->setConnectionName('default'); $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); - $queue->expects($this->once())->method('availableAt')->with($date)->willReturn(5); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldReceive('isCluster')->once()->andReturn(false); @@ -289,8 +287,8 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoRedis(): void LuaScripts::later(), 1, 'queues:default:delayed', - 5, - json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'createdAt' => $now->getTimestamp(), 'id' => 'foo', 'attempts' => 0, 'delay' => 5]) + 1002, + json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'createdAt' => $now->getTimestamp(), 'id' => 'foo', 'attempts' => 0, 'delay' => 1]) ); $redis->shouldReceive('connection')->twice()->andReturn($redisProxy); @@ -298,6 +296,33 @@ public function testDelayedPushWithDateTimeProperlyPushesJobOntoRedis(): void $container->shouldHaveReceived('bound')->with('events')->twice(); } + public function testDelayedPushWithIntervalNeverRunsBeforeRequestedLifetime(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $now = CarbonImmutable::now(); + $uuid = $this->mockUuid(); + $delay = new DateInterval('PT1S'); + + $queue = $this->getMockBuilder(RedisQueue::class)->onlyMethods(['getRandomId'])->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default'])->getMock(); + $queue->setContainer($container = m::spy(Container::class)); + $queue->setConnectionName('default'); + $queue->expects($this->once())->method('getRandomId')->willReturn('foo'); + + $redisProxy = m::mock(RedisProxy::class); + $redisProxy->shouldReceive('isCluster')->once()->andReturn(false); + $redisProxy->shouldReceive('eval')->once()->with( + LuaScripts::later(), + 1, + 'queues:default:delayed', + 1002, + json_encode(['uuid' => $uuid, 'displayName' => 'foo', 'job' => 'foo', 'maxTries' => null, 'maxExceptions' => null, 'failOnTimeout' => false, 'backoff' => null, 'timeout' => null, 'data' => ['data'], 'createdAt' => $now->getTimestamp(), 'id' => 'foo', 'attempts' => 0, 'delay' => 1]) + ); + $redis->shouldReceive('connection')->twice()->andReturn($redisProxy); + + $queue->later($delay, 'foo', ['data']); + $container->shouldHaveReceived('bound')->with('events')->twice(); + } + public function testGetQueueRemainsUnchangedForNonCluster(): void { $queue = new RedisQueue(m::mock(Redis::class), 'default', 'default'); @@ -486,11 +511,9 @@ public function testSizeUsesClusterSafeRedisKeys(): void public function testPopUsesClusterSafeRedisKeys(): void { - $queue = $this->getMockBuilder(RedisQueue::class) - ->onlyMethods(['availableAt']) - ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) - ->getMock(); - $queue->expects($this->once())->method('availableAt')->with(60)->willReturn(123); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + $queue = new RedisQueue($redis = m::mock(Redis::class), 'default', 'default'); $redisProxy = m::mock(RedisProxy::class); $redisProxy->shouldReceive('isCluster')->once()->andReturn(true); @@ -518,7 +541,7 @@ public function testPopUsesClusterSafeRedisKeys(): void 'queues:{default}', 'queues:{default}:reserved', 'queues:{default}:notify', - 123 + 1061 )->andReturn([]); $redis->shouldReceive('connection')->times(4)->andReturn($redisProxy); @@ -587,11 +610,9 @@ public function testDeleteReservedUsesClusterSafeRedisKey(): void public function testDeleteAndReleaseUsesClusterSafeRedisKeys(): void { - $queue = $this->getMockBuilder(RedisQueue::class) - ->onlyMethods(['availableAt']) - ->setConstructorArgs([$redis = m::mock(Redis::class), 'default', 'default']) - ->getMock(); - $queue->expects($this->once())->method('availableAt')->with(30)->willReturn(456); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + $queue = new RedisQueue($redis = m::mock(Redis::class), 'default', 'default'); $job = m::mock(RedisJob::class); $job->shouldReceive('getReservedJob')->once()->andReturn('reserved-payload'); @@ -604,7 +625,7 @@ public function testDeleteAndReleaseUsesClusterSafeRedisKeys(): void 'queues:{emails}:delayed', 'queues:{emails}:reserved', 'reserved-payload', - 456 + 1031 ); $redis->shouldReceive('connection')->twice()->andReturn($redisProxy); From 78314a997fa8541bd0208ef4bb5ab47efc5a5cc9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:23:39 +0000 Subject: [PATCH 09/22] Preserve Redis all-tag expiry metadata Centralize all-tag expiration scores in StoreContext and round positive TTLs upward to the same whole-second boundary used by the cached value. Use the shared score for standalone and Cluster add, put, put-many, touch, and entry tracking paths while retaining the forever sentinel and floored stale-removal cutoff. This prevents tag metadata from disappearing before a still-live value. Add direct coverage for every operation, both Redis topologies, forever entries, and stale pruning at the preceding whole second. --- src/cache/src/Redis/Operations/AllTag/Add.php | 6 +- .../src/Redis/Operations/AllTag/AddEntry.php | 4 +- src/cache/src/Redis/Operations/AllTag/Put.php | 6 +- .../src/Redis/Operations/AllTag/PutMany.php | 6 +- .../src/Redis/Operations/AllTag/Touch.php | 6 +- src/cache/src/Redis/Support/StoreContext.php | 9 +++ .../Redis/Operations/AllTag/AddEntryTest.php | 20 +++++-- .../Cache/Redis/Operations/AllTag/AddTest.php | 17 ++++-- .../Operations/AllTag/FlushStaleTest.php | 21 +++++++ .../Redis/Operations/AllTag/PutManyTest.php | 22 +++++--- .../Cache/Redis/Operations/AllTag/PutTest.php | 17 ++++-- .../Redis/Operations/AllTag/TouchTest.php | 56 +++++++++++++++++++ .../Cache/Redis/Support/StoreContextTest.php | 8 +++ 13 files changed, 158 insertions(+), 40 deletions(-) create mode 100644 tests/Cache/Redis/Operations/AllTag/TouchTest.php diff --git a/src/cache/src/Redis/Operations/AllTag/Add.php b/src/cache/src/Redis/Operations/AllTag/Add.php index 1215ca47e..3b512efb8 100644 --- a/src/cache/src/Redis/Operations/AllTag/Add.php +++ b/src/cache/src/Redis/Operations/AllTag/Add.php @@ -8,8 +8,6 @@ use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Redis\RedisConnection; -use function Hypervel\Support\now; - /** * Store an item in the cache if it doesn't exist, with all tag tracking. * @@ -60,7 +58,7 @@ private function executePipeline(string $key, mixed $value, int $seconds, array { return $this->context->withConnection(function (RedisConnection $connection) use ($key, $value, $seconds, $tagIds) { $prefix = $this->context->prefix(); - $score = now()->addSeconds($seconds)->getTimestamp(); + $score = $this->context->expirationScore($seconds); // Pipeline the ZADD operations for tag tracking if (! empty($tagIds)) { @@ -94,7 +92,7 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $ { return $this->context->withConnection(function (RedisConnection $connection) use ($key, $value, $seconds, $tagIds) { $prefix = $this->context->prefix(); - $score = now()->addSeconds($seconds)->getTimestamp(); + $score = $this->context->expirationScore($seconds); // ZADD to each tag's sorted set (sequential - cross-slot) foreach ($tagIds as $tagId) { diff --git a/src/cache/src/Redis/Operations/AllTag/AddEntry.php b/src/cache/src/Redis/Operations/AllTag/AddEntry.php index ffaa47c53..a5563c05b 100644 --- a/src/cache/src/Redis/Operations/AllTag/AddEntry.php +++ b/src/cache/src/Redis/Operations/AllTag/AddEntry.php @@ -7,8 +7,6 @@ use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Redis\RedisConnection; -use function Hypervel\Support\now; - /** * Adds a cache key reference to all tag sorted sets. * @@ -50,7 +48,7 @@ public function execute(string $key, int $ttl, array $tagIds, ?string $updateWhe // Convert TTL to timestamp score: // - If TTL > 0: timestamp when this entry expires // - If TTL <= 0: -1 to indicate "forever" (won't be cleaned by ZREMRANGEBYSCORE) - $score = $ttl > 0 ? now()->addSeconds($ttl)->getTimestamp() : -1; + $score = $ttl > 0 ? $this->context->expirationScore($ttl) : -1; // Cluster mode: RedisCluster doesn't support pipeline, and tags // may be in different slots requiring sequential commands diff --git a/src/cache/src/Redis/Operations/AllTag/Put.php b/src/cache/src/Redis/Operations/AllTag/Put.php index 53f629a90..63a523aea 100644 --- a/src/cache/src/Redis/Operations/AllTag/Put.php +++ b/src/cache/src/Redis/Operations/AllTag/Put.php @@ -8,8 +8,6 @@ use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Redis\RedisConnection; -use function Hypervel\Support\now; - /** * Store an item in the cache with all tag tracking. * @@ -58,7 +56,7 @@ private function executePipeline(string $key, mixed $value, int $seconds, array { return $this->context->withConnection(function (RedisConnection $connection) use ($key, $value, $seconds, $tagIds): bool { $prefix = $this->context->prefix(); - $score = now()->addSeconds($seconds)->getTimestamp(); + $score = $this->context->expirationScore($seconds); $serialized = $this->serialization->serialize($connection, $value); $pipeline = $connection->pipeline(); @@ -88,7 +86,7 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $ { return $this->context->withConnection(function (RedisConnection $connection) use ($key, $value, $seconds, $tagIds): bool { $prefix = $this->context->prefix(); - $score = now()->addSeconds($seconds)->getTimestamp(); + $score = $this->context->expirationScore($seconds); $serialized = $this->serialization->serialize($connection, $value); // ZADD to each tag's sorted set (sequential - cross-slot) diff --git a/src/cache/src/Redis/Operations/AllTag/PutMany.php b/src/cache/src/Redis/Operations/AllTag/PutMany.php index cb4d463da..bba6b16f8 100644 --- a/src/cache/src/Redis/Operations/AllTag/PutMany.php +++ b/src/cache/src/Redis/Operations/AllTag/PutMany.php @@ -8,8 +8,6 @@ use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Redis\RedisConnection; -use function Hypervel\Support\now; - /** * Store multiple items in the cache with all tag tracking. * @@ -59,7 +57,7 @@ private function executePipeline(array $values, int $seconds, array $tagIds, str { return $this->context->withConnection(function (RedisConnection $connection) use ($values, $seconds, $tagIds, $namespace): bool { $prefix = $this->context->prefix(); - $score = now()->addSeconds($seconds)->getTimestamp(); + $score = $this->context->expirationScore($seconds); $ttl = max(1, $seconds); // Prepare all data up front @@ -106,7 +104,7 @@ private function executeCluster(array $values, int $seconds, array $tagIds, stri { return $this->context->withConnection(function (RedisConnection $connection) use ($values, $seconds, $tagIds, $namespace): bool { $prefix = $this->context->prefix(); - $score = now()->addSeconds($seconds)->getTimestamp(); + $score = $this->context->expirationScore($seconds); $ttl = max(1, $seconds); // Prepare all data up front diff --git a/src/cache/src/Redis/Operations/AllTag/Touch.php b/src/cache/src/Redis/Operations/AllTag/Touch.php index 3c2cbf286..b4e22b4ef 100644 --- a/src/cache/src/Redis/Operations/AllTag/Touch.php +++ b/src/cache/src/Redis/Operations/AllTag/Touch.php @@ -7,8 +7,6 @@ use Hypervel\Cache\Redis\Support\StoreContext; use Hypervel\Redis\RedisConnection; -use function Hypervel\Support\now; - /** * Adjust the expiration time of a tagged cache item and its tag entries. * @@ -54,7 +52,7 @@ private function executeCluster(string $key, int $seconds, array $tagIds): bool return false; } - $score = now()->addSeconds($seconds)->getTimestamp(); + $score = $this->context->expirationScore($seconds); foreach ($tagIds as $tagId) { $connection->zadd($prefix . $tagId, $score, $key); @@ -81,7 +79,7 @@ private function executeUsingLua(string $key, int $seconds, array $tagIds): bool $args = [ $seconds, - now()->addSeconds($seconds)->getTimestamp(), + $this->context->expirationScore($seconds), $key, ]; diff --git a/src/cache/src/Redis/Support/StoreContext.php b/src/cache/src/Redis/Support/StoreContext.php index cab264e0f..7567763db 100644 --- a/src/cache/src/Redis/Support/StoreContext.php +++ b/src/cache/src/Redis/Support/StoreContext.php @@ -7,6 +7,7 @@ use Hypervel\Cache\TagMode; use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Redis\RedisConnection; +use Hypervel\Support\CarbonImmutable; use Redis; /** @@ -64,6 +65,14 @@ public function tagMode(): TagMode return $this->tagMode; } + /** + * Get the tag membership score for a cache lifetime. + */ + public function expirationScore(int $seconds): int + { + return CarbonImmutable::now()->addSeconds($seconds)->ceilSecond()->getTimestamp(); + } + /** * Get the tag identifier (without cache prefix). * diff --git a/tests/Cache/Redis/Operations/AllTag/AddEntryTest.php b/tests/Cache/Redis/Operations/AllTag/AddEntryTest.php index 5be1fd162..bfbdf9fa2 100644 --- a/tests/Cache/Redis/Operations/AllTag/AddEntryTest.php +++ b/tests/Cache/Redis/Operations/AllTag/AddEntryTest.php @@ -5,6 +5,7 @@ namespace Hypervel\Tests\Cache\Redis\Operations\AllTag; use Hypervel\Cache\Redis\Operations\AllTag\AddEntry; +use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\Cache\Redis\RedisCacheTestCase; /** @@ -12,6 +13,13 @@ */ class AddEntryTest extends RedisCacheTestCase { + protected function setUp(): void + { + parent::setUp(); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + } + /** * @test */ @@ -23,7 +31,7 @@ public function testAddEntryWithTtl(): void $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 300, 'mykey') + ->with('prefix:_all:tag:users:entries', 1301, 'mykey') ->andReturn($connection); $connection->shouldReceive('exec') @@ -143,7 +151,7 @@ public function testAddEntryWithUpdateWhenGtCondition(): void $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', ['GT'], now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:users:entries', ['GT'], 1061, 'mykey') ->andReturn($connection); $connection->shouldReceive('exec') @@ -167,11 +175,11 @@ public function testAddEntryWithMultipleTags(): void $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:users:entries', 1061, 'mykey') ->andReturn($connection); $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:posts:entries', now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:posts:entries', 1061, 'mykey') ->andReturn($connection); $connection->shouldReceive('exec') @@ -238,7 +246,7 @@ public function testAddEntryClusterModeUsesSequentialCommands(): void // Should use sequential zadd calls directly on connection $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 300, 'mykey') + ->with('prefix:_all:tag:users:entries', 1301, 'mykey') ->andReturn(1); $operation = new AddEntry($store->getContext()); @@ -256,7 +264,7 @@ public function testAddEntryClusterModeWithMultipleTags(): void $connection->shouldNotReceive('pipeline'); // Should use sequential zadd calls for each tag - $expectedScore = now()->timestamp + 60; + $expectedScore = 1061; $connection->shouldReceive('zadd') ->once() ->with('prefix:_all:tag:users:entries', $expectedScore, 'mykey') diff --git a/tests/Cache/Redis/Operations/AllTag/AddTest.php b/tests/Cache/Redis/Operations/AllTag/AddTest.php index d85c81f02..82813c58e 100644 --- a/tests/Cache/Redis/Operations/AllTag/AddTest.php +++ b/tests/Cache/Redis/Operations/AllTag/AddTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Cache\Redis\Operations\AllTag; +use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\Cache\Redis\RedisCacheTestCase; /** @@ -19,6 +20,8 @@ class AddTest extends RedisCacheTestCase */ public function testAddWithTagsReturnsTrueWhenKeyAdded(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $connection = $this->mockConnection(); $connection->shouldReceive('pipeline')->once()->andReturn($connection); @@ -26,7 +29,7 @@ public function testAddWithTagsReturnsTrueWhenKeyAdded(): void // ZADD for tag with TTL score $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:users:entries', 1061, 'mykey') ->andReturn($connection); $connection->shouldReceive('exec') @@ -84,11 +87,13 @@ public function testAddWithTagsReturnsFalseWhenKeyExists(): void */ public function testAddWithMultipleTags(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $connection = $this->mockConnection(); $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $expectedScore = now()->timestamp + 120; + $expectedScore = 1121; // ZADD for each tag $connection->shouldReceive('zadd') @@ -153,6 +158,8 @@ public function testAddWithEmptyTagsSkipsPipeline(): void */ public function testAddInClusterModeUsesSequentialCommands(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + [$store, , $connection] = $this->createClusterStore(); // Should NOT use pipeline in cluster mode @@ -161,7 +168,7 @@ public function testAddInClusterModeUsesSequentialCommands(): void // Sequential ZADD $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:users:entries', 1061, 'mykey') ->andReturn(1); // SET NX EX for atomic add @@ -185,12 +192,14 @@ public function testAddInClusterModeUsesSequentialCommands(): void */ public function testAddInClusterModeReturnsFalseWhenKeyExists(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + [$store, , $connection] = $this->createClusterStore(); // Sequential ZADD (still happens even if key exists) $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:users:entries', 1061, 'mykey') ->andReturn(1); // SET NX returns false when key exists (RedisCluster return type is string|bool) diff --git a/tests/Cache/Redis/Operations/AllTag/FlushStaleTest.php b/tests/Cache/Redis/Operations/AllTag/FlushStaleTest.php index 1925eac69..8b5eaa0c1 100644 --- a/tests/Cache/Redis/Operations/AllTag/FlushStaleTest.php +++ b/tests/Cache/Redis/Operations/AllTag/FlushStaleTest.php @@ -133,6 +133,27 @@ public function testFlushStaleEntriesUsesCurrentTimestampAsUpperBound(): void $operation->execute(['_all:tag:users:entries']); } + public function testFlushStaleDoesNotReachCeiledScoreBeforeRequestedLifetime(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + + $this->assertSame(1002, $store->getContext()->expirationScore(1)); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1001.900000')); + + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:users:entries', '0', '1001') + ->andReturn($connection); + $connection->shouldReceive('exec')->once(); + + (new FlushStale($store->getContext()))->execute(['_all:tag:users:entries']); + } + /** * @test */ diff --git a/tests/Cache/Redis/Operations/AllTag/PutManyTest.php b/tests/Cache/Redis/Operations/AllTag/PutManyTest.php index 4f2e0c16a..bd78a9978 100644 --- a/tests/Cache/Redis/Operations/AllTag/PutManyTest.php +++ b/tests/Cache/Redis/Operations/AllTag/PutManyTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Cache\Redis\Operations\AllTag; +use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\Cache\Redis\RedisCacheTestCase; /** @@ -11,6 +12,13 @@ */ class PutManyTest extends RedisCacheTestCase { + protected function setUp(): void + { + parent::setUp(); + + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + } + /** * @test */ @@ -20,7 +28,7 @@ public function testPutManyWithTagsInPipelineMode(): void $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $expectedScore = now()->timestamp + 60; + $expectedScore = 1061; // Variadic ZADD: one command with all members for the tag // Format: key, score1, member1, score2, member2, ... @@ -65,7 +73,7 @@ public function testPutManyWithMultipleTags(): void $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $expectedScore = now()->timestamp + 120; + $expectedScore = 1121; // Variadic ZADD for each tag (one command per tag, all keys as members) $connection->shouldReceive('zadd') @@ -159,7 +167,7 @@ public function testPutManyInClusterModeUsesVariadicZadd(): void // Should NOT use pipeline in cluster mode $connection->shouldNotReceive('pipeline'); - $expectedScore = now()->timestamp + 60; + $expectedScore = 1061; // Variadic ZADD: one command with all members for the tag // This works in cluster because all members go to ONE sorted set (one slot) @@ -318,7 +326,7 @@ public function testPutManyUsesCorrectPrefix(): void $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $expectedScore = now()->timestamp + 30; + $expectedScore = 1031; // Custom prefix should be used $connection->shouldReceive('zadd') @@ -359,7 +367,7 @@ public function testPutManyWithMultipleTagsAndMultipleKeys(): void $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $expectedScore = now()->timestamp + 60; + $expectedScore = 1061; // Variadic ZADD for first tag with all keys $connection->shouldReceive('zadd') @@ -412,7 +420,7 @@ public function testPutManyInClusterModeWithMultipleTags(): void { [$store, , $connection] = $this->createClusterStore(); - $expectedScore = now()->timestamp + 60; + $expectedScore = 1061; // Variadic ZADD for each tag (different slots, separate commands) $connection->shouldReceive('zadd') @@ -479,7 +487,7 @@ public function testPutManyInClusterModeReturnsFalseOnSetexFailure(): void { [$store, , $connection] = $this->createClusterStore(); - $expectedScore = now()->timestamp + 60; + $expectedScore = 1061; $connection->shouldReceive('zadd') ->once() diff --git a/tests/Cache/Redis/Operations/AllTag/PutTest.php b/tests/Cache/Redis/Operations/AllTag/PutTest.php index 050a62ae1..3218917b2 100644 --- a/tests/Cache/Redis/Operations/AllTag/PutTest.php +++ b/tests/Cache/Redis/Operations/AllTag/PutTest.php @@ -4,6 +4,7 @@ namespace Hypervel\Tests\Cache\Redis\Operations\AllTag; +use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\Cache\Redis\RedisCacheTestCase; /** @@ -16,6 +17,8 @@ class PutTest extends RedisCacheTestCase */ public function testPutStoresValueWithTagsInPipelineMode(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $connection = $this->mockConnection(); $connection->shouldReceive('pipeline')->once()->andReturn($connection); @@ -23,7 +26,7 @@ public function testPutStoresValueWithTagsInPipelineMode(): void // ZADD for tag $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:users:entries', 1061, 'mykey') ->andReturn($connection); // SETEX for cache value @@ -52,11 +55,13 @@ public function testPutStoresValueWithTagsInPipelineMode(): void */ public function testPutWithMultipleTags(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $connection = $this->mockConnection(); $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $expectedScore = now()->timestamp + 120; + $expectedScore = 1121; // ZADD for each tag $connection->shouldReceive('zadd') @@ -125,13 +130,15 @@ public function testPutWithEmptyTagsStillStoresValue(): void */ public function testPutUsesCorrectPrefix(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $connection = $this->mockConnection(); $connection->shouldReceive('pipeline')->once()->andReturn($connection); $connection->shouldReceive('zadd') ->once() - ->with('custom:_all:tag:users:entries', now()->timestamp + 30, 'mykey') + ->with('custom:_all:tag:users:entries', 1031, 'mykey') ->andReturn($connection); $connection->shouldReceive('setex') @@ -187,6 +194,8 @@ public function testPutReturnsFalseOnFailure(): void */ public function testPutInClusterModeUsesSequentialCommands(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + [$store, , $connection] = $this->createClusterStore(); // Should NOT use pipeline in cluster mode @@ -195,7 +204,7 @@ public function testPutInClusterModeUsesSequentialCommands(): void // Sequential ZADD $connection->shouldReceive('zadd') ->once() - ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->with('prefix:_all:tag:users:entries', 1061, 'mykey') ->andReturn(1); // Sequential SETEX diff --git a/tests/Cache/Redis/Operations/AllTag/TouchTest.php b/tests/Cache/Redis/Operations/AllTag/TouchTest.php new file mode 100644 index 000000000..974195301 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/TouchTest.php @@ -0,0 +1,56 @@ +mockConnection(); + $connection->shouldReceive('evalWithShaCache') + ->once() + ->with( + m::type('string'), + ['prefix:mykey', 'prefix:_all:tag:users:entries'], + [60, 1061, 'mykey'], + ) + ->andReturn(1); + + $store = $this->createStore($connection); + + $this->assertTrue($store->allTagOps()->touch()->execute( + 'mykey', + 60, + ['_all:tag:users:entries'], + )); + } + + public function testTouchRoundsTagScoreUpAtFractionalSecondInClusterMode(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + [$store, , $connection] = $this->createClusterStore(); + $connection->shouldReceive('expire') + ->once() + ->with('prefix:mykey', 60) + ->andReturn(true); + $connection->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', 1061, 'mykey') + ->andReturn(1); + + $this->assertTrue($store->allTagOps()->touch()->execute( + 'mykey', + 60, + ['_all:tag:users:entries'], + )); + } +} diff --git a/tests/Cache/Redis/Support/StoreContextTest.php b/tests/Cache/Redis/Support/StoreContextTest.php index 466dc8ade..f0c21c264 100644 --- a/tests/Cache/Redis/Support/StoreContextTest.php +++ b/tests/Cache/Redis/Support/StoreContextTest.php @@ -9,6 +9,7 @@ use Hypervel\Contracts\Redis\Factory as RedisFactory; use Hypervel\Redis\PhpRedisConnection; use Hypervel\Redis\RedisProxy; +use Hypervel\Support\CarbonImmutable; use Hypervel\Tests\TestCase; use Mockery as m; use Redis; @@ -67,6 +68,13 @@ public function testRegistryKeyBuildsCorrectFormat(): void $this->assertSame('myapp:_any:tag:registry', $context->registryKey()); } + public function testExpirationScoreNeverPrecedesRequestedLifetime(): void + { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + + $this->assertSame(1002, $this->createContext()->expirationScore(1)); + } + public function testWithConnectionExecutesCallbackAndReturnsResult(): void { $connection = m::mock(PhpRedisConnection::class); From 4cc7324d84917642b8da10ba161bb62dc8d591b0 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:23:50 +0000 Subject: [PATCH 10/22] Reclaim released coroutine mutex channels Return truthful acquisition and release results from Mutex, fail invalid unlocks immediately, and remove a channel once its held token is released and no waiter has received the slot. Guard reclamation by channel identity so an older unlock cannot remove a replacement published for the same key. Retain the channel during native waiter handoff and keep clear as the explicit cancellation and reset operation. Mark mutating channel operations as impure for static analysis and cover uncontended cleanup, contention, timeouts, double unlocks, replacement races, clear, and static reset behavior. --- src/contracts/src/Engine/ChannelInterface.php | 4 + src/coroutine/src/Mutex.php | 31 +-- src/docs/coroutines.md | 4 +- src/engine/src/Channel.php | 4 + tests/Coroutine/MutexTest.php | 215 +++++++++++++++++- 5 files changed, 240 insertions(+), 18 deletions(-) diff --git a/src/contracts/src/Engine/ChannelInterface.php b/src/contracts/src/Engine/ChannelInterface.php index c08756a8f..ce537a654 100644 --- a/src/contracts/src/Engine/ChannelInterface.php +++ b/src/contracts/src/Engine/ChannelInterface.php @@ -12,12 +12,14 @@ interface ChannelInterface /** * @param TValue $data * @param float $timeout Timeout in seconds (values less than or equal to zero wait indefinitely) + * @phpstan-impure */ public function push(mixed $data, float $timeout = -1): bool; /** * @param float $timeout Timeout in seconds (values less than or equal to zero wait indefinitely) * @return false|TValue Returns false when pop fails + * @phpstan-impure Removes a value and may hand the freed slot to a blocked producer. */ public function pop(float $timeout = -1): mixed; @@ -28,6 +30,8 @@ public function pop(float $timeout = -1): mixed; * but push will no longer succeed. Native-backed channels must be closed * from a deterministic lifecycle path while the runtime is active, never * from a destructor after native teardown. + * + * @phpstan-impure */ public function close(): bool; diff --git a/src/coroutine/src/Mutex.php b/src/coroutine/src/Mutex.php index f1b5bdfef..f94fc2867 100644 --- a/src/coroutine/src/Mutex.php +++ b/src/coroutine/src/Mutex.php @@ -17,7 +17,7 @@ class Mutex * Acquire a mutex lock for the given key. * * @param float $timeout Timeout in seconds (-1 for unlimited) - * @return bool True if lock acquired, false if timeout or channel closing + * @return bool True when the acquisition token was accepted, false when the push failed */ public static function lock(string $key, float $timeout = -1): bool { @@ -26,29 +26,32 @@ public static function lock(string $key, float $timeout = -1): bool } $channel = static::$channels[$key]; - $channel->push(1, $timeout); - if ($channel->isTimeout() || $channel->isClosing()) { - return false; - } - return true; + return $channel->push(1, $timeout); } /** * Release a mutex lock for the given key. * * @param float $timeout Timeout in seconds - * @return bool True if unlocked successfully, false if timeout (unlock called more than once) + * @return bool True when a held token was released, false when no token was held or the pop failed */ public static function unlock(string $key, float $timeout = 5): bool { - if (isset(static::$channels[$key])) { - $channel = static::$channels[$key]; - $channel->pop($timeout); - if ($channel->isTimeout()) { - // unlock more than once - return false; - } + if (! isset(static::$channels[$key])) { + return false; + } + + $channel = static::$channels[$key]; + + if ($channel->getLength() === 0 || $channel->pop($timeout) === false) { + return false; + } + + // A fast waiter may have released this channel and published a replacement. + if ((static::$channels[$key] ?? null) === $channel && $channel->getLength() === 0) { + unset(static::$channels[$key]); + $channel->close(); } return true; diff --git a/src/docs/coroutines.md b/src/docs/coroutines.md index 0c63d5cf3..3bb51d068 100644 --- a/src/docs/coroutines.md +++ b/src/docs/coroutines.md @@ -762,7 +762,9 @@ if (! Mutex::unlock('reports', timeout: 1.0)) { } ``` -You may clear the mutex for a key using the `clear` method: +The mutex does not track coroutine ownership. Each successful `lock` call must be matched by exactly one `unlock` call from the coroutine that acquired it. + +The `clear` method closes the current mutex channel and cancels any waiting acquisitions. Use it only to explicitly cancel and reset a key, not for normal release: ```php Mutex::clear('reports'); diff --git a/src/engine/src/Channel.php b/src/engine/src/Channel.php index 9aea7ae6b..171945a57 100644 --- a/src/engine/src/Channel.php +++ b/src/engine/src/Channel.php @@ -20,6 +20,7 @@ class Channel extends \Swoole\Coroutine\Channel implements ChannelInterface * * @param TValue $data * @param float $timeout Timeout in seconds (values less than or equal to zero wait indefinitely) + * @phpstan-impure */ public function push(mixed $data, float $timeout = -1): bool { @@ -31,6 +32,7 @@ public function push(mixed $data, float $timeout = -1): bool * * @param float $timeout Timeout in seconds (values less than or equal to zero wait indefinitely) * @return false|TValue Returns false when pop fails + * @phpstan-impure Removes a value and may hand the freed slot to a blocked producer. */ public function pop(float $timeout = -1): mixed { @@ -67,6 +69,8 @@ public function isAvailable(): bool * Call only from a deterministic lifecycle path while the Swoole runtime * is live. Native channel methods are uncatchably fatal after the native * handle is torn down, so destructors must not call this method. + * + * @phpstan-impure */ public function close(): bool { diff --git a/tests/Coroutine/MutexTest.php b/tests/Coroutine/MutexTest.php index 78c3adbae..c2ee33ec8 100644 --- a/tests/Coroutine/MutexTest.php +++ b/tests/Coroutine/MutexTest.php @@ -14,10 +14,10 @@ class MutexTest extends TestCase { - public function testMutexLock() + public function testMutexLock(): void { $chan = new Channel(5); - $func = function (string $value) use ($chan) { + $func = function (string $value) use ($chan): void { if (Mutex::lock('test')) { try { usleep(1000); @@ -45,7 +45,111 @@ public function testMutexLock() $this->assertSame('hello', $res); } - public function testFlushStateReleasesAbandonedLock() + public function testLockReturnsTheNativePushResult(): void + { + $channel = new RejectingMutexChannel(1); + $this->publishChannel('rejected', $channel); + + $this->assertFalse(Mutex::lock('rejected')); + $this->assertSame($channel, $this->channels()['rejected']); + } + + public function testManyUncontendedMutexesReleaseTheirChannels(): void + { + for ($index = 0; $index < 100; ++$index) { + $key = 'mutex-' . $index; + + $this->assertTrue(Mutex::lock($key)); + $this->assertTrue(Mutex::unlock($key)); + } + + $this->assertSame([], $this->channels()); + } + + public function testInvalidUnlocksFailWithoutPoppingAnEmptyChannel(): void + { + $this->assertFalse(Mutex::unlock('absent')); + + $channel = new EmptyMutexChannel(1); + $this->publishChannel('empty', $channel); + + $this->assertFalse(Mutex::unlock('empty')); + $this->assertSame(0, $channel->popCalls); + + $this->assertTrue(Mutex::lock('double')); + $this->assertTrue(Mutex::unlock('double')); + $this->assertFalse(Mutex::unlock('double')); + } + + public function testFailedPopRetainsTheHeldChannel(): void + { + $channel = new FailingPopMutexChannel(1); + $channel->push(1); + $this->publishChannel('held', $channel); + + $this->assertFalse(Mutex::unlock('held')); + $this->assertSame($channel, $this->channels()['held']); + } + + public function testContendedUnlockHandsThePublishedChannelToTheWaiter(): void + { + $this->assertTrue(Mutex::lock('contended')); + $channel = $this->channels()['contended']; + $waiterStarted = new Channel(1); + $waiterAcquired = new Channel(1); + $releaseWaiter = new Channel(1); + $waiterReleased = new Channel(1); + + go(static function () use ($waiterStarted, $waiterAcquired, $releaseWaiter, $waiterReleased): void { + $waiterStarted->push(true); + $waiterAcquired->push(Mutex::lock('contended')); + $releaseWaiter->pop(); + $waiterReleased->push(Mutex::unlock('contended')); + }); + + $this->assertTrue($waiterStarted->pop(1)); + $this->assertTrue(Mutex::unlock('contended')); + $this->assertTrue($waiterAcquired->pop(1)); + $this->assertSame($channel, $this->channels()['contended']); + + $releaseWaiter->push(true); + + $this->assertTrue($waiterReleased->pop(1)); + $this->assertArrayNotHasKey('contended', $this->channels()); + } + + public function testTimedOutWaiterLeavesTheOwnersChannelIntact(): void + { + $this->assertTrue(Mutex::lock('timeout')); + $channel = $this->channels()['timeout']; + $waiterResult = new Channel(1); + + go(static function () use ($waiterResult): void { + $waiterResult->push(Mutex::lock('timeout', 0.001)); + }); + + $this->assertFalse($waiterResult->pop(1)); + $this->assertSame($channel, $this->channels()['timeout']); + $this->assertTrue(Mutex::unlock('timeout')); + $this->assertArrayNotHasKey('timeout', $this->channels()); + } + + public function testOlderUnlockDoesNotRemoveAReplacementChannel(): void + { + $channel = new ReplacingMutexChannel('replaced'); + $channel->push(1); + $this->publishChannel('replaced', $channel); + + $this->assertTrue(Mutex::unlock('replaced')); + + $replacement = $this->channels()['replaced']; + $this->assertNotSame($channel, $replacement); + $this->assertSame(1, $replacement->getLength()); + $this->assertTrue(Mutex::unlock('replaced')); + $this->assertArrayNotHasKey('replaced', $this->channels()); + } + + public function testFlushStateReleasesAbandonedLock(): void { try { $this->assertTrue(Mutex::lock('held')); @@ -58,6 +162,49 @@ public function testFlushStateReleasesAbandonedLock() } } + public function testClearCancelsABlockedAcquisition(): void + { + $this->assertTrue(Mutex::lock('blocked')); + $waiterStarted = new Channel(1); + $waiterResult = new Channel(1); + + go(static function () use ($waiterStarted, $waiterResult): void { + $waiterStarted->push(true); + $waiterResult->push(Mutex::lock('blocked')); + }); + + $this->assertTrue($waiterStarted->pop(1)); + + Mutex::clear('blocked'); + + $this->assertFalse($waiterResult->pop(1)); + $this->assertArrayNotHasKey('blocked', $this->channels()); + } + + public function testFlushStateCancelsBlockedAcquisitions(): void + { + $this->assertTrue(Mutex::lock('first')); + $this->assertTrue(Mutex::lock('second')); + $waitersStarted = new Channel(2); + $waiterResults = new Channel(2); + + foreach (['first', 'second'] as $key) { + go(static function () use ($key, $waitersStarted, $waiterResults): void { + $waitersStarted->push(true); + $waiterResults->push(Mutex::lock($key)); + }); + } + + $this->assertTrue($waitersStarted->pop(1)); + $this->assertTrue($waitersStarted->pop(1)); + + Mutex::flushState(); + + $this->assertFalse($waiterResults->pop(1)); + $this->assertFalse($waiterResults->pop(1)); + $this->assertSame([], $this->channels()); + } + public function testClearRemovesReleasedKeys(): void { try { @@ -73,4 +220,66 @@ public function testClearRemovesReleasedKeys(): void Mutex::flushState(); } } + + /** + * @return array + */ + private function channels(): array + { + return (new ReflectionProperty(Mutex::class, 'channels'))->getValue(); + } + + private function publishChannel(string $key, Channel $channel): void + { + $channels = $this->channels(); + $channels[$key] = $channel; + + (new ReflectionProperty(Mutex::class, 'channels'))->setValue(null, $channels); + } +} + +class RejectingMutexChannel extends Channel +{ + public function push(mixed $data, float $timeout = -1): bool + { + return false; + } +} + +class EmptyMutexChannel extends Channel +{ + public int $popCalls = 0; + + public function pop(float $timeout = -1): mixed + { + ++$this->popCalls; + + return parent::pop($timeout); + } +} + +class FailingPopMutexChannel extends Channel +{ + public function pop(float $timeout = -1): mixed + { + return false; + } +} + +class ReplacingMutexChannel extends Channel +{ + public function __construct(private readonly string $key) + { + parent::__construct(1); + } + + public function pop(float $timeout = -1): mixed + { + $value = parent::pop($timeout); + + Mutex::clear($this->key); + Mutex::lock($this->key); + + return $value; + } } From e167ecb730b39dfb18e3117ec9381be61268683e Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:04 +0000 Subject: [PATCH 11/22] Render scheduler runtimes with correct units Use the shared human-duration formatter for schedule completion output instead of rounding seconds and appending a millisecond suffix. Keep ScheduledTaskFinished runtime values in seconds while rendering sub-second work in milliseconds and longer work in the existing concise units. Exercise the real event completion path and assert the formatter output is not relabeled. --- .../src/Commands/ScheduleRunCommand.php | 7 ++- .../Scheduling/ScheduleRunCommandTest.php | 61 +++++++++++++------ 2 files changed, 49 insertions(+), 19 deletions(-) diff --git a/src/console/src/Commands/ScheduleRunCommand.php b/src/console/src/Commands/ScheduleRunCommand.php index f27490e48..d25aea24c 100644 --- a/src/console/src/Commands/ScheduleRunCommand.php +++ b/src/console/src/Commands/ScheduleRunCommand.php @@ -24,6 +24,7 @@ use Hypervel\Support\CarbonImmutable; use Hypervel\Support\Collection; use Hypervel\Support\Facades\Date; +use Hypervel\Support\InteractsWithTime; use Hypervel\Support\Sleep; use RuntimeException; use Symfony\Component\Console\Attribute\AsCommand; @@ -32,6 +33,8 @@ #[AsCommand(name: 'schedule:run')] class ScheduleRunCommand extends Command { + use InteractsWithTime; + /** * The console command signature. */ @@ -457,11 +460,11 @@ protected function runEvent(Event $event): void }; $finishDescription = sprintf( - '%s %s [%s] %sms', + '%s %s [%s] %s', CarbonImmutable::now()->format('Y-m-d H:i:s'), $status, $command, - round(microtime(true) - $start, 2), + $this->runTimeForHumans($start), ); $this->line($finishDescription); diff --git a/tests/Console/Scheduling/ScheduleRunCommandTest.php b/tests/Console/Scheduling/ScheduleRunCommandTest.php index 2419b5696..99d4d2417 100644 --- a/tests/Console/Scheduling/ScheduleRunCommandTest.php +++ b/tests/Console/Scheduling/ScheduleRunCommandTest.php @@ -70,7 +70,7 @@ protected function setUp(): void $this->handler = m::mock(ExceptionHandler::class); } - public function testForegroundCallbackDispatchesStartingAndFinishedEvents() + public function testForegroundCallbackDispatchesStartingAndFinishedEvents(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->andReturn(true); @@ -91,6 +91,20 @@ public function testForegroundCallbackDispatchesStartingAndFinishedEvents() $this->assertIsFloat($this->dispatched[1]->runtime); } + public function testFinishedOutputUsesTheSharedRuntimeFormatterWithoutAppendingUnits(): void + { + $event = new ScheduleRunExitCodeEvent(m::mock(EventMutex::class), 0, 'test:duration'); + $command = $this->makeCommand(command: new ScheduleRunCommandWithFixedRuntime); + $output = $this->captureOutput($command); + + $this->invokeRunEvent($command, $event); + + $rendered = $output->fetch(); + + $this->assertSame(1, substr_count($rendered, '1.50s')); + $this->assertStringNotContainsString('1.50sms', $rendered); + } + public function testForegroundTaskEvaluationsUseFiniteCoroutinesWithSelectedLogContext(): void { ContextRepository::getInstance()->add('trace_id', 'parent-trace'); @@ -219,7 +233,7 @@ public function testTaskLifecycleEventsRemainVisibleToEventFakes(): void EventFacade::assertDispatched(ScheduledTaskFinished::class); } - public function testBackgroundTaskDispatchesAllThreeEvents() + public function testBackgroundTaskDispatchesAllThreeEvents(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->andReturn(true); @@ -231,7 +245,7 @@ public function testBackgroundTaskDispatchesAllThreeEvents() $event->runInBackground(); $command = $this->makeCommand(); - $concurrent = new \Hypervel\Coroutine\Concurrent(10); + $concurrent = new Concurrent(10); (new ReflectionProperty($command, 'concurrent'))->setValue($command, $concurrent); $this->invokeRunEvents($command, [$event]); @@ -247,7 +261,7 @@ public function testBackgroundTaskDispatchesAllThreeEvents() $this->assertSame($event, $this->dispatched[2]->task); } - public function testBackgroundTaskStillDispatchesBackgroundFinishedOnFailure() + public function testBackgroundTaskStillDispatchesBackgroundFinishedOnFailure(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->andReturn(true); @@ -261,7 +275,7 @@ public function testBackgroundTaskStillDispatchesBackgroundFinishedOnFailure() $this->handler->shouldReceive('report')->once()->with($exception); $command = $this->makeCommand(); - $concurrent = new \Hypervel\Coroutine\Concurrent(10); + $concurrent = new Concurrent(10); (new ReflectionProperty($command, 'concurrent'))->setValue($command, $concurrent); $this->invokeRunEvents($command, [$event]); @@ -504,7 +518,7 @@ public function testSkippedNonRepeatableTaskIsOnlyEvaluatedOncePerMinute(): void $this->assertSame($callbackEvent, $this->dispatched[0]->task); } - public function testSkippedTaskEventIsGuardedByRegisteredListeners() + public function testSkippedTaskEventIsGuardedByRegisteredListeners(): void { $eventMutex = m::mock(EventMutex::class); @@ -524,7 +538,7 @@ public function testSkippedTaskEventIsGuardedByRegisteredListeners() $this->assertSame([], $this->dispatched); } - public function testPausedTaskIsSkippedWithoutRunningFilters() + public function testPausedTaskIsSkippedWithoutRunningFilters(): void { $eventMutex = m::mock(EventMutex::class); @@ -547,7 +561,7 @@ public function testPausedTaskIsSkippedWithoutRunningFilters() $this->assertSame($callbackEvent, $this->dispatched[0]->task); } - public function testTaskMarkedEvenWhenPausedRunsWhileSchedulerIsPaused() + public function testTaskMarkedEvenWhenPausedRunsWhileSchedulerIsPaused(): void { $runCount = 0; @@ -629,7 +643,7 @@ public function testNonRepeatableEventOnlyRunsOncePerMinute(): void $this->assertSame(2, $runCount); } - public function testRepeatableEventIsThrottledByLastChecked() + public function testRepeatableEventIsThrottledByLastChecked(): void { $runCount = 0; @@ -772,7 +786,7 @@ public function testRepeatEventsUseTheBackgroundDispatchPath(): void $this->assertSame($event, $backgroundFinished[0]->task); } - public function testConcurrentFinishesUseRunLocalExitCodeForSuccessAndFailureCallbacks() + public function testConcurrentFinishesUseRunLocalExitCodeForSuccessAndFailureCallbacks(): void { $eventMutex = m::mock(EventMutex::class); $event = new Event($eventMutex, 'test:overlap'); @@ -813,7 +827,7 @@ function () use ($event) { $this->assertContains('bravo:failure', $results); } - public function testSignalCleanupReleasesMutexesForRunningOwnedEvents() + public function testSignalCleanupReleasesMutexesForRunningOwnedEvents(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->once()->andReturnTrue(); @@ -833,7 +847,7 @@ public function testSignalCleanupReleasesMutexesForRunningOwnedEvents() $release->invoke($command); } - public function testSignalCleanupDoesNotReleaseMutexesForEventsWithoutOwnedMutexes() + public function testSignalCleanupDoesNotReleaseMutexesForEventsWithoutOwnedMutexes(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldNotReceive('forget'); @@ -850,7 +864,7 @@ public function testSignalCleanupDoesNotReleaseMutexesForEventsWithoutOwnedMutex $release->invoke($command); } - public function testSignalCleanupHonorsReleaseOnTerminationSignalsFlag() + public function testSignalCleanupHonorsReleaseOnTerminationSignalsFlag(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->once()->andReturnTrue(); @@ -900,7 +914,7 @@ public function testRunningEventIsRetainedUntilEveryOverlappingInvocationFinishe $this->assertSame([], (new ReflectionProperty($command, 'runningEvents'))->getValue($command)); } - public function testRunningEventIsForgottenWhenEventThrows() + public function testRunningEventIsForgottenWhenEventThrows(): void { $eventMutex = m::mock(EventMutex::class); $eventMutex->shouldReceive('create')->andReturn(true); @@ -923,9 +937,11 @@ public function testRunningEventIsForgottenWhenEventThrows() /** * Create a ScheduleRunCommand with mocked dependencies. */ - protected function makeCommand(?Cache $cache = null): ScheduleRunCommand - { - $command = new ScheduleRunCommand; + protected function makeCommand( + ?Cache $cache = null, + ?ScheduleRunCommand $command = null, + ): ScheduleRunCommand { + $command ??= new ScheduleRunCommand; $command->setHypervel($this->app); $cache ??= m::mock(Cache::class); @@ -999,6 +1015,17 @@ protected function invokeRunEvent(ScheduleRunCommand $command, Event $event): vo } } +class ScheduleRunCommandWithFixedRuntime extends ScheduleRunCommand +{ + /** + * Given a start time, format the total run time for human readability. + */ + protected function runTimeForHumans(float $startTime, ?float $endTime = null): string + { + return '1.50s'; + } +} + class ScheduleRunExitCodeEvent extends Event { public function __construct(EventMutex $mutex, protected int $result, string $command) From 346487b5920aa03eef083d70c1de331a55f71db7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:14 +0000 Subject: [PATCH 12/22] Make database assertion diagnostics encoding-safe Encode database assertion values with invalid-UTF-8 substitution and partial-output handling so malformed bytes, recursive data, and non-finite numbers cannot replace the intended assertion failure with a JSON type error. Preserve caller formatting flags and unescaped Unicode where Laravel exposes them. Use existence queries for soft-delete constraints so boolean checks can stop at the first matching row. Cover direct constraint output, failure descriptions, additional database details, malformed query results, and representative partial-output cases. --- src/testing/src/Constraints/HasInDatabase.php | 17 +- .../Constraints/NotSoftDeletedInDatabase.php | 9 +- .../src/Constraints/SoftDeletedInDatabase.php | 9 +- .../FoundationInteractsWithDatabaseTest.php | 8 +- .../Constraints/DatabaseConstraintsTest.php | 156 ++++++++++++++++++ 5 files changed, 185 insertions(+), 14 deletions(-) create mode 100644 tests/Testing/Constraints/DatabaseConstraintsTest.php diff --git a/src/testing/src/Constraints/HasInDatabase.php b/src/testing/src/Constraints/HasInDatabase.php index 67bdaa04a..e30576d18 100644 --- a/src/testing/src/Constraints/HasInDatabase.php +++ b/src/testing/src/Constraints/HasInDatabase.php @@ -46,7 +46,7 @@ public function failureDescription($table): string return sprintf( "a row in the table [%s] matches the attributes %s.\n\n%s", $table, - $this->toString(JSON_PRETTY_PRINT), + $this->toString(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE), $this->getAdditionalInfo($table) ); } @@ -67,7 +67,10 @@ protected function getAdditionalInfo($table) )->select(array_keys($this->data))->limit($this->show)->get(); if ($similarResults->isNotEmpty()) { - $description = 'Found similar results: ' . json_encode($similarResults, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + $description = 'Found similar results: ' . json_encode( + $similarResults, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR, + ); } else { $query = $this->database->table($table); @@ -77,7 +80,10 @@ protected function getAdditionalInfo($table) return 'The table is empty'; } - $description = 'Found: ' . json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE); + $description = 'Found: ' . json_encode( + $results, + JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR, + ); } if ($query->count() > $this->show) { @@ -98,6 +104,9 @@ public function toString($options = 0): string $output[$key] = $data instanceof Expression ? $data->getValue($this->database->getQueryGrammar()) : $data; } - return json_encode($output ?? [], (int) $options); + return json_encode( + $output ?? [], + (int) $options | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR, + ); } } diff --git a/src/testing/src/Constraints/NotSoftDeletedInDatabase.php b/src/testing/src/Constraints/NotSoftDeletedInDatabase.php index 3c34b2c3d..305d5a1f7 100644 --- a/src/testing/src/Constraints/NotSoftDeletedInDatabase.php +++ b/src/testing/src/Constraints/NotSoftDeletedInDatabase.php @@ -34,7 +34,7 @@ public function matches($table): bool return $this->database->table($table) ->where($this->data) ->whereNull($this->deletedAtColumn) - ->count() > 0; + ->exists(); } /** @@ -68,7 +68,10 @@ protected function getAdditionalInfo($table) return 'The table is empty'; } - $description = 'Found: ' . json_encode($results, JSON_PRETTY_PRINT); + $description = 'Found: ' . json_encode( + $results, + JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR, + ); if ($query->count() > $this->show) { $description .= sprintf(' and %s others', $query->count() - $this->show); @@ -82,6 +85,6 @@ protected function getAdditionalInfo($table) */ public function toString(): string { - return json_encode($this->data); + return json_encode($this->data, JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR); } } diff --git a/src/testing/src/Constraints/SoftDeletedInDatabase.php b/src/testing/src/Constraints/SoftDeletedInDatabase.php index 47073c86c..4506ce1ea 100644 --- a/src/testing/src/Constraints/SoftDeletedInDatabase.php +++ b/src/testing/src/Constraints/SoftDeletedInDatabase.php @@ -34,7 +34,7 @@ public function matches($table): bool return $this->database->table($table) ->where($this->data) ->whereNotNull($this->deletedAtColumn) - ->count() > 0; + ->exists(); } /** @@ -68,7 +68,10 @@ protected function getAdditionalInfo($table) return 'The table is empty'; } - $description = 'Found: ' . json_encode($results, JSON_PRETTY_PRINT); + $description = 'Found: ' . json_encode( + $results, + JSON_PRETTY_PRINT | JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR, + ); if ($query->count() > $this->show) { $description .= sprintf(' and %s others', $query->count() - $this->show); @@ -82,6 +85,6 @@ protected function getAdditionalInfo($table) */ public function toString(): string { - return json_encode($this->data); + return json_encode($this->data, JSON_INVALID_UTF8_SUBSTITUTE | JSON_PARTIAL_OUTPUT_ON_ERROR); } } diff --git a/tests/Foundation/FoundationInteractsWithDatabaseTest.php b/tests/Foundation/FoundationInteractsWithDatabaseTest.php index 300796630..da9e8b822 100644 --- a/tests/Foundation/FoundationInteractsWithDatabaseTest.php +++ b/tests/Foundation/FoundationInteractsWithDatabaseTest.php @@ -226,7 +226,7 @@ public function testAssertSoftDeletedSupportsArrays(): void $builder->shouldReceive('where')->with(['title' => 'Spark', 'name' => 'Laravel'])->once()->andReturnSelf(); $builder->shouldReceive('where')->with(['title' => 'Forge', 'name' => 'Laravel'])->once()->andReturnSelf(); $builder->shouldReceive('whereNotNull')->with('deleted_at')->twice()->andReturnSelf(); - $builder->shouldReceive('count')->twice()->andReturn(1); + $builder->shouldReceive('exists')->twice()->andReturnTrue(); $this->connection->shouldReceive('table')->with($this->table)->andReturn($builder); @@ -242,7 +242,7 @@ public function testAssertNotSoftDeletedSupportsArrays(): void $builder->shouldReceive('where')->with(['title' => 'Spark', 'name' => 'Laravel'])->once()->andReturnSelf(); $builder->shouldReceive('where')->with(['title' => 'Forge', 'name' => 'Laravel'])->once()->andReturnSelf(); $builder->shouldReceive('whereNull')->with('deleted_at')->twice()->andReturnSelf(); - $builder->shouldReceive('count')->twice()->andReturn(1); + $builder->shouldReceive('exists')->twice()->andReturnTrue(); $this->connection->shouldReceive('table')->with($this->table)->andReturn($builder); @@ -257,7 +257,7 @@ public function testAssertSoftDeletedTableSupportsIterablesWithCustomDeletedAtCo $builder = m::mock(Builder::class); $builder->shouldReceive('where')->with($this->data)->twice()->andReturnSelf(); $builder->shouldReceive('whereNotNull')->with('removed_at')->twice()->andReturnSelf(); - $builder->shouldReceive('count')->twice()->andReturn(1); + $builder->shouldReceive('exists')->twice()->andReturnTrue(); $this->connection->shouldReceive('table')->with($this->table)->andReturn($builder); $this->connection->shouldReceive('table')->with('orders')->andReturn($builder); @@ -270,7 +270,7 @@ public function testAssertNotSoftDeletedTableSupportsIterablesWithCustomDeletedA $builder = m::mock(Builder::class); $builder->shouldReceive('where')->with($this->data)->twice()->andReturnSelf(); $builder->shouldReceive('whereNull')->with('removed_at')->twice()->andReturnSelf(); - $builder->shouldReceive('count')->twice()->andReturn(1); + $builder->shouldReceive('exists')->twice()->andReturnTrue(); $this->connection->shouldReceive('table')->with($this->table)->andReturn($builder); $this->connection->shouldReceive('table')->with('orders')->andReturn($builder); diff --git a/tests/Testing/Constraints/DatabaseConstraintsTest.php b/tests/Testing/Constraints/DatabaseConstraintsTest.php new file mode 100644 index 000000000..fb24564b0 --- /dev/null +++ b/tests/Testing/Constraints/DatabaseConstraintsTest.php @@ -0,0 +1,156 @@ + $invalidUtf8]), + new SoftDeletedInDatabase($database, ['value' => $invalidUtf8], 'deleted_at'), + new NotSoftDeletedInDatabase($database, ['value' => $invalidUtf8], 'deleted_at'), + ]; + + foreach ($constraints as $constraint) { + $description = $constraint->toString(); + + $this->assertStringContainsString('\ufffd', $description); + } + } + + public function testHasInDatabaseFailureDescriptionKeepsUnicodeReadable(): void + { + $constraint = new HasInDatabaseWithoutAdditionalInfo( + m::mock(Connection::class), + ['name' => '世界'], + ); + + $description = $constraint->failureDescription('users'); + + $this->assertStringContainsString('世界', $description); + $this->assertStringNotContainsString('\u4e16\u754c', $description); + } + + public function testHasInDatabaseAssertionFailureIncludesMalformedSimilarResults(): void + { + $invalidUtf8 = "\xB1"; + $builder = m::mock(Builder::class); + $builder->shouldReceive('where')->with(['name' => 'expected'])->once()->andReturnSelf(); + $builder->shouldReceive('exists')->once()->andReturnFalse(); + $builder->shouldReceive('where')->with('name', 'expected')->once()->andReturnSelf(); + $builder->shouldReceive('select')->with(['name'])->once()->andReturnSelf(); + $builder->shouldReceive('limit')->with(3)->once()->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn(collect([['name' => $invalidUtf8]])); + $builder->shouldReceive('count')->once()->andReturn(1); + + $database = m::mock(Connection::class); + $database->shouldReceive('table')->with('users')->twice()->andReturn($builder); + + $failure = null; + + try { + $this->assertThat('users', new HasInDatabase($database, ['name' => 'expected'])); + } catch (ExpectationFailedException $exception) { + $failure = $exception; + } + + $this->assertInstanceOf(ExpectationFailedException::class, $failure); + $this->assertStringContainsString('Found similar results', $failure->getMessage()); + $this->assertStringContainsString("\u{FFFD}", $failure->getMessage()); + } + + public function testHasInDatabaseAdditionalInfoIncludesMalformedFallbackResults(): void + { + $invalidUtf8 = "\xB1"; + $similarBuilder = m::mock(Builder::class); + $similarBuilder->shouldReceive('where')->with('name', 'expected')->once()->andReturnSelf(); + $similarBuilder->shouldReceive('select')->with(['name'])->once()->andReturnSelf(); + $similarBuilder->shouldReceive('limit')->with(3)->once()->andReturnSelf(); + $similarBuilder->shouldReceive('get')->once()->andReturn(collect()); + + $fallbackBuilder = m::mock(Builder::class); + $fallbackBuilder->shouldReceive('select')->with(['name'])->once()->andReturnSelf(); + $fallbackBuilder->shouldReceive('limit')->with(3)->once()->andReturnSelf(); + $fallbackBuilder->shouldReceive('get')->once()->andReturn(collect([['name' => $invalidUtf8]])); + $fallbackBuilder->shouldReceive('count')->once()->andReturn(1); + + $database = m::mock(Connection::class); + $database->shouldReceive('table')->with('users')->twice()->andReturn($similarBuilder, $fallbackBuilder); + + $constraint = new ExposedHasInDatabase($database, ['name' => 'expected']); + $description = $constraint->additionalInfo('users'); + + $this->assertStringContainsString('Found:', $description); + $this->assertStringContainsString("\u{FFFD}", $description); + } + + public function testSoftDeleteAdditionalInfoSubstitutesMalformedResults(): void + { + foreach ([ExposedSoftDeletedInDatabase::class, ExposedNotSoftDeletedInDatabase::class] as $constraintClass) { + $builder = m::mock(Builder::class); + $builder->shouldReceive('limit')->with(3)->once()->andReturnSelf(); + $builder->shouldReceive('get')->once()->andReturn(collect([['name' => "\xB1"]])); + $builder->shouldReceive('count')->once()->andReturn(1); + + $database = m::mock(Connection::class); + $database->shouldReceive('table')->with('users')->once()->andReturn($builder); + + $constraint = new $constraintClass($database, ['name' => 'expected'], 'deleted_at'); + + $this->assertStringContainsString('\ufffd', $constraint->additionalInfo('users')); + } + } + + public function testStringRepresentationUsesPartialOutputForNonFiniteNumbers(): void + { + $constraint = new HasInDatabase(m::mock(Connection::class), ['number' => INF]); + + $this->assertSame('{"number":0}', $constraint->toString()); + } +} + +class HasInDatabaseWithoutAdditionalInfo extends HasInDatabase +{ + protected function getAdditionalInfo($table): string + { + return 'No additional rows'; + } +} + +class ExposedHasInDatabase extends HasInDatabase +{ + public function additionalInfo(string $table): string + { + return $this->getAdditionalInfo($table); + } +} + +class ExposedSoftDeletedInDatabase extends SoftDeletedInDatabase +{ + public function additionalInfo(string $table): string + { + return $this->getAdditionalInfo($table); + } +} + +class ExposedNotSoftDeletedInDatabase extends NotSoftDeletedInDatabase +{ + public function additionalInfo(string $table): string + { + return $this->getAdditionalInfo($table); + } +} From 21fa93140a82dfb7faaff444c5039c5ea77c70cc Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:23 +0000 Subject: [PATCH 13/22] Complete fake HTTP sink writes safely Make fake responses write the entire body to resource and PSR-7 sinks, advancing by each accepted byte count and failing on false or zero progress instead of silently truncating output. Match the real Guzzle transport by rewinding only seekable sinks. Preserve caller ownership of resources and let native stream exceptions surface. Cover partial writes, zero progress, nonblocking resources, nonseekable streams, successful rewinds, rewind failures, and request recording on sink errors. --- src/http/src/Client/PendingRequest.php | 40 +++- tests/Http/HttpClientTest.php | 261 +++++++++++++++++++++++-- 2 files changed, 275 insertions(+), 26 deletions(-) diff --git a/src/http/src/Client/PendingRequest.php b/src/http/src/Client/PendingRequest.php index 53c5af917..5431178b6 100644 --- a/src/http/src/Client/PendingRequest.php +++ b/src/http/src/Client/PendingRequest.php @@ -1651,13 +1651,14 @@ public function buildStubHandler(): Closure * * @param resource|StreamInterface|string $sink */ - protected function sinkStubHandler($sink): Closure + protected function sinkStubHandler(mixed $sink): Closure { - return function ($psrResponse) use ($sink) { + return function (ResponseInterface $psrResponse) use ($sink): ResponseInterface { $body = $psrResponse->getBody()->getContents(); + $length = strlen($body); if (is_string($sink)) { - if (@file_put_contents($sink, $body) !== strlen($body)) { + if (@file_put_contents($sink, $body) !== $length) { throw new RuntimeException("Unable to write response body to sink [{$sink}]."); } @@ -1665,17 +1666,40 @@ protected function sinkStubHandler($sink): Closure } if (is_resource($sink)) { - if (@fwrite($sink, $body) === false) { - throw new RuntimeException('Unable to write to stream'); + $offset = 0; + + while ($offset < $length) { + $written = @fwrite($sink, $offset === 0 ? $body : substr($body, $offset)); + + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write to stream'); + } + + $offset += $written; } - rewind($sink); + if (stream_get_meta_data($sink)['seekable'] && ! @rewind($sink)) { + throw new RuntimeException('Unable to rewind stream'); + } return $psrResponse; } - $sink->write($body); - $sink->rewind(); + $offset = 0; + + while ($offset < $length) { + $written = $sink->write($offset === 0 ? $body : substr($body, $offset)); + + if ($written === 0) { + throw new RuntimeException('Unable to write to stream'); + } + + $offset += $written; + } + + if ($sink->isSeekable()) { + $sink->rewind(); + } return $psrResponse; }; diff --git a/tests/Http/HttpClientTest.php b/tests/Http/HttpClientTest.php index 903d34165..be703cebd 100644 --- a/tests/Http/HttpClientTest.php +++ b/tests/Http/HttpClientTest.php @@ -17,8 +17,10 @@ use GuzzleHttp\Middleware; use GuzzleHttp\Promise\Create; use GuzzleHttp\Promise\PromiseInterface; +use GuzzleHttp\Psr7\NoSeekStream; use GuzzleHttp\Psr7\Request as GuzzleRequest; use GuzzleHttp\Psr7\Response as Psr7Response; +use GuzzleHttp\Psr7\StreamDecoratorTrait; use GuzzleHttp\Psr7\Utils; use GuzzleHttp\TransferStats; use Hypervel\Config\Repository as ConfigRepository; @@ -2937,37 +2939,43 @@ public function testOnErrorCallsClosureOnServerError() $this->assertSame(501, $response->status()); } - public function testSinkToFile() + public function testSinkToFile(): void { $this->factory->fakeSequence()->push('abc123'); - $destination = __DIR__ . '/Fixtures/sunk.txt'; - - if (file_exists($destination)) { - unlink($destination); - } - - $this->factory->withOptions(['sink' => $destination])->get('https://example.com'); + $directory = ParallelTesting::tempDir('HttpClientSink'); + $filesystem = new Filesystem; + $filesystem->deleteDirectory($directory); + $filesystem->ensureDirectoryExists($directory); + $destination = $directory . '/sunk.txt'; - $this->assertFileExists($destination); - $this->assertSame('abc123', file_get_contents($destination)); + try { + $this->factory->withOptions(['sink' => $destination])->get('https://example.com'); - unlink($destination); + $this->assertFileExists($destination); + $this->assertSame('abc123', file_get_contents($destination)); + } finally { + $filesystem->deleteDirectory($directory); + } } - public function testSinkToResource() + public function testSinkToResource(): void { $this->factory->fakeSequence()->push('abc123'); $resource = fopen('php://temp', 'w'); - $this->factory->sink($resource)->get('https://example.com'); + try { + $this->factory->sink($resource)->get('https://example.com'); - $this->assertSame(0, ftell($resource)); - $this->assertSame('abc123', stream_get_contents($resource)); + $this->assertSame(0, ftell($resource)); + $this->assertSame('abc123', stream_get_contents($resource)); + } finally { + fclose($resource); + } } - public function testSinkWhenStubbedByPath() + public function testSinkWhenStubbedByPath(): void { $this->factory->fake([ 'foo.com/*' => ['page' => 'foo'], @@ -2975,9 +2983,13 @@ public function testSinkWhenStubbedByPath() $resource = fopen('php://temp', 'w'); - $this->factory->sink($resource)->get('http://foo.com/test'); + try { + $this->factory->sink($resource)->get('http://foo.com/test'); - $this->assertSame(json_encode(['page' => 'foo']), stream_get_contents($resource)); + $this->assertSame(json_encode(['page' => 'foo']), stream_get_contents($resource)); + } finally { + fclose($resource); + } } public function testSinkToPsrStreamWhenFaked(): void @@ -2992,6 +3004,132 @@ public function testSinkToPsrStreamWhenFaked(): void $this->assertSame('abc123', $stream->getContents()); } + public function testPartialPsrSinkWritesTheCompleteBody(): void + { + $this->factory->fakeSequence()->push('abc123'); + + $inner = Utils::streamFor(''); + $stream = new PrefixWriteStream($inner, 2); + + $this->factory->sink($stream)->get('https://example.com'); + + $this->assertSame(3, $stream->writeCount); + $this->assertSame(0, $stream->tell()); + $this->assertSame('abc123', $stream->getContents()); + } + + public function testZeroProgressPsrSinkFailsAndRecordsTheRequest(): void + { + $this->factory->fakeSequence()->push('abc123'); + $stream = new PrefixWriteStream(Utils::streamFor(''), 0); + + try { + $this->factory->sink($stream)->get('https://example.com'); + $this->fail('RuntimeException was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to write to stream', $exception->getMessage()); + } + + $this->factory->assertSentCount(1); + $this->factory->assertSent(fn (Request $request, ?Response $response): bool => $request->url() === 'https://example.com' + && $response === null); + } + + public function testNonseekableResourceSinkReceivesTheCompleteBody(): void + { + $this->factory->fakeSequence()->push('abc123'); + [$sink, $reader] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + + try { + $this->factory->sink($sink)->get('https://example.com'); + + $this->assertSame('abc123', fread($reader, 6)); + } finally { + fclose($sink); + fclose($reader); + } + } + + public function testNonseekablePsrSinkReceivesTheCompleteBody(): void + { + $this->factory->fakeSequence()->push('abc123'); + $inner = Utils::streamFor(''); + $stream = new NoSeekStream($inner); + + $this->factory->sink($stream)->get('https://example.com'); + + $inner->rewind(); + + $this->assertSame('abc123', $inner->getContents()); + } + + public function testNonblockingResourceSinkFailsAfterWritingAPrefix(): void + { + $this->factory->fakeSequence()->push('abc123'); + $scheme = 'httpclientpartialwrite'; + $this->assertTrue(stream_wrapper_register($scheme, PartialWriteStreamWrapper::class)); + $sink = fopen($scheme . '://sink', 'w'); + + try { + $this->assertTrue(stream_set_blocking($sink, false)); + + try { + $this->factory->sink($sink)->get('https://example.com'); + $this->fail('RuntimeException was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to write to stream', $exception->getMessage()); + } + + $this->assertTrue(is_resource($sink)); + $this->assertSame('abc', PartialWriteStreamWrapper::$contents); + } finally { + fclose($sink); + stream_wrapper_unregister($scheme); + } + + $this->factory->assertSentCount(1); + } + + public function testResourceSinkRewindFailureIsPropagated(): void + { + $this->factory->fakeSequence()->push('abc123'); + $scheme = 'httpclientrewindfailure'; + $this->assertTrue(stream_wrapper_register($scheme, RewindFailureStreamWrapper::class)); + $sink = fopen($scheme . '://sink', 'w'); + + try { + try { + $this->factory->sink($sink)->get('https://example.com'); + $this->fail('RuntimeException was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame('Unable to rewind stream', $exception->getMessage()); + } + + $this->assertTrue(is_resource($sink)); + $this->assertSame('abc123', RewindFailureStreamWrapper::$contents); + } finally { + fclose($sink); + stream_wrapper_unregister($scheme); + } + } + + public function testPsrSinkRewindFailureIsPropagated(): void + { + $this->factory->fakeSequence()->push('abc123'); + $failure = new RuntimeException('Unable to rewind PSR stream'); + $stream = m::mock(StreamInterface::class); + $stream->shouldReceive('write')->once()->with('abc123')->andReturn(6); + $stream->shouldReceive('isSeekable')->once()->andReturnTrue(); + $stream->shouldReceive('rewind')->once()->andThrow($failure); + + try { + $this->factory->sink($stream)->get('https://example.com'); + $this->fail('RuntimeException was not thrown.'); + } catch (RuntimeException $exception) { + $this->assertSame($failure, $exception); + } + } + #[DataProvider('failedSinkProvider')] public function testFailedFakeSinksAreRecordedAndPropagated(string $sinkType): void { @@ -5937,3 +6075,90 @@ protected function buildPreparedBodyHandler(): Closure return parent::buildPreparedBodyHandler(); } } + +class PrefixWriteStream implements StreamInterface +{ + use StreamDecoratorTrait; + + protected StreamInterface $stream; + + public int $writeCount = 0; + + public function __construct(StreamInterface $stream, protected int $prefixLength) + { + $this->stream = $stream; + } + + public function write($string): int + { + ++$this->writeCount; + + return $this->stream->write(substr($string, 0, $this->prefixLength)); + } +} + +class PartialWriteStreamWrapper +{ + public mixed $context; + + public static string $contents = ''; + + public static int $writeCount = 0; + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + static::$contents = ''; + static::$writeCount = 0; + + return true; + } + + public function stream_write(string $data): int + { + ++static::$writeCount; + + if (static::$writeCount > 1) { + return 0; + } + + static::$contents = substr($data, 0, 3); + + return strlen(static::$contents); + } + + public function stream_set_option(int $option, int $argumentOne, ?int $argumentTwo): bool + { + return true; + } +} + +class RewindFailureStreamWrapper +{ + public mixed $context; + + public static string $contents = ''; + + public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool + { + static::$contents = ''; + + return true; + } + + public function stream_write(string $data): int + { + static::$contents .= $data; + + return strlen($data); + } + + public function stream_eof(): bool + { + return false; + } + + public function stream_seek(int $offset, int $whence = SEEK_SET): bool + { + return false; + } +} From dd096e5f785bfe61fc2ea023f9e13699cdfeed47 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:31 +0000 Subject: [PATCH 14/22] Complete log stream writes without replay Write each formatted record through successive unwritten suffixes while holding the optional stream lock for the complete logical attempt. Treat false and zero progress as terminal instead of accepting a truncated record. Keep the single URL reopen retry only when no bytes were written. A positive prefix now fails without replaying from byte zero, which prevents duplicate log content, and caller-owned resources remain open. Separate inode refresh from write retry and cover partial completion, retry boundaries, lock ownership, repeated rotations, caller resources, and the shared rotating-file handler path. --- .../Concerns/PerformsSafeStreamOperations.php | 163 ++++++++++-------- tests/Log/StreamHandlerTest.php | 149 +++++++++++++++- 2 files changed, 239 insertions(+), 73 deletions(-) diff --git a/src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php b/src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php index 3cfcadd22..b38a06618 100644 --- a/src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php +++ b/src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php @@ -42,101 +42,120 @@ protected function closeStreamSafely(): void /** * Write a record while preserving Monolog's single reopen retry. */ - protected function writeStreamSafely(LogRecord $record, bool $retrying = false): void + protected function writeStreamSafely(LogRecord $record): void { - if (! $retrying && $this->hasStreamInodeChanged()) { - $this->closeStreamSafely(); - $this->writeStreamSafely($record, true); + $contents = (string) $record->formatted; + $length = strlen($contents); + $retried = false; + + while (true) { + // Closing clears the saved inode, so reopening establishes one new baseline. + if ($this->hasStreamInodeChanged()) { + $this->closeStreamSafely(); + } - return; - } + if (! is_resource($this->stream)) { + $url = $this->url; - if (! is_resource($this->stream)) { - $url = $this->url; + if ($url === null || $url === '') { + throw new LogicException( + 'Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' + . Utils::getRecordMessageForException($record) + ); + } - if ($url === null || $url === '') { - throw new LogicException( - 'Missing stream url, the stream can not be opened. This may be caused by a premature call to close().' - . Utils::getRecordMessageForException($record) - ); - } + $this->createStreamDirectory($url); + try { + $stream = fopen($url, $this->fileOpenMode); + } catch (Throwable) { + $stream = false; + } - $this->createStreamDirectory($url); - try { - $stream = fopen($url, $this->fileOpenMode); - } catch (Throwable) { - $stream = false; - } + if (! is_resource($stream)) { + $this->stream = null; - if (! is_resource($stream)) { - $this->stream = null; + throw new UnexpectedValueException( + sprintf( + 'The stream or file "%s" could not be opened using mode "%s".', + $url, + $this->fileOpenMode + ) . Utils::getRecordMessageForException($record) + ); + } - throw new UnexpectedValueException( - sprintf( - 'The stream or file "%s" could not be opened using mode "%s".', - $url, - $this->fileOpenMode - ) . Utils::getRecordMessageForException($record) - ); + if ($this->filePermission !== null) { + try { + chmod($url, $this->filePermission); + } catch (Throwable) { + // File permissions are best-effort, matching Monolog. + } + } + + stream_set_chunk_size($stream, $this->streamChunkSize); + $this->stream = $stream; + $this->safeInodeUrl = $this->getStreamInode(); } - if ($this->filePermission !== null) { + $stream = $this->stream; + $locked = false; + + if ($this->useLocking) { try { - chmod($url, $this->filePermission); + $locked = flock($stream, LOCK_EX); } catch (Throwable) { - // File permissions are best-effort, matching Monolog. + // Locking is best-effort, matching Monolog. } } - stream_set_chunk_size($stream, $this->streamChunkSize); - $this->stream = $stream; - $this->safeInodeUrl = $this->getStreamInode(); - } - - $stream = $this->stream; - $locked = false; + $offset = 0; - if ($this->useLocking) { try { - $locked = flock($stream, LOCK_EX); - } catch (Throwable) { - // Locking is best-effort, matching Monolog. + while ($offset < $length) { + try { + $written = fwrite( + $stream, + $offset === 0 ? $contents : substr($contents, $offset), + ); + } catch (Throwable) { + $written = false; + } + + if ($written === false || $written === 0) { + break; + } + + $offset += $written; + } + } finally { + if ($locked) { + try { + flock($stream, LOCK_UN); + } catch (Throwable) { + // Unlocking is best-effort, matching Monolog. + } + } } - } - try { - try { - $written = fwrite($stream, (string) $record->formatted); - } catch (Throwable) { - $written = false; + if ($offset === $length) { + return; } - } finally { - if ($locked) { - try { - flock($stream, LOCK_UN); - } catch (Throwable) { - // Unlocking is best-effort, matching Monolog. - } - } - } - if ($written !== false) { - return; - } + // Replaying a positive prefix would duplicate part of the log record. + if ($offset === 0 && ! $retried && $this->url !== null && $this->url !== 'php://memory') { + $retried = true; + $this->closeStreamSafely(); - if (! $retrying && $this->url !== null && $this->url !== 'php://memory') { - $this->closeStreamSafely(); - $this->writeStreamSafely($record, true); + continue; + } - return; + throw new UnexpectedValueException( + sprintf( + 'Writing to the log %s failed%s.', + $this->url === null ? 'stream' : sprintf('file "%s"', $this->url), + $offset === 0 ? '' : sprintf(' after %d of %d bytes', $offset, $length), + ) . Utils::getRecordMessageForException($record) + ); } - - throw new UnexpectedValueException( - sprintf( - 'Writing to the log %s failed.', - $this->url === null ? 'stream' : sprintf('file "%s"', $this->url) - ) . Utils::getRecordMessageForException($record) - ); } private function createStreamDirectory(string $url): void diff --git a/tests/Log/StreamHandlerTest.php b/tests/Log/StreamHandlerTest.php index 51514b546..5dceb8761 100644 --- a/tests/Log/StreamHandlerTest.php +++ b/tests/Log/StreamHandlerTest.php @@ -9,6 +9,7 @@ use Hypervel\Log\Handlers\RotatingFileHandler; use Hypervel\Log\Handlers\StreamHandler; use Hypervel\Tests\TestCase; +use Monolog\Formatter\LineFormatter; use Monolog\Logger; use UnexpectedValueException; @@ -116,6 +117,102 @@ public function testFailedWriteReopensOnceAndRetries(): void $this->assertStringContainsString('retry me', LogStreamWrapper::$written); } + public function testPartialWritesCompleteTheFormattedRecordExactlyOnce(): void + { + LogStreamWrapper::$maximumWriteLength = 3; + $handler = new StreamHandler(self::STREAM_SCHEME . '://partial'); + $handler->setFormatter(new LineFormatter('%message%')); + + (new Logger('test', [$handler]))->info('complete record'); + + $this->assertGreaterThan(1, LogStreamWrapper::$writeCount); + $this->assertSame('complete record', LogStreamWrapper::$written); + } + + public function testFailureAfterAPositivePrefixDoesNotRetryOrDuplicateThePrefix(): void + { + LogStreamWrapper::$failAfterBytes = 6; + $handler = new StreamHandler(self::STREAM_SCHEME . '://prefix-failure'); + $handler->setFormatter(new LineFormatter('%message%')); + + try { + (new Logger('test', [$handler]))->info('prefix failure'); + $this->fail('Expected the log write to fail.'); + } catch (UnexpectedValueException $exception) { + $this->assertStringContainsString( + 'Writing to the log file "' . self::STREAM_SCHEME . '://prefix-failure" failed after 6 of 14 bytes.', + $exception->getMessage(), + ); + } + + $this->assertSame(1, LogStreamWrapper::$openCount); + $this->assertSame('prefix', LogStreamWrapper::$written); + } + + public function testLockIsHeldOnceAcrossPartialWrites(): void + { + LogStreamWrapper::$maximumWriteLength = 2; + $handler = new StreamHandler(self::STREAM_SCHEME . '://locked', useLocking: true); + $handler->setFormatter(new LineFormatter('%message%')); + + (new Logger('test', [$handler]))->info('locked record'); + + $this->assertSame([LOCK_EX, LOCK_UN], LogStreamWrapper::$lockOperations); + $this->assertSame('locked record', LogStreamWrapper::$written); + } + + public function testInodeRefreshDoesNotConsumeTheWriteFailureRetry(): void + { + $url = self::STREAM_SCHEME . '://inode-refresh'; + $handler = new StreamHandler($url); + $handler->setFormatter(new LineFormatter('%message%')); + $logger = new Logger('test', [$handler]); + + $logger->info('first'); + + LogStreamWrapper::$inode = 2; + clearstatcache(true, $url); + $logger->info('second'); + + LogStreamWrapper::$inode = 3; + LogStreamWrapper::$zeroWritesRemaining = 1; + clearstatcache(true, $url); + $logger->info('third'); + + $this->assertSame(4, LogStreamWrapper::$openCount); + $this->assertSame(1, substr_count(LogStreamWrapper::$written, 'first')); + $this->assertSame(1, substr_count(LogStreamWrapper::$written, 'second')); + $this->assertSame(1, substr_count(LogStreamWrapper::$written, 'third')); + } + + public function testCallerOwnedNonblockingResourceFailsWithoutClosingOrReplaying(): void + { + LogStreamWrapper::$failAfterBytes = 6; + $resource = fopen(self::STREAM_SCHEME . '://caller-resource', 'w'); + $this->assertTrue(stream_set_blocking($resource, false)); + $handler = new StreamHandler($resource); + $handler->setFormatter(new LineFormatter('%message%')); + + try { + try { + (new Logger('test', [$handler]))->info('caller failure'); + $this->fail('Expected the log write to fail.'); + } catch (UnexpectedValueException $exception) { + $this->assertStringContainsString( + 'Writing to the log stream failed after 6 of 14 bytes.', + $exception->getMessage(), + ); + } + + $handler->close(); + + $this->assertTrue(is_resource($resource)); + $this->assertSame('caller', LogStreamWrapper::$written); + } finally { + fclose($resource); + } + } + public function testDirectoryCreationFailureThrowsDeterministically(): void { $file = tempnam(sys_get_temp_dir(), 'hypervel-log-parent-'); @@ -182,12 +279,25 @@ class LogStreamWrapper public static bool $failFirstWrite = false; + public static ?int $maximumWriteLength = null; + + public static ?int $failAfterBytes = null; + + public static int $zeroWritesRemaining = 0; + + public static int $inode = 1; + public static int $openCount = 0; public static int $writeCount = 0; public static string $written = ''; + /** + * @var list + */ + public static array $lockOperations = []; + public mixed $context; public static function reset(): void @@ -197,9 +307,14 @@ public static function reset(): void self::$failOpenAfterYield = false; self::$failOpen = false; self::$failFirstWrite = false; + self::$maximumWriteLength = null; + self::$failAfterBytes = null; + self::$zeroWritesRemaining = 0; + self::$inode = 1; self::$openCount = 0; self::$writeCount = 0; self::$written = ''; + self::$lockOperations = []; } public function stream_open(string $path, string $mode, int $options, ?string &$openedPath): bool @@ -228,11 +343,43 @@ public function stream_write(string $data): int|false return false; } + if (self::$zeroWritesRemaining > 0) { + --self::$zeroWritesRemaining; + + return 0; + } + + if (self::$failAfterBytes !== null) { + $remaining = self::$failAfterBytes - strlen(self::$written); + + if ($remaining <= 0) { + return 0; + } + + $data = substr($data, 0, $remaining); + } + + if (self::$maximumWriteLength !== null) { + $data = substr($data, 0, self::$maximumWriteLength); + } + self::$written .= $data; return strlen($data); } + public function stream_lock(int $operation): bool + { + self::$lockOperations[] = $operation; + + return true; + } + + public function stream_set_option(int $option, int $argumentOne, ?int $argumentTwo): bool + { + return true; + } + public function stream_close(): void { } @@ -261,7 +408,7 @@ private function stat(): array { return [ 'dev' => 0, - 'ino' => 1, + 'ino' => self::$inode, 'mode' => 0100666, 'nlink' => 1, 'uid' => 0, From 58c8810f2b7d18444925b80986af121e4ab03438 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:39 +0000 Subject: [PATCH 15/22] Remove premature Boost installation guidance Remove installation commands and feature claims for the reserved Boost package while it has no provider, installer, commands, or tools to deliver them. Leave the package metadata truthful and keep the product work in the repository TODO. The installation guide can be restored once the documented workflow exists and can be verified end to end. --- docs/todo.md | 3 +-- src/boost/README.md | 2 -- src/boost/composer.json | 2 +- src/docs/installation.md | 38 -------------------------------------- 4 files changed, 2 insertions(+), 43 deletions(-) diff --git a/docs/todo.md b/docs/todo.md index 8b9fbb264..3f5f03920 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -12,7 +12,7 @@ ## Boost -- Implement Hypervel Boost's installation flow and revisit the Boost section of `installation.md` once the implementation is complete. The current docs describe the intended `composer require hypervel/boost --dev` and `php artisan boost:install` workflow, but `src/boost` currently contains the documentation package only. Correct fix: add the interactive installer command and supporting tools, consume the existing Wayfinder and Horizon skill templates, then update the installation docs for any differences from Laravel Boost. +- Implement Hypervel Boost's interactive installer and supporting AI tools, consuming the existing Wayfinder and Horizon skill templates where appropriate. Once the package ships working functionality, add and verify its installation documentation. ## Wayfinder @@ -60,7 +60,6 @@ ## Redis -- Audit transformed Redis command wrapper return types against serializer-configured phpredis connections. For example, `RedisConnection::callGet(): ?string` can receive unserialized non-string values from phpredis when a serializer is enabled under `strict_types`; check the other `call*` wrappers for the same mismatch and update signatures/tests to match real client behavior. - Revisit the rate limiter's portable fixed-window Lua script once native bounded increment-with-expiry support is mature across the supported Redis-compatible ecosystem. Redis 8.8's `INCREX` can atomically reject increments above an upper bound and set expiry only for a new window, but Redis 8.6 and Valkey 9 do not provide it, [Valkey #3253](https://github.com/valkey-io/valkey/pull/3253) is still an open related proposal rather than equivalent `INCREX` support, and phpredis 6.3 exposes no typed `INCREX` method (while `rawCommand()` bypasses key prefixing and has different Redis Cluster routing semantics). Re-benchmark and switch only when Redis and Valkey expose equivalent semantics and phpredis has prefix-aware, cluster-aware client support; keep the corresponding focused `@TODO` beside the Lua script until then. ## Collections diff --git a/src/boost/README.md b/src/boost/README.md index e85a5aeb9..5a1bb31c5 100644 --- a/src/boost/README.md +++ b/src/boost/README.md @@ -2,5 +2,3 @@ Boost for Hypervel === [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/hypervel/boost) - -Documentation: https://hypervel.org/docs/installation#hypervel-and-ai diff --git a/src/boost/composer.json b/src/boost/composer.json index b6546d79b..7d8f891f9 100644 --- a/src/boost/composer.json +++ b/src/boost/composer.json @@ -1,7 +1,7 @@ { "name": "hypervel/boost", "type": "library", - "description": "AI agent tools and guidelines for Hypervel applications.", + "description": "Reserved package for future Hypervel AI tooling.", "license": "MIT", "keywords": [ "php", diff --git a/src/docs/installation.md b/src/docs/installation.md index b31fc4655..6487ac088 100644 --- a/src/docs/installation.md +++ b/src/docs/installation.md @@ -10,8 +10,6 @@ - [Databases and Migrations](#databases-and-migrations) - [Directory Configuration](#directory-configuration) - [IDE Support](#ide-support) -- [Hypervel and AI](#hypervel-and-ai) - - [Installing Hypervel Boost](#installing-hypervel-boost) - [Next Steps](#next-steps) - [Hypervel the Full Stack Framework](#hypervel-the-fullstack-framework) - [Hypervel the API Backend](#hypervel-the-api-backend) @@ -207,42 +205,6 @@ For extensive and robust PHP support, take a look at [PhpStorm](https://www.jetb Hypervel's application skeleton includes the `swoole/ide-helper` package in development so IDEs can understand Swoole classes, constants, and functions. - -## Hypervel and AI - -[Hypervel Boost](https://github.com/hypervel/boost) is a powerful tool that bridges the gap between AI coding agents and Hypervel applications. Boost provides AI agents with Hypervel-specific context, tools, and guidelines so they can generate more accurate, version-specific code that follows Hypervel conventions. - -When you install Boost in your Hypervel application, AI agents gain access to specialized tools including the ability to know which packages you are using, query your database, search the Hypervel documentation, read browser logs, generate tests, and execute code via Tinker. - -In addition, Boost gives AI agents access to vectorized Hypervel ecosystem documentation, specific to your installed package versions. This means agents can provide guidance targeted to the exact versions your project uses. - -Boost also includes Hypervel-maintained AI guidelines that help agents to follow framework conventions, write appropriate tests, and avoid common pitfalls when generating Hypervel code. - - -### Installing Hypervel Boost - -Boost can be installed in Hypervel applications running PHP 8.4 or higher. To get started, install Boost as a development dependency: - -```shell -composer require hypervel/boost --dev -``` - -Once installed, run the interactive installer: - -```shell -php artisan boost:install -``` - -The installer will auto-detect your IDE and AI agents, allowing you to opt into the features that make sense for your project. Boost respects existing project conventions and does not force opinionated style rules by default. - -> [!NOTE] -> To learn more about Boost, check out the [Hypervel Boost source on GitHub](https://github.com/hypervel/boost). - - -#### Adding Custom AI Guidelines - -To augment Hypervel Boost with your own custom AI guidelines, add `.blade.php` or `.md` files to your application's `.ai/guidelines/*` directory. These files will automatically be included with Hypervel Boost's guidelines when you run `boost:install`. - ## Next Steps From d7152025cea12e1af1e185b06229177fa2abfab7 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Fri, 21 Aug 2026 23:24:52 +0000 Subject: [PATCH 16/22] Finalize the audit correctness implementation plan Update the plan to match the implemented Redis configuration boundary, finite Dispatcher preparation, bounded cache maintenance, exact expiry handling, mutex reclamation, native Swoole test isolation, and final verification requirements. Remove superseded designs and retain the load-bearing behavior, edge cases, and test expectations needed to understand and maintain the completed changes. --- ...-correctness-and-worker-lifetime-bounds.md | 354 ++++++++++++++---- 1 file changed, 290 insertions(+), 64 deletions(-) diff --git a/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md index 2cfec240b..1bbd966bf 100644 --- a/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md +++ b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md @@ -2,7 +2,7 @@ ## Objective -Correct the confirmed non-watcher findings from the 0.4 audit without changing Laravel-compatible APIs, adding material hot-path machinery, or documenting behavior the repository does not ship. The finished code should have truthful Redis result contracts, reentrant Redis command events, connection-owned database tracking, correct secondary Swoole settings, bounded event lookup caches, accurate scheduler timing, robust diagnostics and stream writes, and no premature Boost installation instructions. +Correct the confirmed non-watcher findings from the 0.4 audit without changing Laravel-compatible APIs, adding material hot-path machinery, or documenting behavior the repository does not ship. The finished code should have truthful Redis result contracts, reentrant Redis command events, connection-owned database tracking, correct secondary Swoole settings, event preparation keyed only by finite registrations, bounded-work reclamation of expired worker-local cache state, whole-second deadlines that never shorten requested lifetimes, self-reclaiming mutex channels, accurate scheduler timing, robust diagnostics and stream writes, and no premature Boost installation instructions. This plan targets branch `fix/audit-correctness-follow-up` from 0.4 commit `88190e640498`. Research references are the local Laravel checkout at `a659f095965b`, phpredis at `777f7377674a`, Swoole at `8e8c49915ca5`, and the installed PHP 8.4.23 / phpredis 6.3.0 / Swoole 6.2.2 runtime. @@ -14,7 +14,12 @@ This plan targets branch `fix/audit-correctness-follow-up` from 0.4 commit `8819 - `Swoole\Server::set()` masks the primary port's recoverable `false` result by returning `true`. Hypervel will not duplicate Swoole validation or apply primary settings twice. The upstream handoff is `_tmp/swoole/pr-ideas/server-port-set-return-contract.md`. - No Redis `EXISTS` probe will be added. With a serializer, a stored `false` and a missing key are deliberately indistinguishable through phpredis `GET`; a second command would also be racy. - Redis listener reentrancy will reuse the connection that owns the event. It will not release the wrapper before dispatch, add a recursion guard, reserve a second pool slot, or alter event payloads. As in Laravel, nested commands emit their own events; an unconditional same-command listener therefore recurses instead of timing out on an accidental second pool checkout. -- Event cache bounds are private implementation constants. There will be no configuration, timer, request cleanup, LRU, FIFO queue, or hit-time mutation. +- Event listener and observer preparation will be retained only under finite registration keys. Dispatched runtime names will never enter Dispatcher state; there will be no cap, eviction policy, timer, request cleanup, wildcard index, or class-name memo. +- `Hypervel\RateLimiter\WorkerArrayStore` is not a defect. Its tests-only scope, per-worker isolation, and retention of expired untouched keys are explicit in shipped config and documentation; Reverb owns and clears its per-connection keys. It will not gain production-oriented pruning machinery. +- Eloquent mutator keys remain semantic model/schema identifiers. Arbitrary growth requires arbitrary request strings to be used as model attributes, while replacing one `StrCache` call would leave the parallel negative mutator maps unchanged. No Eloquent metadata redesign will be added for application code that passes unfiltered input to an unguarded model. +- `Hypervel\Cache\WorkerArrayStore` deliberately retains live and forever values across requests, but abandoned TTL-expired values and locks are dead state. Reclamation will perform fixed work per explicitly requested record write, never work proportional to existing store size. There will be no whole-map sweep, cadence, cap, eviction, expiry index, timer, coroutine, lifecycle hook, or new configuration. +- A future instant stored with whole-second precision must round upward so it never occurs before the requested duration or absolute deadline. Immediate, past, and zero-duration values retain their current floor. This is one shared time conversion plus direct storage-boundary uses, not a clock service or per-package timing mechanism. +- Mutex release will reclaim only a quiescent channel that is still the exact channel published for the key. It will not add owner tracking, reference counts, a wrapper entry type, producer introspection, polling, or cleanup timers. - Stream completion loops stop on `false` or zero progress. They will not poll readiness, sleep, spin, or add asynchronous buffering. - Laravel port structure remains recognizable: inline JSON flags stay inline, the HTTP fake keeps its local sink branches, and the scheduler reuses the existing support trait. No shared codec, sink writer service, or clock API will be introduced. - The only removed public-looking method is `RedisConnection::setDatabase()`: it is an undocumented Hypervel-only bookkeeping hook, absent from Laravel, all contracts, and the generated facade, and is replaced by automatic tracking at the command execution boundary. No Laravel API is removed or narrowed. @@ -29,7 +34,12 @@ This plan targets branch `fix/audit-correctness-follow-up` from 0.4 commit `8819 | Redis selected-database bookkeeping | Fix at `RedisConnection::__call()`; remove proxy-only setter plumbing | | Empty Boost package advertised as working tooling | Gate installation docs; leave product implementation in TODO | | Secondary Swoole configuration | Explicitly set every secondary and check the real false sentinel | -| Unbounded dispatcher caches | Remove redundant caches and cap the three useful caches | +| Unbounded dispatcher caches | Replace runtime-name caches with lazy preparation keyed only by finite registrations | +| Rate-limiter `WorkerArrayStore` retention | Intentional documented tests-only behavior; no change | +| Eloquent mutator caches with arbitrary attribute input | Incorrect application usage and no coherent narrow fix; no change | +| Cache array-store expiry | Prevent expired-value revival and reclaim abandoned expired worker-local values/locks with bounded work | +| Whole-second future deadlines | Round up future cache, queue, credential, and client deadlines; preserve immediate/past behavior and precise monotonic Worker time | +| Coroutine mutex channel retention | Reclaim quiescent channels after successful release without disturbing waiter handoff | | Scheduler seconds labelled as milliseconds | Reuse `InteractsWithTime` | | Database assertion JSON failures | Use the two deliberate tolerant-encoding flags and restore Laravel's Unicode option | | Fake HTTP sink writes/rewinds | Complete partial writes and rewind only seekable sinks | @@ -68,9 +78,26 @@ return array_map( ); ``` -Do not cast native values to satisfy annotations. Atomic result normalization remains separate from MULTI/PIPELINE: queueing mode must still reshape arguments but return the native `Redis`/`RedisCluster` queue object until `exec()`. +Do not cast native values to satisfy annotations. Atomic result normalization remains separate from MULTI/PIPELINE: queueing mode must still reshape arguments but return the native `Redis`/`RedisCluster` queue object until `exec()`. Redis Cluster supports MULTI queueing only; it does not support PIPELINE. -Update the corresponding class-level `@method` annotations on `RedisConnection` (`set`, `mget`, `zinterstore`, and `zunionstore`; the other affected annotations are already broad enough), then regenerate `src/support/src/Facades/Redis.php` with the repository facade tool after source signatures settle. Inspect the generated diff so `get`, `set`, `mget`, `hmget`, `zadd`, both score-range methods, and both store methods advertise the corrected unions. Remove the completed transformed-return-type audit item from `docs/todo.md`. +Correct the full class-level `@method` queue-object metadata on `RedisConnection`, then regenerate `src/support/src/Facades/Redis.php` and inspect/lint the result. The metadata tracks the pinned current phpredis source rather than only the installed 6.3.0 release, matching the existing tag block's inclusion of newer commands. Of 204 distinct shared tags that explicitly name `Redis`, 194 exist on the pinned Cluster surface and 184 already declare `RedisCluster`. Add that queue object to those methods plus six declarations proven from the C implementation: `brpoplpush`, `getdel`, `ping`, `bzpopmax`, `bzpopmin`, and `msetex`. The first five use `CLUSTER_PROCESS_CMD` / `CLUSTER_PROCESS_KW_CMD` or an equivalent explicit MULTI branch, and current-source `msetex` uses the same macro; all enqueue the response and return `getThis()`. Standalone arginfo already carries its fluent `Redis` return for the same methods. + +Keep the deliberate exclusions precise: + +- `auth`, `debug`, `failover`, `function`, `migrate`, `move`, `replicaof`, `select`, `sunsubscribe`, and `swapdb` have no pinned-source `RedisCluster` method; +- `exec` ends queueing and returns the executed result array rather than a queue object; +- `unsubscribe` and `punsubscribe` send directly through the active subscription slot and are not MULTI commands; +- Cluster `unwatch()` appends a boolean response in MULTI but falls through with a `null` method result, so its truthful shared type is `null|bool|Redis`, not a `RedisCluster` union. + +Cluster `unwatch()` also pollutes the later `exec()` response with that appended boolean, and its failure macro appends `false` but continues iterating watched nodes. Do not add a Hypervel workaround. Record that native defect in `_tmp/phpredis/pr-ideas/redis-cluster-arginfo-and-unwatch-multi.md` together with the wrong declarations for `bzpopmax`, `bzpopmin`, and `msetex`, the imprecise `mixed` declarations for `brpoplpush`, `getdel`, and `ping`, and the four related wrong-class declarations corrected upstream after 6.3.0. + +For the transformed methods in this audit, `get` and `set` remain `mixed`; `mget`, `hmget`, `zadd`, both score-range methods, and both store methods include both native queue objects. Type score-range bounds as `float|int|string`, matching accepted numeric and infinity bounds. Keep `false`, not the overly broad `bool` from the Cluster stub, in the reverse-score-range result: Redis has no successful boolean reply for that command. Remove the completed Redis result-metadata TODOs after the source and generated facade are correct. + +Keep case-insensitive duplicate command tags identical. Both lowercase and camelCase score-range declarations must use the same widened bounds. Update the exact `KEYS` and `LINSERT` metadata assertions to include the Cluster queue object rather than weakening those guards. + +Keep the transformed score-range wrapper parameters aligned with those declarations: `callZrangebyscore()` and `callZrevrangebyscore()` accept `float|int|string` bounds, not `mixed`. Mirror the existing descriptions from the lowercase transformed command declarations onto their camelCase duplicates so case aliases generate equally useful facade documentation. + +There is no configured live Redis Cluster in the copied `.env`, so Cluster metadata verification rests on installed 6.3.0 reflection plus the newer pinned stub and C source. Standalone Redis integration still runs in the full suite. ### Tests @@ -155,10 +182,12 @@ if ($name === 'select' && $result === true && array_key_exists(0, $arguments)) { Keep this beside the existing WATCH/EXEC state tracking. It covers proxy calls, calls made through `withConnection()`, and calls made through a pinned proxy. Its sole purpose is reconnect safety when the current native client is no longer available to inspect; release-time cleanup does not depend on inferred wrapper state. +Normalize the standalone `database` value to `int` in `RedisConfig::connectionConfig()` after URL parsing and standalone defaults are applied. `ConfigurationUrlParser` correctly returns URL path components as strings for its general database contract, while Redis accepts only numeric database indexes. This owning connection-config boundary must convert both URL-derived values and Laravel-style string environment values once so construction, reconnect tracking, and strict release comparisons all receive the declared integer shape. Cluster configurations continue to omit `database`. + The supported MULTI/PIPELINE API deliberately returns or passes the raw phpredis object, so queued commands do not cross `RedisConnection::__call()`. Preserve that Laravel-compatible API and observe native state at the two lifecycle boundaries that need it: - in `PhpRedisConnection::reconnect()`, before replacing an existing connected standalone `Redis` instance, read its `getDBNum()` into `$database`; this preserves the database the old native generation actually entered, including a raw queued `SELECT` that executed, while an aborted/discarded queue reports the unchanged database; -- in `RedisConnection::release()`, retain the existing queueing/WATCH detection, CRITICAL diagnostic, and discard branch. Immediately before database restoration, if a standalone `Redis` client is disconnected, mark the wrapper invalid without calling `getDBNum()` or `select()`; otherwise compare its connected `getDBNum()` with the configured database and restore only when they differ. The normal `finally` clears tracked database/WATCH state and returns the invalid wrapper object to the pool, where the next borrower makes `check()` reject and reconnect its native generation directly to the configured database. +- in `RedisConnection::release()`, retain the existing queueing/WATCH detection, CRITICAL diagnostic, and discard branch. Immediately before database restoration, if a standalone `Redis` client is disconnected, mark the wrapper invalid without calling `getDBNum()` or `select()`; otherwise compare its connected `getDBNum()` with the configured database and restore only when they differ. Require that restoration to return exactly `true`. A refused or thrown restore marks the wrapper invalid and closes that native generation before the normal `finally` clears tracked database/WATCH state and returns the wrapper to the pool. Closing is required because an invalidated but still-connected client would otherwise have its rejected database replayed by reconnect's `getDBNum()` inheritance. Resolve the reconnect target before constructing the replacement client: @@ -168,13 +197,15 @@ $database = $this->connection instanceof Redis && $this->connection->isConnected : ($this->database ?? $this->config['database']); ``` -Use that resolved value for the new client's `select()` without converting a raw queued intention into state. The `isConnected()` guard is required: phpredis's `getDBNum()` goes through `redis_sock_get_connected()`, whose connection accessor can reopen a disconnected socket, so reconnect must not trigger an unnecessary first reconnection merely to inspect it. Keep the existing tracked atomic value available as the fallback for an explicitly closed/null or disconnected native client. +Use that resolved value for the new client's `select()` without converting a raw queued intention into state. When the value is nonzero, require `select()` to return exactly `true`; otherwise throw a `ConnectionException` naming the database and connection before publishing the replacement client or marking the wrapper reconnected. The `isConnected()` guard is required: phpredis's `getDBNum()` goes through `redis_sock_get_connected()`, whose connection accessor can reopen a disconnected socket, so reconnect must not trigger an unnecessary first reconnection merely to inspect it. Keep the existing tracked atomic value available as the fallback for an explicitly closed/null or disconnected native client. + +Persist the resolved database when publishing the replacement client. The tracked value must describe the live native generation so a later reconnect from a disconnected replacement does not fall back to the configured database and silently switch databases. -The release guard belongs after the queueing/WATCH branch. Native `getMode()` reads the stored `RedisSock` through `redis_sock_get_instance()` without opening a socket, so a disconnected wrapper that was abandoned in MULTI/PIPELINE or WATCH state must still take the existing logged discard path. Only `getDBNum()` reaches the reconnecting accessor and therefore needs the connectedness guard. On an otherwise atomic disconnected wrapper, returning the invalid wrapper object through the normal `finally` is intentional: the next checkout makes `getActiveConnection()` replace its native generation, and the cleared database fallback selects `config['database']`. Do not reconnect during cleanup or return a valid-looking client whose phpredis `dbNumber` can be replayed for the next borrower. +The release guard belongs after the queueing/WATCH branch. Native `getMode()` reads the stored `RedisSock` through `redis_sock_get_instance()` without opening a socket, so a disconnected wrapper that was abandoned in MULTI/PIPELINE or WATCH state must still take the existing logged discard path. Only `getDBNum()` reaches the reconnecting accessor and therefore needs the connectedness guard. On an otherwise atomic disconnected wrapper, returning the invalid wrapper object through the normal `finally` is intentional: the next checkout makes `getActiveConnection()` replace its native generation, and the cleared database fallback selects `config['database']`. A connected generation whose restore failed must be closed before that return so reconnect cannot read its stale database back. Do not reconnect during cleanup. On the connected-client paths above, `getDBNum()` returns phpredis's local `redis_sock->dbNumber` (measured locally at approximately 0.022 microseconds per call) and sends no Redis command. phpredis nevertheless declares `getDBNum(): int` while its disconnected C branch executes `RETURN_FALSE`; the explicit connectedness guards make that false sentinel unreachable and avoid another narrow static-analysis workaround. The release read is therefore simpler and more correct than a flag spread across exposure/reconnect/close/release paths, while adding no work to command execution. The `instanceof Redis` guard directly excludes Cluster, whose `getDBNum()` returns `false`, and safely handles a wrapper whose native client was explicitly closed. Abandoned queues retain the existing safer behavior of discarding the entire native generation before this cleanup path. -The reconnect observation must happen before `PhpRedisConnection::reconnect()` chooses `$this->database ?? $this->config['database']` and replaces the old client. This covers native MULTI/PIPELINE chaining without speculative queue state. If the native client is already null, the last successfully applied atomic wrapper `SELECT` remains the fallback. `release()` then restores the configured database before returning a healthy wrapper to the pool. Do not add a database-dirty flag, `client()` special case, close-time synchronization, Cluster branch, or database-state lookup to ordinary command execution; release cleanup owns the connected client's local read. +The reconnect observation must happen before `PhpRedisConnection::reconnect()` chooses `$this->database ?? $this->config['database']` and replaces the old client. This covers native MULTI/PIPELINE chaining without speculative queue state. If the native client is already null, the last successfully applied atomic wrapper `SELECT` remains the fallback. `release()` then either restores the configured database or closes the refused native generation before returning its invalid wrapper to the pool. Do not add a database-dirty flag, `client()` special case, close-time synchronization, Cluster branch, or database-state lookup to ordinary command execution; release cleanup owns the connected client's local read. Remove all proxy-only bookkeeping: @@ -192,14 +223,19 @@ Rewrite setter-based cases in `tests/Redis/RedisConnectionTest.php`, `RedisProxy Cover, with a one-slot pool or exact native-client doubles: +- URL-derived and explicit string standalone database indexes leave `RedisConfig::connectionConfig()` as integers, including database zero, before a pool or connection consumes them; + - ordinary proxy `select` remains pinned and release restores the configured database; - `withConnection(fn (RedisConnection $connection) => $connection->select(...))` restores on release; - `withPinnedConnection(fn () => $proxy->select(...))` restores on release; - a queued wrapper `select` is not recorded as applied before `exec()`, and release restores from the actual post-transaction state; - raw callback-form and chaining-form MULTI/PIPELINE `select` calls restore the actual post-`exec()` database before release, including an aborted `exec()` and `discard()`; - reconnect after a raw MULTI/PIPELINE selection preserves the old native client's actual database, not a queued intention; +- after inheriting an old native client's actual database, a second reconnect from the now-disconnected replacement still selects that inherited database; - reconnect does not call `getDBNum()` or reopen an old native client when `isConnected()` is false, and instead uses the last applied atomic selection/configured fallback; +- reconnect aborts before publishing a new client when its nonzero database selection returns `false`, while database zero performs no native `SELECT`; - release preserves queueing/WATCH detection, then marks an otherwise atomic disconnected standalone client invalid without `getDBNum()` or an implicit socket reopen; the subsequent borrower reconnects directly to the configured database rather than inheriting the previous phpredis `dbNumber`; +- a restore that returns `false` or throws marks the wrapper invalid, closes that connected native generation, logs the existing CRITICAL diagnostic, and lets the next checkout reconnect directly to the configured database without reading the failed generation's `getDBNum()`; - a disconnected wrapper left in MULTI/PIPELINE or WATCH state still emits the existing CRITICAL diagnostic and is discarded instead of being requeued as an invalid wrapper; - invalidating/reconnecting after selection reconnects to the selected database, and later release restores the configured database; - failed `select === false` and queue-object `select` results do not change tracked state; @@ -214,14 +250,14 @@ Edit the secondary branch in `src/server/src/Server.php`: ```php $settings = array_replace($config->getSettings(), $server->getSettings()); -if ($slaveServer->set($settings) === false) { // narrow PHPStan ignore: Swoole's void arginfo is wrong +if ($slaveServer->set($settings) === false) { throw new ServerException("Failed to configure server [{$name}]."); } ``` Call `Port::set()` even when the secondary has no local settings. Swoole stores the settings applied through the primary `Server::set()` and otherwise copies that first port's complete settings into untouched secondary ports during startup. Explicitly applying `global + this secondary local` prevents first-listener protocol/TLS options from leaking while still delivering global port-level settings such as `document_root`, HTTP/2, compression, and socket buffer options. `array_replace()` preserves the existing local-over-global precedence. -The installed IDE helper and Swoole stub declare `Port::set(): void`, but the 6.2.2 C implementation has `RETURN_FALSE` branches and falls through with `null` on success. Use only the exact PHPStan identifier reported for this comparison, with a WHY note naming the upstream contract defect. Do not widen global static-analysis configuration. +The installed IDE helper and Swoole stub declare `Port::set(): void`, but the 6.2.2 C implementation has `RETURN_FALSE` branches and falls through with `null` on success. Keep the exact runtime comparison and its short WHY note local to this call; PHPStan accepts the runtime check without an ignore, wrapper, or global configuration change. Keep the primary `Server::set()` path unchanged. Swoole itself calls primary `Port::set()` without observing its result and returns `true`; applying settings twice or reimplementing SSL validation locally would be a fragile workaround. The upstream handoff owns that remaining native inconsistency. @@ -235,68 +271,245 @@ Extend `tests/Server/ServerTest.php` for the mocked configuration cases, and put - set malformed `ssl_sni_certs` on the SSL secondary whose hostname value is not an array, contain the expected warning locally, and assert `ServerException("Failed to configure server [name].")` occurs before that secondary's callbacks, `ServerManager` publication, `beforeStart`, and `BeforeServerStart` event; - do not mock `Port::set()` returning `false`: the generated `void` signature makes that double misleading and cannot validate the runtime mismatch. -Keep the real test process-local and never start the server; object teardown releases both ephemeral listeners. Do not move it into an isolated subprocess unless direct non-coroutine construction proves nondeterministic under the repository's ParaTest runner. +Run the native test method under PHPUnit's `RunInSeparateProcess` attribute, without `PreserveGlobalState`. A real `Swoole\Server` owns process-global native lifecycle state that must not enter a reusable PHPUnit worker. The supported 6.2.2 floor makes the failure concrete: discarding a never-started server leaves `SwooleG.server` set, so the next Swoole timer takes the server path and later coroutine timeouts never fire. Our merged Swoole PR #6136 (`637d6a884589`) fixes that released-version destructor defect upstream, but no release contains it yet and process isolation remains the correct boundary for this native test. Keep the real assertion and never start the server; the child process owns both ephemeral listeners and all native teardown. + +This released-version defect has no supported production continuation path. `ServerFactory::configure()` propagates `ServerException`, and source has no catch that would discard the failed native server and continue boot into timer use. Do not add Hypervel cleanup or a version skip: cleanup is not exposed by Swoole, and skipping would remove the real `Port::set() === false` coverage on every currently supported release. -## 5. Bound event lookup caches and remove redundant wildcard caches +## 5. Prepare event handlers by finite registration key ### Source changes Edit `src/events/src/Dispatcher.php`: -- remove `wildcardsCache` and `observerWildcardsCache`, including their declarations, registration invalidations, assignments, reads, and selective loops in `forget()`; -- have `getWildcardListeners()` and `getWildcardObservers()` return their computed arrays directly; -- retain `listenersCache`, `observersCache`, and `hasListenersCache`, because those avoid repeated listener construction/interface resolution/wildcard scans; -- add one private `10_000` entry limit shared by all three caches and one private insertion helper. +- keep the raw `$listeners`, `$wildcards`, `$observers`, and `$observerWildcards` registries as the finite boot-time source of truth; +- remove `$wildcardsCache`, `$listenersCache`, `$hasListenersCache`, `$observerWildcardsCache`, and `$observersCache` plus their full-map invalidation and selective-sweep code; +- add four lazily prepared maps keyed only by keys already present in the corresponding raw registry: exact listeners, wildcard listeners, exact observers, and wildcard observers; +- never insert an empty bucket for an unregistered exact runtime name; +- invalidate only the prepared bucket whose raw registration changed, and remove the corresponding prepared bucket in `forget()` while retaining the existing `interfaceListeners` cleanup; +- keep `getRawListeners()` returning the unchanged raw exact-listener registry. Do not add `getRawObservers()`. -The wildcard-only caches are write-only in steady state: `getListeners()`/`getObservers()` write the final cache on the same miss, and every later call returns that final value before consulting the wildcard cache. Removing them reduces memory and invalidation code without changing behavior. +Lazy preparation preserves the first-resolution timing of public, overridable `makeListener()` and `createClassListener()` and protected, overridable `makeObserver()`. Eager preparation inside `listen()` or `observe()` would move those extension points to registration time. The framework preparation methods only allocate closures: container resolution, queue decisions, and after-commit decisions remain inside the returned closure and run for each invocation. Preparation must remain non-yielding so the check-and-store is atomic under cooperative scheduling. A subclass override that yields can produce different prepared closure instances for two concurrent first resolutions without changing event behavior; closure identity is best-effort for such overrides, so do not add synchronization machinery. -The insertion helper should flush only the individual cache receiving a new miss: +`getListeners($eventName)` must assemble handlers in the existing order: -```php -private function cacheEventLookup(array &$cache, string $eventName, mixed $value): mixed -{ - if (count($cache) >= self::EVENT_CACHE_LIMIT) { - $cache = []; - } +1. the prepared exact bucket when `$eventName` is a registered key; +2. each matching registered wildcard bucket in registration order; +3. each registered interface bucket implemented by a class event. - return $cache[$eventName] = $value; -} -``` +For the boolean interface guard, scan the finite registered interface keys with `is_a($eventName, $interface, true)` and stop at the first match. For resolved listeners, retain `class_implements($eventName)` order and select registered interface buckets from that result. This asymmetry is deliberate: the guard needs only a boolean and avoids allocating the event's full interface map, while resolution preserves Laravel's observable direct/inherited interface order. A guarded dispatch still builds the full interface map once during listener resolution; the change removes only the duplicate build and introduces no memoized runtime names. + +`getObservers()` similarly assembles exact then matching wildcard buckets. Prepared closures are shared: two runtime names matching the same wildcard receive the same closure objects, and repeated `getListeners()` calls remain identical under PHP array `===`. The assembled result is returned without being stored under the runtime name. + +`hasListeners()` must evaluate the existing exact, targeted-wildcard, and interface predicate directly. Observers remain excluded, including catch-all `listen('*', ...)` registrations routed through the observer pipeline. With no wildcard or interface registrations, framework guards perform only direct/empty checks. Registered wildcard matching measured approximately 0.36 microseconds for one pattern, 0.73 microseconds for two, and 2.9 microseconds for eight unmatched patterns on the reference runtime. If profiling ever finds a wildcard-heavy bottleneck, optimize the finite registered patterns; never reintroduce runtime-name memoization. + +Interface resolution intentionally uses autoloading `class_exists($eventName)` when at least one interface listener is registered. Unlike Laravel's `class_exists(..., false)`, this preserves Hypervel's tested support for string-dispatched unloaded class events; the `interfaceListeners !== []` gate avoids autoloading when the feature is unused. A class-shaped nonexistent runtime string can therefore enter Composer's own worker-lifetime `ClassLoader::$missingClasses`, as it already does today. Do not restore Laravel's non-autoloading call. The invariant here is precise: dispatched runtime names cannot grow Dispatcher state. + +### Tests + +Replace the existing reflection assertions for the removed listener and `hasListeners` caches while retaining their behavioral coverage: + +- repeated calls return the same prepared closure objects without relying on a runtime-name result cache; +- adding and forgetting exact, wildcard, interface, and observer registrations immediately changes resolution correctly; +- exact → wildcard → interface listener order and exact → wildcard observer order remain explicit invariants; +- multiple directly implemented interface listeners retain the class's `implements`-clause order even when registered in reverse order; +- `hasListeners()` preserves the exact/wildcard/interface truth table while observers remain invisible; +- raw listeners remain raw and keep their existing array shape; +- callable, class-string, queued, after-commit, subscriber, catch-all observer, halt, propagation-stop, and listener/observer failure behavior remains covered. + +Use a test subclass that counts preparation calls to prove each registered bucket is prepared once. Assert directly that two distinct runtime names matching one wildcard receive the same closure instance. + +Add one structural no-growth regression. Register and warm exact, wildcard, interface, exact-observer, and wildcard-observer buckets, then snapshot recursive entry counts for every non-static array property on the Dispatcher. Query and dispatch many distinct matching, nonmatching, dotted, and class-shaped nonexistent names and assert the snapshot is unchanged. The class-shaped cases exercise the intentional autoload path; Composer's external missing-class map is not Dispatcher state. + +Search all source and tests after implementation to prove the five removed cache names have no remaining references. + +## 6. Reclaim expired array-cache state with bounded mutation work + +### Source changes + +Keep the two array-cache lifetimes distinct: + +- request-local `ArrayStore` remains coroutine-context backed and needs no background or rotating reclamation; +- worker-local `WorkerArrayStore` continues to persist live and forever values, tags, counters, serialization state, and cache locks across requests handled by one worker; +- worker-array locks coordinate only that worker. They are not distributed locks and must not be used for cross-worker or cross-server uniqueness. + +Edit `src/cache/src/AbstractArrayStore.php`: + +- factor the existing value expiry/decoding branch into one protected helper used by `get()` and the existing-key `increment()` branch, accepting an already-observed timestamp so a mutation does not construct the clock twice; +- keep cache misses from acquiring a timestamp and preserve null-value behavior; +- add a protected no-op `reclaimExpiredRecords()` seam. `put()`, the existing-key `increment()` branch, and a successful `touch()` call it once for the record they write. `forever()` and `decrement()` retain their current public delegation through `put()` and `increment()`; `RetrievesMultipleKeys::putMany()` retains its per-key public `put()` calls, so subclass overrides, serialization, and the “attempt every key” contract remain intact; +- make `touch()` evaluate the raw item's expiry before calling `calculateExpiration()` or extending it. An expired item is forgotten and returns `false`, matching Laravel's ordering and the existing `get()` contract. Never infer the current time by subtracting `$seconds` from `calculateExpiration()`: that protected Laravel extension point may apply jitter, alignment, or a TTL clamp; +- implement `getLockRecord()` once over a protected abstract `getLockRecords()` seam, mirroring the existing cache-item storage boundary. Both concrete stores expose their raw lock maps through that seam. The shared exact read deletes an expired physical record and returns `null` while avoiding a clock read for missing and permanent records; +- add named `isCacheItemExpired()` and `isLockRecordExpired()` predicates. The cache predicate documents that callers must exclude the `0.0` permanent sentinel. The lock predicate accepts exact `CarbonImmutable` values for both arguments and records that inclusive `<=` is the deliberate expiry boundary. Exact reads and worker maintenance guard permanent null expiries before calling it, avoiding clock work and preserving permanent locks; +- keep `putCacheItem()` as the raw storage primitive with no hidden maintenance and leave `all()`'s existing raw-store behavior unchanged. + +Run the maintenance seam after successful existing-key `increment()` and `touch()` operations even though those operations do not grow the map. A workload that only increments counters or refreshes TTLs must still advance reclamation of unrelated expired records. + +The worker override in `src/cache/src/WorkerArrayStore.php` must: + +- return before acquiring a timestamp when both `$storage` and `$locks` are empty; +- otherwise inspect at most eight entries from each nonempty map per record write. Each reclamation pass takes at most one fakeable clock observation and reuses a real caller-supplied value timestamp when possible; this is separate from any expiration or read timestamps the public operation already took. When locks exist, one exact Carbon observation owns all lock comparisons and supplies the value timestamp only when the caller supplied none; +- rotate deterministically with `key()`, `next()`, and `reset()`. Maintenance is the only code that positions the maps' PHP internal pointers, but deletion elsewhere may advance one. At the top of every individual scan-step iteration, normalize `key($map) === null` with `reset($map)` before selecting an entry; do not perform this normalization only once before the loop. Advance the pointer before deleting the selected key. This makes arbitrary deletion and append-after-end safe and wraps within the same maintenance pass instead of following an appended tail forever; +- unset only values whose nonzero `expiresAt` has passed and locks whose non-null `expiresAt` has passed; +- perform the same bounded maintenance from `putLockRecord()`, covering both `ArrayLock::acquire()` and `ArrayLock::refresh()`; +- keep the full maintenance section non-yielding. `increment()` remains one cooperative-worker-atomic read/modify/write section. + +Each write can introduce at most one record to either map while inspecting eight existing records in both populated maps, so traversal outpaces continuous growth without a store-size-dependent pause. Reference-runtime simulation over 200,000 one-record writes converged near the live TTL working set (budget eight: TTL 1,000, steady 1,004 / peak 1,142; TTL 5,000, steady 5,127 / peak 5,714). Permanent entries correctly grow when applications continually call `forever()`; they are live application-owned state and are removed only explicitly or by flush. + +Reject periodic whole-map cleanup based on measured non-yielding stalls for a half-expired PHP map: approximately 1.1 ms at 10,000 entries, 22–35 ms at 100,000, and 217–254 ms at 1,000,000. The eight-entry pointer work measured below one microsecond per populated map and remains independent of existing cardinality. Work for `putMany()` is proportional only to the records the caller explicitly writes. A `forever()` write acquires a clock once existing state needs maintenance; the empty-map guard preserves today's cold-store cost. + +In `src/cache/src/ArrayStore.php`, retain the existing coroutine-backed `getLockRecords()` implementation. Add the matching raw-map implementation to `src/cache/src/WorkerArrayStore.php`; neither concrete store duplicates exact-read expiry logic. This preserves every `ArrayLock` result: `acquire()` already treats expiry as available, while owner lookup, `refresh()`, and remaining-lifetime inspection already treat expiry as absent. + +Update `src/docs/cache.md` without exposing implementation details: + +- state that worker-array cache values and locks are visible only inside one worker; +- explain that subsequent mutations reclaim expired untouched records, while forever values and permanent locks remain until explicitly removed or the worker exits; +- do not advertise worker-array locks for distributed uniqueness, scheduling, or cross-process handoff. + +### Tests + +Extend `tests/Cache/CacheArrayStoreTest.php` and `tests/Cache/CacheWorkerArrayStoreTest.php`: -Bind the helper's value type explicitly so PHPStan preserves the concrete return at every call site, especially the `bool` required by `hasListeners()`: +- direct `touch()` cannot revive an expired value; +- exact expired lock reads remove the physical record and retain the existing acquire/owner/refresh/lifetime results; +- later unrelated writes reclaim abandoned expired worker values and locks while preserving live, forever, and permanent-lock records; +- one write into large expired maps removes no more than the fixed eight entries per map, proving no whole-map pass; +- seed a map larger than one maintenance budget, then perform enough one-key writes for a correct per-step cursor to cycle through it while an incorrect tail-following cursor would retain the old rows; assert that resident cardinality stays below a bound derived from the live TTL working set, rather than merely checking that one chosen key disappeared; +- arbitrary exact deletion, an expired lock read deleting the current pointer, append-after-end, cache/lock flushes, and copy-on-write through `all()` do not break cursor rotation; +- existing coroutine sharing, tags, counters, serialization, lock restore/refresh, and separate cache/lock flush behavior remain covered. + +Use test-only subclasses or reflection for physical-state and pointer assertions; add no production introspection or timing-based tests. + +## 7. Preserve requested lifetimes at whole-second boundaries + +### Shared time conversion + +The full-suite file-funnel regression exposed a shared rounding defect: `InteractsWithTime::availableAt()` floors future instants to an integer timestamp. A one-second TTL or delay created at `1000.999999` can therefore expire or become runnable at `1001`, almost immediately. This is unsafe for locks and queue visibility and also shortens cache, credential, and client lifetimes promised by every other caller. + +Edit `src/support/src/InteractsWithTime.php` so a whole-second deadline never falls before the instant the caller requested: ```php -/** - * @template TValue - * @param array $cache - * @param TValue $value - * @return TValue - */ +$delay = $this->parseDateInterval($delay); + +$now = Date::now(); + +$target = $delay instanceof DateTimeInterface + ? Date::instance($delay) + : $now->addSeconds($delay); + +return $target > $now + ? $target->ceilSecond()->getTimestamp() + : $target->getTimestamp(); ``` -The native signature still takes `array &$cache` so the flushed/replaced map is returned to the caller by reference. Call the helper only after the existing `isset` hit checks and after computing a miss. The hot hit remains a single `isset`; a miss adds one `count`/comparison. Full per-cache flush is intentional: it is bounded, allocation-free eviction metadata, and lets a changed working set recache. A 10,000-entry bound avoids thrashing ordinary Eloquent model-event namespaces (roughly fifteen names per model) while bounding high-cardinality external names; a local empty-result probe placed all three maps at roughly 2 MiB per worker at the limit. +Keep `parseDateInterval()` authoritative. It preserves interval microseconds and remains shared with `secondsUntil()`. Capturing `$now` after parsing makes a zero interval equal to or older than `$now`, so it stays immediate. The same comparison also keeps zero/negative integers and past absolute times floored, while future integers, intervals, and fractional `DateTimeInterface` values round upward by less than one second. `DatabaseQueue::pushToDatabase()` calls `availableAt()` with no argument for immediately available batch rows; this is the load-bearing reason not to ceiling every call unconditionally. + +Keep `secondsUntil()` unchanged. In particular, do not combine a ceiled duration with a ceiled storage deadline. `Worker::currentTime()` also remains unchanged: coroutine job timeouts already use the same precise monotonic float for registration and expiry checks. + +This shared correction intentionally reaches all existing `availableAt()` consumers, including File/Storage cache entries and file locks, Redis and database queue delays, Redis reserved-job visibility, signed URLs, cookies/sessions, request-forgery cookies, rate-limit reset headers, Slack timestamps, and Inertia once-prop expiry. It preserves every signature and Laravel-shaped API; no porting-guide note is needed because callers receive the lifetime their existing call already expresses. + +### Preserve absolute expiry during file and storage increments + +`FileStore::increment()` and `StorageStore::increment()` currently reconstruct the original expiry by subtracting one whole-second clock observation in `getPayload()` and adding the remaining duration to a later observation in `put()`. This already extends the item by one second when the observations cross a whole-second boundary; rounding future deadlines upward merely makes the lossy round trip extend fractional-clock increments consistently. The existing tests that require an increment to retain the exact stored header are correct and must remain unchanged. + +Edit `src/cache/src/FileStore.php` and `src/cache/src/StorageStore.php`: + +- preserve Laravel's protected `time` payload member and add the original absolute `expiresAt` alongside it. Both `getPayload()` and `emptyPayload()` must document `array{data: mixed, time: ?int, expiresAt: ?int}`. Use one `currentTime()` observation for expiry validation and the remaining `time`, improving on Laravel's two observations without adding read-path work; +- add a short WHY comment where both representations are returned: `time` remains for Laravel-shaped subclasses, while `expiresAt` is authoritative for exact internal rewrites; +- make `emptyPayload()` return all three keys with null metadata; +- add a protected `putWithExpiresAt(string $key, mixed $value, int $expiresAt): bool` as the internal absolute-expiry write boundary. Public `put()` converts its duration once with `expiration($seconds)` and delegates; `increment()` writes the payload's original expiry directly, with `PERMANENT_TIMESTAMP` for the existing missing-key behavior; +- have `increment()` branch on `$raw['expiresAt'] ?? null`, not key presence. A non-null absolute expiry uses `putWithExpiresAt()`; a Laravel-shaped override or cache miss falls back to `put($key, $value, $raw['time'] ?? 0)`. This preserves Laravel subclasses that return only `data + time`, keeps cache misses permanent, and preserves exact base-store deadlines; +- replace the Hypervel-only protected `expirationHeader()` helper with `expiresAtHeader()`, which formats an already-computed absolute timestamp. Every duration caller must first call `expiration($seconds)`, keeping the fixed-width representation in one method without mixing duration and timestamp units. The new name makes the unit change explicit and causes old Hypervel subclass calls that still pass durations to fail loudly instead of silently writing invalid headers; +- keep `add()`, `touch()`, file-lock acquire/refresh, and every operation that deliberately starts a new TTL on the duration path. + +`FileStore::add()` already writes without calling public `put()`, so `put()` is not a supported single write choke point; the new protected absolute writer gives both stores a clear internal boundary without changing the Laravel cache API or its protected payload extension point. + +### Integer storage boundaries outside `availableAt()` + +Edit `src/cache/src/DatabaseStore.php`: + +- compute `putMany()` and `add()` expiry through `$this->availableAt($seconds)`; +- in `touch()`, keep the existing `$now = $this->getTime()` observation for the live-row predicate, then compute the new expiry separately through `$this->availableAt($seconds)`; +- do not add a protected expiry helper or change `getTime()`. + +Edit `src/cache/src/DatabaseLock.php` so `expiresAt()` keeps the existing positive/default-timeout selection and returns `$this->availableAt($lockTimeout)`. A database lock created during a fractional second then remains held for `[N, N + 1)` seconds rather than `(N - 1, N]`. + +Edit `src/queue/src/Jobs/DatabaseJobRecord.php` so `touch()` stores `CarbonImmutable::now()->ceilSecond()->getTimestamp()`. Keep `DatabaseQueue::isReservedButExpired()` unchanged. The integer `reserved_at` marker may be less than one second later than the actual reservation, but the reclaim comparison can no longer dispatch the job before `retryAfter`; repository inspection only tests marker nullness and does not expose it as the displayed job time. + +### Redis all-tag expiry metadata -`getListeners()` and `getObservers()` are public, so eviction is observable to callers that enumerate more than 10,000 distinct event names in one worker: an evicted lookup is recomputed and freshly prepared closures may have new object identities. Listener resolution and dispatch results remain unchanged. This is the deliberate bounded-memory contract; do not imply that the cap is invisible or preserve closure identity with a second unbounded structure. +Add `StoreContext::expirationScore(int $seconds): int`, returning `now()->addSeconds($seconds)->ceilSecond()->getTimestamp()`. `StoreContext` already owns the all-tag expiry constants and is injected into every affected operation, so this one named accessor prevents nine copies of a subtle rule without adding a new class or service. -Listener and observer registries remain unbounded because they contain intentional boot-time registrations, not request-derived lookup names. Existing `listen()`, `observe()`, and `forget()` full invalidations remain authoritative. +Use it for both standalone and Cluster branches in: + +- `AllTag/Add.php`; +- `AllTag/Put.php`; +- `AllTag/PutMany.php`; +- `AllTag/Touch.php`; +- `AllTag/AddEntry.php` for positive TTLs, retaining `-1` for forever entries. + +Keep both `FlushStale` current-time cutoffs floored. Ceiling the tag score reduces its former early-removal window from almost one second to at most the PHP-to-Redis command gap: the native `SETEX`/`EXPIRE` countdown begins after PHP computes the score. Do not add a fixed margin, extra command/round trip, fractional score, Lua clock, or other machinery for that residual window. Tag metadata lasting briefly after the value expires is safe; disappearing while the value is still live is the direction to avoid. ### Tests -Add cache-boundary cases to `tests/Events/EventsDispatcherTest.php` or `CoroutineEventsTest.php` using reflection/test subclasses to seed each protected cache to the production constant. Do not dispatch 10,000 events merely to reach the boundary. +Extend `tests/Foundation/FoundationInteractsWithTimeTest.php` with a clock frozen at fractional seconds and cover: + +- future positive integers round up, while zero and negative integers stay immediate/past; +- positive intervals round up, while zero and inverted intervals do not; +- future fractional absolute dates round up, while past fractional dates do not; +- whole-second future targets remain unchanged. + +Add deterministic fractional-clock regressions to `CacheFileStoreTest`, `CacheStorageStoreTest`, `CacheDatabaseStoreTest`, and `CacheDatabaseLockTest`. Prove a one-second item/lock created at `1000.900000` receives integer expiry `1002`, remains valid at `1001`, and expires at `1002` where the driver's public surface supports direct reads. For both file-backed stores, retain the existing exact-header increment assertions, add a finite-TTL fractional-clock increment case, and prove incrementing a forever value at a fractional instant preserves `PERMANENT_TIMESTAMP`. + +For both file-backed stores, add a test subclass whose `getPayload()` returns Laravel's original `data + time` shape and prove `increment()` uses that finite duration rather than making the item permanent. + +Keep general file/database lock tests that assert nominal durations on a whole-second frozen clock. They must assert simple exact deadlines rather than copying the production ceiling expression or expecting the conservative fractional extension. Dedicated fractional tests alone own that storage-boundary behavior. -For each retained cache: +Update the affected AllTag operation tests and add focused `Touch` coverage if no current file owns it. Pin fractional clocks to fixed instants and assert the resulting integer scores directly rather than recomputing them with the production formula. Assert every standalone and Cluster score uses `StoreContext::expirationScore()`, positive `AddEntry` TTLs ceiling correctly, forever entries remain `-1`, and stale pruning at the preceding whole second cannot remove the ceiled membership. -- a hit at the limit remains cached and does not flush; -- the next distinct miss flushes that cache and inserts the new result; -- the other two caches are untouched; -- a previously evicted event recomputes correctly; -- false `hasListeners` entries remain cache hits; -- exact, wildcard, interface, and observer resolution still return the same callbacks after eviction; -- listener/observer registration and `forget()` still invalidate all affected final caches. +Add queue regressions in `QueueRedisQueueTest`, `QueueDatabaseQueueUnitTest`, and the relevant integration suites. Cover integer, interval, and absolute delayed jobs; Redis reservation scores; ceiled database `reserved_at`; and reclaim only at or after the requested visibility duration. Retain the existing precise `QueueWorkerTest` assertions unchanged. -Search the repository after implementation to prove both removed wildcard cache names have no references. +Correct `CacheFunnelTestCase::testLeakedFunnelLeaseIsReclaimedAfterReleaseAfter()`: + +- keep the abandoned and immediate competing leases at `releaseAfter(1)`; +- replace the in-flight expected-exception/finally shape with an explicit narrow `LimiterTimeoutException` catch; +- wait 2.2 seconds before reacquisition because a ceiled one-second expiry lives for `[1, 2)` seconds; +- use `releaseAfter(60)` for the cleanup lease so its asserted release cannot race its own short TTL; +- add one concise comment explaining why the wait exceeds the nominal TTL. + +Run each changed test file immediately. Then run the complete `tests/Cache`, `tests/Cache/Redis`, and `tests/Queue` unit groups plus the affected cache/queue integration groups. Inspect every exact timestamp assertion that moves under a fractional fake clock; update it only when the new value represents the corrected never-early contract. The final `composer fix` remains authoritative because `availableAt()` also reaches auth, cookie, foundation, inertia, notifications, routing, session, and support callers. + +## 8. Reclaim released coroutine mutex channels + +### Source changes -## 6. Render scheduler durations with the existing time formatter +Edit `src/coroutine/src/Mutex.php`. This package is Hyperf-ported, but Hyperf is only a historical reference under `docs/ai/porting-hyperf.md`; the current Hypervel API and Swoole behavior define the contract. The static channel map is worker-lifetime state keyed by the public arbitrary mutex key. Normal `lock()` / `finally { unlock(); }` use currently leaves one native channel per distinct key forever, while the documented `clear()` call appears optional. + +- return the boolean result of `Channel::push(1, $timeout)` from `lock()` so every native failure is represented truthfully; +- make `unlock()` return `false` immediately when the key is absent or the published channel has length zero. The absent-key case is a deliberate correction from `true` to `false` because no mutex was released; an existing empty channel already eventually returns `false`, but this avoids waiting up to the default five seconds for a token that no holder owns; +- return `false` when `pop($timeout)` fails, including timeout or channel closure; +- after a successful `pop($timeout)`, first verify that the map still contains the exact same channel. Only then, when its length is zero, unset the map entry and close it. Unset before close; +- retain the channel when a producer was waiting. Native Swoole 6.2.2 hands the freed slot to the blocked producer before the owner's `pop()` returns, leaving length one; an uncontended release leaves length zero; +- check map identity before the post-pop length call so a fast waiter that releases the old channel and a later caller that publishes a replacement cannot have that replacement deleted by the earlier unlock; +- do not call `Channel::hasProducers()` or `hasConsumers()`; Hypervel's single Swoole `Channel` implementation throws for those methods (and for the other two inspection methods), as documented in `src/docs/coroutines.md`; +- mark `push()`, `pop()`, and `close()` as `@phpstan-impure` on both `ChannelInterface` and `Channel`: each returns a value while mutating native channel state. The concrete `pop()` tag is load-bearing for the pre-pop/post-pop length checks because the inherited Swoole declaration prevents PHPStan from inheriting the interface tag; `getLength()` remains a pure observation of current state; +- rewrite the `lock()` and `unlock()` return docblocks to describe their actual operation contracts. `lock()` returns whether the channel accepted the acquisition token. `unlock()` returns `true` only when it released a held mutex token and `false` when the key is absent, no token is held, or the pop fails; do not describe every `false` result as a timeout. + +The API does not track ownership. Document that a successful acquisition must be released exactly once by its holder; an invalid concurrent/double unlock already destroys mutual exclusion today and will not gain owner-tracking machinery. Update `src/docs/coroutines.md` to describe `clear()` as explicit cancellation/reset rather than normal release hygiene. + +### Tests + +Extend `tests/Coroutine/MutexTest.php`: + +- many unique uncontended lock/unlock pairs leave the static channel map empty; +- absent, empty, and sequential double unlocks return `false` immediately; +- contended acquisition remains mutually exclusive and hands the existing channel to one waiter at a time without publishing a replacement; +- a timed-out waiter leaves the owner's channel intact, while the final uncontended release removes it; +- a fast waiter may release the old channel and a later caller may publish a replacement without an older unlock touching or deleting that replacement; +- `clear()` and `flushState()` retain their cancellation/reset behavior and blocked callers fail safely. + +Run `tests/Database/Eloquent/ModelBootTest.php` with the focused suite because model boot uses class-name-keyed `Mutex` acquisition and release. + +## 9. Render scheduler durations with the existing time formatter Edit `src/console/src/Commands/ScheduleRunCommand.php`: @@ -310,7 +523,7 @@ In `tests/Console/Scheduling/ScheduleRunCommandTest.php`, use a test subclass th Also add deterministic shared-formatter coverage in `tests/Foundation/FoundationInteractsWithTimeTest.php`. Expose `Hypervel\Support\InteractsWithTime::runTimeForHumans()` through a tiny test fixture and pass explicit start/end values; cover a sub-second interval as milliseconds and representative values above one second through the cascading branch. These assertions exercise the trait's `* 1000` conversion directly, which the command-delegation double otherwise bypasses and which console `Task` and queue `WorkCommand` also rely on. Do not add a production clock seam, sleep, `hrtime()` refactor, or duplicate the formatter in the command. -## 7. Make database assertion diagnostics encoding-safe +## 10. Make database assertion diagnostics encoding-safe Edit these Laravel-ported constraints: @@ -318,6 +531,8 @@ Edit these Laravel-ported constraints: - `src/testing/src/Constraints/SoftDeletedInDatabase.php` - `src/testing/src/Constraints/NotSoftDeletedInDatabase.php` +Match current Laravel's `exists()` query in both soft-delete `matches()` methods instead of counting every matching row; the boolean contract is unchanged and the database can stop at the first match. + At all seven `json_encode()` sites, include: ```php @@ -336,7 +551,7 @@ Add focused coverage in `tests/Foundation/FoundationInteractsWithDatabaseTest.ph - one representative recursive/non-finite/depth case proves partial-output behavior without asserting unstable JSON error prose; - the surrounding assertion still fails as a PHPUnit expectation with useful table/attribute diagnostics, not an encoding exception. -## 8. Make fake HTTP sinks match real transport completion and seekability +## 11. Make fake HTTP sinks match real transport completion and seekability Edit only `PendingRequest::sinkStubHandler()` in `src/http/src/Client/PendingRequest.php`. @@ -368,7 +583,7 @@ Extend `tests/Http/HttpClientTest.php`: - seekable sinks still end at offset zero, and an actual rewind failure is propagated; - failed fake requests remain recorded exactly as current tests require. -## 9. Complete log records without duplicate replay +## 12. Complete log records without duplicate replay Edit `src/log/src/Handlers/Concerns/PerformsSafeStreamOperations.php`, shared by Hypervel's `StreamHandler` and `RotatingFileHandler`. @@ -382,6 +597,8 @@ Preserve the one URL reopen retry with a stricter safety condition: - URL-backed blocking file/stdout streams retain their one-write normal path; - no readiness polling or coroutine scheduling is introduced. +Keep two short WHY comments in the loop: inode refresh cannot repeat because closing clears the saved inode before reopening establishes a new baseline, and a positive prefix cannot be retried from byte zero because that would duplicate log content. When a positive prefix is followed by no progress, retain the shared failure-message structure and append the written and expected byte counts; leave the zero-progress message unchanged. + Simplify inode rotation handling so closing a changed inode and opening the current URL does not consume the one write-failure retry. Close the stale stream and continue in the same first attempt instead of recursively marking the refreshed stream as already retrying. This is safe without a second inode-refresh guard because `closeStreamSafely()` clears `safeInodeUrl`, while `hasStreamInodeChanged()` can enter only when that property is non-null; opening the replacement stream establishes one new baseline rather than re-entering refresh. Preserve that invariant explicitly when restructuring the loop. This restores Monolog's intended distinction between inode refresh and write retry while reducing recursion. Representative loop shape: @@ -415,7 +632,7 @@ Extend `tests/Log/StreamHandlerTest.php` and its local stream wrapper: Retain the current normalized exception context and safe open/directory behavior. -## 10. Gate Boost documentation until Boost exists +## 13. Gate Boost documentation until Boost exists `src/boost` has only package metadata, a README, license, and a dependency on `hypervel/docs`; it has no autoload surface, provider, command, installer, or tools. Do not build that product in this audit PR. @@ -436,22 +653,28 @@ Follow tests-first development within each slice and edit one file at a time as 1. Redis result contracts and generated facade. 2. Redis event ownership and selected-database tracking. 3. Swoole secondary settings. -4. Dispatcher cache simplification/bounds. -5. Scheduler timing. -6. Testing constraint JSON diagnostics. -7. HTTP fake sink completion/seekability. -8. Log stream completion/retry behavior. -9. Boost documentation and TODO cleanup. +4. Dispatcher finite-key preparation and runtime-name state removal. +5. Array-cache expiry correctness and bounded worker-local reclamation. +6. Whole-second future-deadline rounding across support, cache, queue, and Redis tag metadata. +7. Coroutine Mutex channel reclamation. +8. Scheduler timing. +9. Testing constraint JSON diagnostics. +10. HTTP fake sink completion/seekability. +11. Log stream completion/retry behavior. +12. Boost documentation and TODO cleanup. For each slice, add/adjust the focused test first, observe the intended failure where practical, implement, and rerun that file. Use at least these focused commands after the slice is complete: ```shell -composer test -- tests/Redis/RedisConnectionTest.php tests/Redis/RedisProxyTest.php tests/Redis/RedisProxyNonCoroutineTest.php tests/Redis/MultiExecTest.php tests/Redis/RedisPoolHeartbeatTest.php +composer test -- tests/Redis/PackageMetadataTest.php tests/Redis/RedisConnectionTest.php tests/Redis/RedisProxyTest.php tests/Redis/RedisProxyNonCoroutineTest.php tests/Redis/MultiExecTest.php tests/Redis/RedisPoolHeartbeatTest.php composer test -- tests/Integration/Redis/RedisProxyIntegrationTest.php composer facade "Hypervel\\Support\\Facades\\Redis" composer facade -- --lint "Hypervel\\Support\\Facades\\Redis" composer test -- tests/Server/ServerTest.php tests/Server/ServerNativeTest.php composer test -- tests/Events/EventsDispatcherTest.php tests/Events/CoroutineEventsTest.php +composer test -- tests/Cache/CacheArrayStoreTest.php tests/Cache/CacheWorkerArrayStoreTest.php +composer test -- tests/Foundation/FoundationInteractsWithTimeTest.php tests/Cache tests/Queue +composer test -- tests/Coroutine/MutexTest.php tests/Database/Eloquent/ModelBootTest.php composer test -- tests/Console/Scheduling/ScheduleRunCommandTest.php tests/Foundation/FoundationInteractsWithTimeTest.php composer test -- tests/Foundation/FoundationInteractsWithDatabaseTest.php composer test -- tests/Http/HttpClientTest.php @@ -463,9 +686,9 @@ Redis integration tests are opt-in through the copied `.env`; if the configured After all focused suites pass: -- run `rg` for removed symbols (`wildcardsCache`, `observerWildcardsCache`, `setDatabase`) and false Boost instructions; +- run `grep` for removed Dispatcher cache symbols, `setDatabase`, and false Boost instructions; - inspect every generated facade line and every TODO edit for stale claims; -- inspect the complete diff for accidental watcher changes, public API narrowing, broad ignores, new configuration, polling, recursion guards, or dead comments; +- inspect the complete diff for accidental watcher changes, public API narrowing, broad ignores, new configuration, polling, recursion guards, store-size-dependent cleanup, or dead comments; - run `composer fix` once at the final checkpoint. This owns formatting, both PHPStan configurations, the parallel suite, Testbench package tests, and dogfood tests; - inspect `git status --short` to ensure Composer/vendor artifacts and temporary probes are not included. @@ -473,9 +696,12 @@ After all focused suites pass: - Serializer-backed Redis values and `SET ... GET` cross the facade without post-mutation `TypeError`; documented false/float results remain observable. - A Redis command event can make a nested same-connection command with a one-slot pool, with exact context cleanup and no no-listener hot-path cost. -- Every applied atomic wrapper-level `SELECT` is tracked by the owning connection, reconnect observes the old standalone client's actual selected database, and every standalone release restores the configured database without an external setter or dirty-state machinery. +- Every standalone Redis database index is normalized to `int` before connection construction; every applied atomic wrapper-level `SELECT` is tracked by the owning connection, reconnect observes the old standalone client's actual selected database, refused selections abort before publication, and every standalone release either restores the configured database or closes the refused native generation without external setter or dirty-state machinery. - Every secondary Swoole port receives only global plus its own settings, and a recoverable native false aborts configuration before publication. -- Dynamic event names cannot grow any lookup cache beyond 10,000 entries; removed wildcard caches leave no dead invalidation code. +- Dynamic event names cannot grow Dispatcher state; each finite registration bucket is prepared once with no dead invalidation code. +- Array-cache `touch()` never revives expired data; exact expired lock reads are physically removed; abandoned expired worker-array values and locks are reclaimed with fixed work per requested write and no operation scans existing state by cardinality. +- Future whole-second deadlines never precede the requested instant across cache TTLs, locks, queue delays/visibility, credentials, and client metadata; immediate/past values retain their current behavior, Redis tag tracking cannot disappear almost a second early, and precise Worker timeouts remain unchanged. +- Normal Mutex release removes quiescent channels, preserves contended handoff and replacement identity, and invalid unlocks fail immediately without unsupported producer introspection. - Scheduler output uses truthful human-readable units while event runtime remains seconds. - All database constraint diagnostics return useful strings for malformed data and preserve Laravel's Unicode formatting. - Fake HTTP sinks and log handlers either write every byte exactly once or fail deterministically; neither spins nor closes caller resources. From ed9d04fb8599650360fc70afafea9d270482eb84 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 01:17:44 +0000 Subject: [PATCH 17/22] Fix database limiter first-use deadlocks Concurrent cold-key mutations on InnoDB could exhaust the transaction retry limit because each transaction kept a missing-row gap lock while trying to insert the same state row. End the read-only miss transaction before a cold-only key upsert transaction initializes, locks, calculates, and writes the state. Established rows retain their existing single-transaction hot path, while SQLite keeps its insert-first writer-lock path. Enforce the documented PostgreSQL READ COMMITTED requirement before limiter mutations, share the empty state-row shape across driver paths, and document the supported connection setup. Add deterministic unit coverage for statement ordering, cold versus established paths, PostgreSQL isolation validation, and active-transaction rejection while preserving the existing concurrent capacity integration assertions. --- ...-correctness-and-worker-lifetime-bounds.md | 67 +++++- src/docs/rate-limiting.md | 2 +- src/rate-limiter/src/DatabaseStore.php | 150 ++++++++++--- tests/RateLimiter/DatabaseStoreTest.php | 203 +++++++++++++++--- 4 files changed, 362 insertions(+), 60 deletions(-) diff --git a/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md index 1bbd966bf..ebe5d25ea 100644 --- a/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md +++ b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md @@ -2,7 +2,7 @@ ## Objective -Correct the confirmed non-watcher findings from the 0.4 audit without changing Laravel-compatible APIs, adding material hot-path machinery, or documenting behavior the repository does not ship. The finished code should have truthful Redis result contracts, reentrant Redis command events, connection-owned database tracking, correct secondary Swoole settings, event preparation keyed only by finite registrations, bounded-work reclamation of expired worker-local cache state, whole-second deadlines that never shorten requested lifetimes, self-reclaiming mutex channels, accurate scheduler timing, robust diagnostics and stream writes, and no premature Boost installation instructions. +Correct the confirmed non-watcher findings from the 0.4 audit without changing Laravel-compatible APIs, adding material hot-path machinery, or documenting behavior the repository does not ship. The finished code should have truthful Redis result contracts, reentrant Redis command events, connection-owned database tracking, correct secondary Swoole settings, event preparation keyed only by finite registrations, bounded-work reclamation of expired worker-local cache state, whole-second deadlines that never shorten requested lifetimes, self-reclaiming mutex channels, deadlock-free database rate-limiter initialization, accurate scheduler timing, robust diagnostics and stream writes, and no premature Boost installation instructions. This plan targets branch `fix/audit-correctness-follow-up` from 0.4 commit `88190e640498`. Research references are the local Laravel checkout at `a659f095965b`, phpredis at `777f7377674a`, Swoole at `8e8c49915ca5`, and the installed PHP 8.4.23 / phpredis 6.3.0 / Swoole 6.2.2 runtime. @@ -18,6 +18,7 @@ This plan targets branch `fix/audit-correctness-follow-up` from 0.4 commit `8819 - `Hypervel\RateLimiter\WorkerArrayStore` is not a defect. Its tests-only scope, per-worker isolation, and retention of expired untouched keys are explicit in shipped config and documentation; Reverb owns and clears its per-connection keys. It will not gain production-oriented pruning machinery. - Eloquent mutator keys remain semantic model/schema identifiers. Arbitrary growth requires arbitrary request strings to be used as model attributes, while replacing one `StrCache` call would leave the parallel negative mutator maps unchanged. No Eloquent metadata redesign will be added for application code that passes unfiltered input to an unguarded model. - `Hypervel\Cache\WorkerArrayStore` deliberately retains live and forever values across requests, but abandoned TTL-expired values and locks are dead state. Reclamation will perform fixed work per explicitly requested record write, never work proportional to existing store size. There will be no whole-map sweep, cadence, cap, eviction, expiry index, timer, coroutine, lifecycle hook, or new configuration. +- Database rate-limiter mutations will retain the existing one-transaction path for established rows. A non-SQLite cold miss will release its missing-row lock before a second transaction uses a parameterized key-only upsert to create or exclusively lock the row through calculation and write. PostgreSQL mutations will reject explicitly configured isolation levels other than the documented `READ COMMITTED`; no query or cached state will be added for that check. There will be no unconditional hot-path upsert, retry backoff, application lock, isolation-level mutation, unlocked seed window, or unbounded initialization loop. - A future instant stored with whole-second precision must round upward so it never occurs before the requested duration or absolute deadline. Immediate, past, and zero-duration values retain their current floor. This is one shared time conversion plus direct storage-boundary uses, not a clock service or per-package timing mechanism. - Mutex release will reclaim only a quiescent channel that is still the exact channel published for the key. It will not add owner tracking, reference counts, a wrapper entry type, producer introspection, polling, or cleanup timers. - Stream completion loops stop on `false` or zero progress. They will not poll readiness, sleep, spin, or add asynchronous buffering. @@ -40,6 +41,7 @@ This plan targets branch `fix/audit-correctness-follow-up` from 0.4 commit `8819 | Cache array-store expiry | Prevent expired-value revival and reclaim abandoned expired worker-local values/locks with bounded work | | Whole-second future deadlines | Round up future cache, queue, credential, and client deadlines; preserve immediate/past behavior and precise monotonic Worker time | | Coroutine mutex channel retention | Reclaim quiescent channels after successful release without disturbing waiter handoff | +| Database rate-limiter first-use deadlock | Release missing-row locks before a cold-only upsert transaction owns initialization and mutation continuously | | Scheduler seconds labelled as milliseconds | Reuse `InteractsWithTime` | | Database assertion JSON failures | Use the two deliberate tolerant-encoding flags and restore Laravel's Unicode option | | Fake HTTP sink writes/rewinds | Complete partial writes and rewind only seekable sinks | @@ -646,6 +648,62 @@ Edit `src/boost/README.md` to remove the now-invalid installation documentation Do not add a placeholder command, service provider, fake package test, or partial MCP/tool roster. After deletion, search all tracked Markdown outside historical plans/TODO handoffs and assert no shipped documentation mentions `boost:install` or claims the tooling exists. +## 14. Make database rate-limiter first use deadlock-free + +### Root cause + +`DatabaseStore::stateForUpdate()` currently performs `SELECT ... FOR UPDATE`, `INSERT IGNORE`, and a second locking read in one transaction when a row is missing. Under InnoDB REPEATABLE READ, concurrent callers can all hold compatible locks on the same missing primary-key gap and then deadlock when their inserts request insert-intention locks. Immediate retries can recreate the cycle until `attempts: 3` is exhausted and a production limiter mutation throws. MariaDB 11.8 reproduced the CI failure under the unchanged ten-coroutine fixed-window test, and MariaDB 10.11 reproduced the two-transaction lock cycle directly. The relevant source and tests predate this branch. + +Laravel does not own this sequence: its cache-backed limiter seeds a database-cache counter before the later locking increment transaction. The correction belongs to Hypervel's dedicated database limiter. + +### Source changes + +Edit `src/rate-limiter/src/DatabaseStore.php`: + +- resolve the connection once in each public mutation, rename `ensureOutsideTransaction()` to `ensureCanMutate()`, and keep the active-transaction check first before any transaction; +- when the connection driver is PostgreSQL, read its in-memory `isolation_level` configuration and throw `InvalidArgumentException` naming the connection unless the value is null or a case-insensitive `read committed`. Apply this to all five mutation methods, including `clear()` and `pruneExpired()`, while leaving read-only `inspect()` available. Do not coerce non-string values, trim malformed values, query `SHOW transaction_isolation`, or inspect server-side defaults outside Hypervel's connection configuration; +- add one generic internal mutation helper shared by `consume()`, `block()`, and `recordFailure()`, with a result template bounded to `LimitResult|CooldownResult|BackoffResult` so each public method retains its exact inferred return type; +- run the existing locking transaction first. When state exists, invoke the calculation/write callback immediately, preserving the established-row statements, locks, three-attempt retry policy, and server-time ordering; +- on a non-SQLite miss, return the helper's private `null` sentinel and commit that read-only transaction before issuing an insert. This releases InnoDB's missing-row gap lock and starts initialization in a new transaction; +- run exactly one second transaction whose first statement is a cold-only no-op upsert. Use the associative update form so MySQL does not emit its deprecated `VALUES()` function or depend on the optional `use_upsert_alias` connection setting: + +```php +$connection->table($this->table)->upsert( + $this->emptyStateRow($key), + 'key', + ['key' => $key], +); +``` + +- in that same second transaction, retain `findStateForUpdate()` and its `lockForUpdate()` call, observe server time once, calculate, and write. The upsert either inserts the row or performs a no-op update that acquires its exclusive lock; `clear()` and `pruneExpired()` therefore order before the upsert or after the mutation commit and cannot remove a temporary seed between initialization and use; +- retain the invariant exception when the post-upsert locking read is absent, with one short WHY comment explaining that the transaction already created or exclusively locked the row; +- return the shared key plus zeroed state columns from one protected accessor used by both SQLite insertion and the non-SQLite upsert, so every driver initializes from the same state literal; +- keep SQLite's current insert-first transaction unchanged because `FOR UPDATE` is ignored and the insert acquires its database writer lock; +- rewrite the old insert-first/duplicate-shared-lock comments so they describe the final two-phase cold path accurately. + +Do not add an unconditional upsert to established-key mutations, a bare autocommit seed, a cold retry loop or cap, a retry delay, an application mutex, a session-isolation change, or driver-specific raw SQL. The cold-only upsert adds no database statement and only the private sentinel branch to the established-row hot path; the PostgreSQL contract guard is an in-memory driver/config comparison. A cold key pays one additional transaction boundary while retaining the same state-query count; contended first use avoids repeated deadlock rollbacks. + +### Tests and verification + +Update `tests/RateLimiter/DatabaseStoreTest.php` deterministically: + +- established non-SQLite state uses one locking mutation transaction and never upserts; +- missing MySQL/MariaDB/PostgreSQL state completes the miss transaction before the second transaction starts; +- the second transaction contains the associative key-only upsert, locked read, server clock, and write in that order; +- the upsert's update list contains only the key and cannot overwrite an existing limiter value; +- SQLite retains insert-ignore before its locking read in one transaction; +- the miss round never reads the server clock; +- explicit PostgreSQL `READ COMMITTED` reaches the normal mutation path, while `REPEATABLE READ` and non-string isolation configuration fail before transaction or SQL work; +- an unset PostgreSQL isolation level follows the normal default `READ COMMITTED` mutation path; +- every mutation method, including `clear()` and `pruneExpired()`, rejects an explicitly unsupported PostgreSQL isolation level, while MySQL/MariaDB `REPEATABLE READ` remains accepted; +- all five mutation methods reject an already-active transaction before any limiter SQL. + +Keep both shared integration regressions unchanged: fixed-window and sliding-window concurrent first use must admit exactly configured capacity without test retries, reduced concurrency, or relaxed assertions. + +Keep the PostgreSQL integration subclass at its default `READ COMMITTED` configuration. PostgreSQL aborts contended same-row updates at stronger isolation levels rather than safely queueing them, so the documented connection requirement is enforced before any mutation instead of adding retry or locking machinery for an unsupported mode. + +Run the unit file, all four database-store integration variants, repeated MariaDB 11.8 contention that reproduced the failure, MySQL contention, and PostgreSQL at its documented default `READ COMMITTED`. No timing-dependent competing-pruner test is needed because the deterministic same-transaction ordering assertion proves the deletion window is closed. + ## Implementation order and verification Follow tests-first development within each slice and edit one file at a time as required by `AGENTS.md`: @@ -662,6 +720,7 @@ Follow tests-first development within each slice and edit one file at a time as 10. HTTP fake sink completion/seekability. 11. Log stream completion/retry behavior. 12. Boost documentation and TODO cleanup. +13. Database rate-limiter cold-key initialization ordering. For each slice, add/adjust the focused test first, observe the intended failure where practical, implement, and rerun that file. Use at least these focused commands after the slice is complete: @@ -680,6 +739,11 @@ composer test -- tests/Foundation/FoundationInteractsWithDatabaseTest.php composer test -- tests/Http/HttpClientTest.php composer test -- tests/Log/StreamHandlerTest.php composer --working-dir=src/boost validate --strict +composer test -- tests/RateLimiter/DatabaseStoreTest.php +bin/run-database-tests.sh mariadb --filter=DatabaseStoreTest +bin/run-database-tests.sh mysql --filter=DatabaseStoreTest +bin/run-database-tests.sh pgsql --filter=DatabaseStoreTest +bin/run-database-tests.sh sqlite --filter=DatabaseStoreTest ``` Redis integration tests are opt-in through the copied `.env`; if the configured service is unavailable, retain deterministic unit coverage and report the skipped environmental verification rather than weakening assertions. The real Swoole test requires the repository floor, 6.2.2. @@ -706,4 +770,5 @@ After all focused suites pass: - All database constraint diagnostics return useful strings for malformed data and preserve Laravel's Unicode formatting. - Fake HTTP sinks and log handlers either write every byte exactly once or fail deterministically; neither spins nor closes caller resources. - Shipped docs contain no Boost installation command until the package implements it, while TODOs accurately describe remaining future work. +- Established database limiter rows retain their current hot path; a non-SQLite cold miss releases its missing-row lock before one transaction initializes, locks, calculates, and writes without an unlocked seed window or exhausted deadlock retries. Every mutation rejects explicitly unsupported PostgreSQL isolation configuration before opening a transaction. - No watcher implementation/config/docs/tests change in this branch, no Laravel API is broken, and the full repository quality gate passes. diff --git a/src/docs/rate-limiting.md b/src/docs/rate-limiting.md index f238ccc32..9b03b3fdc 100644 --- a/src/docs/rate-limiting.md +++ b/src/docs/rate-limiting.md @@ -121,7 +121,7 @@ The `rate-limiter:table` command is also available as an alias. If your application needs to rate limit while another connection is inside a transaction, configure a separate named connection using the store's `connection` option. The connection may use the same database server or a dedicated rate limiter database. Run the `rate_limits` migration on every connection used by a database rate limiter store. -PostgreSQL limiter connections must use the default `READ COMMITTED` transaction isolation level. MySQL and MariaDB's default `REPEATABLE READ` isolation level is supported. +PostgreSQL limiter connections must use the default `READ COMMITTED` transaction isolation level. Hypervel will throw an `InvalidArgumentException` before changing limiter state if another isolation level is configured. Higher isolation levels can cause concurrent updates to the same limit to fail. MySQL and MariaDB's default `REPEATABLE READ` isolation level is supported. > [!NOTE] > The `inspect` method remains available inside a transaction because it does not change rate limit state. Under MySQL or MariaDB's `REPEATABLE READ` isolation, it reads the outer transaction's snapshot and may not include changes committed after the transaction began. diff --git a/src/rate-limiter/src/DatabaseStore.php b/src/rate-limiter/src/DatabaseStore.php index 74a68319b..09ee27dd6 100644 --- a/src/rate-limiter/src/DatabaseStore.php +++ b/src/rate-limiter/src/DatabaseStore.php @@ -4,6 +4,7 @@ namespace Hypervel\RateLimiter; +use Closure; use Hypervel\Database\ConnectionInterface; use Hypervel\Database\ConnectionResolverInterface; use Hypervel\RateLimiter\Concerns\CalculatesRateLimits; @@ -35,10 +36,13 @@ public function __construct( public function consume(string $key, AdmissionPolicy $policy): LimitResult { $connection = $this->connections->connection($this->connectionName); - $this->ensureOutsideTransaction($connection); + $this->ensureCanMutate($connection); - return $connection->transaction(function (ConnectionInterface $connection) use ($key, $policy): LimitResult { - [$value, $secondaryValue, $expiresAt] = $this->stateForUpdate($connection, $key); + return $this->mutateState($connection, $key, function ( + ConnectionInterface $connection, + array $state, + ) use ($key, $policy): LimitResult { + [$value, $secondaryValue, $expiresAt] = $state; $result = $this->calculateConsume( $policy, $this->currentDatabaseTimeInMicroseconds($connection), @@ -52,7 +56,7 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult } return $result; - }, attempts: 3); + }); } /** @@ -61,10 +65,13 @@ public function consume(string $key, AdmissionPolicy $policy): LimitResult public function block(string $key, int $durationMicroseconds): CooldownResult { $connection = $this->connections->connection($this->connectionName); - $this->ensureOutsideTransaction($connection); + $this->ensureCanMutate($connection); - return $connection->transaction(function (ConnectionInterface $connection) use ($key, $durationMicroseconds): CooldownResult { - [$value, $secondaryValue, $expiresAt] = $this->stateForUpdate($connection, $key); + return $this->mutateState($connection, $key, function ( + ConnectionInterface $connection, + array $state, + ) use ($key, $durationMicroseconds): CooldownResult { + [$value, $secondaryValue, $expiresAt] = $state; $result = $this->calculateCooldownBlock( $durationMicroseconds, $this->currentDatabaseTimeInMicroseconds($connection), @@ -76,7 +83,7 @@ public function block(string $key, int $durationMicroseconds): CooldownResult $this->writeState($connection, $key, $value, $secondaryValue, $expiresAt); return $result; - }, attempts: 3); + }); } /** @@ -112,10 +119,13 @@ public function inspect( public function recordFailure(string $key, Backoff $backoff): BackoffResult { $connection = $this->connections->connection($this->connectionName); - $this->ensureOutsideTransaction($connection); + $this->ensureCanMutate($connection); - return $connection->transaction(function (ConnectionInterface $connection) use ($key, $backoff): BackoffResult { - [$value, $secondaryValue, $expiresAt] = $this->stateForUpdate($connection, $key); + return $this->mutateState($connection, $key, function ( + ConnectionInterface $connection, + array $state, + ) use ($key, $backoff): BackoffResult { + [$value, $secondaryValue, $expiresAt] = $state; $result = $this->calculateFailure( $backoff, $this->currentDatabaseTimeInMicroseconds($connection), @@ -127,7 +137,7 @@ public function recordFailure(string $key, Backoff $backoff): BackoffResult $this->writeState($connection, $key, $value, $secondaryValue, $expiresAt); return $result; - }, attempts: 3); + }); } /** @@ -136,7 +146,7 @@ public function recordFailure(string $key, Backoff $backoff): BackoffResult public function clear(string $key): bool { $connection = $this->connections->connection($this->connectionName); - $this->ensureOutsideTransaction($connection); + $this->ensureCanMutate($connection); return $connection ->table($this->table) @@ -157,7 +167,7 @@ public function pruneExpired(int $chunkSize = 1000): int } $connection = $this->connections->connection($this->connectionName); - $this->ensureOutsideTransaction($connection); + $this->ensureCanMutate($connection); $cutoff = $this->currentDatabaseTimeInMicroseconds($connection); $pruned = 0; @@ -190,40 +200,95 @@ public function pruneExpired(int $chunkSize = 1000): int } /** - * Insert an empty state row if the key does not exist. + * Mutate locked state, initializing a missing non-SQLite row in a new transaction. + * + * @template TResult of BackoffResult|CooldownResult|LimitResult + * @param Closure(ConnectionInterface, array{int, int, int}): TResult $callback + * @return TResult */ - protected function insertStateRow(ConnectionInterface $connection, string $key): void + protected function mutateState( + ConnectionInterface $connection, + string $key, + Closure $callback, + ): BackoffResult|CooldownResult|LimitResult { + $result = $connection->transaction(function (ConnectionInterface $connection) use ($key, $callback): BackoffResult|CooldownResult|LimitResult|null { + $state = $this->stateForUpdate($connection, $key); + + return $state === null ? null : $callback($connection, $state); + }, attempts: 3); + + if ($result !== null) { + return $result; + } + + // End the missing-row transaction so InnoDB releases its gap lock before + // initialization. PostgreSQL shares this path so every non-SQLite driver + // uses the same initialization order. + return $connection->transaction(function (ConnectionInterface $connection) use ($key, $callback): BackoffResult|CooldownResult|LimitResult { + return $callback($connection, $this->initializeStateRowForUpdate($connection, $key)); + }, attempts: 3); + } + + /** + * Return an empty state row for a physical limiter key. + * + * @return array{key: string, value: int, secondary_value: int, expires_at: int} + */ + protected function emptyStateRow(string $key): array { - $connection->table($this->table)->insertOrIgnore([ + return [ 'key' => $key, 'value' => 0, 'secondary_value' => 0, 'expires_at' => 0, - ]); + ]; + } + + /** + * Insert an empty state row if the key does not exist. + */ + protected function insertStateRow(ConnectionInterface $connection, string $key): void + { + $connection->table($this->table)->insertOrIgnore($this->emptyStateRow($key)); } /** - * Lock and read state, inserting an empty row when necessary. + * Initialize and lock state for a missing non-SQLite physical limiter key. * * @return array{int, int, int} */ - protected function stateForUpdate(ConnectionInterface $connection, string $key): array + protected function initializeStateRowForUpdate(ConnectionInterface $connection, string $key): array { - if ($connection->getDriverName() === 'sqlite') { - // SQLite ignores FOR UPDATE, so writing first acquires its database writer lock. - $this->insertStateRow($connection, $key); - } else { - // Lock established rows first. Insert-first makes concurrent InnoDB transactions - // repeatedly deadlock while upgrading duplicate-key shared locks. - $state = $this->findStateForUpdate($connection, $key); - - if ($state !== null) { - return $state; - } + $connection->table($this->table)->upsert( + $this->emptyStateRow($key), + 'key', + ['key' => $key], + ); + + $state = $this->findStateForUpdate($connection, $key); + + // The upsert created or exclusively locked the row, so a concurrent clear or + // prune cannot remove it before this transaction's locking read. + if ($state === null) { + throw new UnexpectedValueException('The database rate limiter state row could not be read after insertion.'); + } + + return $state; + } - $this->insertStateRow($connection, $key); + /** + * Lock and read state, initializing SQLite state under its writer lock. + * + * @return null|array{int, int, int} + */ + protected function stateForUpdate(ConnectionInterface $connection, string $key): ?array + { + if ($connection->getDriverName() !== 'sqlite') { + return $this->findStateForUpdate($connection, $key); } + // SQLite ignores FOR UPDATE, so writing first acquires its database writer lock. + $this->insertStateRow($connection, $key); $state = $this->findStateForUpdate($connection, $key); if ($state === null) { @@ -286,9 +351,9 @@ protected function writeState( } /** - * Ensure limiter mutations own their database transaction. + * Ensure the connection can safely mutate limiter state. */ - protected function ensureOutsideTransaction(ConnectionInterface $connection): void + protected function ensureCanMutate(ConnectionInterface $connection): void { if ($connection->transactionLevel() > 0) { throw new LogicException( @@ -296,6 +361,23 @@ protected function ensureOutsideTransaction(ConnectionInterface $connection): vo . 'Configure a dedicated rate limiter connection or call the limiter outside the transaction.' ); } + + if ($connection->getDriverName() !== 'pgsql') { + return; + } + + $isolationLevel = $connection->getConfig('isolation_level'); + + // At stronger isolation levels, PostgreSQL can abort a locking read after a + // concurrent update commits, exhausting the limiter's transaction attempts. + if ($isolationLevel !== null + && (! is_string($isolationLevel) || strcasecmp($isolationLevel, 'read committed') !== 0)) { + $connectionName = $connection->getName() ?? $this->connectionName ?? 'default'; + + throw new InvalidArgumentException( + "PostgreSQL database rate limiter connection [{$connectionName}] must use READ COMMITTED transaction isolation." + ); + } } /** diff --git a/tests/RateLimiter/DatabaseStoreTest.php b/tests/RateLimiter/DatabaseStoreTest.php index ab9be8b7a..9b6fdad64 100644 --- a/tests/RateLimiter/DatabaseStoreTest.php +++ b/tests/RateLimiter/DatabaseStoreTest.php @@ -12,6 +12,7 @@ use Hypervel\RateLimiter\DatabaseStore; use Hypervel\RateLimiter\Limit; use Hypervel\Tests\TestCase; +use InvalidArgumentException; use LogicException; use Mockery as m; use PHPUnit\Framework\Attributes\DataProvider; @@ -61,7 +62,7 @@ public function testEstablishedNonSqlMutationLocksBeforeReadingServerTimeWithout ->with(m::type(Closure::class), 3) ->andReturnUsing(static fn (Closure $callback): mixed => $callback($connection)); $connection->shouldReceive('getDriverName') - ->twice() + ->times(3) ->andReturnUsing(static function () use (&$operations): string { $operations[] = 'driver'; @@ -113,36 +114,57 @@ public function testEstablishedNonSqlMutationLocksBeforeReadingServerTimeWithout $this->assertTrue($result->allowed()); $this->assertSame(9, $result->remaining()); - $this->assertSame(['driver', 'lock', 'driver', 'clock', 'update'], $operations); + $this->assertSame(['driver', 'driver', 'lock', 'driver', 'clock', 'update'], $operations); } - public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLocks(): void - { + #[DataProvider('nonSqliteDrivers')] + public function testMissingNonSqliteStateIsInitializedAndMutatedInASecondTransaction( + string $driver, + string $clockQuery, + ): void { $connections = m::mock(ConnectionResolverInterface::class); $connection = m::mock(ConnectionInterface::class); $missing = m::mock(Builder::class); - $insert = m::mock(Builder::class); + $initialize = m::mock(Builder::class); $locked = m::mock(Builder::class); $update = m::mock(Builder::class); $operations = []; + $transaction = 0; $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); $connection->shouldReceive('transactionLevel')->once()->andReturn(0); $connection->shouldReceive('transaction') - ->once() + ->twice() ->with(m::type(Closure::class), 3) - ->andReturnUsing(static fn (Closure $callback): mixed => $callback($connection)); + ->andReturnUsing(static function (Closure $callback) use ($connection, &$operations, &$transaction): mixed { + ++$transaction; + $operations[] = "transaction-{$transaction}-begin"; + $result = $callback($connection); + $operations[] = "transaction-{$transaction}-commit"; + + return $result; + }); $connection->shouldReceive('getDriverName') - ->twice() - ->andReturnUsing(static function () use (&$operations): string { + ->times(3) + ->andReturnUsing(static function () use ($driver, &$operations): string { $operations[] = 'driver'; - return 'pgsql'; + return $driver; }); + + if ($driver === 'pgsql') { + $connection->shouldReceive('getConfig') + ->once() + ->with('isolation_level') + ->andReturn('READ COMMITTED'); + } else { + $connection->shouldNotReceive('getConfig'); + } + $connection->shouldReceive('table') ->times(4) ->with('custom_rate_limits') - ->andReturn($missing, $insert, $locked, $update); + ->andReturn($missing, $initialize, $locked, $update); $missing->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); $missing->shouldReceive('lockForUpdate')->once()->andReturnSelf(); $missing->shouldReceive('first')->once()->andReturnUsing(static function () use (&$operations): null { @@ -150,18 +172,22 @@ public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLock return null; }); - $insert->shouldReceive('insertOrIgnore') + $initialize->shouldReceive('upsert') ->once() - ->with([ - 'key' => 'physical-key', - 'value' => 0, - 'secondary_value' => 0, - 'expires_at' => 0, - ]) + ->with( + [ + 'key' => 'physical-key', + 'value' => 0, + 'secondary_value' => 0, + 'expires_at' => 0, + ], + 'key', + ['key' => 'physical-key'], + ) ->andReturnUsing(static function () use (&$operations): int { - $operations[] = 'insert'; + $operations[] = 'upsert'; - return 1; + return 0; }); $locked->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); $locked->shouldReceive('lockForUpdate')->once()->andReturnSelf(); @@ -177,7 +203,7 @@ public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLock $connection->shouldReceive('scalar') ->once() ->with( - 'SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint', + $clockQuery, [], false, ) @@ -206,11 +232,45 @@ public function testMissingNonSqlStateIsInsertedBetweenTheInitialAndFinalRowLock $this->assertTrue($result->allowed()); $this->assertSame(9, $result->remaining()); $this->assertSame( - ['driver', 'missing-lock', 'insert', 'final-lock', 'driver', 'clock', 'update'], + [ + 'driver', + 'transaction-1-begin', + 'driver', + 'missing-lock', + 'transaction-1-commit', + 'transaction-2-begin', + 'upsert', + 'final-lock', + 'driver', + 'clock', + 'update', + 'transaction-2-commit', + ], $operations, ); } + /** + * @return array + */ + public static function nonSqliteDrivers(): array + { + return [ + 'MySQL' => [ + 'mysql', + 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(6)) * 1000000)', + ], + 'MariaDB' => [ + 'mariadb', + 'SELECT FLOOR(UNIX_TIMESTAMP(CURRENT_TIMESTAMP(6)) * 1000000)', + ], + 'PostgreSQL' => [ + 'pgsql', + 'SELECT FLOOR(EXTRACT(EPOCH FROM clock_timestamp()) * 1000000)::bigint', + ], + ]; + } + public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void { $connections = m::mock(ConnectionResolverInterface::class); @@ -227,7 +287,7 @@ public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void ->with(m::type(Closure::class), 3) ->andReturnUsing(static fn (Closure $callback): mixed => $callback($connection)); $connection->shouldReceive('getDriverName') - ->twice() + ->times(3) ->andReturnUsing(static function () use (&$operations): string { $operations[] = 'driver'; @@ -278,7 +338,100 @@ public function testSqliteMutationInsertsBeforeReadingTheLockedState(): void $this->assertTrue($result->allowed()); $this->assertSame(9, $result->remaining()); - $this->assertSame(['driver', 'insert', 'lock', 'driver', 'update'], $operations); + $this->assertSame(['driver', 'driver', 'insert', 'lock', 'driver', 'update'], $operations); + } + + #[DataProvider('mutatingOperations')] + public function testMutatingOperationsRejectUnsupportedPostgresIsolation(string $operation): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getDriverName')->once()->andReturn('pgsql'); + $connection->shouldReceive('getConfig') + ->once() + ->with('isolation_level') + ->andReturn('repeatable read'); + $connection->shouldReceive('getName')->once()->andReturn('limiter'); + $connection->shouldNotReceive('transaction'); + $connection->shouldNotReceive('table'); + $connection->shouldNotReceive('scalar'); + + $store = new DatabaseStore($connections, 'limiter', 'custom_rate_limits'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'PostgreSQL database rate limiter connection [limiter] must use READ COMMITTED transaction isolation.' + ); + + match ($operation) { + 'consume' => $store->consume('physical-key', Limit::perMinute(10)), + 'block' => $store->block('physical-key', 1_000_000), + 'recordFailure' => $store->recordFailure('physical-key', Backoff::exponential()), + 'clear' => $store->clear('physical-key'), + 'pruneExpired' => $store->pruneExpired(), + }; + } + + public function testMutationRejectsNonStringPostgresIsolation(): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getDriverName')->once()->andReturn('pgsql'); + $connection->shouldReceive('getConfig')->once()->with('isolation_level')->andReturn(1); + $connection->shouldReceive('getName')->once()->andReturn('limiter'); + $connection->shouldNotReceive('table'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage( + 'PostgreSQL database rate limiter connection [limiter] must use READ COMMITTED transaction isolation.' + ); + + (new DatabaseStore($connections, 'limiter', 'custom_rate_limits'))->clear('physical-key'); + } + + public function testPostgresMutationAllowsDefaultIsolation(): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $query = m::mock(Builder::class); + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getDriverName')->once()->andReturn('pgsql'); + $connection->shouldReceive('getConfig')->once()->with('isolation_level')->andReturnNull(); + $connection->shouldNotReceive('getName'); + $connection->shouldReceive('table')->once()->with('custom_rate_limits')->andReturn($query); + $query->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $query->shouldReceive('delete')->once()->andReturn(1); + + $this->assertTrue( + (new DatabaseStore($connections, 'limiter', 'custom_rate_limits'))->clear('physical-key') + ); + } + + public function testMysqlMutationDoesNotInspectPostgresIsolationConfiguration(): void + { + $connections = m::mock(ConnectionResolverInterface::class); + $connection = m::mock(ConnectionInterface::class); + $query = m::mock(Builder::class); + + $connections->shouldReceive('connection')->once()->with('limiter')->andReturn($connection); + $connection->shouldReceive('transactionLevel')->once()->andReturn(0); + $connection->shouldReceive('getDriverName')->once()->andReturn('mysql'); + $connection->shouldNotReceive('getConfig'); + $connection->shouldReceive('table')->once()->with('custom_rate_limits')->andReturn($query); + $query->shouldReceive('where')->once()->with('key', 'physical-key')->andReturnSelf(); + $query->shouldReceive('delete')->once()->andReturn(1); + + $this->assertTrue( + (new DatabaseStore($connections, 'limiter', 'custom_rate_limits'))->clear('physical-key') + ); } #[DataProvider('mutatingOperations')] @@ -303,6 +456,7 @@ public function testMutatingOperationsRejectAnActiveTransactionBeforeLimiterSql( match ($operation) { 'consume' => $store->consume('physical-key', Limit::perMinute(10)), + 'block' => $store->block('physical-key', 1_000_000), 'recordFailure' => $store->recordFailure('physical-key', Backoff::exponential()), 'clear' => $store->clear('physical-key'), 'pruneExpired' => $store->pruneExpired(), @@ -313,6 +467,7 @@ public static function mutatingOperations(): array { return [ 'consume' => ['consume'], + 'block' => ['block'], 'record failure' => ['recordFailure'], 'clear' => ['clear'], 'prune expired' => ['pruneExpired'], From 559ce8dccbb7f91dca1624fa8935bfbed3674db9 Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:25:41 +0000 Subject: [PATCH 18/22] Preserve future deadlines with mutable dates InteractsWithTime compares a calculated target against the captured current time before deciding whether to round a future instant upward. With the supported mutable Date factory, addSeconds() changed that captured baseline in place, causing future integer delays to bypass the ceiling correction. Use avoidMutation() before applying integer delays. Immutable dates retain the same allocation-free path, while mutable dates now preserve the comparison baseline. Add a regression that proves the mutable factory is active and covers positive, zero, and negative delays. --- src/support/src/InteractsWithTime.php | 2 +- tests/Foundation/FoundationInteractsWithTimeTest.php | 12 ++++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/support/src/InteractsWithTime.php b/src/support/src/InteractsWithTime.php index 1df47df84..98be2631e 100644 --- a/src/support/src/InteractsWithTime.php +++ b/src/support/src/InteractsWithTime.php @@ -34,7 +34,7 @@ protected function availableAt(DateInterval|DateTimeInterface|int|null $delay = $target = $delay instanceof DateTimeInterface ? Date::instance($delay) - : $now->addSeconds($delay); + : $now->avoidMutation()->addSeconds($delay); return $target > $now ? $target->ceilSecond()->getTimestamp() diff --git a/tests/Foundation/FoundationInteractsWithTimeTest.php b/tests/Foundation/FoundationInteractsWithTimeTest.php index 9a561a4ef..c5f395a28 100644 --- a/tests/Foundation/FoundationInteractsWithTimeTest.php +++ b/tests/Foundation/FoundationInteractsWithTimeTest.php @@ -138,6 +138,18 @@ public function testFutureIntegerDeadlinesRoundUpWithoutDelayingImmediateOrPastV $this->assertSame(999, $formatter->availableAt(-1)); } + public function testFutureIntegerDeadlinesRoundUpWithMutableDates(): void + { + Date::use(Carbon::class); + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); + $formatter = new SupportInteractsWithTimeTestFixture; + + $this->assertSame(Carbon::class, Date::now()::class); + $this->assertSame(1002, $formatter->availableAt(1)); + $this->assertSame(1000, $formatter->availableAt(0)); + $this->assertSame(999, $formatter->availableAt(-1)); + } + public function testFutureIntervalDeadlinesRoundUpWithoutDelayingZeroOrInvertedIntervals(): void { CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); From 2d3015fef153523b45f059f9d1a0070d6c88a9ee Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:25:52 +0000 Subject: [PATCH 19/22] Normalize Redis tag TTLs before metadata writes Redis requires expiring writes to use a positive TTL. Several tagged-cache operations clamped the value TTL only at the final Redis call while computing tag scores and related metadata from the original value. Non-positive durations could therefore create past registry scores or send invalid expiries to AnyTag hash fields. Normalize each expiring TTL once at the public operation boundary and pass that value through standalone, Cluster, pipeline, and Lua paths. Preserve null as AnyTag Add's permanent sentinel and keep empty PutMany calls as no-ops. Document the contract and add regressions that assert value, reverse-index, hash-field, and registry metadata stay consistent. --- src/cache/src/Redis/Operations/AllTag/Add.php | 8 +++-- src/cache/src/Redis/Operations/AllTag/Put.php | 8 +++-- .../src/Redis/Operations/AllTag/PutMany.php | 10 +++--- .../src/Redis/Operations/AllTag/Touch.php | 6 ++-- src/cache/src/Redis/Operations/AnyTag/Add.php | 10 +++--- src/cache/src/Redis/Operations/AnyTag/Put.php | 10 +++--- .../src/Redis/Operations/AnyTag/PutMany.php | 18 +++++----- .../src/Redis/Operations/AnyTag/Touch.php | 8 +++-- .../Cache/Redis/Operations/AllTag/AddTest.php | 11 +++++-- .../Redis/Operations/AllTag/PutManyTest.php | 5 ++- .../Cache/Redis/Operations/AllTag/PutTest.php | 6 +++- .../Cache/Redis/Operations/AnyTag/AddTest.php | 32 ++++++++++++++++++ .../Redis/Operations/AnyTag/PutManyTest.php | 33 +++++++++++++++++++ .../Cache/Redis/Operations/AnyTag/PutTest.php | 31 +++++++++++++++++ 14 files changed, 157 insertions(+), 39 deletions(-) diff --git a/src/cache/src/Redis/Operations/AllTag/Add.php b/src/cache/src/Redis/Operations/AllTag/Add.php index 3b512efb8..b1c29d75b 100644 --- a/src/cache/src/Redis/Operations/AllTag/Add.php +++ b/src/cache/src/Redis/Operations/AllTag/Add.php @@ -36,12 +36,14 @@ public function __construct( * * @param string $key The cache key (already namespaced by caller) * @param mixed $value The value to store - * @param int $seconds TTL in seconds + * @param int $seconds TTL in seconds; values below one are stored for one second * @param array $tagIds Array of tag identifiers * @return bool True if the key was added (didn't exist), false if it already existed */ public function execute(string $key, mixed $value, int $seconds, array $tagIds): bool { + $seconds = max(1, $seconds); + if ($this->context->isCluster()) { return $this->executeCluster($key, $value, $seconds, $tagIds); } @@ -75,7 +77,7 @@ private function executePipeline(string $key, mixed $value, int $seconds, array $result = $connection->set( $prefix . $key, $this->serialization->serialize($connection, $value), - ['EX' => max(1, $seconds), 'NX'] + ['EX' => $seconds, 'NX'] ); return (bool) $result; @@ -103,7 +105,7 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $ $result = $connection->set( $prefix . $key, $this->serialization->serialize($connection, $value), - ['EX' => max(1, $seconds), 'NX'] + ['EX' => $seconds, 'NX'] ); return (bool) $result; diff --git a/src/cache/src/Redis/Operations/AllTag/Put.php b/src/cache/src/Redis/Operations/AllTag/Put.php index 63a523aea..910cfe060 100644 --- a/src/cache/src/Redis/Operations/AllTag/Put.php +++ b/src/cache/src/Redis/Operations/AllTag/Put.php @@ -34,12 +34,14 @@ public function __construct( * * @param string $key The cache key (already namespaced by caller) * @param mixed $value The value to store - * @param int $seconds TTL in seconds + * @param int $seconds TTL in seconds; values below one are stored for one second * @param array $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") * @return bool True if successful */ public function execute(string $key, mixed $value, int $seconds, array $tagIds): bool { + $seconds = max(1, $seconds); + if ($this->context->isCluster()) { return $this->executeCluster($key, $value, $seconds, $tagIds); } @@ -67,7 +69,7 @@ private function executePipeline(string $key, mixed $value, int $seconds, array } // SETEX for the cache value - $pipeline->setex($prefix . $key, max(1, $seconds), $serialized); + $pipeline->setex($prefix . $key, $seconds, $serialized); $results = $pipeline->exec(); @@ -95,7 +97,7 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $ } // SETEX for the cache value - return (bool) $connection->setex($prefix . $key, max(1, $seconds), $serialized); + return (bool) $connection->setex($prefix . $key, $seconds, $serialized); }); } } diff --git a/src/cache/src/Redis/Operations/AllTag/PutMany.php b/src/cache/src/Redis/Operations/AllTag/PutMany.php index bba6b16f8..f51f5378b 100644 --- a/src/cache/src/Redis/Operations/AllTag/PutMany.php +++ b/src/cache/src/Redis/Operations/AllTag/PutMany.php @@ -29,7 +29,7 @@ public function __construct( * Execute the putMany operation with tag tracking. * * @param array $values Key-value pairs (keys already namespaced) - * @param int $seconds TTL in seconds + * @param int $seconds TTL in seconds; values below one are stored for one second * @param array $tagIds Array of tag identifiers * @param string $namespace The namespace prefix for keys (for building namespaced keys) * @return bool True if all operations successful @@ -40,6 +40,8 @@ public function execute(array $values, int $seconds, array $tagIds, string $name return true; } + $seconds = max(1, $seconds); + if ($this->context->isCluster()) { return $this->executeCluster($values, $seconds, $tagIds, $namespace); } @@ -58,7 +60,6 @@ private function executePipeline(array $values, int $seconds, array $tagIds, str return $this->context->withConnection(function (RedisConnection $connection) use ($values, $seconds, $tagIds, $namespace): bool { $prefix = $this->context->prefix(); $score = $this->context->expirationScore($seconds); - $ttl = max(1, $seconds); // Prepare all data up front $preparedEntries = []; @@ -84,7 +85,7 @@ private function executePipeline(array $values, int $seconds, array $tagIds, str // Then all SETEXs foreach ($preparedEntries as $namespacedKey => $serialized) { - $pipeline->setex($prefix . $namespacedKey, $ttl, $serialized); + $pipeline->setex($prefix . $namespacedKey, $seconds, $serialized); } $results = $pipeline->exec(); @@ -105,7 +106,6 @@ private function executeCluster(array $values, int $seconds, array $tagIds, stri return $this->context->withConnection(function (RedisConnection $connection) use ($values, $seconds, $tagIds, $namespace): bool { $prefix = $this->context->prefix(); $score = $this->context->expirationScore($seconds); - $ttl = max(1, $seconds); // Prepare all data up front $preparedEntries = []; @@ -130,7 +130,7 @@ private function executeCluster(array $values, int $seconds, array $tagIds, stri // Then all SETEXs $allSucceeded = true; foreach ($preparedEntries as $namespacedKey => $serialized) { - if (! $connection->setex($prefix . $namespacedKey, $ttl, $serialized)) { + if (! $connection->setex($prefix . $namespacedKey, $seconds, $serialized)) { $allSucceeded = false; } } diff --git a/src/cache/src/Redis/Operations/AllTag/Touch.php b/src/cache/src/Redis/Operations/AllTag/Touch.php index b4e22b4ef..c8cec198e 100644 --- a/src/cache/src/Redis/Operations/AllTag/Touch.php +++ b/src/cache/src/Redis/Operations/AllTag/Touch.php @@ -28,10 +28,13 @@ public function __construct( /** * Execute the touch operation. * + * @param int $seconds TTL in seconds; values below one are stored for one second * @param array $tagIds Array of tag identifiers */ public function execute(string $key, int $seconds, array $tagIds): bool { + $seconds = max(1, $seconds); + if ($this->context->isCluster()) { return $this->executeCluster($key, $seconds, $tagIds); } @@ -46,7 +49,6 @@ private function executeCluster(string $key, int $seconds, array $tagIds): bool { return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $tagIds) { $prefix = $this->context->prefix(); - $seconds = max(1, $seconds); if (! $connection->expire($prefix . $key, $seconds)) { return false; @@ -68,8 +70,6 @@ private function executeCluster(string $key, int $seconds, array $tagIds): bool private function executeUsingLua(string $key, int $seconds, array $tagIds): bool { return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $tagIds) { - $seconds = max(1, $seconds); - // Static tag ZSET keys belong in KEYS so phpredis applies // OPT_PREFIX; ARGV-built keys are only for dynamic Lua paths. $keys = [ diff --git a/src/cache/src/Redis/Operations/AnyTag/Add.php b/src/cache/src/Redis/Operations/AnyTag/Add.php index 7d2741c1b..727ff699f 100644 --- a/src/cache/src/Redis/Operations/AnyTag/Add.php +++ b/src/cache/src/Redis/Operations/AnyTag/Add.php @@ -30,12 +30,14 @@ public function __construct( * * @param string $key The cache key (without prefix) * @param mixed $value The value to store (will be serialized) - * @param null|int $seconds TTL in seconds, or null for no expiration + * @param null|int $seconds TTL in seconds; null means no expiration and values below one are stored for one second * @param array $tags Array of tag names (will be cast to strings) * @return bool True if item was added, false if it already exists */ public function execute(string $key, mixed $value, ?int $seconds, array $tags): bool { + $seconds = $seconds === null ? null : max(1, $seconds); + // 1. Cluster Mode: Must use sequential commands if ($this->context->isCluster()) { return $this->executeCluster($key, $value, $seconds, $tags); @@ -56,7 +58,7 @@ private function executeCluster(string $key, mixed $value, ?int $seconds, array // First try to add the key with NX flag $options = $seconds === null ? ['NX'] - : ['EX' => max(1, $seconds), 'NX']; + : ['EX' => $seconds, 'NX']; $added = $connection->set( $prefix . $key, @@ -81,7 +83,7 @@ private function executeCluster(string $key, mixed $value, ?int $seconds, array $multi->sadd($tagsKey, ...$tags); if ($seconds !== null) { - $multi->expire($tagsKey, max(1, $seconds)); + $multi->expire($tagsKey, $seconds); } $multi->exec(); @@ -137,7 +139,7 @@ private function executeUsingLua(string $key, mixed $value, ?int $seconds, array $args = [ $this->serialization->serializeForLua($connection, $value), // ARGV[1] - $seconds === null ? 0 : max(1, $seconds), // ARGV[2] + $seconds ?? 0, // ARGV[2] $this->context->fullTagPrefix(), // ARGV[3] $this->context->fullRegistryKey(), // ARGV[4] time(), // ARGV[5] diff --git a/src/cache/src/Redis/Operations/AnyTag/Put.php b/src/cache/src/Redis/Operations/AnyTag/Put.php index 996702d75..752a70608 100644 --- a/src/cache/src/Redis/Operations/AnyTag/Put.php +++ b/src/cache/src/Redis/Operations/AnyTag/Put.php @@ -41,12 +41,14 @@ public function __construct( * * @param string $key The cache key (without prefix) * @param mixed $value The value to store (will be serialized) - * @param int $seconds TTL in seconds (must be > 0) + * @param int $seconds TTL in seconds; values below one are stored for one second * @param array $tags Array of tag names (will be cast to strings) * @return bool True if successful, false on failure */ public function execute(string $key, mixed $value, int $seconds, array $tags): bool { + $seconds = max(1, $seconds); + // 1. Cluster Mode: Must use sequential commands if ($this->context->isCluster()) { return $this->executeCluster($key, $value, $seconds, $tags); @@ -71,7 +73,7 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $ // Store the actual cache value $connection->setex( $prefix . $key, - max(1, $seconds), + $seconds, $this->serialization->serialize($connection, $value) ); @@ -82,7 +84,7 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $ if (! empty($tags)) { $multi->sadd($tagsKey, ...$tags); - $multi->expire($tagsKey, max(1, $seconds)); + $multi->expire($tagsKey, $seconds); } $multi->exec(); @@ -149,7 +151,7 @@ private function executeUsingLua(string $key, mixed $value, int $seconds, array $args = [ $this->serialization->serializeForLua($connection, $value), // ARGV[1] - max(1, $seconds), // ARGV[2] + $seconds, // ARGV[2] $this->context->fullTagPrefix(), // ARGV[3] $this->context->fullRegistryKey(), // ARGV[4] time(), // ARGV[5] diff --git a/src/cache/src/Redis/Operations/AnyTag/PutMany.php b/src/cache/src/Redis/Operations/AnyTag/PutMany.php index 542a141f9..51c0b26fb 100644 --- a/src/cache/src/Redis/Operations/AnyTag/PutMany.php +++ b/src/cache/src/Redis/Operations/AnyTag/PutMany.php @@ -31,7 +31,7 @@ public function __construct( * Execute the putMany operation. * * @param array $values Array of key => value pairs - * @param int $seconds TTL in seconds + * @param int $seconds TTL in seconds; values below one are stored for one second * @param array $tags Array of tag names * @return bool True if successful, false on failure */ @@ -41,6 +41,8 @@ public function execute(array $values, int $seconds, array $tags): bool return true; } + $seconds = max(1, $seconds); + // 1. Cluster Mode: Must use sequential commands if ($this->context->isCluster()) { return $this->executeCluster($values, $seconds, $tags); @@ -59,7 +61,6 @@ private function executeCluster(array $values, int $seconds, array $tags): bool $prefix = $this->context->prefix(); $registryKey = $this->context->registryKey(); $expiry = time() + $seconds; - $ttl = max(1, $seconds); foreach (array_chunk($values, self::CHUNK_SIZE, true) as $chunk) { // Step 1: Retrieve old tags for all keys in the chunk @@ -89,7 +90,7 @@ private function executeCluster(array $values, int $seconds, array $tags): bool // 1. Store the actual cache value $connection->setex( $prefix . $key, - $ttl, + $seconds, $this->serialization->serialize($connection, $value) ); @@ -102,7 +103,7 @@ private function executeCluster(array $values, int $seconds, array $tags): bool if (! empty($tags)) { $multi->sadd($tagsKey, ...$tags); - $multi->expire($tagsKey, $ttl); + $multi->expire($tagsKey, $seconds); } $multi->exec(); @@ -132,7 +133,7 @@ private function executeCluster(array $values, int $seconds, array $tags): bool $fields = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); - $connection->hsetex($tagHashKey, $fields, ['EX' => $ttl]); + $connection->hsetex($tagHashKey, $fields, ['EX' => $seconds]); } // 5. Batch update Registry (Same slot, single command optimization) @@ -161,7 +162,6 @@ private function executeUsingPipeline(array $values, int $seconds, array $tags): $prefix = $this->context->prefix(); $registryKey = $this->context->registryKey(); $expiry = time() + $seconds; - $ttl = max(1, $seconds); foreach (array_chunk($values, self::CHUNK_SIZE, true) as $chunk) { // Step 1: Retrieve old tags for all keys in the chunk @@ -194,7 +194,7 @@ private function executeUsingPipeline(array $values, int $seconds, array $tags): // 1. Store the actual cache value $pipeline->setex( $prefix . $key, - $ttl, + $seconds, $this->serialization->serialize($connection, $value) ); @@ -204,7 +204,7 @@ private function executeUsingPipeline(array $values, int $seconds, array $tags): if (! empty($tags)) { $pipeline->sadd($tagsKey, ...$tags); - $pipeline->expire($tagsKey, $ttl); + $pipeline->expire($tagsKey, $seconds); } // Collect keys for batch tag update (New Tags) @@ -232,7 +232,7 @@ private function executeUsingPipeline(array $values, int $seconds, array $tags): $fields = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); - $pipeline->hsetex($tagHashKey, $fields, ['EX' => $ttl]); + $pipeline->hsetex($tagHashKey, $fields, ['EX' => $seconds]); } // Update Registry in batch diff --git a/src/cache/src/Redis/Operations/AnyTag/Touch.php b/src/cache/src/Redis/Operations/AnyTag/Touch.php index 2d63444ae..d6999a78a 100644 --- a/src/cache/src/Redis/Operations/AnyTag/Touch.php +++ b/src/cache/src/Redis/Operations/AnyTag/Touch.php @@ -27,9 +27,13 @@ public function __construct( /** * Execute the touch operation. + * + * @param int $seconds TTL in seconds; values below one are stored for one second */ public function execute(string $key, int $seconds): bool { + $seconds = max(1, $seconds); + if ($this->context->isCluster()) { return $this->executeCluster($key, $seconds); } @@ -43,8 +47,6 @@ public function execute(string $key, int $seconds): bool private function executeCluster(string $key, int $seconds): bool { return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds) { - $seconds = max(1, $seconds); - if (! $connection->expire($this->context->prefix() . $key, $seconds)) { return false; } @@ -88,7 +90,7 @@ private function executeUsingLua(string $key, int $seconds): bool ]; $args = [ - max(1, $seconds), + $seconds, $this->context->fullTagPrefix(), $this->context->fullRegistryKey(), time(), diff --git a/tests/Cache/Redis/Operations/AllTag/AddTest.php b/tests/Cache/Redis/Operations/AllTag/AddTest.php index 82813c58e..0805a8f7e 100644 --- a/tests/Cache/Redis/Operations/AllTag/AddTest.php +++ b/tests/Cache/Redis/Operations/AllTag/AddTest.php @@ -223,10 +223,15 @@ public function testAddInClusterModeReturnsFalseWhenKeyExists(): void */ public function testAddEnforcesMinimumTtlOfOne(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); $connection = $this->mockConnection(); - // No pipeline for empty tags - $connection->shouldNotReceive('pipeline'); + $connection->shouldReceive('pipeline')->once()->andReturn($connection); + $connection->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', 1002, 'mykey') + ->andReturn($connection); + $connection->shouldReceive('exec')->once()->andReturn([1]); // TTL should be at least 1 $connection->shouldReceive('set') @@ -239,7 +244,7 @@ public function testAddEnforcesMinimumTtlOfOne(): void 'mykey', 'myvalue', 0, // Zero TTL - [] + ['_all:tag:users:entries'] ); $this->assertTrue($result); diff --git a/tests/Cache/Redis/Operations/AllTag/PutManyTest.php b/tests/Cache/Redis/Operations/AllTag/PutManyTest.php index bd78a9978..bd20c44f1 100644 --- a/tests/Cache/Redis/Operations/AllTag/PutManyTest.php +++ b/tests/Cache/Redis/Operations/AllTag/PutManyTest.php @@ -262,7 +262,10 @@ public function testPutManyEnforcesMinimumTtlOfOne(): void $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $connection->shouldReceive('zadd')->andReturn($connection); + $connection->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', 1002, 'ns:foo') + ->andReturn($connection); // TTL should be at least 1 $connection->shouldReceive('setex') diff --git a/tests/Cache/Redis/Operations/AllTag/PutTest.php b/tests/Cache/Redis/Operations/AllTag/PutTest.php index 3218917b2..663727c8c 100644 --- a/tests/Cache/Redis/Operations/AllTag/PutTest.php +++ b/tests/Cache/Redis/Operations/AllTag/PutTest.php @@ -228,11 +228,15 @@ public function testPutInClusterModeUsesSequentialCommands(): void */ public function testPutEnforcesMinimumTtlOfOne(): void { + CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC('1000.900000')); $connection = $this->mockConnection(); $connection->shouldReceive('pipeline')->once()->andReturn($connection); - $connection->shouldReceive('zadd')->andReturn($connection); + $connection->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', 1002, 'mykey') + ->andReturn($connection); // TTL should be at least 1 $connection->shouldReceive('setex') diff --git a/tests/Cache/Redis/Operations/AnyTag/AddTest.php b/tests/Cache/Redis/Operations/AnyTag/AddTest.php index 80b4c80be..e1ba23a55 100644 --- a/tests/Cache/Redis/Operations/AnyTag/AddTest.php +++ b/tests/Cache/Redis/Operations/AnyTag/AddTest.php @@ -79,6 +79,38 @@ public function testAddWithoutTtlIsPermanentInClusterMode(): void $this->assertTrue($redis->anyTagOps()->add()->execute('foo', 'bar', null, ['users'])); } + public function testAddNormalizesExpiringMetadataInClusterMode(): void + { + [$redis, , $connection] = $this->createClusterStore(tagMode: 'any'); + $startedAt = time(); + + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('bar'), ['EX' => 1, 'NX']) + ->andReturn(true); + $connection->shouldReceive('multi')->once()->andReturnSelf(); + $connection->shouldReceive('sadd')->once()->with('prefix:foo:_any:tags', 'users')->andReturnSelf(); + $connection->shouldReceive('expire')->once()->with('prefix:foo:_any:tags', 1)->andReturnSelf(); + $connection->shouldReceive('exec')->once()->andReturn([]); + $connection->shouldReceive('hsetex') + ->once() + ->with('prefix:_any:tag:users:entries', ['foo' => '1'], ['EX' => 1]) + ->andReturn(1); + $connection->shouldReceive('zadd') + ->once() + ->withArgs(function (string $key, array $options, int $expiresAt, string $tag) use ($startedAt): bool { + $this->assertSame('prefix:_any:tag:registry', $key); + $this->assertSame(['GT'], $options); + $this->assertGreaterThan($startedAt, $expiresAt); + $this->assertSame('users', $tag); + + return true; + }) + ->andReturn(1); + + $this->assertTrue($redis->anyTagOps()->add()->execute('foo', 'bar', -60, ['users'])); + } + public function testAddWithoutTtlUsesPermanentLuaBranch(): void { $connection = $this->mockConnection(); diff --git a/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php b/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php index eb9961c3a..708908acf 100644 --- a/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php +++ b/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php @@ -84,4 +84,37 @@ public function testPutManyUsesOneBatchedHsetexPerTagInClusterMode(): void $this->assertTrue($result); } + + public function testPutManyNormalizesExpiringMetadata(): void + { + $connection = $this->mockConnection(); + $startedAt = time(); + + $connection->shouldReceive('pipeline')->twice()->andReturn($connection); + $connection->shouldReceive('smembers')->once()->with('prefix:foo:_any:tags')->andReturn($connection); + $connection->shouldReceive('exec')->twice()->andReturn([[]], []); + $connection->shouldReceive('setex')->once()->with('prefix:foo', 1, serialize('bar'))->andReturn($connection); + $connection->shouldReceive('del')->once()->with('prefix:foo:_any:tags')->andReturn($connection); + $connection->shouldReceive('sadd')->once()->with('prefix:foo:_any:tags', 'users')->andReturn($connection); + $connection->shouldReceive('expire')->once()->with('prefix:foo:_any:tags', 1)->andReturn($connection); + $connection->shouldReceive('hsetex') + ->once() + ->with('prefix:_any:tag:users:entries', ['foo' => '1'], ['EX' => 1]) + ->andReturn($connection); + $connection->shouldReceive('zadd') + ->once() + ->withArgs(function (string $key, array $options, int $expiresAt, string $tag) use ($startedAt): bool { + $this->assertSame('prefix:_any:tag:registry', $key); + $this->assertSame(['GT'], $options); + $this->assertGreaterThan($startedAt, $expiresAt); + $this->assertSame('users', $tag); + + return true; + }) + ->andReturn($connection); + + $redis = $this->createStore($connection, tagMode: 'any'); + + $this->assertTrue($redis->anyTagOps()->putMany()->execute(['foo' => 'bar'], -60, ['users'])); + } } diff --git a/tests/Cache/Redis/Operations/AnyTag/PutTest.php b/tests/Cache/Redis/Operations/AnyTag/PutTest.php index 8e09ab45f..cc979348f 100644 --- a/tests/Cache/Redis/Operations/AnyTag/PutTest.php +++ b/tests/Cache/Redis/Operations/AnyTag/PutTest.php @@ -68,6 +68,37 @@ public function testPutWithTagsUsesSequentialCommandsInClusterMode(): void $this->assertTrue($result); } + public function testPutNormalizesExpiringMetadataInClusterMode(): void + { + [$redis, , $connection] = $this->createClusterStore(tagMode: 'any'); + $startedAt = time(); + + $connection->shouldReceive('smembers')->once()->with('prefix:foo:_any:tags')->andReturn([]); + $connection->shouldReceive('setex')->once()->with('prefix:foo', 1, serialize('bar'))->andReturn(true); + $connection->shouldReceive('multi')->once()->andReturnSelf(); + $connection->shouldReceive('del')->once()->with('prefix:foo:_any:tags')->andReturnSelf(); + $connection->shouldReceive('sadd')->once()->with('prefix:foo:_any:tags', 'users')->andReturnSelf(); + $connection->shouldReceive('expire')->once()->with('prefix:foo:_any:tags', 1)->andReturnSelf(); + $connection->shouldReceive('exec')->once()->andReturn([]); + $connection->shouldReceive('hsetex') + ->once() + ->with('prefix:_any:tag:users:entries', ['foo' => '1'], ['EX' => 1]) + ->andReturn(1); + $connection->shouldReceive('zadd') + ->once() + ->withArgs(function (string $key, array $options, int $expiresAt, string $tag) use ($startedAt): bool { + $this->assertSame('prefix:_any:tag:registry', $key); + $this->assertSame(['GT'], $options); + $this->assertGreaterThan($startedAt, $expiresAt); + $this->assertSame('users', $tag); + + return true; + }) + ->andReturn(1); + + $this->assertTrue($redis->anyTagOps()->put()->execute('foo', 'bar', -60, ['users'])); + } + /** * @test */ From abcd3302773fbbf73f2df9f6484d31d0bcea3bba Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:25:57 +0000 Subject: [PATCH 20/22] Use the shared database constraint test namespace The database constraint helpers have feature-specific names and do not collide with helpers in other test files. Keep the test in the package namespace instead of adding an unnecessary test-class namespace segment, matching the repository's helper namespacing rule. --- tests/Testing/Constraints/DatabaseConstraintsTest.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/Testing/Constraints/DatabaseConstraintsTest.php b/tests/Testing/Constraints/DatabaseConstraintsTest.php index fb24564b0..21a09d449 100644 --- a/tests/Testing/Constraints/DatabaseConstraintsTest.php +++ b/tests/Testing/Constraints/DatabaseConstraintsTest.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Hypervel\Tests\Testing\Constraints\DatabaseConstraintsTest; +namespace Hypervel\Tests\Testing\Constraints; use Hypervel\Database\Connection; use Hypervel\Database\Query\Builder; From f6e0c0b9893f1ee5e1fe98b517a2a36031c29fcd Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:26:02 +0000 Subject: [PATCH 21/22] Clarify worker-lifetime lookup cache guidance State the rule in terms of internal lookup caches retained across requests, including static and singleton-held state. Require naturally limited keys or safe recomputation instead of size caps that hide growth from request-derived input. Explicitly distinguish framework lookup caches from application-owned stores such as worker-array, whose retention is part of their public storage contract. --- AGENTS.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index da02201c6..c4ea9ede4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -347,7 +347,7 @@ Decide where state lives before writing code: - **Use `Hypervel\Context\CoroutineContext` for invocation-scoped state** — anything that must not be visible to other concurrent coroutines in the same worker. Static properties and singleton fields leak across coroutines: whatever one coroutine sets becomes visible to all others in the worker. Use the established key-naming convention: `__.` value prefix, `_CONTEXT_KEY` / `_CONTEXT_KEY_PREFIX` constant suffixes, public only when other classes or tests reference the constant. Do not use `Hypervel\Support\Facades\Context` as the low-level coroutine store; it provides Laravel-style application context instead. - **Configure process-global values only during worker boot** — config is a process-global singleton, so `Config::set()` during request handling changes behavior for every concurrent request in the worker. Never mutate config for request-specific behavior; use `CoroutineContext` or middleware instead. Provider boot-time configuration is fine — it runs once per worker. - **Name static cache properties for what they store** — not with a `Cache` suffix; static properties in Swoole workers are caches by nature. Exception: matching an existing Laravel-ported pattern in the same class (e.g. `$classCastCache`, `$attributeCastCache` on `HasAttributes`). -- Any cache retained across requests in a worker—for example, in static properties or properties on singleton instances—must either have a naturally limited set of keys or deliberately discard entries that can safely be recomputed. Do not add a size limit merely to hide accidental growth from request- or user-derived keys. +- **Bound worker-lifetime lookup caches** — any internal lookup cache retained across requests in a worker—for example, in static properties or singleton instances—must have a naturally limited set of keys or discard entries that are safe to recompute. Do not add a size limit merely to hide growth from request- or user-derived keys. This governs framework-derived caches, not application-owned cache stores such as the `worker-array` driver. - **Review worker-lifetime state explicitly** — whenever a change introduces or modifies static properties/caches, singletons or other long-lived state, STOP and report the Swoole persistence impact (memory leaks, cross-request behavior) with a recommendation. - **Document worker-lifetime mutators** — when adding or touching a public method that mutates static state, singleton-held configuration, manager registries, cached drivers, global callbacks, or other worker-lifetime state, add a short warning to the method docblock if the method is intended only for boot-time configuration or tests. Use the tag-first format so humans and LLMs can recognize it quickly: - `Boot-only.` — for startup configuration methods From 2b2863cdc6b3d0f92e373ed15be5d2e2c1a8295f Mon Sep 17 00:00:00 2001 From: Raj Siva-Rajah <5361908+binaryfire@users.noreply.github.com> Date: Sat, 22 Aug 2026 03:26:08 +0000 Subject: [PATCH 22/22] Update the audit correctness implementation plan Record the mutable-date baseline preservation required by future-deadline rounding and the single-boundary TTL normalization used by Redis tag operations. Keep the test plan aligned with the implemented regressions, including deterministic negative-TTL coverage for AnyTag registry metadata. --- ...-0713-audit-correctness-and-worker-lifetime-bounds.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md index ebe5d25ea..be2afa82e 100644 --- a/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md +++ b/docs/plans/2026-08-21-0713-audit-correctness-and-worker-lifetime-bounds.md @@ -396,7 +396,7 @@ $now = Date::now(); $target = $delay instanceof DateTimeInterface ? Date::instance($delay) - : $now->addSeconds($delay); + : $now->avoidMutation()->addSeconds($delay); return $target > $now ? $target->ceilSecond()->getTimestamp() @@ -405,6 +405,8 @@ return $target > $now Keep `parseDateInterval()` authoritative. It preserves interval microseconds and remains shared with `secondsUntil()`. Capturing `$now` after parsing makes a zero interval equal to or older than `$now`, so it stays immediate. The same comparison also keeps zero/negative integers and past absolute times floored, while future integers, intervals, and fractional `DateTimeInterface` values round upward by less than one second. `DatabaseQueue::pushToDatabase()` calls `availableAt()` with no argument for immediately available batch rows; this is the load-bearing reason not to ceiling every call unconditionally. +Use `avoidMutation()` before adding an integer delay. Hypervel defaults to immutable dates, where this returns the same instance without allocation, but the supported `Date::use(Carbon::class)` opt-out would otherwise mutate `$now` in place and compare the target with itself, bypassing the future ceiling. + Keep `secondsUntil()` unchanged. In particular, do not combine a ceiled duration with a ceiled storage deadline. `Worker::currentTime()` also remains unchanged: coroutine job timeouts already use the same precise monotonic float for registration and expiry checks. This shared correction intentionally reaches all existing `availableAt()` consumers, including File/Storage cache entries and file locks, Redis and database queue delays, Redis reserved-job visibility, signed URLs, cookies/sessions, request-forgery cookies, rate-limit reset headers, Slack timestamps, and Inertia once-prop expiry. It preserves every signature and Laravel-shaped API; no porting-guide note is needed because callers receive the lifetime their existing call already expresses. @@ -451,11 +453,14 @@ Use it for both standalone and Cluster branches in: Keep both `FlushStale` current-time cutoffs floored. Ceiling the tag score reduces its former early-removal window from almost one second to at most the PHP-to-Redis command gap: the native `SETEX`/`EXPIRE` countdown begins after PHP computes the score. Do not add a fixed margin, extra command/round trip, fractional score, Lua clock, or other machinery for that residual window. Tag metadata lasting briefly after the value expires is safe; disappearing while the value is still live is the direction to avoid. +Normalize expiring Redis-tag TTLs once at each operation's public `execute()` boundary before choosing a standalone or Cluster path. `AllTag` `Add`, `Put`, `PutMany`, and `Touch` use `max(1, $seconds)` for both the value TTL and tag score. `AnyTag` `Put`, `PutMany`, and `Touch` use the same boundary; `AnyTag` `Add` preserves `null` as permanent and normalizes only non-null values. Remove private duplicate clamps so cache values, reverse indexes, hash-field expiries, registry scores, and Lua arguments receive one consistent TTL. This adds no Redis command or allocation and preserves every positive-TTL call. Keep AnyTag registry scores based on `time() + $seconds`: the registry is only a pruning index, hash fields expire independently, and reads and flushes do not rely on it, so subsecond ceiling machinery would not prevent a meaningful failure. + ### Tests Extend `tests/Foundation/FoundationInteractsWithTimeTest.php` with a clock frozen at fractional seconds and cover: - future positive integers round up, while zero and negative integers stay immediate/past; +- the mutable Date factory opt-out produces the same future, immediate, and past integer deadlines without mutating the comparison baseline; - positive intervals round up, while zero and inverted intervals do not; - future fractional absolute dates round up, while past fractional dates do not; - whole-second future targets remain unchanged. @@ -468,6 +473,8 @@ Keep general file/database lock tests that assert nominal durations on a whole-s Update the affected AllTag operation tests and add focused `Touch` coverage if no current file owns it. Pin fractional clocks to fixed instants and assert the resulting integer scores directly rather than recomputing them with the production formula. Assert every standalone and Cluster score uses `StoreContext::expirationScore()`, positive `AddEntry` TTLs ceiling correctly, forever entries remain `-1`, and stale pruning at the preceding whole second cannot remove the ceiled membership. +Strengthen the existing AllTag zero-TTL operation tests so `Add`, `Put`, and `PutMany` each assert that both the stored value and tag score use the normalized one-second TTL. Add equivalent non-positive-TTL coverage for the AnyTag paths that previously diverged: Cluster `Add` and `Put`, plus pipelined `PutMany`, must use one second for the value, reverse index, hash field, and registry score. Use a negative TTL there so the old raw registry score is deterministically in the past without adding a clock seam. Existing positive-TTL and topology coverage owns the unchanged paths; moving already-correct `Touch` normalization needs no duplicate test. + Add queue regressions in `QueueRedisQueueTest`, `QueueDatabaseQueueUnitTest`, and the relevant integration suites. Cover integer, interval, and absolute delayed jobs; Redis reservation scores; ceiled database `reserved_at`; and reclaim only at or after the requested visibility duration. Retain the existing precise `QueueWorkerTest` assertions unchanged. Correct `CacheFunnelTestCase::testLeakedFunnelLeaseIsReclaimedAfterReleaseAfter()`: