diff --git a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md
index d062cfd16..602369297 100644
--- a/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md
+++ b/docs/plans/2026-07-12-0900-framework-coroutine-state-lifecycle-audit.md
@@ -990,8 +990,8 @@ An exceptionally large shared work unit may receive its own linked detail plan w
This compact index routes the completed-work history that must be consulted with the full plan after compaction. Detailed history remains in the [companion ledger](2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md).
-- **Active package or work unit:** `cache`
-- **Ledger entries required for the active work:** `Coordinate shared container construction and complete current contextual resolution`; `Normalize framework enum identifiers at string boundaries`; `Harden filesystem I/O, streaming, and response teardown`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`.
+- **Active package or work unit:** `session`
+- **Ledger entries required for the active work:** `Normalize framework enum identifiers at string boundaries`; `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; `Complete Cache parity, cleanup, permanence, and tagged ownership`.
- **Pending revalidation carried into the active work:** None.
Update these three lines when a package starts, completes, or gains a cross-package dependency. Name exact work-unit headings or shared finding IDs from the companion ledger; never use “see recent entries” or require a full-ledger reread.
@@ -1100,6 +1100,7 @@ Add one row only for a shared finding or changed lower-level assumption that ano
| `telescope-01` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-01` |
| `telescope-02` | `telescope` | `redis` (revalidation complete); later full `telescope` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `telescope-02` |
| `sentry-01` | `sentry` | `redis` (revalidation complete); later full `sentry` audit | `Complete Redis pooling, subscriber transport, topology, parity, and lifecycle safety`; finding `sentry-01` |
+| `cache-04` | `cache` | `auth`, `sanctum`, and `testbench` (revalidation complete); later full consumer audits | `Complete Cache parity, cleanup, permanence, and tagged ownership`; finding `cache-04` |
## Package checklist
@@ -1170,7 +1171,7 @@ The order is lower-level first where practical. Hypervel has cross-cutting depen
- [x] `database`
- [x] `redis`
-- [ ] `cache`
+- [x] `cache`
- [ ] `session`
- [ ] `queue`
- [ ] `horizon`
diff --git a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
index 0f0da3bd1..fd0a06485 100644
--- a/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
+++ b/docs/plans/2026-07-12-0915-framework-coroutine-state-lifecycle-audit-ledger.md
@@ -1191,3 +1191,40 @@ Append package entries in checklist order. Keep each entry compact but complete
- **Laravel-facing result:** Current supported phpredis options, validation, macros, manager event controls, facade metadata, tests, and task-first documentation are restored while Hypervel retains phpredis-only pooling, exact coroutine ownership, connection-local topology, transforms, SafeScan, raw held access, immediate callback release, and other advanced helpers. The deliberate differences are limited to behaviors Laravel's request-lifetime connector model cannot safely provide through a pooled Swoole connection.
- **Validation and review:** Every changed test owner, Redis integration path, and affected Reverb, Horizon, Telescope, and Sentry group passed. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split-manifest validation, `git diff --check`, stale-path and dependency scans, fresh caller/callee, protocol, lifecycle, API, documentation, hot-path, retained-memory, and overengineering self-review, and independent code review are complete with no remaining finding.
- **Assessment:** Every accepted finding in Redis and every linked consumer correction included in this work is fixed at its lowest owner while all protected Hypervel capabilities remain intact; the newly identified Cache sibling is explicitly routed to the active Cache work. The result removes unsafe replay, corrupt framing, unbounded queueing, per-message timers, false extension state, dead compatibility, and stale metadata; it adds only the approved noise-level ordinary-command work and no workaround, speculative abstraction, compatibility shim, or hot-path synchronization.
+
+### Complete Cache parity, cleanup, permanence, and tagged ownership
+
+- **Architecture and inspected risk surfaces:** Cache is a Laravel-derived repository, manager, driver, lock, limiter, and event surface extended with Hypervel's Swoole table store, failover and stack stores, Redis all/any tag modes, pooled Redis operations, nullable values, memoization, and Redis maintenance tooling. The audit covered every Cache source and test file; Foundation/Testbench configuration; Filesystem, Redis, Pool, Container, Support, Contracts, Boost, and Testing consumers; current Laravel source, tests, documentation, and originating pull requests; historical Hypervel behavior; live Redis/Valkey scan behavior; split metadata; and every carried Cache finding.
+
+| ID | Category | Severity | Confidence | Failure and owning boundary | Final decision |
+|---|---|---|---|---|---|
+| `cache-01` | Supported current Laravel parity and upstream defect | Minor | High | Hypervel lacks the Storage cache driver, while Laravel's current implementation writes variable-width expiration headers | Port the complete current driver/config/test/facade/docs surface but publish checked filesystem writes and fixed-width ten-byte expirations |
+| `cache-02` | Supported current Laravel parity | Improvement | High | The repository lacks `rememberWithWarmth()` and closure TTL parity | Port the current API, widen the existing remember TTL union, add the method to the Hypervel Repository contract, and preserve nullable extensions |
+| `cache-03` | Supported current Laravel parity and defect | Major | High | Cached incomplete classes have no configurable handling boundary, while composed raw repositories could invoke a handler more than once | Add a boot/test-only normalized Closure callback at the owning non-raw read boundary, handle direct Any/Stack reads once, and reset the static slot after tests |
+| `cache-04` | Security/configuration defect | Major | High | Missing `cache.serializable_classes` configuration resolves to permissive `null`, SwooleStore bypasses the policy entirely, and native PhpRedis serializers deserialize before Cache can apply it | Declare the secure key once, apply it to Swoole user payloads without restricting framework metadata, inherit it through Testbench, and document narrow object opt-in plus the native Redis boundary |
+| `cache-05` | Defect | Major | High | Flexible-cache lock and deferred identities use logical keys, so distinct tagged namespaces can serialize or suppress each other | Derive one named marker/lock/defer identity from `itemKey()` without adding store-specific stale-marker cleanup machinery |
+| `cache-06` | Defect and supported current Laravel parity | Major | High | Null, memoized, and failover stores expose incomplete or vacuous lock-flush capability and cleanup | Add truthful non-vacuous all-lock-provider capability, preflight, exhaustive cleanup, false aggregation, earliest-throwable precedence, and separate-lock reporting |
+| `cache-07` | Defect | Major | High | Lock and limiter release failures can replace callback failures | Always release directly at the four owning callback boundaries, preserving the callback as primary and retaining current false-result behavior |
+| `cache-08` | Defect | Major | High | Stack writes can lose the original failure during compensation, while forced forget/flush stop early or forget prior false results | Compensate completed writes in reverse and make terminal cleanup exhaustive with false aggregation and earliest-failure precedence |
+| `cache-09` | Defect and upstream defect | Major | High | File-backed expiration headers can exceed their persisted ten-byte format | Use fixed-width expiration timestamps in FileStore put/add/refresh and the new StorageStore |
+| `cache-10` | Defect and upstream defect | Minor | High | A newly created custom-amount rate-limit counter is compared with `1`, so its expiry may never be installed | Compare the counter with the requested amount and port the complete regression surface |
+| `cache-11` | Defect | Major | High | Swoole forever data and internal metadata expire after one year, permanent lock refresh can report false ownership, and partial timer startup is not transactional | Use `PHP_FLOAT_MAX` only for pseudo-permanent rows, preserve real TTLs, verify ownership, retain timer IDs, and roll back earlier registrations when a later tick fails |
+| `cache-12` | Defect and performance correction | Major | High | Hidden remember implementations retain one Redis connection across arbitrary user callbacks, exhausting small pools and blocking child-coroutine Redis work | Delete all six remember operation classes and automatic pinning, retain explicit `withPinnedConnection()`, and split misses into short read and write checkouts |
+| `cache-13` | Improvement and dead-code cleanup | Improvement | High | Redis tag operations repeatedly resolve a factory already owned by RedisStore and retain unrelated factory, serialization, exception, and operation-container surfaces | Inject the exact Redis factory and delete every unused constructor, accessor, helper, exception, clear method, test, and stale comment |
+| `cache-14` | Defect and performance correction | Major | High | Redis tag scans can stop on empty nonterminal pages, perform an extra terminal request, retain complete tags and long leases, or nest a second checkout during flush | Follow PhpRedis SafeScan cursor semantics, borrow per page/chunk, retain only per-tag uniqueness, continue sparse pages, and remove lease-held sleeps |
+| `cache-15` | Defect | Minor | High | Redis any-mode `add(null)` expires after one year instead of remaining permanent | Implement a true atomic permanent add in cluster and Lua paths without changing positive-TTL behavior |
+| `cache-16` | Dead configuration and metadata defect | Minor | High | Required config keys retain drift-prone call-site fallbacks while package docs/facades/metadata omit accepted current behavior | Remove only proven-dead required defaults and complete the natural config, facade, documentation, and split-manifest surfaces |
+| `cache-17` | Event metadata defect | Minor | High | Twenty-seven custom tagged events publish `null` store names, including an All flush override that regresses from its parent | Publish every surviving custom tagged event with the repository name and preserve tags |
+| `cache-18` | Improvement | Improvement | High | Seven Cache container array-access sites hide types or mutation semantics | Replace them with typed class-string/contract `make()` and Config repository writes while retaining the memo spy's bound precheck |
+| `cache-19` | Event-contract defect | Minor | High | Custom All/Any/Stack reads, writes, failures, and non-positive-TTL deletes omit events emitted by the corresponding Repository operation | Restore exactly the established event set and no more, with two shared protected Any-mode plain-key helpers and focused event-order regressions |
+
+- **Approved owner gates and intentional differences:** The owner approved the secure serialization default, current Laravel APIs/configuration, the explicit nullable `cache.limiter` key, permanent any-mode additions, removal of hidden callback pinning, exact Redis factory injection, typed container modernization, complete tagged-event behavior, and every source-proven noise-level hot-path cost. Redis any-mode `add()` remains atomic and event-free with or without a TTL; all-mode and stack null-TTL additions use read-then-write fallbacks and therefore emit retrieval/write events. This capability difference is documented at the public Events surface.
+- **Important rejected concerns:** Do not add Redis prefix clearing from an unmerged topic branch, storage tags/locks/atomic add, generic finalizers, retry/state-machine machinery, timer registries, scan schedulers, replacement pinning, flexible-marker translation, per-layer event documentation, lock-message harmonization, compatibility shims, or a speculative increment/decrement base-key correction. Do not reject documented native PhpRedis serializers under the shipped non-null security default, toggle connection serializers, decode raw transport payloads, or add a Cache-owned format wrapper; those approaches would disable a supported feature or fork the stored representation. Do not retain the six remember operation classes for benchmark callers: the benchmark resolves a Repository, not RedisStore. Failover and memo outer repositories disable events by default, so no per-layer event correction is needed.
+- **Implementation boundary:** Current Laravel pull requests identify complete source/test/config/docs surfaces, but the checked-out current branch remains the porting reference. Incomplete-class handling occurs only in non-`RawReadable` Repository branches and direct Any/Stack readers so composed repositories invoke it once before the hit event. Swoole applies the configured class policy only to public user values; numeric counters and framework-owned interval, index, and lock metadata retain their internal deserialization boundary. Stack promotion preserves blocked object payloads, so the defect changes the returned representation without corrupting the stored class identity. The Redis allowlist applies when the connection used by the cache store retains `Redis::SERIALIZER_NONE`; native serializers deserialize before Cache receives a value, so configuration and Cache/Redis documentation identify that per-connection security boundary without adding an incomplete runtime validator. All mode inherits ordinary remember behavior; Any and Stack keep only the specialized plain-read/tagged-write shape their semantics require. Atomic add, positive touch, increment, and decrement receive no invented events. Swoole pseudo-permanence changes only forever rows and permanent metadata; real expirations keep their native TTL.
+- **Regression strategy:** Port current upstream Storage, warmth, incomplete-class, configuration, facade, contract, and documentation coverage; add fixed-header, flexible tagged identity, lock/limiter precedence, failover capability, Stack compensation/exhaustion, custom limiter amount, Swoole permanence/ownership/timer rollback, Swoole serialization-policy and internal-resolver boundaries, max-one Redis callback-release, exhaustive RedisStore boot-reconfiguration cache invalidation, exact scan-cursor/sparse-page/pool-lease, permanent any-add, dependency/dead-code, container-resolution, and exact All/Any/Stack event sequence/store-name/tag/payload regressions. Use real Redis/Valkey integration tests for pool ownership and sparse scanning, with bounded waits and per-worker isolation.
+- **Performance and complexity:** Redis hits retain one checkout; misses replace one lease held across arbitrary user code with two short local pool checkouts and no extra Redis command. `rememberWithWarmth()` adds one two-element array allocation. Fixed-width formatting is noise beside filesystem I/O. Restored tagged pre-events add one closure allocation and listener lookup at each missing event site, negligible beside Redis/Lua/stack I/O but explicitly owner-approved. Factory injection removes repeated container resolutions, and scan changes reduce retained memory and lease duration. No new registry, retry loop, state machine, context slot, lock layer, compatibility path, or unbounded worker state is added.
+- **Implementation:** Added the Storage driver, `rememberWithWarmth()`, closure TTLs, incomplete-class handling, the secure serialization policy, current facade/configuration metadata, and the full accepted documentation surface. Flexible identities now include the resolved namespace; lock, limiter, failover, Stack, timer, and Swoole permanence paths preserve exact ownership and failure precedence; Redis tag scans borrow per page or chunk, follow SafeScan cursors, and no longer hold connections across callbacks or sleeps; permanent any-mode additions are atomic; and custom tagged operations publish the established event sequence with truthful store names. The six hidden remember operations, their tests, the unused Redis operation containers and exception surface, duplicate factory resolution, dead call-site defaults, stale comments, and superseded documentation are removed.
+- **Cross-package revalidation:** `filesystem-04` remains compatible with the Storage driver's checked `Filesystem` contract and failure results. `redis-09` confirms Cache never retries Redis work, `redis-12` is completed at both Cache limiter callback owners, and `redis-13` preserves the normalized Redis configuration and prefix boundary. `container-08` and `container-09` remain intact through canonical service-key resolution, enum-aware manager routing, and null-only default selection. `support-02` remains complete across Cache keys, tags, events, commands, the facade, and raw failover reads. Auth and Sanctum now point to the Cache-owned serialization guidance, their model fixtures remain readable under the secure default, and Testbench inherits that default through the merged framework configuration.
+- **Validation and review:** Every changed test file, the complete Cache suite, and the affected Auth, Redis/Valkey, and Testbench paths passed. The authoritative `composer fix` gate changed no formatting, both PHPStan configurations passed, and the complete parallel components, Testbench package, and dogfood suites passed. Split-manifest validation, `git diff --check`, stale-reference scans, and fresh caller/callee, event, serialization, pool-ownership, failure-precedence, hot-path, retained-state, documentation, and overengineering review are complete. Independent code review is signed off with every final re-review correction incorporated.
+- **Laravel-facing result:** Current supported Laravel Cache APIs, facade metadata, tests, documentation, and configuration behavior are restored while Hypervel preserves nullable values, the Swoole store, failover/stack stores, Redis pooling, transforms, tag modes, SafeScan behavior, and the explicit pinned-connection API. Hypervel additionally declares the documented nullable `cache.limiter` key through `CACHE_LIMITER`; the owner approved this configuration-structure difference because it makes existing provider behavior explicit without changing the unset default. Other intentional differences remain limited to verified Swoole/pooling requirements and the documented atomic any-mode add event behavior.
+- **Assessment:** Every accepted Cache finding and carried lower-level assumption is implemented at its lowest owner. The result removes callback-held leases, duplicate remember machinery, stale operation containers, year-long pseudo-permanence, partial cleanup, repeated factory resolution, and event gaps while adding only the approved noise-level local checks and events beside existing I/O. It contains no registry, retry loop, state machine, compatibility shim, runtime serializer wrapper, hot-path synchronization, unresolved accepted defect, or stale superseded path.
diff --git a/src/auth/README.md b/src/auth/README.md
index d241f5027..3a4a02270 100644
--- a/src/auth/README.md
+++ b/src/auth/README.md
@@ -38,6 +38,16 @@ Per-provider config in `config/auth.php`:
],
```
+Allow every configured user model to be unserialized in `config/cache.php`:
+
+```php
+'serializable_classes' => [
+ App\Models\User::class,
+],
+```
+
+For supported stores and Redis serializer requirements, see [Serializable Cached Objects](https://hypervel.org/docs/cache#serializable-cached-objects).
+
Minimum env setup for single Redis node:
```env
@@ -279,6 +289,8 @@ Cache::store('auth')->tags(['tenant:5'])->flush(); // just tenant 5's users
### Threat model
+Keep `cache.serializable_classes` limited to the user model classes this cache needs. Broad allowlists expand PHP's unserialization surface.
+
For auth-sensitive contexts (admin panels, financial actions), consider:
- Shorter L1 TTL (1–2s) — still absorbs bursts, narrower staleness window
diff --git a/src/boost/README.md b/src/boost/README.md
index fbf13f670..3d5b7e464 100644
--- a/src/boost/README.md
+++ b/src/boost/README.md
@@ -6,7 +6,7 @@ Boost for Hypervel
# Hypervel Documentation
-You can find the online version of the Hypervel documentation at [https://hypervel.com/docs](https://hypervel.org/docs)
+You can find the online version of the Hypervel documentation at [https://hypervel.org/docs](https://hypervel.org/docs)
## Contribution Guidelines
diff --git a/src/boost/docs/authentication.md b/src/boost/docs/authentication.md
index a7a08f05b..0b832e59a 100644
--- a/src/boost/docs/authentication.md
+++ b/src/boost/docs/authentication.md
@@ -207,6 +207,16 @@ You may enable the cache per Eloquent provider in your application's `config/aut
],
```
+Because the supported stores serialize cached user models, add every configured user model to the `serializable_classes` option in your application's `config/cache.php` file:
+
+```php
+'serializable_classes' => [
+ App\Models\User::class,
+],
+```
+
+Keep the allowlist limited to the user model classes your authentication cache needs. For supported stores and Redis serializer requirements, see [Serializable Cached Objects](/docs/{{version}}/cache#serializable-cached-objects).
+
When `store` is `null`, Hypervel uses your default cache store. For a single Redis-backed deployment, you may enable the cache like this:
```ini
diff --git a/src/boost/docs/cache.md b/src/boost/docs/cache.md
index cc8c625a3..4c89ef9ca 100644
--- a/src/boost/docs/cache.md
+++ b/src/boost/docs/cache.md
@@ -3,6 +3,7 @@
- [Introduction](#introduction)
- [Configuration](#configuration)
- [Array Cache Stores](#array-cache-stores)
+ - [Serializable Cached Objects](#serializable-cached-objects)
- [Driver Prerequisites](#driver-prerequisites)
- [Swoole Table Cache](#swoole-table-cache)
- [Building Cache Stacks](#building-cache-stacks)
@@ -47,7 +48,7 @@ Thankfully, Hypervel provides an expressive, unified API for various cache backe
## Configuration
-Your application's cache configuration file is located at `config/cache.php`. In this file, you may specify which cache store you would like to be used by default throughout your application. Hypervel supports Redis, relational databases, file storage, Swoole tables, session storage, cache stacks, failover stores, and the `array`, `worker-array`, and `null` stores that are convenient for automated tests and in-memory cache data.
+Your application's cache configuration file is located at `config/cache.php`. In this file, you may specify which cache store you would like to be used by default throughout your application. Hypervel supports Redis, relational databases, local files, filesystem disks, Swoole tables, session storage, cache stacks, failover stores, and the `array`, `worker-array`, and `null` stores that are convenient for automated tests and in-memory cache data.
The cache configuration file also contains a variety of other options that you may review. By default, Hypervel is configured to use the `database` cache driver, which stores serialized cache values in your application's database.
@@ -78,6 +79,31 @@ Use `array` for request-local test and scratch data. Use `worker-array` only whe
],
```
+
+### Serializable Cached Objects
+
+By default, Hypervel cache stores that serialize values do not instantiate PHP classes while unserializing them. If your application intentionally caches objects, list the classes that may be unserialized using the `serializable_classes` option:
+
+```php
+'serializable_classes' => [
+ App\Data\UserProfile::class,
+],
+```
+
+This applies to database, file, Redis, storage, and Swoole stores, as well as stacks containing them. Array and worker-array stores configured with `'serialize' => false` keep values in memory and do not use this setting.
+
+You may register a callback during application boot to observe cached objects whose classes are not allowed or available:
+
+```php
+use Hypervel\Support\Facades\Cache;
+
+Cache::handleUnserializableClassUsing(function (string $key, ?string $class) {
+ // ...
+});
+```
+
+If you use Redis and rely on this allowlist, configure the connection used by your cache store with the default `Redis::SERIALIZER_NONE` serializer. Native PhpRedis serializers handle cached values before Hypervel can apply the allowlist; `Redis::SERIALIZER_PHP` and `Redis::SERIALIZER_IGBINARY` may instantiate PHP classes when reading cached values. With a native serializer, the callback may still report unavailable classes, but it cannot report classes excluded by the allowlist. Other Redis connections may continue using any supported serializer.
+
### Driver Prerequisites
@@ -114,6 +140,19 @@ You may run the `cache:redis-doctor` command to verify your Redis cache configur
php artisan cache:redis-doctor
```
+
+#### Storage
+
+The `storage` cache driver allows you to store cached values on any configured [filesystem disk](/docs/{{version}}/filesystem). This can be useful when you want to use an existing disk, such as an S3 disk, as a key / value cache store:
+
+```php
+'storage' => [
+ 'driver' => 'storage',
+ 'disk' => env('CACHE_STORAGE_DISK'),
+ 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
+],
+```
+
### Swoole Table Cache
@@ -316,6 +355,14 @@ $value = Cache::remember('users', $seconds, function () {
If the item does not exist in the cache, the closure passed to the `remember` method will be executed and its result will be placed in the cache.
+If you need to know whether the item was retrieved from the cache instead of by executing the given closure, you may use the `rememberWithWarmth` method. This method returns an array containing the cached value and a boolean indicating whether the item was "warm", meaning it was retrieved from the cache and not resolved from the closure:
+
+```php
+[$value, $warm] = Cache::rememberWithWarmth('users', $seconds, function () {
+ return DB::table('users')->get();
+});
+```
+
You may use the `rememberForever` method to retrieve an item from the cache or store it forever if it does not exist:
```php
@@ -432,7 +479,7 @@ Cache::forever('key', 'value');
```
> [!NOTE]
-> If you are using the `swoole` driver, items stored using the `forever` method are stored with a long expiration time and may still expire or be evicted when the table reaches its capacity, depending on the configured eviction policy.
+> If you are using the `swoole` driver, items stored using the `forever` method do not expire based on time, but may still be evicted when the table reaches its capacity, depending on the configured eviction policy.
### Removing Items From the Cache
@@ -538,7 +585,7 @@ cache()->remember('users', $seconds, function () {
## Cache Tags
> [!WARNING]
-> Cache tags are supported by the `redis`, `array`, `failover`, `null`, and `stack` cache drivers. Stack tags require an any-mode composition; see [Tagged Cache Stacks](#tagged-cache-stacks). Cache tags are not supported by the `file`, `database`, `swoole`, `session`, or `memo` drivers.
+> Cache tags are supported by the `redis`, `array`, `failover`, `null`, and `stack` cache drivers. Stack tags require an any-mode composition; see [Tagged Cache Stacks](#tagged-cache-stacks). Cache tags are not supported by the `file`, `storage`, `database`, `swoole`, `session`, or `memo` drivers.
### Redis Tag Modes
@@ -1047,6 +1094,9 @@ Hypervel includes several Artisan commands for working with cache stores:
To execute code on every cache operation, you may listen for various [events](/docs/{{version}}/events) dispatched by the cache:
+> [!NOTE]
+> Cache additions handled by a store's native atomic `add` operation do not dispatch cache events. Redis `any` tag mode performs `add` atomically with or without a time-to-live.
+
| Event Name |
diff --git a/src/boost/docs/redis.md b/src/boost/docs/redis.md
index 5eed0a83d..bde211a7e 100644
--- a/src/boost/docs/redis.md
+++ b/src/boost/docs/redis.md
@@ -210,6 +210,8 @@ The PhpRedis extension may also be configured to use a variety of serializers an
Currently supported serializers include: `Redis::SERIALIZER_NONE` (default), `Redis::SERIALIZER_PHP`, `Redis::SERIALIZER_JSON`, `Redis::SERIALIZER_IGBINARY`, and `Redis::SERIALIZER_MSGPACK`.
+When relying on Cache's [serializable class allowlist](/docs/{{version}}/cache#serializable-cached-objects), configure the connection used by the Redis cache store with `Redis::SERIALIZER_NONE`. Options in the shared `options` array also apply to that connection.
+
Supported compression algorithms include: `Redis::COMPRESSION_NONE` (default), `Redis::COMPRESSION_LZF`, `Redis::COMPRESSION_ZSTD`, and `Redis::COMPRESSION_LZ4`.
diff --git a/src/boost/docs/sanctum.md b/src/boost/docs/sanctum.md
index 013f49c56..39ba51c23 100644
--- a/src/boost/docs/sanctum.md
+++ b/src/boost/docs/sanctum.md
@@ -181,6 +181,17 @@ Token caching is disabled by default. You may enable and configure it in your ap
],
```
+Because token caching stores both personal access token and tokenable model objects, add the configured model classes to the `serializable_classes` option in your application's `config/cache.php` file:
+
+```php
+'serializable_classes' => [
+ App\Models\User::class,
+ Hypervel\Sanctum\PersonalAccessToken::class,
+],
+```
+
+If you use a custom personal access token model, list it instead of the default model. For supported stores and Redis serializer requirements, see [Serializable Cached Objects](/docs/{{version}}/cache#serializable-cached-objects).
+
The `store` option determines which cache store is used. When this value is `null`, Sanctum uses your application's default cache store. The `ttl` option controls how long token and tokenable entries remain cached, in seconds. The `prefix` option is prepended to Sanctum's cache keys.
Sanctum also caches missing token IDs and missing tokenable models as `null` results for the configured TTL. This protects your database from repeated lookups for the same revoked, deleted, or orphaned token data. Because token IDs come from request input, use a cache store with bounded memory or an eviction policy when enabling token caching on public endpoints.
diff --git a/src/cache/src/AnyModeTaggedCache.php b/src/cache/src/AnyModeTaggedCache.php
index c8ec605dc..4ddedd578 100644
--- a/src/cache/src/AnyModeTaggedCache.php
+++ b/src/cache/src/AnyModeTaggedCache.php
@@ -7,8 +7,16 @@
use BadMethodCallException;
use DateInterval;
use DateTimeInterface;
+use Hypervel\Cache\Events\CacheHit;
+use Hypervel\Cache\Events\CacheMissed;
+use Hypervel\Cache\Events\ForgettingKey;
+use Hypervel\Cache\Events\KeyForgetFailed;
+use Hypervel\Cache\Events\KeyForgotten;
+use Hypervel\Cache\Events\RetrievingKey;
use UnitEnum;
+use function Hypervel\Support\enum_value;
+
/**
* Tagged cache for any-mode tag semantics.
*
@@ -118,4 +126,55 @@ public function touch(UnitEnum|string $key, DateInterval|DateTimeInterface|int|n
. 'its TTL; a direct Cache::touch() uses the store\'s plain-key semantics.'
);
}
+
+ /**
+ * Retrieve a plain-key item without exposing reads through the any-mode API.
+ */
+ protected function getPlainRaw(UnitEnum|string $key): mixed
+ {
+ $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
+
+ $this->event(RetrievingKey::class, fn (): RetrievingKey => new RetrievingKey($this->getName(), $key));
+
+ $value = $this->handleIncompleteClass($key, $this->store->get($key));
+
+ if (is_null($value)) {
+ $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key));
+ } else {
+ $this->event(
+ CacheHit::class,
+ fn (): CacheHit => new CacheHit($this->getName(), $key, NullSentinel::unwrap($value))
+ );
+ }
+
+ return $value;
+ }
+
+ /**
+ * Retrieve a plain-key item for remember operations.
+ */
+ protected function getRawForRemember(UnitEnum|string $key): mixed
+ {
+ return $this->getPlainRaw($key);
+ }
+
+ /**
+ * Remove a plain-key item without exposing deletes through the any-mode API.
+ */
+ protected function forgetPlainKey(UnitEnum|string $key): bool
+ {
+ $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
+
+ $this->event(ForgettingKey::class, fn (): ForgettingKey => new ForgettingKey($this->getName(), $key));
+
+ $result = $this->store->forget($key);
+
+ if ($result) {
+ $this->event(KeyForgotten::class, fn (): KeyForgotten => new KeyForgotten($this->getName(), $key));
+ } else {
+ $this->event(KeyForgetFailed::class, fn (): KeyForgetFailed => new KeyForgetFailed($this->getName(), $key));
+ }
+
+ return $result;
+ }
}
diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php
index 23426c5c7..44a6525eb 100644
--- a/src/cache/src/CacheManager.php
+++ b/src/cache/src/CacheManager.php
@@ -11,6 +11,7 @@
use Hypervel\Contracts\Cache\Store;
use Hypervel\Contracts\Container\Container;
use Hypervel\Contracts\Events\Dispatcher as DispatcherContract;
+use Hypervel\Contracts\Redis\Factory as RedisFactory;
use Hypervel\Contracts\Session\Session;
use Hypervel\Support\Arr;
use Hypervel\Support\RebindsCallbacksToSelf;
@@ -106,7 +107,8 @@ public function memo(UnitEnum|string|null $driver = null): CacheRepository
*/
protected function createMemoizedRepository(string $driver): CacheRepository
{
- $isSpy = isset($this->app['cache']) && $this->app['cache'] instanceof LegacyMockInterface;
+ $isSpy = $this->app->bound('cache')
+ && $this->app->make('cache') instanceof LegacyMockInterface;
/** @var Repository $store */
$store = $this->store($driver);
@@ -194,7 +196,7 @@ protected function createWorkerArrayDriver(array $config): Repository
*/
protected function createDatabaseDriver(array $config): Repository
{
- $connectionResolver = $this->app['db'];
+ $connectionResolver = $this->app->make('db');
$store = new DatabaseStore(
$connectionResolver,
@@ -233,7 +235,7 @@ protected function createFileDriver(array $config): Repository
{
return $this->repository(
(new FileStore(
- $this->app['files'],
+ $this->app->make('files'),
$config['path'],
$config['permission'] ?? null,
$this->getSerializableClasses($config),
@@ -243,6 +245,19 @@ protected function createFileDriver(array $config): Repository
);
}
+ /**
+ * Create an instance of the storage cache driver.
+ */
+ protected function createStorageDriver(array $config): Repository
+ {
+ return $this->repository(new StorageStore(
+ $this->app->make('filesystem')->disk($config['disk'] ?? null),
+ $config['path'] ?? '',
+ $this->getPrefix($config),
+ $this->getSerializableClasses($config),
+ ), $config);
+ }
+
/**
* Create an instance of the Null cache driver.
*/
@@ -256,7 +271,8 @@ protected function createNullDriver(): Repository
*/
protected function createRedisDriver(array $config): Repository
{
- $redis = $this->app['redis'];
+ /** @var RedisFactory $redis */
+ $redis = $this->app->make('redis');
$connection = $config['connection'] ?? 'default';
@@ -312,7 +328,8 @@ protected function createSwooleDriver(array $config): Repository
$tableState,
$config['memory_limit_buffer'] ?? 0.05,
$config['eviction_policy'] ?? SwooleStore::EVICTION_POLICY_LRU,
- $config['eviction_proportion'] ?? 0.05
+ $config['eviction_proportion'] ?? 0.05,
+ $this->getSerializableClasses($config),
);
return $this->repository($store, $config);
@@ -359,7 +376,7 @@ protected function setEventDispatcher(Repository $repository): void
}
$repository->setEventDispatcher(
- $this->app[DispatcherContract::class]
+ $this->app->make(DispatcherContract::class)
);
}
@@ -409,7 +426,7 @@ protected function getConfig(string $name): ?array
*/
public function getDefaultDriver(): string
{
- return $this->app->make('config')->string('cache.default', 'null');
+ return $this->app->make('config')->string('cache.default');
}
/**
@@ -423,7 +440,7 @@ public function setDefaultDriver(UnitEnum|string $name): void
$name = (string) enum_value($name);
}
- $this->app['config']['cache.default'] = $name;
+ $this->app->make('config')->set('cache.default', $name);
}
/**
@@ -502,6 +519,17 @@ public function setApplication(Container $app): static
return $this;
}
+ /**
+ * Register a callback to be invoked when an unserializable class is encountered.
+ *
+ * Boot or tests only. The callback persists for the worker lifetime and
+ * affects every subsequent cache read.
+ */
+ public function handleUnserializableClassUsing(?callable $callback): void
+ {
+ Repository::handleUnserializableClassUsing($callback);
+ }
+
/**
* Dynamically call the default driver instance.
*/
diff --git a/src/cache/src/CacheServiceProvider.php b/src/cache/src/CacheServiceProvider.php
index 03d4ca4b8..c1510f743 100644
--- a/src/cache/src/CacheServiceProvider.php
+++ b/src/cache/src/CacheServiceProvider.php
@@ -26,7 +26,7 @@ public function register(): void
{
$this->app->singleton('cache', fn ($app) => new CacheManager($app));
- $this->app->singleton('cache.store', fn ($app) => $app['cache']->driver());
+ $this->app->singleton('cache.store', fn ($app) => $app->make('cache')->driver());
$this->app->singleton(RateLimiter::class, fn ($app) => new RateLimiter(
$app->make('cache')->driver(
diff --git a/src/cache/src/FailoverStore.php b/src/cache/src/FailoverStore.php
index 684a92101..f1337ef62 100644
--- a/src/cache/src/FailoverStore.php
+++ b/src/cache/src/FailoverStore.php
@@ -4,8 +4,10 @@
namespace Hypervel\Cache;
+use BadMethodCallException;
use Hypervel\Cache\Events\CacheFailedOver;
use Hypervel\Context\CoroutineContext;
+use Hypervel\Contracts\Cache\CanFlushLocks;
use Hypervel\Contracts\Cache\Lock as LockContract;
use Hypervel\Contracts\Cache\LockProvider;
use Hypervel\Contracts\Cache\RawReadable;
@@ -17,7 +19,7 @@
use function Hypervel\Support\enum_value;
-class FailoverStore extends TaggableStore implements LockProvider, RawReadable
+class FailoverStore extends TaggableStore implements CanFlushLocks, LockProvider, RawReadable
{
/**
* Context key prefix for the caches which failed on the last action.
@@ -169,8 +171,7 @@ public function flush(): bool
/**
* Remove all expired tag set entries.
- */
- /**
+ *
* @return null|array
*/
public function flushStaleTags(): ?array
@@ -186,6 +187,89 @@ public function flushStaleTags(): ?array
return null;
}
+ /**
+ * Determine if the store can currently flush locks.
+ */
+ public function supportsFlushingLocks(): bool
+ {
+ $stores = $this->lockStores();
+
+ if ($stores === []) {
+ return false;
+ }
+
+ foreach ($stores as $store) {
+ if (! $store instanceof CanFlushLocks || ! $store->supportsFlushingLocks()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Flush all locks managed by the store.
+ *
+ * @throws BadMethodCallException
+ * @throws Throwable
+ */
+ public function flushLocks(): bool
+ {
+ $stores = $this->lockStores();
+
+ if ($stores === []) {
+ throw new BadMethodCallException('This failover cache store has no lock-providing stores to flush.');
+ }
+
+ foreach ($stores as $store) {
+ if (! $store instanceof CanFlushLocks || ! $store->supportsFlushingLocks()) {
+ throw new BadMethodCallException(sprintf(
+ 'The failover cache store [%s] does not support flushing locks.',
+ $store::class
+ ));
+ }
+ }
+
+ $result = true;
+ $exception = null;
+
+ foreach ($stores as $store) {
+ try {
+ if (! $store->flushLocks()) {
+ $result = false;
+ }
+ } catch (Throwable $throwable) {
+ $exception ??= $throwable;
+ }
+ }
+
+ if ($exception !== null) {
+ throw $exception;
+ }
+
+ return $result;
+ }
+
+ /**
+ * Determine if the lock store is separate from the cache store.
+ */
+ public function hasSeparateLockStore(): bool
+ {
+ $stores = $this->lockStores();
+
+ if ($stores === []) {
+ return false;
+ }
+
+ foreach ($stores as $store) {
+ if (! $store instanceof CanFlushLocks || ! $store->hasSeparateLockStore()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
/**
* Get the cache key prefix.
*/
@@ -216,7 +300,7 @@ protected function attemptOnAllStores(string $method, array $arguments): mixed
$failedCaches[] = $store;
- if (! in_array($store, $failingCaches) && $this->events->hasListeners(CacheFailedOver::class)) {
+ if (! in_array($store, $failingCaches, true) && $this->events->hasListeners(CacheFailedOver::class)) {
$this->events->dispatch(new CacheFailedOver($store, $e));
}
}
@@ -235,4 +319,24 @@ protected function store(string $store): RepositoryContract
{
return $this->cache->store($store);
}
+
+ /**
+ * Get every lock-capable backing store.
+ *
+ * @return list
+ */
+ protected function lockStores(): array
+ {
+ $stores = [];
+
+ foreach ($this->stores as $store) {
+ $underlyingStore = $this->store($store)->getStore();
+
+ if ($underlyingStore instanceof LockProvider) {
+ $stores[] = $underlyingStore;
+ }
+ }
+
+ return $stores;
+ }
}
diff --git a/src/cache/src/FileStore.php b/src/cache/src/FileStore.php
index 1f258ade8..35eaac034 100644
--- a/src/cache/src/FileStore.php
+++ b/src/cache/src/FileStore.php
@@ -81,7 +81,7 @@ public function put(string $key, mixed $value, int $seconds): bool
$result = $this->files->put(
$path,
- $this->expiration($seconds) . serialize($value),
+ $this->expirationHeader($seconds) . serialize($value),
true
);
@@ -115,7 +115,7 @@ public function add(string $key, mixed $value, int $seconds): bool
if (empty($expire) || $this->currentTime() >= $expire) {
$file->truncate()
- ->write($this->expiration($seconds) . serialize($value))
+ ->write($this->expirationHeader($seconds) . serialize($value))
->close();
$this->ensurePermissionsAreCorrect($path);
@@ -163,7 +163,7 @@ public function refreshIfOwned(string $key, string $expectedOwner, int $seconds)
}
$file->truncate()
- ->write($this->expiration($seconds) . serialize($expectedOwner))
+ ->write($this->expirationHeader($seconds) . serialize($expectedOwner))
->close();
$this->ensurePermissionsAreCorrect($path);
@@ -490,6 +490,14 @@ protected function expiration(int $seconds): int
return $seconds === 0 || $time > self::PERMANENT_TIMESTAMP ? self::PERMANENT_TIMESTAMP : $time;
}
+ /**
+ * Get the fixed-width expiration header for a cache item.
+ */
+ protected function expirationHeader(int $seconds): string
+ {
+ return sprintf('%010d', $this->expiration($seconds));
+ }
+
/**
* Determine if the lock store is separate from the cache store.
*/
diff --git a/src/cache/src/Limiters/ConcurrencyLimiter.php b/src/cache/src/Limiters/ConcurrencyLimiter.php
index b25b6a7f6..fb07e451f 100644
--- a/src/cache/src/Limiters/ConcurrencyLimiter.php
+++ b/src/cache/src/Limiters/ConcurrencyLimiter.php
@@ -87,10 +87,20 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 250
if (is_callable($callback)) {
try {
- return $callback();
- } finally {
- $lease->release();
+ $result = $callback();
+ } catch (Throwable $throwable) {
+ try {
+ $lease->release();
+ } catch (Throwable) {
+ // Preserve the callback failure as primary.
+ }
+
+ throw $throwable;
}
+
+ $lease->release();
+
+ return $result;
}
return true;
diff --git a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php
index fbdf470d1..f421f81f6 100644
--- a/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php
+++ b/src/cache/src/Limiters/ConcurrencyLimiterBuilder.php
@@ -13,6 +13,7 @@
use Hypervel\Contracts\Limiters\Lease;
use Hypervel\Contracts\Limiters\LimiterTimeoutException;
use Hypervel\Support\InteractsWithTime;
+use Throwable;
class ConcurrencyLimiterBuilder
{
@@ -109,19 +110,29 @@ public function then(callable $callback, ?callable $failure = null): mixed
{
try {
$lease = $this->acquire();
- } catch (LimiterTimeoutException $e) {
+ } catch (LimiterTimeoutException $timeoutException) {
if ($failure !== null) {
- return $failure($e);
+ return $failure($timeoutException);
}
- throw $e;
+ throw $timeoutException;
}
try {
- return $callback();
- } finally {
- $lease->release();
+ $result = $callback();
+ } catch (Throwable $throwable) {
+ try {
+ $lease->release();
+ } catch (Throwable) {
+ // Preserve the callback failure as primary.
+ }
+
+ throw $throwable;
}
+
+ $lease->release();
+
+ return $result;
}
/**
diff --git a/src/cache/src/Listeners/BaseListener.php b/src/cache/src/Listeners/BaseListener.php
index a68e3e020..49f3d9ae4 100644
--- a/src/cache/src/Listeners/BaseListener.php
+++ b/src/cache/src/Listeners/BaseListener.php
@@ -15,7 +15,7 @@ public function __construct(protected Container $container)
protected function swooleStores(): Collection
{
- $config = $this->container->make('config')->array('cache.stores', []);
+ $config = $this->container->make('config')->array('cache.stores');
return collect($config)->where('driver', 'swoole');
}
diff --git a/src/cache/src/Listeners/CreateSwooleTimers.php b/src/cache/src/Listeners/CreateSwooleTimers.php
index d21004105..3fca2709e 100644
--- a/src/cache/src/Listeners/CreateSwooleTimers.php
+++ b/src/cache/src/Listeners/CreateSwooleTimers.php
@@ -8,6 +8,8 @@
use Hypervel\Cache\SwooleTimer;
use Hypervel\Contracts\Container\Container;
use Hypervel\Core\Events\AfterWorkerStart;
+use RuntimeException;
+use Throwable;
class CreateSwooleTimers extends BaseListener
{
@@ -25,17 +27,43 @@ public function handle(AfterWorkerStart $event): void
return;
}
- $this->swooleStores()->each(function (array $config, string $name) {
- $this->timer->tick(
- $config['eviction_interval'] ?? 10000,
- fn () => $this->store($name)->evictRecords(),
- );
-
- $this->timer->tick(
- $config['interval_refresh_interval'] ?? 1000,
- fn () => $this->store($name)->refreshIntervalCaches(),
- );
- });
+ $timerIds = [];
+
+ try {
+ foreach ($this->swooleStores() as $name => $config) {
+ $timerId = $this->timer->tick(
+ $config['eviction_interval'] ?? 10000,
+ fn () => $this->store($name)->evictRecords(),
+ );
+
+ if ($timerId === false) {
+ throw new RuntimeException("Unable to register the Swoole cache eviction timer for store [{$name}].");
+ }
+
+ $timerIds[] = $timerId;
+
+ $timerId = $this->timer->tick(
+ $config['interval_refresh_interval'] ?? 1000,
+ fn () => $this->store($name)->refreshIntervalCaches(),
+ );
+
+ if ($timerId === false) {
+ throw new RuntimeException("Unable to register the Swoole cache interval refresh timer for store [{$name}].");
+ }
+
+ $timerIds[] = $timerId;
+ }
+ } catch (Throwable $throwable) {
+ for ($index = count($timerIds) - 1; $index >= 0; --$index) {
+ try {
+ $this->timer->clear($timerIds[$index]);
+ } catch (Throwable) {
+ // Preserve the timer registration failure.
+ }
+ }
+
+ throw $throwable;
+ }
}
/**
diff --git a/src/cache/src/Lock.php b/src/cache/src/Lock.php
index b7cf48442..7710957f1 100644
--- a/src/cache/src/Lock.php
+++ b/src/cache/src/Lock.php
@@ -10,6 +10,7 @@
use Hypervel\Support\Sleep;
use Hypervel\Support\Str;
use RuntimeException;
+use Throwable;
use function Hypervel\Support\now;
@@ -75,10 +76,18 @@ public function get(?callable $callback = null): mixed
if ($result && is_callable($callback)) {
try {
- return $callback();
- } finally {
- $this->release();
+ $result = $callback();
+ } catch (Throwable $throwable) {
+ try {
+ $this->release();
+ } catch (Throwable) {
+ // Preserve the callback failure as primary.
+ }
+
+ throw $throwable;
}
+
+ $this->release();
}
return $result;
@@ -107,10 +116,20 @@ public function block(int $seconds, ?callable $callback = null): mixed
if (is_callable($callback)) {
try {
- return $callback();
- } finally {
- $this->release();
+ $result = $callback();
+ } catch (Throwable $throwable) {
+ try {
+ $this->release();
+ } catch (Throwable) {
+ // Preserve the callback failure as primary.
+ }
+
+ throw $throwable;
}
+
+ $this->release();
+
+ return $result;
}
return true;
diff --git a/src/cache/src/MemoizedStore.php b/src/cache/src/MemoizedStore.php
index 0ffaaa193..22ed40184 100644
--- a/src/cache/src/MemoizedStore.php
+++ b/src/cache/src/MemoizedStore.php
@@ -5,6 +5,7 @@
namespace Hypervel\Cache;
use BadMethodCallException;
+use Hypervel\Contracts\Cache\CanFlushLocks;
use Hypervel\Contracts\Cache\Lock as LockContract;
use Hypervel\Contracts\Cache\LockProvider;
use Hypervel\Contracts\Cache\RawReadable;
@@ -13,7 +14,7 @@
use function Hypervel\Support\enum_value;
-class MemoizedStore implements LockProvider, RawReadable, Store
+class MemoizedStore implements CanFlushLocks, LockProvider, RawReadable, Store
{
/**
* The memoized cache values.
@@ -181,6 +182,45 @@ public function restoreLock(string $name, string $owner): LockContract
return $this->repository->getStore()->restoreLock(...func_get_args());
}
+ /**
+ * Determine if the store can currently flush locks.
+ */
+ public function supportsFlushingLocks(): bool
+ {
+ $store = $this->repository->getStore();
+
+ return $store instanceof CanFlushLocks && $store->supportsFlushingLocks();
+ }
+
+ /**
+ * Flush all locks managed by the store.
+ *
+ * @throws BadMethodCallException
+ */
+ public function flushLocks(): bool
+ {
+ $store = $this->repository->getStore();
+
+ if (! $store instanceof CanFlushLocks || ! $store->supportsFlushingLocks()) {
+ throw new BadMethodCallException(sprintf(
+ 'The memoized cache store\'s underlying store [%s] does not support flushing locks.',
+ $store::class
+ ));
+ }
+
+ return $store->flushLocks();
+ }
+
+ /**
+ * Determine if the lock store is separate from the cache store.
+ */
+ public function hasSeparateLockStore(): bool
+ {
+ $store = $this->repository->getStore();
+
+ return $store instanceof CanFlushLocks && $store->hasSeparateLockStore();
+ }
+
/**
* Adjust the expiration time of a cached item.
*/
diff --git a/src/cache/src/NullStore.php b/src/cache/src/NullStore.php
index d366a4343..bd2715697 100644
--- a/src/cache/src/NullStore.php
+++ b/src/cache/src/NullStore.php
@@ -4,9 +4,10 @@
namespace Hypervel\Cache;
+use Hypervel\Contracts\Cache\CanFlushLocks;
use Hypervel\Contracts\Cache\LockProvider;
-class NullStore extends TaggableStore implements LockProvider
+class NullStore extends TaggableStore implements CanFlushLocks, LockProvider
{
use RetrievesMultipleKeys;
@@ -66,6 +67,30 @@ public function restoreLock(string $name, string $owner): NoLock
return $this->lock($name, 0, $owner);
}
+ /**
+ * Determine if the store can currently flush locks.
+ */
+ public function supportsFlushingLocks(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Flush all locks managed by the store.
+ */
+ public function flushLocks(): bool
+ {
+ return true;
+ }
+
+ /**
+ * Determine if the lock store is separate from the cache store.
+ */
+ public function hasSeparateLockStore(): bool
+ {
+ return false;
+ }
+
/**
* Adjust the expiration time of a cached item.
*/
diff --git a/src/cache/src/RateLimiter.php b/src/cache/src/RateLimiter.php
index 9dda17d71..566ec2921 100644
--- a/src/cache/src/RateLimiter.php
+++ b/src/cache/src/RateLimiter.php
@@ -190,9 +190,9 @@ public function increment(string $key, DateInterval|DateTimeInterface|int $decay
$hits = (int) $this->cache->increment($key, $amount);
- if (! $added && $hits === 1) {
+ if (! $added && $hits === $amount) {
$this->withoutSerializationOrCompression(
- fn () => $this->cache->put($key, 1, $decaySeconds)
+ fn () => $this->cache->put($key, $amount, $decaySeconds)
);
}
diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php
index 0f2a6de43..b1d3e21d6 100644
--- a/src/cache/src/Redis/AllTaggedCache.php
+++ b/src/cache/src/Redis/AllTaggedCache.php
@@ -4,14 +4,14 @@
namespace Hypervel\Cache\Redis;
-use Closure;
use DateInterval;
use DateTimeInterface;
use Hypervel\Cache\Events\CacheFlushed;
use Hypervel\Cache\Events\CacheFlushing;
-use Hypervel\Cache\Events\CacheHit;
-use Hypervel\Cache\Events\CacheMissed;
+use Hypervel\Cache\Events\KeyWriteFailed;
use Hypervel\Cache\Events\KeyWritten;
+use Hypervel\Cache\Events\WritingKey;
+use Hypervel\Cache\Events\WritingManyKeys;
use Hypervel\Cache\NamespacedTaggedCache;
use Hypervel\Cache\NullSentinel;
use Hypervel\Cache\RedisStore;
@@ -69,17 +69,7 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt
// Null TTL: non-atomic get + forever (matches Repository::add behavior)
if (is_null($this->get($key))) {
- $result = $this->store->allTagOps()->forever()->execute(
- $this->itemKey($key),
- $value,
- $this->tags->tagIds()
- );
-
- if ($result) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
- }
-
- return $result;
+ return $this->forever($key, $value);
}
return false;
@@ -106,6 +96,11 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT
return $this->forget($key);
}
+ $this->event(
+ WritingKey::class,
+ fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
+
$result = $this->store->allTagOps()->put()->execute(
$this->itemKey($key),
$value,
@@ -114,7 +109,15 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT
);
if ($result) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
}
return $result;
@@ -135,6 +138,16 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $
return $this->deleteMultiple(array_map(static fn ($key) => (string) $key, array_keys($values)));
}
+ $this->event(
+ WritingManyKeys::class,
+ fn (): WritingManyKeys => new WritingManyKeys(
+ $this->getName(),
+ array_map(static fn ($key): string => (string) $key, array_keys($values)),
+ array_map(NullSentinel::unwrap(...), array_values($values)),
+ $seconds
+ )
+ );
+
$result = $this->store->allTagOps()->putMany()->execute(
$values,
$seconds,
@@ -142,9 +155,17 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $
$this->taggedItemKeyPrefix()
);
- if ($result) {
- foreach ($values as $key => $value) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, NullSentinel::unwrap($value), $seconds));
+ foreach ($values as $key => $value) {
+ if ($result) {
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds)
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds)
+ );
}
}
@@ -209,6 +230,12 @@ public function forever(UnitEnum|string $key, mixed $value): bool
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
+ $this->event(WritingKey::class, fn (): WritingKey => new WritingKey(
+ $this->getName(),
+ $key,
+ NullSentinel::unwrap($value)
+ ));
+
$result = $this->store->allTagOps()->forever()->execute(
$this->itemKey($key),
$value,
@@ -216,7 +243,15 @@ public function forever(UnitEnum|string $key, mixed $value): bool
);
if ($result) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value))
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value))
+ );
}
return $result;
@@ -227,11 +262,11 @@ public function forever(UnitEnum|string $key, mixed $value): bool
*/
public function flush(): bool
{
- $this->event(CacheFlushing::class, fn (): CacheFlushing => new CacheFlushing(null));
+ $this->event(CacheFlushing::class, fn (): CacheFlushing => new CacheFlushing($this->getName()));
$this->store->allTagOps()->flush()->execute($this->tags->tagIds(), $this->tags->getNames());
- $this->event(CacheFlushed::class, fn (): CacheFlushed => new CacheFlushed(null));
+ $this->event(CacheFlushed::class, fn (): CacheFlushed => new CacheFlushed($this->getName()));
return true;
}
@@ -246,80 +281,6 @@ public function flushStale(): bool
return true;
}
- /**
- * Get an item from the cache, or execute the given Closure and store the result.
- *
- * Optimized to use a single connection for both GET and PUT operations,
- * avoiding double pool overhead for cache misses. Also ensures tag tracking
- * entries are properly created (which the parent implementation bypasses).
- *
- * @template TCacheValue
- *
- * @param Closure(): TCacheValue $callback
- * @return TCacheValue
- */
- public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed
- {
- if ($ttl === null) {
- return $this->rememberForever($key, $callback);
- }
-
- $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
- $seconds = $this->getSeconds($ttl);
-
- if ($seconds <= 0) {
- // Invalid TTL, just execute callback without caching
- return $callback();
- }
-
- [$value, $wasHit] = $this->store->allTagOps()->remember()->execute(
- $this->itemKey($key),
- $seconds,
- $callback,
- $this->tags->tagIds()
- );
-
- if ($wasHit) {
- $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
- } else {
- $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
- }
-
- return NullSentinel::unwrap($value);
- }
-
- /**
- * Get an item from the cache, or execute the given Closure and store the result forever.
- *
- * Optimized to use a single connection for both GET and SET operations,
- * avoiding double pool overhead for cache misses.
- *
- * @template TCacheValue
- *
- * @param Closure(): TCacheValue $callback
- * @return TCacheValue
- */
- public function rememberForever(UnitEnum|string $key, Closure $callback): mixed
- {
- $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
-
- [$value, $wasHit] = $this->store->allTagOps()->rememberForever()->execute(
- $this->itemKey($key),
- $callback,
- $this->tags->tagIds()
- );
-
- if ($wasHit) {
- $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
- } else {
- $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
- }
-
- return NullSentinel::unwrap($value);
- }
-
/**
* Get a fully qualified key for a tagged item.
*/
diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php
index ebfb67336..b01cbc0ab 100644
--- a/src/cache/src/Redis/AnyTaggedCache.php
+++ b/src/cache/src/Redis/AnyTaggedCache.php
@@ -4,14 +4,14 @@
namespace Hypervel\Cache\Redis;
-use Closure;
use DateInterval;
use DateTimeInterface;
use Generator;
use Hypervel\Cache\AnyModeTaggedCache;
-use Hypervel\Cache\Events\CacheHit;
-use Hypervel\Cache\Events\CacheMissed;
+use Hypervel\Cache\Events\KeyWriteFailed;
use Hypervel\Cache\Events\KeyWritten;
+use Hypervel\Cache\Events\WritingKey;
+use Hypervel\Cache\Events\WritingManyKeys;
use Hypervel\Cache\NullSentinel;
use Hypervel\Cache\RedisStore;
use Hypervel\Cache\TagSet;
@@ -23,8 +23,7 @@
/**
* Any-mode tagged cache for Redis 8.0+ enhanced tagging.
*
- * Uses Redis hashes with field expiration and single-connection operations
- * for tagged writes and remember-style cache misses.
+ * Uses Redis hashes with field expiration for tagged writes.
*/
class AnyTaggedCache extends AnyModeTaggedCache
{
@@ -70,13 +69,26 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT
$seconds = $this->getSeconds($ttl);
if ($seconds <= 0) {
- return $this->store->forget($key);
+ return $this->forgetPlainKey($key);
}
+ $this->event(
+ WritingKey::class,
+ fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
+
$result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames());
if ($result) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
}
return $result;
@@ -97,7 +109,7 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $
$result = true;
foreach (array_keys($values) as $key) {
- if (! $this->store->forget((string) $key)) {
+ if (! $this->forgetPlainKey((string) $key)) {
$result = false;
}
}
@@ -105,11 +117,29 @@ public function putMany(array $values, DateInterval|DateTimeInterface|int|null $
return $result;
}
+ $this->event(
+ WritingManyKeys::class,
+ fn (): WritingManyKeys => new WritingManyKeys(
+ $this->getName(),
+ array_map(static fn ($key): string => (string) $key, array_keys($values)),
+ array_map(NullSentinel::unwrap(...), array_values($values)),
+ $seconds
+ )
+ );
+
$result = $this->store->anyTagOps()->putMany()->execute($values, $seconds, $this->tags->getNames());
- if ($result) {
- foreach ($values as $key => $value) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, (string) $key, NullSentinel::unwrap($value), $seconds));
+ foreach ($values as $key => $value) {
+ if ($result) {
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds)
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), (string) $key, NullSentinel::unwrap($value), $seconds)
+ );
}
}
@@ -123,10 +153,9 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
- if ($ttl === null) {
- // Default to 1 year for "null" TTL on add
- $seconds = 31536000;
- } else {
+ $seconds = null;
+
+ if ($ttl !== null) {
$seconds = $this->getSeconds($ttl);
if ($seconds <= 0) {
@@ -144,10 +173,24 @@ public function forever(UnitEnum|string $key, mixed $value): bool
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
+ $this->event(WritingKey::class, fn (): WritingKey => new WritingKey(
+ $this->getName(),
+ $key,
+ NullSentinel::unwrap($value)
+ ));
+
$result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames());
if ($result) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value))
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value))
+ );
}
return $result;
@@ -185,79 +228,6 @@ public function items(): Generator
return $this->store->anyTagOps()->getTagItems()->execute($this->tags->getNames());
}
- /**
- * Get an item from the cache, or execute the given Closure and store the result.
- *
- * Optimized to use a single connection for both GET and PUT operations,
- * avoiding double pool overhead for cache misses.
- *
- * @template TCacheValue
- *
- * @param Closure(): TCacheValue $callback
- * @return TCacheValue
- */
- public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed
- {
- if ($ttl === null) {
- return $this->rememberForever($key, $callback);
- }
-
- $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
- $seconds = $this->getSeconds($ttl);
-
- if ($seconds <= 0) {
- // Invalid TTL, just execute callback without caching
- return $callback();
- }
-
- [$value, $wasHit] = $this->store->anyTagOps()->remember()->execute(
- $key,
- $seconds,
- $callback,
- $this->tags->getNames()
- );
-
- if ($wasHit) {
- $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
- } else {
- $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
- }
-
- return NullSentinel::unwrap($value);
- }
-
- /**
- * Get an item from the cache, or execute the given Closure and store the result forever.
- *
- * Optimized to use a single connection for both GET and SET operations,
- * avoiding double pool overhead for cache misses.
- *
- * @template TCacheValue
- *
- * @param Closure(): TCacheValue $callback
- * @return TCacheValue
- */
- public function rememberForever(UnitEnum|string $key, Closure $callback): mixed
- {
- $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
-
- [$value, $wasHit] = $this->store->anyTagOps()->rememberForever()->execute(
- $key,
- $callback,
- $this->tags->getNames()
- );
-
- if ($wasHit) {
- $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
- } else {
- $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
- }
-
- return NullSentinel::unwrap($value);
- }
-
/**
* Get the tag set instance (covariant return type).
*/
diff --git a/src/cache/src/Redis/Console/BenchmarkCommand.php b/src/cache/src/Redis/Console/BenchmarkCommand.php
index 3132efc38..f7fb4cb8f 100644
--- a/src/cache/src/Redis/Console/BenchmarkCommand.php
+++ b/src/cache/src/Redis/Console/BenchmarkCommand.php
@@ -569,7 +569,7 @@ protected function displayMemoryError(BenchmarkMemoryException $e): void
$this->line(' Option 2 - Clear only benchmark keys (preserves other cache):');
$cachePrefix = $config->string(
"cache.stores.{$this->storeName}.prefix",
- $config->string('cache.prefix', ''),
+ $config->string('cache.prefix'),
);
$this->line(' redis-cli KEYS "' . $cachePrefix . BenchmarkContext::KEY_PREFIX . '*" | xargs redis-cli DEL>');
}
diff --git a/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php
index 51f5e8a6d..87998cfbb 100644
--- a/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php
+++ b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php
@@ -15,7 +15,7 @@ trait DetectsRedisStore
protected function detectRedisStore(): ?string
{
$config = $this->hypervel->make('config');
- $stores = $config->array('cache.stores', []);
+ $stores = $config->array('cache.stores');
foreach ($stores as $name => $storeConfig) {
if (($storeConfig['driver'] ?? null) === 'redis') {
diff --git a/src/cache/src/Redis/Console/DoctorCommand.php b/src/cache/src/Redis/Console/DoctorCommand.php
index 5c891be7e..52cb2263a 100644
--- a/src/cache/src/Redis/Console/DoctorCommand.php
+++ b/src/cache/src/Redis/Console/DoctorCommand.php
@@ -306,7 +306,7 @@ protected function displaySystemInformation(): void
// Cache Store
$config = $this->hypervel->make('config');
- $defaultStore = $config->string('cache.default', 'file');
+ $defaultStore = $config->string('cache.default');
$this->line(" Default Cache Store: {$defaultStore}>");
// Redis/Valkey Service
diff --git a/src/cache/src/Redis/Exceptions/RedisCacheException.php b/src/cache/src/Redis/Exceptions/RedisCacheException.php
deleted file mode 100644
index 45af4824c..000000000
--- a/src/cache/src/Redis/Exceptions/RedisCacheException.php
+++ /dev/null
@@ -1,18 +0,0 @@
- $tagIds Array of tag identifiers
*/
private function flushValues(array $tagIds): void
@@ -54,29 +39,25 @@ private function flushValues(array $tagIds): void
$prefix = $this->context->prefix();
$isCluster = $this->context->isCluster();
- // Collect all entries and prepare chunks
- // (materialize the LazyCollection to get prefixed keys)
$entries = $this->getEntries->execute($tagIds)
->map(fn (string $key) => $prefix . $key);
- // Use a single connection for all chunk deletions
- $this->context->withConnection(function (RedisConnection $connection) use ($entries, $isCluster) {
- foreach ($entries->chunk(self::CHUNK_SIZE) as $chunk) {
- $keys = $chunk->all();
+ foreach ($entries->chunk(self::CHUNK_SIZE) as $chunk) {
+ $keys = $chunk->all();
- if (empty($keys)) {
- continue;
- }
+ if (empty($keys)) {
+ continue;
+ }
+ $this->context->withConnection(function (RedisConnection $connection) use ($keys, $isCluster) {
+ // Cluster keys may occupy different slots and cannot share a pipeline.
if ($isCluster) {
- // Cluster mode: sequential DEL (keys may be in different slots)
$connection->del(...$keys);
} else {
- // Standard mode: pipeline for batching
$this->deleteChunkPipelined($connection, $keys);
}
- }
- });
+ });
+ }
}
/**
diff --git a/src/cache/src/Redis/Operations/AllTag/GetEntries.php b/src/cache/src/Redis/Operations/AllTag/GetEntries.php
index 6323af1d5..b8126a36c 100644
--- a/src/cache/src/Redis/Operations/AllTag/GetEntries.php
+++ b/src/cache/src/Redis/Operations/AllTag/GetEntries.php
@@ -5,16 +5,10 @@
namespace Hypervel\Cache\Redis\Operations\AllTag;
use Hypervel\Cache\Redis\Support\StoreContext;
+use Hypervel\Redis\PhpRedis;
use Hypervel\Redis\RedisConnection;
use Hypervel\Support\LazyCollection;
-/**
- * Retrieves all cache key entries from all tag sorted sets.
- *
- * Uses ZSCAN for efficient cursor-based iteration over potentially large
- * sorted sets. Each tag's entries are collected within a single connection
- * hold for efficiency.
- */
class GetEntries
{
public function __construct(
@@ -33,40 +27,34 @@ public function execute(array $tagIds): LazyCollection
$context = $this->context;
$prefix = $this->context->prefix();
- // phpredis 6.1.0+ uses null as initial cursor value, older versions use '0'
- $defaultCursorValue = match (true) {
- version_compare(phpversion('redis'), '6.1.0', '>=') => null,
- default => '0',
- };
-
- return new LazyCollection(function () use ($context, $prefix, $tagIds, $defaultCursorValue) {
+ return new LazyCollection(function () use ($context, $prefix, $tagIds) {
foreach ($tagIds as $tagId) {
- // Collect all entries for this tag within one connection hold
- $tagEntries = $context->withConnection(function (RedisConnection $connection) use ($prefix, $tagId, $defaultCursorValue) {
- $cursor = $defaultCursorValue;
- $allEntries = [];
-
- do {
- $entries = $connection->zscan(
- $prefix . $tagId,
- $cursor,
- '*',
- 1000
- );
+ $cursor = PhpRedis::initialScanCursor();
+ $seen = [];
- if (! is_array($entries)) {
- break;
+ do {
+ $entries = $context->withConnection(
+ function (RedisConnection $connection) use ($prefix, $tagId, &$cursor) {
+ return $connection->zscan($prefix . $tagId, $cursor, '*', 1000);
}
+ );
+
+ if (! is_array($entries)) {
+ break;
+ }
- $allEntries = array_merge($allEntries, array_keys($entries));
- } while (((string) $cursor) !== $defaultCursorValue);
+ foreach (array_keys($entries) as $entry) {
+ $entry = (string) $entry;
+
+ if (isset($seen[$entry])) {
+ continue;
+ }
- return array_unique($allEntries);
- });
+ $seen[$entry] = true;
- foreach ($tagEntries as $entry) {
- yield $entry;
- }
+ yield $entry;
+ }
+ } while ($cursor !== 0);
}
});
}
diff --git a/src/cache/src/Redis/Operations/AllTag/Prune.php b/src/cache/src/Redis/Operations/AllTag/Prune.php
index a3ae09c90..867d26a65 100644
--- a/src/cache/src/Redis/Operations/AllTag/Prune.php
+++ b/src/cache/src/Redis/Operations/AllTag/Prune.php
@@ -82,9 +82,6 @@ public function execute(int $scanCount = self::DEFAULT_SCAN_COUNT): array
$connection->del($tagKey);
++$stats['empty_sets_deleted'];
}
-
- // Throttle between tags to let Redis breathe
- usleep(5000); // 5ms
}
return $stats;
@@ -115,10 +112,14 @@ private function removeOrphanedEntries(
// ZSCAN returns [member => score, ...] array
$members = $connection->zScan($tagKey, $iterator, '*', $scanCount);
- if ($members === false || ! is_array($members) || empty($members)) {
+ if ($members === false || ! is_array($members)) {
break;
}
+ if ($members === []) {
+ continue;
+ }
+
$memberKeys = array_keys($members);
$checked += count($memberKeys);
@@ -148,7 +149,7 @@ private function removeOrphanedEntries(
$connection->zrem($tagKey, ...$orphanedMembers);
$removed += count($orphanedMembers);
}
- } while ($iterator > 0);
+ } while ($iterator !== 0);
return [
'checked' => $checked,
diff --git a/src/cache/src/Redis/Operations/AllTag/Remember.php b/src/cache/src/Redis/Operations/AllTag/Remember.php
deleted file mode 100644
index c90fd6b03..000000000
--- a/src/cache/src/Redis/Operations/AllTag/Remember.php
+++ /dev/null
@@ -1,136 +0,0 @@
- $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries")
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- public function execute(string $key, int $seconds, Closure $callback, array $tagIds): array
- {
- if ($this->context->isCluster()) {
- return $this->executeCluster($key, $seconds, $callback, $tagIds);
- }
-
- return $this->executePipeline($key, $seconds, $callback, $tagIds);
- }
-
- /**
- * Execute using pipeline for standard Redis (non-cluster).
- *
- * GET first, then on miss: pipelines ZADD commands for all tags + SETEX in a single round trip.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executePipeline(string $key, int $seconds, Closure $callback, array $tagIds): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $callback, $tagIds) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Now store with tag tracking using pipeline
- $score = now()->addSeconds($seconds)->getTimestamp();
- $serialized = $this->serialization->serialize($connection, $value);
-
- $pipeline = $connection->pipeline();
-
- // ZADD to each tag's sorted set
- foreach ($tagIds as $tagId) {
- $pipeline->zadd($prefix . $tagId, $score, $key);
- }
-
- // SETEX for the cache value
- $pipeline->setex($prefixedKey, max(1, $seconds), $serialized);
-
- $pipeline->exec();
-
- return [$value, false];
- });
- }
-
- /**
- * Execute using sequential commands for Redis Cluster.
- *
- * Each tag sorted set may be in a different slot, so we must
- * execute commands sequentially rather than in a pipeline.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executeCluster(string $key, int $seconds, Closure $callback, array $tagIds): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $callback, $tagIds) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Now store with tag tracking using sequential commands
- $score = now()->addSeconds($seconds)->getTimestamp();
- $serialized = $this->serialization->serialize($connection, $value);
-
- // ZADD to each tag's sorted set (sequential - cross-slot)
- foreach ($tagIds as $tagId) {
- $connection->zadd($prefix . $tagId, $score, $key);
- }
-
- // SETEX for the cache value
- $connection->setex($prefixedKey, max(1, $seconds), $serialized);
-
- return [$value, false];
- });
- }
-}
diff --git a/src/cache/src/Redis/Operations/AllTag/RememberForever.php b/src/cache/src/Redis/Operations/AllTag/RememberForever.php
deleted file mode 100644
index d82ba2260..000000000
--- a/src/cache/src/Redis/Operations/AllTag/RememberForever.php
+++ /dev/null
@@ -1,133 +0,0 @@
- $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries")
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- public function execute(string $key, Closure $callback, array $tagIds): array
- {
- if ($this->context->isCluster()) {
- return $this->executeCluster($key, $callback, $tagIds);
- }
-
- return $this->executePipeline($key, $callback, $tagIds);
- }
-
- /**
- * Execute using pipeline for standard Redis (non-cluster).
- *
- * GET first, then on miss: pipelines ZADD commands for all tags + SET in a single round trip.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executePipeline(string $key, Closure $callback, array $tagIds): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $callback, $tagIds) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Now store with tag tracking using pipeline
- $serialized = $this->serialization->serialize($connection, $value);
-
- $pipeline = $connection->pipeline();
-
- // ZADD to each tag's sorted set with score -1 (forever)
- foreach ($tagIds as $tagId) {
- $pipeline->zadd($prefix . $tagId, self::FOREVER_SCORE, $key);
- }
-
- // SET for the cache value (no expiration)
- $pipeline->set($prefixedKey, $serialized);
-
- $pipeline->exec();
-
- return [$value, false];
- });
- }
-
- /**
- * Execute using sequential commands for Redis Cluster.
- *
- * Each tag sorted set may be in a different slot, so we must
- * execute commands sequentially rather than in a pipeline.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executeCluster(string $key, Closure $callback, array $tagIds): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $callback, $tagIds) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Now store with tag tracking using sequential commands
- $serialized = $this->serialization->serialize($connection, $value);
-
- // ZADD to each tag's sorted set (sequential - cross-slot)
- foreach ($tagIds as $tagId) {
- $connection->zadd($prefix . $tagId, self::FOREVER_SCORE, $key);
- }
-
- // SET for the cache value (no expiration)
- $connection->set($prefixedKey, $serialized);
-
- return [$value, false];
- });
- }
-}
diff --git a/src/cache/src/Redis/Operations/AllTagOperations.php b/src/cache/src/Redis/Operations/AllTagOperations.php
index 935a4ec7b..aa28f4057 100644
--- a/src/cache/src/Redis/Operations/AllTagOperations.php
+++ b/src/cache/src/Redis/Operations/AllTagOperations.php
@@ -15,20 +15,10 @@
use Hypervel\Cache\Redis\Operations\AllTag\Prune;
use Hypervel\Cache\Redis\Operations\AllTag\Put;
use Hypervel\Cache\Redis\Operations\AllTag\PutMany;
-use Hypervel\Cache\Redis\Operations\AllTag\Remember;
-use Hypervel\Cache\Redis\Operations\AllTag\RememberForever;
use Hypervel\Cache\Redis\Operations\AllTag\Touch;
use Hypervel\Cache\Redis\Support\Serialization;
use Hypervel\Cache\Redis\Support\StoreContext;
-/**
- * Container for all-mode tag operations.
- *
- * This class groups all Redis operations related to all-mode tagging,
- * providing lazy-loaded, singleton-cached operation instances.
- *
- * Used by AllTaggedCache and AllTagSet.
- */
class AllTagOperations
{
// Combined cache + tag operations
@@ -57,10 +47,6 @@ class AllTagOperations
private ?Prune $prune = null;
- private ?Remember $remember = null;
-
- private ?RememberForever $rememberForever = null;
-
public function __construct(
private readonly StoreContext $context,
private readonly Serialization $serialization,
@@ -169,50 +155,4 @@ public function prune(): Prune
{
return $this->prune ??= new Prune($this->context);
}
-
- /**
- * Get the Remember operation for cache-through with tag tracking.
- *
- * This operation is optimized to use a single connection for both
- * GET and PUT operations, avoiding double pool overhead on cache misses.
- */
- public function remember(): Remember
- {
- return $this->remember ??= new Remember($this->context, $this->serialization);
- }
-
- /**
- * Get the RememberForever operation for cache-through with tag tracking (no TTL).
- *
- * This operation is optimized to use a single connection for both
- * GET and SET operations, avoiding double pool overhead on cache misses.
- * Uses ZADD with score -1 for tag entries (prevents cleanup by ZREMRANGEBYSCORE).
- */
- public function rememberForever(): RememberForever
- {
- return $this->rememberForever ??= new RememberForever($this->context, $this->serialization);
- }
-
- /**
- * Clear all cached operation instances.
- *
- * Called when the store's connection or prefix changes.
- */
- public function clear(): void
- {
- $this->put = null;
- $this->putMany = null;
- $this->add = null;
- $this->forever = null;
- $this->touch = null;
- $this->increment = null;
- $this->decrement = null;
- $this->addEntry = null;
- $this->getEntries = null;
- $this->flushStale = null;
- $this->flush = null;
- $this->prune = null;
- $this->remember = null;
- $this->rememberForever = null;
- }
}
diff --git a/src/cache/src/Redis/Operations/AnyTag/Add.php b/src/cache/src/Redis/Operations/AnyTag/Add.php
index 7fddb5693..7d2741c1b 100644
--- a/src/cache/src/Redis/Operations/AnyTag/Add.php
+++ b/src/cache/src/Redis/Operations/AnyTag/Add.php
@@ -13,8 +13,6 @@
*
* Uses Redis SET with NX flag for atomic "add if not exists" operation.
* Only adds tag entries if the cache key was successfully created.
- *
- * Performance: Uses atomic HSETEX for tag entries after successful add.
*/
class Add
{
@@ -32,11 +30,11 @@ 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 null|int $seconds TTL in seconds, or null for no expiration
* @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
+ public function execute(string $key, mixed $value, ?int $seconds, array $tags): bool
{
// 1. Cluster Mode: Must use sequential commands
if ($this->context->isCluster()) {
@@ -50,16 +48,20 @@ public function execute(string $key, mixed $value, int $seconds, array $tags): b
/**
* Execute for cluster using sequential commands.
*/
- private function executeCluster(string $key, mixed $value, int $seconds, array $tags): bool
+ private function executeCluster(string $key, mixed $value, ?int $seconds, array $tags): bool
{
return $this->context->withConnection(function (RedisConnection $connection) use ($key, $value, $seconds, $tags) {
$prefix = $this->context->prefix();
// First try to add the key with NX flag
+ $options = $seconds === null
+ ? ['NX']
+ : ['EX' => max(1, $seconds), 'NX'];
+
$added = $connection->set(
$prefix . $key,
$this->serialization->serialize($connection, $value),
- ['EX' => max(1, $seconds), 'NX']
+ $options
);
if (! $added) {
@@ -77,19 +79,30 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $
// Use multi() for reverse index updates (same slot)
$multi = $connection->multi();
$multi->sadd($tagsKey, ...$tags);
- $multi->expire($tagsKey, max(1, $seconds));
+
+ if ($seconds !== null) {
+ $multi->expire($tagsKey, max(1, $seconds));
+ }
+
$multi->exec();
}
- // Add to tags with field expiration (using HSETEX for atomic operation)
- // And update the Tag Registry
+ // Add each tag reference, applying field expiration only to expiring items.
+ // Then update the tag registry.
$registryKey = $this->context->registryKey();
- $expiry = time() + $seconds;
+ $expiry = $seconds === null
+ ? StoreContext::MAX_EXPIRY
+ : time() + $seconds;
// 1. Update Tag Hashes (Cross-slot, must be sequential)
foreach ($tags as $tag) {
$tag = (string) $tag;
- $connection->hsetex($this->context->tagHashKey($tag), [$key => StoreContext::TAG_FIELD_VALUE], ['EX' => $seconds]);
+
+ if ($seconds === null) {
+ $connection->hSet($this->context->tagHashKey($tag), $key, StoreContext::TAG_FIELD_VALUE);
+ } else {
+ $connection->hsetex($this->context->tagHashKey($tag), [$key => StoreContext::TAG_FIELD_VALUE], ['EX' => $seconds]);
+ }
}
// 2. Update Registry (Same slot, single command optimization)
@@ -112,7 +125,7 @@ private function executeCluster(string $key, mixed $value, int $seconds, array $
/**
* Execute using Lua script for better performance.
*/
- private function executeUsingLua(string $key, mixed $value, int $seconds, array $tags): bool
+ private function executeUsingLua(string $key, mixed $value, ?int $seconds, array $tags): bool
{
return $this->context->withConnection(function (RedisConnection $connection) use ($key, $value, $seconds, $tags) {
$prefix = $this->context->prefix();
@@ -124,7 +137,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 === null ? 0 : max(1, $seconds), // ARGV[2]
$this->context->fullTagPrefix(), // ARGV[3]
$this->context->fullRegistryKey(), // ARGV[4]
time(), // ARGV[5]
@@ -145,7 +158,7 @@ private function executeUsingLua(string $key, mixed $value, int $seconds, array
* KEYS[1] - The cache key (prefixed)
* KEYS[2] - The reverse index key (tracks which tags this key belongs to)
* ARGV[1] - Serialized value
- * ARGV[2] - TTL in seconds
+ * ARGV[2] - TTL in seconds, or zero for no expiration
* ARGV[3] - Tag prefix for building tag hash keys
* ARGV[4] - Tag registry key
* ARGV[5] - Current timestamp
@@ -165,11 +178,17 @@ protected function addWithTagsScript(): string
local now = ARGV[5]
local rawKey = ARGV[6]
local tagHashSuffix = ARGV[7]
- local expiry = now + ttl
+ local permanent = tonumber(ttl) == 0
+ local expiry = permanent and 253402300799 or (now + ttl) -- Must match StoreContext::MAX_EXPIRY.
-- 1. Try to add key (SET NX)
-- redis.call returns a table/object for OK, or false/nil
- local added = redis.call('SET', key, val, 'EX', ttl, 'NX')
+ local added
+ if permanent then
+ added = redis.call('SET', key, val, 'NX')
+ else
+ added = redis.call('SET', key, val, 'EX', ttl, 'NX')
+ end
if not added then
return false
@@ -183,15 +202,21 @@ protected function addWithTagsScript(): string
if #newTagsList > 0 then
redis.call('SADD', tagsKey, unpack(newTagsList))
- redis.call('EXPIRE', tagsKey, ttl)
+
+ if not permanent then
+ redis.call('EXPIRE', tagsKey, ttl)
+ end
end
-- 3. Add to Tag Hashes & Registry
for _, tag in ipairs(newTagsList) do
local tagHash = tagPrefix .. tag .. tagHashSuffix
- -- Use HSET + HEXPIRE instead of HSETEX to avoid potential Lua argument issues
- redis.call('HSET', tagHash, rawKey, '1')
- redis.call('HEXPIRE', tagHash, ttl, 'FIELDS', 1, rawKey)
+ if permanent then
+ redis.call('HSET', tagHash, rawKey, '1')
+ else
+ redis.call('HSETEX', tagHash, 'EX', ttl, 'FIELDS', 1, rawKey, '1')
+ end
+
redis.call('ZADD', registryKey, 'GT', expiry, tag)
end
diff --git a/src/cache/src/Redis/Operations/AnyTag/Decrement.php b/src/cache/src/Redis/Operations/AnyTag/Decrement.php
index ae28a70a7..02b6f9172 100644
--- a/src/cache/src/Redis/Operations/AnyTag/Decrement.php
+++ b/src/cache/src/Redis/Operations/AnyTag/Decrement.php
@@ -172,7 +172,7 @@ protected function decrementWithTagsScript(): string
-- 2. Get TTL
local ttl = redis.call('TTL', key)
- local expiry = 253402300799 -- Default forever
+ local expiry = 253402300799 -- Default forever; must match StoreContext::MAX_EXPIRY.
if ttl > 0 then
expiry = now + ttl
end
diff --git a/src/cache/src/Redis/Operations/AnyTag/Forever.php b/src/cache/src/Redis/Operations/AnyTag/Forever.php
index c10957835..481483931 100644
--- a/src/cache/src/Redis/Operations/AnyTag/Forever.php
+++ b/src/cache/src/Redis/Operations/AnyTag/Forever.php
@@ -192,7 +192,7 @@ protected function storeForeverWithTagsScript(): string
end
-- 5. Add to New Tags
- local expiry = 253402300799
+ local expiry = 253402300799 -- Must match StoreContext::MAX_EXPIRY.
for _, tag in ipairs(newTagsList) do
local tagHash = tagPrefix .. tag .. tagHashSuffix
redis.call('HSET', tagHash, rawKey, '1')
diff --git a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php
index 3d70620f7..8360b0298 100644
--- a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php
+++ b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php
@@ -6,6 +6,7 @@
use Generator;
use Hypervel\Cache\Redis\Support\StoreContext;
+use Hypervel\Redis\PhpRedis;
use Hypervel\Redis\RedisConnection;
/**
@@ -14,9 +15,6 @@
* Uses adaptive scanning strategy based on hash size:
* - At or below threshold: Uses HKEYS (faster, loads all into memory)
* - Above threshold: Uses HSCAN (memory-efficient streaming)
- *
- * IMPORTANT: This implementation uses per-batch connection checkouts for HSCAN
- * to avoid a race condition in Swoole coroutine environments. See FIXES.md for details.
*/
class GetTaggedKeys
{
@@ -80,15 +78,14 @@ private function arrayToGenerator(array $items): Generator
/**
* Create a generator using HSCAN for memory-efficient iteration.
*
- * Acquires a connection per-batch to avoid race conditions in Swoole coroutine
- * environments. The connection is released between HSCAN iterations, ensuring
- * it won't be used by another coroutine while the generator is paused.
+ * Each page owns a short connection checkout so yielding a key never retains a
+ * pooled connection across arbitrary caller work.
*
* @return Generator
*/
private function hscanGenerator(string $tagKey, int $count): Generator
{
- $iterator = null;
+ $iterator = PhpRedis::initialScanCursor();
do {
// Acquire connection just for this HSCAN batch
@@ -98,12 +95,14 @@ function (RedisConnection $connection) use ($tagKey, &$iterator, $count) {
}
);
- if ($fields !== false && ! empty($fields)) {
- // HSCAN returns key-value pairs, we only need keys
- foreach (array_keys($fields) as $key) {
- yield $key;
- }
+ if ($fields === false || ! is_array($fields)) {
+ break;
+ }
+
+ // HSCAN returns key-value pairs, we only need keys.
+ foreach (array_keys($fields) as $key) {
+ yield $key;
}
- } while ($iterator > 0); // @phpstan-ignore greater.alwaysFalse (phpredis updates $iterator by reference)
+ } while ($iterator !== 0);
}
}
diff --git a/src/cache/src/Redis/Operations/AnyTag/Increment.php b/src/cache/src/Redis/Operations/AnyTag/Increment.php
index b8a3cc25e..db39b4866 100644
--- a/src/cache/src/Redis/Operations/AnyTag/Increment.php
+++ b/src/cache/src/Redis/Operations/AnyTag/Increment.php
@@ -172,7 +172,7 @@ protected function incrementWithTagsScript(): string
-- 2. Get TTL
local ttl = redis.call('TTL', key)
- local expiry = 253402300799 -- Default forever
+ local expiry = 253402300799 -- Default forever; must match StoreContext::MAX_EXPIRY.
if ttl > 0 then
expiry = now + ttl
end
diff --git a/src/cache/src/Redis/Operations/AnyTag/Prune.php b/src/cache/src/Redis/Operations/AnyTag/Prune.php
index 55ed00d06..21d64a1b7 100644
--- a/src/cache/src/Redis/Operations/AnyTag/Prune.php
+++ b/src/cache/src/Redis/Operations/AnyTag/Prune.php
@@ -97,9 +97,6 @@ private function executePipeline(int $scanCount): array
if ($result['deleted']) {
++$stats['empty_hashes_deleted'];
}
-
- // Small sleep to let Redis breathe between tag hashes
- usleep(5000); // 5ms
}
return $stats;
@@ -149,9 +146,6 @@ private function executeCluster(int $scanCount): array
if ($result['deleted']) {
++$stats['empty_hashes_deleted'];
}
-
- // Small sleep to let Redis breathe between tag hashes
- usleep(5000); // 5ms
}
return $stats;
@@ -174,10 +168,14 @@ private function cleanupTagHashPipeline(RedisConnection $connection, string $tag
// HSCAN returns [field => value, ...] array
$fields = $connection->hScan($tagHash, $iterator, '*', $scanCount);
- if ($fields === false || ! is_array($fields) || empty($fields)) {
+ if ($fields === false || ! is_array($fields)) {
break;
}
+ if ($fields === []) {
+ continue;
+ }
+
$fieldKeys = array_keys($fields);
$checked += count($fieldKeys);
@@ -201,7 +199,7 @@ private function cleanupTagHashPipeline(RedisConnection $connection, string $tag
$connection->hDel($tagHash, ...$orphanedFields);
$removed += count($orphanedFields);
}
- } while ($iterator > 0);
+ } while ($iterator !== 0);
// Check if hash is now empty and delete it
$deleted = false;
@@ -234,10 +232,14 @@ private function cleanupTagHashCluster(RedisConnection $connection, string $tagH
// HSCAN returns [field => value, ...] array
$fields = $connection->hScan($tagHash, $iterator, '*', $scanCount);
- if ($fields === false || ! is_array($fields) || empty($fields)) {
+ if ($fields === false || ! is_array($fields)) {
break;
}
+ if ($fields === []) {
+ continue;
+ }
+
$fieldKeys = array_keys($fields);
$checked += count($fieldKeys);
@@ -254,7 +256,7 @@ private function cleanupTagHashCluster(RedisConnection $connection, string $tagH
$connection->hDel($tagHash, ...$orphanedFields);
$removed += count($orphanedFields);
}
- } while ($iterator > 0);
+ } while ($iterator !== 0);
// Check if hash is now empty and delete it
$deleted = false;
diff --git a/src/cache/src/Redis/Operations/AnyTag/Remember.php b/src/cache/src/Redis/Operations/AnyTag/Remember.php
deleted file mode 100644
index 0a723b1bf..000000000
--- a/src/cache/src/Redis/Operations/AnyTag/Remember.php
+++ /dev/null
@@ -1,255 +0,0 @@
- 0)
- * @param Closure $callback The callback to execute on cache miss
- * @param array $tags Array of tag names (will be cast to strings)
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- public function execute(string $key, int $seconds, Closure $callback, array $tags): array
- {
- // Cluster Mode: Must use sequential commands
- if ($this->context->isCluster()) {
- return $this->executeCluster($key, $seconds, $callback, $tags);
- }
-
- // Standard Mode: Use Lua for atomicity and performance
- return $this->executeUsingLua($key, $seconds, $callback, $tags);
- }
-
- /**
- * Execute for cluster using sequential commands.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executeCluster(string $key, int $seconds, Closure $callback, array $tags): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $callback, $tags) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Get old tags to handle replacement correctly (remove from old, add to new)
- $tagsKey = $this->context->reverseIndexKey($key);
- $oldTags = $connection->smembers($tagsKey);
-
- // Store the actual cache value
- $connection->setex(
- $prefixedKey,
- max(1, $seconds),
- $this->serialization->serialize($connection, $value)
- );
-
- // Store reverse index of tags for this key
- // Use multi() as these keys are in the same slot
- $multi = $connection->multi();
- $multi->del($tagsKey); // Clear old tags
-
- if (! empty($tags)) {
- $multi->sadd($tagsKey, ...$tags);
- $multi->expire($tagsKey, max(1, $seconds));
- }
-
- $multi->exec();
-
- // Remove item from tags it no longer belongs to
- $tagsToRemove = array_diff($oldTags, $tags);
-
- foreach ($tagsToRemove as $tag) {
- $tag = (string) $tag;
- $connection->hdel($this->context->tagHashKey($tag), $key);
- }
-
- // Add to each tag's hash with expiration (using HSETEX for atomic operation)
- // And update the Tag Registry
- $registryKey = $this->context->registryKey();
- $expiry = time() + $seconds;
-
- // 1. Update Tag Hashes (Cross-slot, must be sequential)
- foreach ($tags as $tag) {
- $tag = (string) $tag;
-
- // Use HSETEX to set field and expiration atomically in one command
- $connection->hsetex(
- $this->context->tagHashKey($tag),
- [$key => StoreContext::TAG_FIELD_VALUE],
- ['EX' => $seconds]
- );
- }
-
- // 2. Update Registry (Same slot, single command optimization)
- if (! empty($tags)) {
- $zaddArgs = [];
-
- foreach ($tags as $tag) {
- $zaddArgs[] = $expiry;
- $zaddArgs[] = (string) $tag;
- }
-
- // Update Registry: ZADD with GT (Greater Than) to only extend expiry
- $connection->zadd($registryKey, ['GT'], ...$zaddArgs);
- }
-
- return [$value, false];
- });
- }
-
- /**
- * Execute using Lua script for performance.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executeUsingLua(string $key, int $seconds, Closure $callback, array $tags): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $callback, $tags) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value first
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Now use Lua script to atomically store with tags
- $keys = [
- $prefixedKey, // KEYS[1]
- $this->context->reverseIndexKey($key), // KEYS[2]
- ];
-
- $args = [
- $this->serialization->serializeForLua($connection, $value), // ARGV[1]
- max(1, $seconds), // ARGV[2]
- $this->context->fullTagPrefix(), // ARGV[3]
- $this->context->fullRegistryKey(), // ARGV[4]
- time(), // ARGV[5]
- $key, // ARGV[6]
- $this->context->tagHashSuffix(), // ARGV[7]
- ...$tags, // ARGV[8...]
- ];
-
- $connection->evalWithShaCache($this->storeWithTagsScript(), $keys, $args);
-
- return [$value, false];
- });
- }
-
- /**
- * Get the Lua script for storing a value with tag tracking.
- *
- * KEYS[1] - The cache key (prefixed)
- * KEYS[2] - The reverse index key (tracks which tags this key belongs to)
- * ARGV[1] - Serialized value
- * ARGV[2] - TTL in seconds
- * ARGV[3] - Tag prefix for building tag hash keys
- * ARGV[4] - Tag registry key
- * ARGV[5] - Current timestamp
- * ARGV[6] - Raw key (without prefix, for hash field name)
- * ARGV[7] - Tag hash suffix (":entries")
- * ARGV[8...] - Tag names
- */
- protected function storeWithTagsScript(): string
- {
- return <<<'LUA'
- local key = KEYS[1]
- local tagsKey = KEYS[2]
- local val = ARGV[1]
- local ttl = ARGV[2]
- local tagPrefix = ARGV[3]
- local registryKey = ARGV[4]
- local now = ARGV[5]
- local rawKey = ARGV[6]
- local tagHashSuffix = ARGV[7]
- local expiry = now + ttl
-
- -- 1. Set Cache
- redis.call('SETEX', key, ttl, val)
-
- -- 2. Get Old Tags
- local oldTags = redis.call('SMEMBERS', tagsKey)
- local newTagsMap = {}
- local newTagsList = {}
-
- -- Parse new tags
- for i = 8, #ARGV do
- local tag = ARGV[i]
- newTagsMap[tag] = true
- table.insert(newTagsList, tag)
- end
-
- -- 3. Remove from Old Tags
- for _, tag in ipairs(oldTags) do
- if not newTagsMap[tag] then
- local tagHash = tagPrefix .. tag .. tagHashSuffix
- redis.call('HDEL', tagHash, rawKey)
- end
- end
-
- -- 4. Update Tags Key
- redis.call('DEL', tagsKey)
- if #newTagsList > 0 then
- redis.call('SADD', tagsKey, unpack(newTagsList))
- redis.call('EXPIRE', tagsKey, ttl)
- end
-
- -- 5. Add to New Tags & Registry
- for _, tag in ipairs(newTagsList) do
- local tagHash = tagPrefix .. tag .. tagHashSuffix
- -- Use HSETEX for atomic field creation and expiration (Redis 8.0+)
- redis.call('HSETEX', tagHash, 'EX', ttl, 'FIELDS', 1, rawKey, '1')
- redis.call('ZADD', registryKey, 'GT', expiry, tag)
- end
-
- return true
- LUA;
- }
-}
diff --git a/src/cache/src/Redis/Operations/AnyTag/RememberForever.php b/src/cache/src/Redis/Operations/AnyTag/RememberForever.php
deleted file mode 100644
index 0d91801b0..000000000
--- a/src/cache/src/Redis/Operations/AnyTag/RememberForever.php
+++ /dev/null
@@ -1,234 +0,0 @@
- $tags Array of tag names (will be cast to strings)
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- public function execute(string $key, Closure $callback, array $tags): array
- {
- // Cluster Mode: Must use sequential commands
- if ($this->context->isCluster()) {
- return $this->executeCluster($key, $callback, $tags);
- }
-
- // Standard Mode: Use Lua for atomicity and performance
- return $this->executeUsingLua($key, $callback, $tags);
- }
-
- /**
- * Execute for cluster using sequential commands.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executeCluster(string $key, Closure $callback, array $tags): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $callback, $tags) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Get old tags to handle replacement correctly (remove from old, add to new)
- $tagsKey = $this->context->reverseIndexKey($key);
- $oldTags = $connection->smembers($tagsKey);
-
- // Store the actual cache value without expiration
- $connection->set(
- $prefixedKey,
- $this->serialization->serialize($connection, $value)
- );
-
- // Store reverse index of tags for this key (no expiration for forever)
- // Use multi() as these keys are in the same slot
- $multi = $connection->multi();
- $multi->del($tagsKey);
-
- if (! empty($tags)) {
- $multi->sadd($tagsKey, ...$tags);
- }
-
- $multi->exec();
-
- // Remove item from tags it no longer belongs to
- $tagsToRemove = array_diff($oldTags, $tags);
-
- foreach ($tagsToRemove as $tag) {
- $tag = (string) $tag;
- $connection->hdel($this->context->tagHashKey($tag), $key);
- }
-
- // Calculate expiry for Registry (Year 9999)
- $expiry = StoreContext::MAX_EXPIRY;
- $registryKey = $this->context->registryKey();
-
- // 1. Add to each tag's hash without expiration (Cross-slot, sequential)
- foreach ($tags as $tag) {
- $tag = (string) $tag;
- $connection->hSet($this->context->tagHashKey($tag), $key, StoreContext::TAG_FIELD_VALUE);
- // No HEXPIRE for forever items
- }
-
- // 2. Update Registry (Same slot, single command optimization)
- if (! empty($tags)) {
- $zaddArgs = [];
-
- foreach ($tags as $tag) {
- $zaddArgs[] = $expiry;
- $zaddArgs[] = (string) $tag;
- }
-
- // Update Registry: ZADD with GT (Greater Than) to only extend expiry
- $connection->zadd($registryKey, ['GT'], ...$zaddArgs);
- }
-
- return [$value, false];
- });
- }
-
- /**
- * Execute using Lua script for performance.
- *
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- private function executeUsingLua(string $key, Closure $callback, array $tags): array
- {
- return $this->context->withConnection(function (RedisConnection $connection) use ($key, $callback, $tags) {
- $prefix = $this->context->prefix();
- $prefixedKey = $prefix . $key;
-
- // Try to get the cached value first
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback
- $value = $callback();
-
- // Now use Lua script to atomically store with tags (forever semantics)
- $keys = [
- $prefixedKey, // KEYS[1]
- $this->context->reverseIndexKey($key), // KEYS[2]
- ];
-
- $args = [
- $this->serialization->serializeForLua($connection, $value), // ARGV[1]
- $this->context->fullTagPrefix(), // ARGV[2]
- $this->context->fullRegistryKey(), // ARGV[3]
- $key, // ARGV[4]
- $this->context->tagHashSuffix(), // ARGV[5]
- ...$tags, // ARGV[6...]
- ];
-
- $connection->evalWithShaCache($this->storeForeverWithTagsScript(), $keys, $args);
-
- return [$value, false];
- });
- }
-
- /**
- * Get the Lua script for storing a value forever with tag tracking.
- *
- * KEYS[1] - The cache key (prefixed)
- * KEYS[2] - The reverse index key (tracks which tags this key belongs to)
- * ARGV[1] - Serialized value
- * ARGV[2] - Tag prefix for building tag hash keys
- * ARGV[3] - Tag registry key
- * ARGV[4] - Raw key (without prefix, for hash field name)
- * ARGV[5] - Tag hash suffix (":entries")
- * ARGV[6...] - Tag names
- */
- protected function storeForeverWithTagsScript(): string
- {
- return <<<'LUA'
- local key = KEYS[1]
- local tagsKey = KEYS[2]
- local val = ARGV[1]
- local tagPrefix = ARGV[2]
- local registryKey = ARGV[3]
- local rawKey = ARGV[4]
- local tagHashSuffix = ARGV[5]
-
- -- 1. Set Value (no expiration)
- redis.call('SET', key, val)
-
- -- 2. Get Old Tags
- local oldTags = redis.call('SMEMBERS', tagsKey)
- local newTagsMap = {}
- local newTagsList = {}
-
- for i = 6, #ARGV do
- local tag = ARGV[i]
- newTagsMap[tag] = true
- table.insert(newTagsList, tag)
- end
-
- -- 3. Remove from Old Tags
- for _, tag in ipairs(oldTags) do
- if not newTagsMap[tag] then
- local tagHash = tagPrefix .. tag .. tagHashSuffix
- redis.call('HDEL', tagHash, rawKey)
- end
- end
-
- -- 4. Update Reverse Index (no expiration for forever)
- redis.call('DEL', tagsKey)
- if #newTagsList > 0 then
- redis.call('SADD', tagsKey, unpack(newTagsList))
- end
-
- -- 5. Add to New Tags (HSET without HEXPIRE, registry with MAX_EXPIRY)
- local expiry = 253402300799
- for _, tag in ipairs(newTagsList) do
- local tagHash = tagPrefix .. tag .. tagHashSuffix
- redis.call('HSET', tagHash, rawKey, '1')
- redis.call('ZADD', registryKey, 'GT', expiry, tag)
- end
-
- return true
- LUA;
- }
-}
diff --git a/src/cache/src/Redis/Operations/AnyTagOperations.php b/src/cache/src/Redis/Operations/AnyTagOperations.php
index 94075aef0..f0951c192 100644
--- a/src/cache/src/Redis/Operations/AnyTagOperations.php
+++ b/src/cache/src/Redis/Operations/AnyTagOperations.php
@@ -15,20 +15,10 @@
use Hypervel\Cache\Redis\Operations\AnyTag\Prune;
use Hypervel\Cache\Redis\Operations\AnyTag\Put;
use Hypervel\Cache\Redis\Operations\AnyTag\PutMany;
-use Hypervel\Cache\Redis\Operations\AnyTag\Remember;
-use Hypervel\Cache\Redis\Operations\AnyTag\RememberForever;
use Hypervel\Cache\Redis\Operations\AnyTag\Touch;
use Hypervel\Cache\Redis\Support\Serialization;
use Hypervel\Cache\Redis\Support\StoreContext;
-/**
- * Container for any-mode tag operations.
- *
- * This class groups all Redis operations related to any-mode tagging,
- * providing lazy-loaded, singleton-cached operation instances.
- *
- * Used by AnyTaggedCache and AnyTagSet.
- */
class AnyTagOperations
{
private ?Put $put = null;
@@ -55,10 +45,6 @@ class AnyTagOperations
private ?Prune $prune = null;
- private ?Remember $remember = null;
-
- private ?RememberForever $rememberForever = null;
-
public function __construct(
private readonly StoreContext $context,
private readonly Serialization $serialization,
@@ -167,49 +153,4 @@ public function prune(): Prune
{
return $this->prune ??= new Prune($this->context);
}
-
- /**
- * Get the Remember operation for cache-through with tags.
- *
- * This operation is optimized to use a single connection for both
- * GET and PUT operations, avoiding double pool overhead on cache misses.
- */
- public function remember(): Remember
- {
- return $this->remember ??= new Remember($this->context, $this->serialization);
- }
-
- /**
- * Get the RememberForever operation for cache-through with tags (no TTL).
- *
- * This operation is optimized to use a single connection for both
- * GET and SET operations, avoiding double pool overhead on cache misses.
- */
- public function rememberForever(): RememberForever
- {
- return $this->rememberForever ??= new RememberForever($this->context, $this->serialization);
- }
-
- /**
- * Clear all cached operation instances.
- *
- * Called when the store's connection or prefix changes.
- */
- public function clear(): void
- {
- $this->put = null;
- $this->putMany = null;
- $this->add = null;
- $this->forever = null;
- $this->touch = null;
- $this->forget = null;
- $this->increment = null;
- $this->decrement = null;
- $this->getTaggedKeys = null;
- $this->getTagItems = null;
- $this->flush = null;
- $this->prune = null;
- $this->remember = null;
- $this->rememberForever = null;
- }
}
diff --git a/src/cache/src/Redis/Operations/Remember.php b/src/cache/src/Redis/Operations/Remember.php
deleted file mode 100644
index fbf3e2b49..000000000
--- a/src/cache/src/Redis/Operations/Remember.php
+++ /dev/null
@@ -1,62 +0,0 @@
-context->withConnection(function (RedisConnection $connection) use ($key, $seconds, $callback) {
- $prefixedKey = $this->context->prefix() . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return $this->serialization->unserialize($connection, $value);
- }
-
- // Cache miss - execute callback and store result
- $value = $callback();
-
- $connection->setex(
- $prefixedKey,
- max(1, $seconds),
- $this->serialization->serialize($connection, $value)
- );
-
- return $value;
- });
- }
-}
diff --git a/src/cache/src/Redis/Operations/RememberForever.php b/src/cache/src/Redis/Operations/RememberForever.php
deleted file mode 100644
index b49637fe6..000000000
--- a/src/cache/src/Redis/Operations/RememberForever.php
+++ /dev/null
@@ -1,62 +0,0 @@
-context->withConnection(function (RedisConnection $connection) use ($key, $callback) {
- $prefixedKey = $this->context->prefix() . $key;
-
- // Try to get the cached value
- $value = $connection->get($prefixedKey);
-
- if ($value !== false && $value !== null) {
- return [$this->serialization->unserialize($connection, $value), true];
- }
-
- // Cache miss - execute callback and store result forever (no TTL)
- $value = $callback();
-
- $connection->set(
- $prefixedKey,
- $this->serialization->serialize($connection, $value)
- );
-
- return [$value, false];
- });
- }
-}
diff --git a/src/cache/src/Redis/Support/StoreContext.php b/src/cache/src/Redis/Support/StoreContext.php
index f582e1937..f9894a409 100644
--- a/src/cache/src/Redis/Support/StoreContext.php
+++ b/src/cache/src/Redis/Support/StoreContext.php
@@ -5,18 +5,12 @@
namespace Hypervel\Cache\Redis\Support;
use Hypervel\Cache\TagMode;
-use Hypervel\Container\Container;
use Hypervel\Contracts\Redis\Factory as RedisFactory;
use Hypervel\Redis\RedisConnection;
use Redis;
/**
* Mode-aware context for Redis cache operations.
- *
- * This class encapsulates the dependencies that all cache operations need,
- * providing a clean interface to Redis connection, client, and configuration.
- * It receives TagMode via dependency injection and delegates mode-specific
- * key formatting to an internal TagKeyBuilder.
*/
class StoreContext
{
@@ -35,6 +29,7 @@ class StoreContext
private readonly TagKeyBuilder $tagKeyBuilder;
public function __construct(
+ private readonly RedisFactory $redis,
private readonly string $connectionName,
private readonly string $prefix,
private readonly TagMode $tagMode,
@@ -138,8 +133,7 @@ public function registryKey(): string
*/
public function withConnection(callable $callback): mixed
{
- return Container::getInstance()
- ->make(RedisFactory::class)
+ return $this->redis
->connection($this->connectionName)
->withConnection($callback, transform: false);
}
@@ -149,8 +143,7 @@ public function withConnection(callable $callback): mixed
*/
public function isCluster(): bool
{
- return Container::getInstance()
- ->make(RedisFactory::class)
+ return $this->redis
->connection($this->connectionName)
->isCluster();
}
diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php
index ea21a5ae4..5d78c8fbe 100644
--- a/src/cache/src/RedisStore.php
+++ b/src/cache/src/RedisStore.php
@@ -4,12 +4,10 @@
namespace Hypervel\Cache;
-use Closure;
use Hypervel\Cache\Redis\AllTaggedCache;
use Hypervel\Cache\Redis\AllTagSet;
use Hypervel\Cache\Redis\AnyTaggedCache;
use Hypervel\Cache\Redis\AnyTagSet;
-use Hypervel\Cache\Redis\Exceptions\RedisCacheException;
use Hypervel\Cache\Redis\Operations\Add;
use Hypervel\Cache\Redis\Operations\AllTagOperations;
use Hypervel\Cache\Redis\Operations\AnyTagOperations;
@@ -22,16 +20,12 @@
use Hypervel\Cache\Redis\Operations\Many;
use Hypervel\Cache\Redis\Operations\Put;
use Hypervel\Cache\Redis\Operations\PutMany;
-use Hypervel\Cache\Redis\Operations\Remember;
-use Hypervel\Cache\Redis\Operations\RememberForever;
use Hypervel\Cache\Redis\Operations\Touch;
use Hypervel\Cache\Redis\Support\Serialization;
use Hypervel\Cache\Redis\Support\StoreContext;
-use Hypervel\Container\Container;
use Hypervel\Contracts\Cache\CanFlushLocks;
use Hypervel\Contracts\Cache\LockProvider;
use Hypervel\Contracts\Redis\Factory as Redis;
-use Hypervel\Redis\Pool\PoolFactory;
use Hypervel\Redis\RedisProxy;
use RuntimeException;
@@ -39,11 +33,6 @@ class RedisStore extends TaggableStore implements CanFlushLocks, LockProvider
{
protected Redis $redis;
- /**
- * The pool factory instance (lazy-loaded if not provided).
- */
- protected ?PoolFactory $poolFactory = null;
-
/**
* A string that should be prepended to keys.
*/
@@ -99,10 +88,6 @@ class RedisStore extends TaggableStore implements CanFlushLocks, LockProvider
private ?Flush $flushOperation = null;
- private ?Remember $rememberOperation = null;
-
- private ?RememberForever $rememberForeverOperation = null;
-
/**
* Cached tag operation containers.
*/
@@ -122,11 +107,9 @@ public function __construct(
Redis $redis,
string $prefix = '',
string $connection = 'default',
- ?PoolFactory $poolFactory = null,
array|bool|null $serializableClasses = null,
) {
$this->redis = $redis;
- $this->poolFactory = $poolFactory;
$this->serializableClasses = $serializableClasses;
$this->setPrefix($prefix);
$this->setConnection($connection);
@@ -269,33 +252,6 @@ public function flushLocks(): bool
return true;
}
- /**
- * Get an item from the cache, or execute the given Closure and store the result.
- *
- * Optimized to use a single connection for both GET and SET operations,
- * avoiding double pool overhead for cache misses.
- *
- * @param Closure(): mixed $callback
- */
- public function remember(string $key, int $seconds, Closure $callback): mixed
- {
- return $this->getRememberOperation()->execute($key, $seconds, $callback);
- }
-
- /**
- * Get an item from the cache, or execute the given Closure and store the result forever.
- *
- * Optimized to use a single connection for both GET and SET operations,
- * avoiding double pool overhead for cache misses.
- *
- * @param Closure(): mixed $callback
- * @return array{0: mixed, 1: bool} Tuple of [value, wasHit]
- */
- public function rememberForever(string $key, Closure $callback): array
- {
- return $this->getRememberForeverOperation()->execute($key, $callback);
- }
-
/**
* Get the any tag operations container.
*
@@ -480,6 +436,7 @@ public function setPrefix(?string $prefix): void
public function getContext(): StoreContext
{
return $this->context ??= new StoreContext(
+ $this->redis,
$this->connection,
$this->prefix,
$this->tagMode,
@@ -494,58 +451,6 @@ public function getSerialization(): Serialization
return $this->serialization ??= new Serialization($this->serializableClasses);
}
- /**
- * Get the PoolFactory instance, lazily resolving if not provided.
- */
- protected function getPoolFactory(): PoolFactory
- {
- return $this->poolFactory ??= $this->resolvePoolFactory();
- }
-
- /**
- * Serialize the value.
- *
- * This method is intentionally disabled to prevent an N+1 pool checkout bug.
- * If serialization methods acquire their own connection, batch operations like
- * putMany(1000) would checkout 1001 connections (1 for the operation + 1000
- * for serialization) instead of 1, causing massive performance degradation.
- *
- * @throws RedisCacheException Always throws - use Serialization::serialize() instead
- */
- protected function serialize(mixed $value): never
- {
- throw new RedisCacheException(
- 'RedisStore::serialize() is disabled to prevent N+1 pool checkout bugs. '
- . 'Use Serialization::serialize($conn, $value) inside a withConnection() callback instead.'
- );
- }
-
- /**
- * Unserialize the value.
- *
- * This method is intentionally disabled to prevent an N+1 pool checkout bug.
- * If serialization methods acquire their own connection, batch operations like
- * many(1000) would checkout 1001 connections (1 for the operation + 1000
- * for unserialization) instead of 1, causing massive performance degradation.
- *
- * @throws RedisCacheException Always throws - use Serialization::unserialize() instead
- */
- protected function unserialize(mixed $value): never
- {
- throw new RedisCacheException(
- 'RedisStore::unserialize() is disabled to prevent N+1 pool checkout bugs. '
- . 'Use Serialization::unserialize($conn, $value) inside a withConnection() callback instead.'
- );
- }
-
- /**
- * Resolve the PoolFactory from the container.
- */
- private function resolvePoolFactory(): PoolFactory
- {
- return Container::getInstance()->make(PoolFactory::class);
- }
-
/**
* Clear all cached instances when connection or prefix changes.
*/
@@ -566,8 +471,6 @@ private function clearCachedInstances(): void
$this->incrementOperation = null;
$this->decrementOperation = null;
$this->flushOperation = null;
- $this->rememberOperation = null;
- $this->rememberForeverOperation = null;
// Tag operation containers
$this->anyTagOperations = null;
@@ -646,20 +549,4 @@ private function getFlushOperation(): Flush
{
return $this->flushOperation ??= new Flush($this->getContext());
}
-
- private function getRememberOperation(): Remember
- {
- return $this->rememberOperation ??= new Remember(
- $this->getContext(),
- $this->getSerialization()
- );
- }
-
- private function getRememberForeverOperation(): RememberForever
- {
- return $this->rememberForeverOperation ??= new RememberForever(
- $this->getContext(),
- $this->getSerialization()
- );
- }
}
diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php
index 38a0ad7bf..623c6bb63 100644
--- a/src/cache/src/Repository.php
+++ b/src/cache/src/Repository.php
@@ -4,6 +4,7 @@
namespace Hypervel\Cache;
+use __PHP_Incomplete_Class;
use ArrayAccess;
use BadMethodCallException;
use Closure;
@@ -54,6 +55,11 @@ class Repository implements ArrayAccess, CacheContract, RawReadable
__call as macroCall;
}
+ /**
+ * The cache key prefix used to track when a flexible cache value was last refreshed.
+ */
+ public const FLEXIBLE_CREATED_KEY_PREFIX = 'hypervel:cache:flexible:created:';
+
/**
* The cache store implementation.
*/
@@ -74,6 +80,11 @@ class Repository implements ArrayAccess, CacheContract, RawReadable
*/
protected array $config = [];
+ /**
+ * The callback to invoke when an unserializable class is encountered.
+ */
+ protected static ?Closure $unserializableClassHandler = null;
+
/**
* Create a new cache repository instance.
*/
@@ -481,28 +492,49 @@ public function forever(UnitEnum|string $key, mixed $value): bool
*
* @return TCacheValue
*/
- public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed
- {
- $remember = function () use ($key, $ttl, $callback) {
- $value = $this->getRaw($key);
+ public function remember(
+ UnitEnum|string $key,
+ Closure|DateInterval|DateTimeInterface|int|null $ttl,
+ Closure $callback,
+ ): mixed {
+ return $this->rememberWithWarmth($key, $ttl, $callback)[0];
+ }
- // Hit — including cached sentinels. Unwrap before returning.
- if (! is_null($value)) {
- return NullSentinel::unwrap($value);
- }
+ /**
+ * Get an item from the cache, or execute the given Closure and store the result.
+ *
+ * @template TCacheValue
+ *
+ * @param Closure(): TCacheValue $callback
+ *
+ * @return array{TCacheValue, bool} the cached value and whether it was warm
+ */
+ public function rememberWithWarmth(
+ UnitEnum|string $key,
+ Closure|DateInterval|DateTimeInterface|int|null $ttl,
+ Closure $callback,
+ ): array {
+ $value = $this->getRawForRemember($key);
+
+ if (! is_null($value)) {
+ // Cached null sentinels are hits and remain internal to the repository.
+ return [NullSentinel::unwrap($value), true];
+ }
- // Miss — run callback and store the raw result (may be a sentinel if
- // the caller is rememberNullable(), which wraps the callback).
- $value = $callback();
+ // rememberNullable wraps null in a sentinel before it reaches this boundary.
+ $value = $callback();
- $this->put($key, $value, value($ttl, $value));
+ $this->put($key, $value, value($ttl, $value));
- return NullSentinel::unwrap($value);
- };
+ return [NullSentinel::unwrap($value), false];
+ }
- return method_exists($this->store, 'withPinnedConnection')
- ? $this->store->withPinnedConnection($remember)
- : $remember();
+ /**
+ * Retrieve an item for remember operations without unwrapping sentinels.
+ */
+ protected function getRawForRemember(UnitEnum|string $key): mixed
+ {
+ return $this->getRaw($key);
}
/**
@@ -565,21 +597,15 @@ public function searNullable(UnitEnum|string $key, Closure $callback): mixed
*/
public function rememberForever(UnitEnum|string $key, Closure $callback): mixed
{
- $remember = function () use ($key, $callback) {
- $value = $this->getRaw($key);
-
- if (! is_null($value)) {
- return NullSentinel::unwrap($value);
- }
-
- $this->forever($key, $value = $callback());
+ $value = $this->getRawForRemember($key);
+ if (! is_null($value)) {
return NullSentinel::unwrap($value);
- };
+ }
+
+ $this->forever($key, $value = $callback());
- return method_exists($this->store, 'withPinnedConnection')
- ? $this->store->withPinnedConnection($remember)
- : $remember();
+ return NullSentinel::unwrap($value);
}
/**
@@ -614,7 +640,7 @@ public function rememberForeverNullable(UnitEnum|string $key, Closure $callback)
public function flexible(UnitEnum|string $key, array $ttl, mixed $callback, ?array $lock = null, bool $alwaysDefer = false): mixed
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
- $markerKey = "hypervel:cache:flexible:created:{$key}";
+ $markerKey = self::FLEXIBLE_CREATED_KEY_PREFIX . $key;
[$key => $value, $markerKey => $created] = $this->manyRaw([$key, $markerKey]);
@@ -635,7 +661,7 @@ public function flexible(UnitEnum|string $key, array $ttl, mixed $callback, ?arr
$refresh = function () use ($key, $markerKey, $ttl, $callback, $lock, $created) {
$this->store->lock( // @phpstan-ignore method.notFound (lock() is on LockProvider, not Store contract)
- "hypervel:cache:flexible:lock:{$key}",
+ "hypervel:cache:flexible:lock:{$this->itemKey($key)}",
$lock['seconds'] ?? 0,
$lock['owner'] ?? null,
)->get(function () use ($key, $markerKey, $callback, $created, $ttl) {
@@ -652,7 +678,7 @@ public function flexible(UnitEnum|string $key, array $ttl, mixed $callback, ?arr
});
};
- defer($refresh, "hypervel:cache:flexible:{$key}", $alwaysDefer);
+ defer($refresh, "hypervel:cache:flexible:{$this->itemKey($key)}", $alwaysDefer);
return NullSentinel::unwrap($value);
}
@@ -936,6 +962,19 @@ public function setEventDispatcher(Dispatcher $events): void
$this->events = $events;
}
+ /**
+ * Register a callback to be invoked when an unserializable class is encountered.
+ *
+ * Boot or tests only. The callback persists for the worker lifetime and
+ * affects every subsequent cache read.
+ */
+ public static function handleUnserializableClassUsing(?callable $callback): void
+ {
+ static::$unserializableClassHandler = $callback === null
+ ? null
+ : Closure::fromCallable($callback);
+ }
+
/**
* Get the cache store name.
*/
@@ -1024,6 +1063,24 @@ protected function itemKey(string $key): string
return $key;
}
+ /**
+ * Handle a cache value that contains an incomplete class.
+ */
+ protected function handleIncompleteClass(string $key, mixed $value): mixed
+ {
+ if (! $value instanceof __PHP_Incomplete_Class) {
+ return $value;
+ }
+
+ $class = ((array) $value)['__PHP_Incomplete_Class_Name'] ?? null;
+
+ if (static::$unserializableClassHandler !== null) {
+ (static::$unserializableClassHandler)($key, $class);
+ }
+
+ return $value;
+ }
+
/**
* Calculate the number of seconds for the given TTL.
*/
@@ -1075,9 +1132,15 @@ public function getRaw(UnitEnum|string $key): mixed
$this->event(RetrievingKey::class, fn (): RetrievingKey => new RetrievingKey($this->getName(), $key));
- $value = $this->store instanceof RawReadable
- ? $this->store->getRaw($this->itemKey($key))
- : $this->store->get($this->itemKey($key));
+ // Raw-readable wrappers already passed the value through an inner Repository.
+ if ($this->store instanceof RawReadable) {
+ $value = $this->store->getRaw($this->itemKey($key));
+ } else {
+ $value = $this->handleIncompleteClass(
+ $key,
+ $this->store->get($this->itemKey($key))
+ );
+ }
if (is_null($value)) {
$this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed($this->getName(), $key));
@@ -1129,13 +1192,20 @@ public function manyRaw(array $keys): array
$itemKeys = array_map(fn (string $key): string => $this->itemKey($key), $keys);
- $storeValues = $this->store instanceof RawReadable
+ $rawReadable = $this->store instanceof RawReadable;
+ $storeValues = $rawReadable
? $this->store->manyRaw($itemKeys)
: $this->store->many($itemKeys);
$result = [];
foreach ($keys as $i => $key) {
$value = $storeValues[$itemKeys[$i]] ?? null;
+
+ // Raw-readable wrappers already passed the value through an inner Repository.
+ if (! $rawReadable) {
+ $value = $this->handleIncompleteClass($key, $value);
+ }
+
$result[$key] = $value;
if (is_null($value)) {
@@ -1154,6 +1224,7 @@ public function manyRaw(array $keys): array
public static function flushState(): void
{
static::flushMacros();
+ static::$unserializableClassHandler = null;
}
/**
diff --git a/src/cache/src/StackStore.php b/src/cache/src/StackStore.php
index c412cb3ea..f7345c558 100644
--- a/src/cache/src/StackStore.php
+++ b/src/cache/src/StackStore.php
@@ -131,18 +131,12 @@ public function touch(string $key, int $seconds): bool
public function forget(string $key): bool
{
- return $this->callStores(
- fn (StackStoreProxy $store) => $store->forget($key),
- force: true
- );
+ return $this->callAllStores(fn (StackStoreProxy $store) => $store->forget($key));
}
public function flush(): bool
{
- return $this->callStores(
- static fn (StackStoreProxy $store) => $store->flush(),
- force: true
- );
+ return $this->callAllStores(static fn (StackStoreProxy $store) => $store->flush());
}
/**
@@ -491,32 +485,71 @@ protected function callStoresStacked(Closure $handler, Closure $bottomLayer): mi
}, $bottomLayer)();
}
- protected function callStores(Closure $handler, ?Closure $rollback = null, bool $force = false): bool
+ protected function callStores(Closure $handler, ?Closure $rollback = null): bool
{
- return $this->callStoresStacked(
- function (StackStoreProxy $store, Closure $next) use ($handler, $rollback, $force): bool {
- if (! $handler($store) && ! $force) {
- return false;
- }
+ $completed = [];
+ $result = true;
+ $exception = null;
- // Only a layer whose write completed is eligible for compensation.
- try {
- $result = $next();
- } catch (Throwable $throwable) {
- if ($rollback !== null) {
- $rollback($store);
- }
+ foreach ($this->stores as $store) {
+ try {
+ if (! $handler($store)) {
+ $result = false;
- throw $throwable;
+ break;
}
- if (! $result && $rollback !== null) {
+ $completed[] = $store;
+ } catch (Throwable $throwable) {
+ $exception = $throwable;
+
+ break;
+ }
+ }
+
+ if ($result && $exception === null) {
+ return true;
+ }
+
+ if ($rollback !== null) {
+ foreach (array_reverse($completed) as $store) {
+ try {
$rollback($store);
+ } catch (Throwable) {
+ // Preserve the write failure that made compensation necessary.
}
+ }
+ }
- return $result;
- },
- static fn (): bool => true
- );
+ if ($exception !== null) {
+ throw $exception;
+ }
+
+ return false;
+ }
+
+ /**
+ * Call the handler for every store.
+ */
+ protected function callAllStores(Closure $handler): bool
+ {
+ $result = true;
+ $exception = null;
+
+ foreach ($this->stores as $store) {
+ try {
+ if (! $handler($store)) {
+ $result = false;
+ }
+ } catch (Throwable $throwable) {
+ $exception ??= $throwable;
+ }
+ }
+
+ if ($exception !== null) {
+ throw $exception;
+ }
+
+ return $result;
}
}
diff --git a/src/cache/src/StackTaggedCache.php b/src/cache/src/StackTaggedCache.php
index 330995955..68ee6a82b 100644
--- a/src/cache/src/StackTaggedCache.php
+++ b/src/cache/src/StackTaggedCache.php
@@ -4,12 +4,11 @@
namespace Hypervel\Cache;
-use Closure;
use DateInterval;
use DateTimeInterface;
-use Hypervel\Cache\Events\CacheHit;
-use Hypervel\Cache\Events\CacheMissed;
+use Hypervel\Cache\Events\KeyWriteFailed;
use Hypervel\Cache\Events\KeyWritten;
+use Hypervel\Cache\Events\WritingKey;
use Hypervel\Contracts\Cache\Store;
use UnitEnum;
@@ -64,16 +63,29 @@ public function put(array|UnitEnum|string $key, mixed $value, DateInterval|DateT
$seconds = $this->getSeconds($ttl);
if ($seconds <= 0) {
- return $this->store->forget($key);
+ return $this->forgetPlainKey($key);
}
+ $this->event(
+ WritingKey::class,
+ fn (): WritingKey => new WritingKey($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
+
$result = $this->store->putRecordTagged($this->tags->getNames(), $key, [
'value' => $value,
'ttl' => $seconds,
]);
if ($result) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value), $seconds));
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value), $seconds)
+ );
}
return $result;
@@ -86,7 +98,7 @@ public function add(UnitEnum|string $key, mixed $value, DateInterval|DateTimeInt
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
- if (! is_null($this->store->get($key))) {
+ if (! is_null($this->getPlainRaw($key))) {
return false;
}
@@ -100,10 +112,24 @@ public function forever(UnitEnum|string $key, mixed $value): bool
{
$key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
+ $this->event(WritingKey::class, fn (): WritingKey => new WritingKey(
+ $this->getName(),
+ $key,
+ NullSentinel::unwrap($value)
+ ));
+
$result = $this->store->putRecordTagged($this->tags->getNames(), $key, ['value' => $value]);
if ($result) {
- $this->event(KeyWritten::class, fn (): KeyWritten => new KeyWritten(null, $key, NullSentinel::unwrap($value)));
+ $this->event(
+ KeyWritten::class,
+ fn (): KeyWritten => new KeyWritten($this->getName(), $key, NullSentinel::unwrap($value))
+ );
+ } else {
+ $this->event(
+ KeyWriteFailed::class,
+ fn (): KeyWriteFailed => new KeyWriteFailed($this->getName(), $key, NullSentinel::unwrap($value))
+ );
}
return $result;
@@ -127,74 +153,6 @@ public function decrement(UnitEnum|string $key, int $value = 1): bool|int
return $this->increment($key, $value * -1);
}
- /**
- * Get an item from the cache, or execute the given Closure and store the result.
- *
- * Reads plain through the stack and writes through the tagged path on a miss.
- *
- * @template TCacheValue
- *
- * @param Closure(): TCacheValue $callback
- * @return TCacheValue
- */
- public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed
- {
- if ($ttl === null) {
- return $this->rememberForever($key, $callback);
- }
-
- $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
- $seconds = $this->getSeconds($ttl);
-
- if ($seconds <= 0) {
- return $callback();
- }
-
- $value = $this->store->get($key);
-
- if (! is_null($value)) {
- $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
-
- return NullSentinel::unwrap($value);
- }
-
- $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
-
- $value = $callback();
-
- $this->put($key, $value, $seconds);
-
- return NullSentinel::unwrap($value);
- }
-
- /**
- * Get an item from the cache, or execute the given Closure and store the result forever.
- *
- * @template TCacheValue
- *
- * @param Closure(): TCacheValue $callback
- * @return TCacheValue
- */
- public function rememberForever(UnitEnum|string $key, Closure $callback): mixed
- {
- $key = $key instanceof UnitEnum ? (string) enum_value($key) : $key;
- $value = $this->store->get($key);
-
- if (! is_null($value)) {
- $this->event(CacheHit::class, fn (): CacheHit => new CacheHit(null, $key, NullSentinel::unwrap($value)));
-
- return NullSentinel::unwrap($value);
- }
-
- $this->event(CacheMissed::class, fn (): CacheMissed => new CacheMissed(null, $key));
-
- $value = $callback();
-
- $this->forever($key, $value);
-
- return NullSentinel::unwrap($value);
- }
-
/**
* Get the tag set instance (covariant return type).
*/
diff --git a/src/cache/src/StorageStore.php b/src/cache/src/StorageStore.php
new file mode 100644
index 000000000..bc7b3e56e
--- /dev/null
+++ b/src/cache/src/StorageStore.php
@@ -0,0 +1,276 @@
+disk = $disk;
+ $this->directory = trim($directory, '/');
+ $this->prefix = $prefix;
+ $this->serializableClasses = $serializableClasses;
+ }
+
+ /**
+ * Retrieve an item from the cache by key.
+ */
+ public function get(string $key): mixed
+ {
+ return $this->getPayload($key)['data'] ?? null;
+ }
+
+ /**
+ * Store an item in the cache for a given number of seconds.
+ */
+ public function put(string $key, mixed $value, int $seconds): bool
+ {
+ return $this->disk->put(
+ $this->path($key),
+ $this->expirationHeader($seconds) . serialize($value)
+ ) !== false;
+ }
+
+ /**
+ * Store an item in the cache if the key doesn't exist.
+ */
+ public function add(string $key, mixed $value, int $seconds): bool
+ {
+ if (! is_null($this->get($key))) {
+ return false;
+ }
+
+ return $this->put($key, $value, $seconds);
+ }
+
+ /**
+ * Increment the value of an item in the cache.
+ */
+ public function increment(string $key, int $value = 1): int
+ {
+ $raw = $this->getPayload($key);
+
+ return tap(((int) $raw['data']) + $value, function (int $newValue) use ($key, $raw): void {
+ $this->put($key, $newValue, $raw['time'] ?? 0);
+ });
+ }
+
+ /**
+ * Decrement the value of an item in the cache.
+ */
+ public function decrement(string $key, int $value = 1): int
+ {
+ return $this->increment($key, $value * -1);
+ }
+
+ /**
+ * Store an item in the cache indefinitely.
+ */
+ public function forever(string $key, mixed $value): bool
+ {
+ return $this->put($key, $value, 0);
+ }
+
+ /**
+ * Adjust the expiration time of a cached item.
+ */
+ public function touch(string $key, int $seconds): bool
+ {
+ $payload = $this->getPayload($key);
+
+ if (is_null($payload['data'])) {
+ return false;
+ }
+
+ return $this->put($key, $payload['data'], $seconds);
+ }
+
+ /**
+ * Remove an item from the cache.
+ */
+ public function forget(string $key): bool
+ {
+ $forgotten = $this->disk->delete($this->path($key));
+
+ if ($forgotten) {
+ $this->disk->delete($this->path(Repository::FLEXIBLE_CREATED_KEY_PREFIX . $key));
+ }
+
+ return $forgotten;
+ }
+
+ /**
+ * Remove all items from the cache.
+ */
+ public function flush(): bool
+ {
+ if ($this->directory === '') {
+ $files = $this->disk->allFiles();
+
+ return $files === [] || $this->disk->delete($files);
+ }
+
+ return $this->disk->deleteDirectory($this->directory)
+ && $this->disk->makeDirectory($this->directory);
+ }
+
+ /**
+ * Retrieve an item and expiry time from the cache by key.
+ */
+ protected function getPayload(string $key): array
+ {
+ $path = $this->path($key);
+
+ try {
+ if (is_null($contents = $this->disk->get($path))) {
+ return $this->emptyPayload();
+ }
+
+ $expire = (int) substr($contents, 0, 10);
+ } catch (Exception) {
+ return $this->emptyPayload();
+ }
+ if ($this->currentTime() >= $expire) {
+ $this->forget($key);
+
+ return $this->emptyPayload();
+ }
+
+ try {
+ $data = $this->unserialize(substr($contents, 10));
+ } catch (Exception) {
+ $this->forget($key);
+
+ return $this->emptyPayload();
+ }
+
+ $time = $expire - $this->currentTime();
+
+ return ['data' => $data, 'time' => $time];
+ }
+
+ /**
+ * Unserialize the given value.
+ */
+ protected function unserialize(string $value): mixed
+ {
+ if ($this->serializableClasses !== null) {
+ return unserialize($value, ['allowed_classes' => $this->serializableClasses]);
+ }
+
+ return unserialize($value);
+ }
+
+ /**
+ * Get a default empty payload for the cache.
+ */
+ protected function emptyPayload(): array
+ {
+ return ['data' => null, 'time' => null];
+ }
+
+ /**
+ * Get the full path for the given cache key.
+ */
+ public function path(string $key): string
+ {
+ $parts = array_slice(str_split($hash = sha1($this->prefix . $key), 2), 0, 2);
+
+ return trim($this->directory . '/' . implode('/', $parts) . '/' . $hash, '/');
+ }
+
+ /**
+ * Get the expiration time based on the given seconds.
+ */
+ protected function expiration(int $seconds): int
+ {
+ $time = $this->availableAt($seconds);
+
+ return $seconds === 0 || $time > self::PERMANENT_TIMESTAMP ? self::PERMANENT_TIMESTAMP : $time;
+ }
+
+ /**
+ * Get the fixed-width expiration header for a cache item.
+ */
+ protected function expirationHeader(int $seconds): string
+ {
+ return sprintf('%010d', $this->expiration($seconds));
+ }
+
+ /**
+ * Get the filesystem disk instance.
+ */
+ public function getDisk(): Filesystem
+ {
+ return $this->disk;
+ }
+
+ /**
+ * Get the working directory of the cache.
+ */
+ public function getDirectory(): string
+ {
+ return $this->directory;
+ }
+
+ /**
+ * Get the cache key prefix.
+ */
+ public function getPrefix(): string
+ {
+ return $this->prefix;
+ }
+
+ /**
+ * Set the cache key prefix.
+ *
+ * Boot-only. Persists on the cached store for the worker lifetime;
+ * per-request use races across coroutines.
+ */
+ public function setPrefix(string $prefix): void
+ {
+ $this->prefix = $prefix;
+ }
+}
diff --git a/src/cache/src/SwooleLock.php b/src/cache/src/SwooleLock.php
index d8c4146bb..93a39f274 100644
--- a/src/cache/src/SwooleLock.php
+++ b/src/cache/src/SwooleLock.php
@@ -61,7 +61,7 @@ protected function getCurrentOwner(): ?string
public function refresh(?int $seconds = null): bool
{
if ($seconds === null && $this->seconds <= 0) {
- return true;
+ return $this->isOwnedByCurrentProcess();
}
$seconds ??= $this->seconds;
diff --git a/src/cache/src/SwooleStore.php b/src/cache/src/SwooleStore.php
index 4a078e54d..311ec5f78 100644
--- a/src/cache/src/SwooleStore.php
+++ b/src/cache/src/SwooleStore.php
@@ -26,8 +26,6 @@ class SwooleStore implements CanFlushLocks, LockProvider, Store
public const EVICTION_POLICY_NOEVICTION = 'noeviction';
- protected const ONE_YEAR = 31536000;
-
protected const USER_PREFIX = 'u:';
protected const INTERVAL_PREFIX = 'i:';
@@ -61,7 +59,8 @@ public function __construct(
protected SwooleTableState $state,
protected float $memoryLimitBuffer,
protected string $evictionPolicy,
- protected float $evictionProportion
+ protected float $evictionProportion,
+ protected array|bool|null $serializableClasses = null,
) {
$this->table = $this->state->table();
}
@@ -77,7 +76,7 @@ public function get(string $key): mixed
if (! $this->recordIsFalseOrExpired($record)) {
$this->recordHit($tableKey);
- return unserialize($record['value']);
+ return $this->unserialize($record['value']);
}
if ($this->hasLocalInterval($key)) {
@@ -119,18 +118,11 @@ public function many(array $keys): array
*/
public function put(string $key, mixed $value, int $seconds): bool
{
- $tableKey = $this->userKey($key);
- $serialized = serialize($value);
- $expiration = $this->expiration($seconds);
-
- $result = $this->state->withRowLock(
- $tableKey,
- fn (): bool => $this->rawPutSerialized($tableKey, $serialized, $expiration),
+ return $this->putSerialized(
+ $this->userKey($key),
+ serialize($value),
+ $this->expiration($seconds),
);
-
- $this->evictRecordsIfNeeded();
-
- return $result;
}
/**
@@ -186,11 +178,12 @@ public function increment(string $key, int $value = 1): int
if ($this->recordIsFalseOrExpired($record)) {
$wroteNewRecord = true;
- $this->rawPutSerialized($tableKey, serialize($value), $this->expiration(static::ONE_YEAR));
+ $this->rawPutSerialized($tableKey, serialize($value), PHP_FLOAT_MAX);
return $value;
}
+ // Counters never deserialize classes, regardless of the user-value policy.
$incremented = (int) (unserialize($record['value'], ['allowed_classes' => false]) + $value);
$this->rawPutSerialized($tableKey, serialize($incremented), $record['expiration']);
@@ -218,7 +211,22 @@ public function decrement(string $key, int $value = 1): int
*/
public function forever(string $key, mixed $value): bool
{
- return $this->put($key, $value, static::ONE_YEAR);
+ return $this->putSerialized($this->userKey($key), serialize($value), PHP_FLOAT_MAX);
+ }
+
+ /**
+ * Store a serialized user value.
+ */
+ private function putSerialized(string $tableKey, string $serialized, float $expiration): bool
+ {
+ $result = $this->state->withRowLock(
+ $tableKey,
+ fn (): bool => $this->rawPutSerialized($tableKey, $serialized, $expiration),
+ );
+
+ $this->evictRecordsIfNeeded();
+
+ return $result;
}
/**
@@ -411,7 +419,7 @@ public function acquireLock(string $name, string $owner, int $seconds): bool
return $this->rawPutSerialized($key, serialize([
'owner' => $owner,
'expiresAt' => $expiresAt,
- ]), $this->expiration(static::ONE_YEAR));
+ ]), PHP_FLOAT_MAX);
});
}
@@ -469,7 +477,7 @@ public function refreshLock(string $name, string $owner, int $seconds): bool
$lock['expiresAt'] = $this->expiration($seconds);
- return $this->rawPutSerialized($key, serialize($lock), $this->expiration(static::ONE_YEAR));
+ return $this->rawPutSerialized($key, serialize($lock), PHP_FLOAT_MAX);
});
}
@@ -818,7 +826,7 @@ protected function registerIntervalIndex(string $metadataKey): void
$index[$metadataKey] = true;
- return $this->rawPutSerialized($indexKey, serialize($index), $this->expiration(static::ONE_YEAR));
+ return $this->rawPutSerialized($indexKey, serialize($index), PHP_FLOAT_MAX);
});
if (! $result) {
@@ -844,8 +852,6 @@ protected function registeredIntervalMetadataKeys(): array
foreach (array_keys(unserialize($record['value'])) as $metadataKey) {
$metadataKeys[$metadataKey] = true;
}
-
- $this->touchInternalRow($indexKey);
}
return array_keys($metadataKeys);
@@ -1021,19 +1027,7 @@ protected function getIntervalMetadataByInternalKey(string $metadataKey): ?array
*/
protected function putIntervalMetadataByInternalKey(string $metadataKey, array $metadata): bool
{
- return $this->rawPutSerialized($metadataKey, serialize($metadata), $this->expiration(static::ONE_YEAR));
- }
-
- /**
- * Touch an internal row.
- */
- protected function touchInternalRow(string $key): void
- {
- $this->state->withRowLock($key, function () use ($key): void {
- if ($this->rawGet($key) !== false) {
- $this->table->set($key, ['expiration' => $this->expiration(static::ONE_YEAR)]);
- }
- });
+ return $this->rawPutSerialized($metadataKey, serialize($metadata), PHP_FLOAT_MAX);
}
/**
@@ -1060,6 +1054,18 @@ protected function expiration(int $seconds): float
return $this->getCurrentTimestamp() + $seconds;
}
+ /**
+ * Unserialize a cached user value.
+ */
+ protected function unserialize(string $value): mixed
+ {
+ if ($this->serializableClasses !== null) {
+ return unserialize($value, ['allowed_classes' => $this->serializableClasses]);
+ }
+
+ return unserialize($value);
+ }
+
/**
* Get a raw lock record.
*
diff --git a/src/cache/src/SwooleTimer.php b/src/cache/src/SwooleTimer.php
index a6789cc2c..027202a08 100644
--- a/src/cache/src/SwooleTimer.php
+++ b/src/cache/src/SwooleTimer.php
@@ -16,4 +16,12 @@ public function tick(int $milliseconds, Closure $callback): int|false
{
return Timer::tick($milliseconds, $callback);
}
+
+ /**
+ * Clear a Swoole timer.
+ */
+ public function clear(int $timerId): bool
+ {
+ return Timer::clear($timerId);
+ }
}
diff --git a/src/contracts/src/Cache/Repository.php b/src/contracts/src/Cache/Repository.php
index ebb7e4022..a6522f474 100644
--- a/src/contracts/src/Cache/Repository.php
+++ b/src/contracts/src/Cache/Repository.php
@@ -55,7 +55,25 @@ public function forever(UnitEnum|string $key, mixed $value): bool;
* @param Closure(): TCacheValue $callback
* @return TCacheValue
*/
- public function remember(UnitEnum|string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed;
+ public function remember(
+ UnitEnum|string $key,
+ Closure|DateInterval|DateTimeInterface|int|null $ttl,
+ Closure $callback,
+ ): mixed;
+
+ /**
+ * Get an item from the cache, or execute the given Closure and store the result.
+ *
+ * @template TCacheValue
+ *
+ * @param Closure(): TCacheValue $callback
+ * @return array{TCacheValue, bool} the cached value and whether it was warm
+ */
+ public function rememberWithWarmth(
+ UnitEnum|string $key,
+ Closure|DateInterval|DateTimeInterface|int|null $ttl,
+ Closure $callback,
+ ): array;
/**
* Get an item from the cache, or execute the given Closure and store the result forever.
diff --git a/src/foundation/config/cache.php b/src/foundation/config/cache.php
index d48258fcb..2d9d2dbbf 100644
--- a/src/foundation/config/cache.php
+++ b/src/foundation/config/cache.php
@@ -18,6 +18,18 @@
'default' => env('CACHE_STORE', 'database'),
+ /*
+ |--------------------------------------------------------------------------
+ | Rate Limiter Cache Store
+ |--------------------------------------------------------------------------
+ |
+ | This option controls the cache store used by the rate limiter. When
+ | this option is not configured, the default cache store is used.
+ |
+ */
+
+ 'limiter' => env('CACHE_LIMITER'),
+
/*
|--------------------------------------------------------------------------
| Cache Stores
@@ -28,7 +40,7 @@
| same cache driver to group types of items stored in your caches.
|
| Supported drivers: "array", "worker-array", "database", "file",
- | "redis", "swoole", "stack", "session",
+ | "storage", "redis", "swoole", "stack", "session",
| "failover", "null"
|
*/
@@ -65,6 +77,12 @@
'lock_path' => storage_path('framework/cache/data'),
],
+ 'storage' => [
+ 'driver' => 'storage',
+ 'disk' => env('CACHE_STORAGE_DISK'),
+ 'path' => env('CACHE_STORAGE_PATH', 'framework/cache/data'),
+ ],
+
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
@@ -101,6 +119,21 @@
],
],
+ /*
+ |--------------------------------------------------------------------------
+ | Serializable Classes
+ |--------------------------------------------------------------------------
+ |
+ | This value determines the classes that can be unserialized from cache
+ | storage. By default, no PHP classes will be unserialized from your
+ | cache to prevent gadget chain attacks if your APP_KEY is leaked.
+ | If the Redis cache connection uses a native PhpRedis serializer, that
+ | serializer handles deserialization and this setting does not apply.
+ |
+ */
+
+ 'serializable_classes' => false,
+
/*
|--------------------------------------------------------------------------
| Swoole Tables Configuration
diff --git a/src/support/src/Facades/Cache.php b/src/support/src/Facades/Cache.php
index a2be8d736..3d42d3a5c 100644
--- a/src/support/src/Facades/Cache.php
+++ b/src/support/src/Facades/Cache.php
@@ -20,13 +20,15 @@
* @method static void purge(\UnitEnum|string|null $name = null)
* @method static \Hypervel\Cache\CacheManager extend(string $driver, \Closure $callback)
* @method static \Hypervel\Cache\CacheManager setApplication(\Hypervel\Contracts\Container\Container $app)
+ * @method static void handleUnserializableClassUsing(callable|null $callback)
* @method static mixed pull(\UnitEnum|string $key, \Closure|mixed $default = null)
* @method static bool put(\UnitEnum|array|string $key, mixed $value, \DateInterval|\DateTimeInterface|int|null $ttl = null)
* @method static bool add(\UnitEnum|string $key, mixed $value, \DateInterval|\DateTimeInterface|int|null $ttl = null)
* @method static int|bool increment(\UnitEnum|string $key, int $value = 1)
* @method static int|bool decrement(\UnitEnum|string $key, int $value = 1)
* @method static bool forever(\UnitEnum|string $key, mixed $value)
- * @method static mixed remember(\UnitEnum|string $key, \DateInterval|\DateTimeInterface|int|null $ttl, \Closure $callback)
+ * @method static mixed remember(\UnitEnum|string $key, \Closure|\DateInterval|\DateTimeInterface|int|null $ttl, \Closure $callback)
+ * @method static array rememberWithWarmth(\UnitEnum|string $key, \Closure|\DateInterval|\DateTimeInterface|int|null $ttl, \Closure $callback)
* @method static mixed sear(\UnitEnum|string $key, \Closure $callback)
* @method static mixed rememberForever(\UnitEnum|string $key, \Closure $callback)
* @method static mixed rememberNullable(\UnitEnum|string $key, \DateInterval|\DateTimeInterface|int|null $ttl, \Closure $callback)
diff --git a/tests/Cache/CacheFailoverStoreTest.php b/tests/Cache/CacheFailoverStoreTest.php
new file mode 100644
index 000000000..abc71c538
--- /dev/null
+++ b/tests/Cache/CacheFailoverStoreTest.php
@@ -0,0 +1,228 @@
+forever(
+ 'key',
+ unserialize(serialize(new stdClass), ['allowed_classes' => false])
+ );
+
+ $handled = [];
+
+ Repository::handleUnserializableClassUsing(function (string $key, ?string $class) use (&$handled): void {
+ $handled[] = [$key, $class];
+ });
+
+ (new Repository($this->makeFailoverStore(['cache' => $backingStore])))->get('key');
+
+ $this->assertSame([['key', 'stdClass']], $handled);
+ }
+
+ public function testLockFlushCapabilityRequiresAtLeastOneLockProvider(): void
+ {
+ $store = $this->makeFailoverStore([
+ 'plain' => m::mock(Store::class),
+ ]);
+
+ $this->assertFalse($store->supportsFlushingLocks());
+ $this->assertFalse($store->hasSeparateLockStore());
+
+ $this->expectException(BadMethodCallException::class);
+ $this->expectExceptionMessage('This failover cache store has no lock-providing stores to flush.');
+
+ $store->flushLocks();
+ }
+
+ public function testLockFlushCapabilityRequiresEveryLockProviderToSupportFlushing(): void
+ {
+ $flushable = $this->lockStore();
+ $flushable->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+
+ $unsupported = m::mock(implode(',', [Store::class, LockProvider::class]));
+
+ $store = $this->makeFailoverStore([
+ 'plain' => m::mock(Store::class),
+ 'flushable' => $flushable,
+ 'unsupported' => $unsupported,
+ ]);
+
+ $this->assertFalse($store->supportsFlushingLocks());
+ }
+
+ public function testLockFlushCapabilityRequiresEveryFlushableStoreToBeCurrentlySupported(): void
+ {
+ $supported = $this->lockStore();
+ $supported->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+
+ $unsupported = $this->lockStore();
+ $unsupported->shouldReceive('supportsFlushingLocks')->once()->andReturnFalse();
+
+ $store = $this->makeFailoverStore([
+ 'supported' => $supported,
+ 'unsupported' => $unsupported,
+ ]);
+
+ $this->assertFalse($store->supportsFlushingLocks());
+ }
+
+ public function testLockFlushCapabilityIsTrueWhenEveryLockProviderSupportsIt(): void
+ {
+ $first = $this->lockStore();
+ $first->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+
+ $second = $this->lockStore();
+ $second->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+
+ $store = $this->makeFailoverStore([
+ 'plain' => m::mock(Store::class),
+ 'first' => $first,
+ 'second' => $second,
+ ]);
+
+ $this->assertTrue($store->supportsFlushingLocks());
+ }
+
+ public function testFlushLocksPreflightsEveryLockProviderBeforeChangingState(): void
+ {
+ $first = $this->lockStore();
+ $first->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+ $first->shouldNotReceive('flushLocks');
+
+ $second = $this->lockStore();
+ $second->shouldReceive('supportsFlushingLocks')->once()->andReturnFalse();
+ $second->shouldNotReceive('flushLocks');
+
+ $store = $this->makeFailoverStore([
+ 'first' => $first,
+ 'second' => $second,
+ ]);
+
+ $this->expectException(BadMethodCallException::class);
+ $this->expectExceptionMessage(sprintf(
+ 'The failover cache store [%s] does not support flushing locks.',
+ $second::class
+ ));
+
+ $store->flushLocks();
+ }
+
+ public function testFlushLocksAttemptsEveryStoreAndAggregatesFalseResults(): void
+ {
+ $first = $this->lockStore();
+ $first->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+ $first->shouldReceive('flushLocks')->once()->andReturnFalse();
+
+ $second = $this->lockStore();
+ $second->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+ $second->shouldReceive('flushLocks')->once()->andReturnTrue();
+
+ $store = $this->makeFailoverStore([
+ 'first' => $first,
+ 'second' => $second,
+ ]);
+
+ $this->assertFalse($store->flushLocks());
+ }
+
+ public function testFlushLocksAttemptsEveryStoreAndPreservesTheEarliestFailure(): void
+ {
+ $first = $this->lockStore();
+ $first->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+ $first->shouldReceive('flushLocks')->once()->andThrow(new RuntimeException('first failure'));
+
+ $second = $this->lockStore();
+ $second->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+ $second->shouldReceive('flushLocks')->once()->andThrow(new RuntimeException('second failure'));
+
+ $store = $this->makeFailoverStore([
+ 'first' => $first,
+ 'second' => $second,
+ ]);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('first failure');
+
+ $store->flushLocks();
+ }
+
+ public function testSeparateLockStoreReportingRequiresEveryLockProviderToBeSeparate(): void
+ {
+ $separate = $this->lockStore();
+ $separate->shouldReceive('hasSeparateLockStore')->once()->andReturnTrue();
+
+ $shared = $this->lockStore();
+ $shared->shouldReceive('hasSeparateLockStore')->once()->andReturnFalse();
+
+ $store = $this->makeFailoverStore([
+ 'plain' => m::mock(Store::class),
+ 'separate' => $separate,
+ 'shared' => $shared,
+ ]);
+
+ $this->assertFalse($store->hasSeparateLockStore());
+ }
+
+ public function testSeparateLockStoreReportingIsTrueWhenEveryLockProviderIsSeparate(): void
+ {
+ $first = $this->lockStore();
+ $first->shouldReceive('hasSeparateLockStore')->once()->andReturnTrue();
+
+ $second = $this->lockStore();
+ $second->shouldReceive('hasSeparateLockStore')->once()->andReturnTrue();
+
+ $store = $this->makeFailoverStore([
+ 'plain' => m::mock(Store::class),
+ 'first' => $first,
+ 'second' => $second,
+ ]);
+
+ $this->assertTrue($store->hasSeparateLockStore());
+ }
+
+ /**
+ * Create a failover store with the given named backing stores.
+ *
+ * @param array $stores
+ */
+ protected function makeFailoverStore(array $stores): FailoverStore
+ {
+ $cache = m::mock(CacheManager::class);
+
+ foreach ($stores as $name => $store) {
+ $cache->shouldReceive('store')
+ ->with($name)
+ ->andReturn(new Repository($store));
+ }
+
+ return new FailoverStore($cache, m::mock(Dispatcher::class), array_keys($stores));
+ }
+
+ /**
+ * Create a lock-capable store with flush support.
+ */
+ protected function lockStore(): Store&LockProvider&CanFlushLocks
+ {
+ return m::mock(implode(',', [Store::class, LockProvider::class, CanFlushLocks::class]));
+ }
+}
diff --git a/tests/Cache/CacheFileStoreTest.php b/tests/Cache/CacheFileStoreTest.php
index f7ce613e8..6705c4b1e 100644
--- a/tests/Cache/CacheFileStoreTest.php
+++ b/tests/Cache/CacheFileStoreTest.php
@@ -109,6 +109,29 @@ public function testStoreItemProperlyStoresValues()
$this->assertTrue($result);
}
+ public function testPutPadsShortTimestampsToTenDigits(): void
+ {
+ $files = $this->mockFilesystem();
+ $store = $this->getMockBuilder(FileStore::class)->onlyMethods(['expiration'])->setConstructorArgs([$files, __DIR__])->getMock();
+ $store->expects($this->once())->method('expiration')->with(3)->willReturn(990464403);
+ $contents = '0990464403' . serialize('Hello World');
+ $hash = hash('xxh128', 'foo');
+ $cacheDir = substr($hash, 0, 2) . '/' . substr($hash, 2, 2);
+ $files->expects($this->once())->method('put')->with(__DIR__ . '/' . $cacheDir . '/' . $hash, $contents)->willReturn(strlen($contents));
+
+ $this->assertTrue($store->put('foo', 'Hello World', 3));
+ }
+
+ public function testGetPayloadReadsZeroPaddedTimestampsCorrectly(): void
+ {
+ CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(990464400));
+
+ $files = $this->mockFilesystem();
+ $files->expects($this->once())->method('get')->willReturn('0990464403' . serialize('Hello World'));
+
+ $this->assertSame('Hello World', (new FileStore($files, __DIR__))->get('foo'));
+ }
+
public function testTouchExtendsTtl()
{
$files = $this->mockFilesystem();
@@ -204,6 +227,23 @@ public function testAddReturnsFalseWhenFileLockCannotBeAcquired(): void
}
}
+ public function testAddPadsShortTimestampsToTenDigits(): void
+ {
+ CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(990464400));
+ $tempDir = ParallelTesting::tempDir('CacheFileStoreTest-add-header');
+ mkdir($tempDir, 0777, true);
+
+ try {
+ $store = new FileStore(new Filesystem, $tempDir);
+
+ $this->assertTrue($store->add('foo', 'bar', 3));
+ $this->assertStringStartsWith('0990464403', file_get_contents($store->path('foo')));
+ $this->assertSame('bar', $store->get('foo'));
+ } finally {
+ (new Filesystem)->deleteDirectory($tempDir);
+ }
+ }
+
public function testRefreshReturnsFalseWhenFileLockCannotBeAcquired(): void
{
$tempDir = ParallelTesting::tempDir('CacheFileStoreTest-refresh');
@@ -225,6 +265,24 @@ public function testRefreshReturnsFalseWhenFileLockCannotBeAcquired(): void
}
}
+ public function testRefreshPadsShortTimestampsToTenDigits(): void
+ {
+ CarbonImmutable::setTestNow(CarbonImmutable::createFromTimestampUTC(990464400));
+ $tempDir = ParallelTesting::tempDir('CacheFileStoreTest-refresh-header');
+ mkdir($tempDir, 0777, true);
+
+ try {
+ $store = new FileStore(new Filesystem, $tempDir);
+
+ $this->assertTrue($store->put('foo', 'owner', 60));
+ $this->assertTrue($store->refreshIfOwned('foo', 'owner', 3));
+ $this->assertStringStartsWith('0990464403', file_get_contents($store->path('foo')));
+ $this->assertSame('owner', $store->get('foo'));
+ } finally {
+ (new Filesystem)->deleteDirectory($tempDir);
+ }
+ }
+
public function testForeversAreStoredWithHighTimestamp()
{
$files = $this->mockFilesystem();
diff --git a/tests/Cache/CacheLockTest.php b/tests/Cache/CacheLockTest.php
new file mode 100644
index 000000000..9d79d07ed
--- /dev/null
+++ b/tests/Cache/CacheLockTest.php
@@ -0,0 +1,93 @@
+get(fn () => throw new RuntimeException('callback failure'));
+
+ $this->fail('Expected the callback failure to be thrown.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('callback failure', $exception->getMessage());
+ }
+
+ $this->assertTrue($lock->released);
+ }
+
+ public function testGetPropagatesReleaseFailureAfterSuccessfulCallback(): void
+ {
+ $lock = new FailingReleaseLock;
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('release failure');
+
+ $lock->get(fn () => 'result');
+ }
+
+ public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(): void
+ {
+ $lock = new FailingReleaseLock;
+
+ try {
+ $lock->block(0, fn () => throw new RuntimeException('callback failure'));
+
+ $this->fail('Expected the callback failure to be thrown.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('callback failure', $exception->getMessage());
+ }
+
+ $this->assertTrue($lock->released);
+ }
+
+ public function testBlockPropagatesReleaseFailureAfterSuccessfulCallback(): void
+ {
+ $lock = new FailingReleaseLock;
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('release failure');
+
+ $lock->block(0, fn () => 'result');
+ }
+}
+
+class FailingReleaseLock extends Lock
+{
+ public bool $released = false;
+
+ public function __construct()
+ {
+ parent::__construct('lock', 10, 'owner');
+ }
+
+ public function acquire(): bool
+ {
+ return true;
+ }
+
+ public function release(): bool
+ {
+ $this->released = true;
+
+ throw new RuntimeException('release failure');
+ }
+
+ public function forceRelease(): void
+ {
+ }
+
+ protected function getCurrentOwner(): ?string
+ {
+ return $this->owner;
+ }
+}
diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php
index 01eccba2d..d2b400aad 100644
--- a/tests/Cache/CacheManagerTest.php
+++ b/tests/Cache/CacheManagerTest.php
@@ -4,26 +4,33 @@
namespace Hypervel\Tests\Cache;
+use __PHP_Incomplete_Class;
use Hypervel\Cache\ArrayStore;
use Hypervel\Cache\CacheManager;
use Hypervel\Cache\NullStore;
use Hypervel\Cache\RedisStore;
+use Hypervel\Cache\StorageStore;
+use Hypervel\Cache\SwooleStore;
+use Hypervel\Cache\SwooleTableManager;
use Hypervel\Cache\TagMode;
use Hypervel\Cache\WorkerArrayStore;
use Hypervel\Config\Repository as ConfigRepository;
use Hypervel\Container\Container;
use Hypervel\Contracts\Cache\Repository as CacheRepository;
use Hypervel\Contracts\Events\Dispatcher;
+use Hypervel\Contracts\Filesystem\Factory as FilesystemFactory;
use Hypervel\Contracts\Redis\Factory as RedisFactory;
use Hypervel\Events\Dispatcher as Event;
use Hypervel\Redis\PhpRedisConnection;
use Hypervel\Redis\Pool\PoolFactory;
use Hypervel\Redis\Pool\RedisPool;
+use Hypervel\Tests\Cache\Fixtures\ArrayFilesystem;
use Hypervel\Tests\TestCase;
use InvalidArgumentException;
use Mockery as m;
use Mockery\MockInterface;
use Redis;
+use stdClass;
class CacheManagerTest extends TestCase
{
@@ -84,6 +91,38 @@ public function testItCanBuildRepositories()
$this->assertInstanceOf(NullStore::class, $nullCache->getStore());
}
+ public function testItCanCreateStorageDriver(): void
+ {
+ $disk = new ArrayFilesystem;
+
+ $filesystem = m::mock(FilesystemFactory::class);
+ $filesystem->shouldReceive('disk')->with('s3')->once()->andReturn($disk);
+
+ $app = $this->getApp([
+ 'cache' => [
+ 'prefix' => 'cache:',
+ 'serializable_classes' => false,
+ 'stores' => [
+ 'storage' => [
+ 'driver' => 'storage',
+ 'disk' => 's3',
+ 'path' => 'cache',
+ ],
+ ],
+ ],
+ ]);
+ $app->instance('filesystem', $filesystem);
+
+ $store = (new CacheManager($app))->store('storage')->getStore();
+
+ $this->assertInstanceOf(StorageStore::class, $store);
+ $this->assertSame($disk, $store->getDisk());
+ $this->assertSame('cache', $store->getDirectory());
+ $this->assertSame('cache:', $store->getPrefix());
+ $this->assertTrue($store->put('foo', new stdClass, 60));
+ $this->assertInstanceOf(__PHP_Incomplete_Class::class, $store->get('foo'));
+ }
+
public function testItCanBuildWorkerArrayRepositories(): void
{
$app = $this->getApp([]);
@@ -94,6 +133,35 @@ public function testItCanBuildWorkerArrayRepositories(): void
$this->assertInstanceOf(WorkerArrayStore::class, $repository->getStore());
}
+ public function testSwooleDriverUsesConfiguredSerializableClasses(): void
+ {
+ $app = $this->getApp([
+ 'cache' => [
+ 'serializable_classes' => false,
+ 'stores' => [
+ 'swoole' => [
+ 'driver' => 'swoole',
+ 'table' => 'default',
+ ],
+ ],
+ 'swoole_tables' => [
+ 'default' => [
+ 'rows' => 128,
+ 'bytes' => 10240,
+ 'conflict_proportion' => 0.2,
+ ],
+ ],
+ ],
+ ]);
+ $app->instance(SwooleTableManager::class, new SwooleTableManager($app));
+
+ $store = (new CacheManager($app))->store('swoole')->getStore();
+
+ $this->assertInstanceOf(SwooleStore::class, $store);
+ $this->assertTrue($store->put('foo', new stdClass, 60));
+ $this->assertInstanceOf(__PHP_Incomplete_Class::class, $store->get('foo'));
+ }
+
public function testItResolvesMultiWordInternalDriversUsingStudlyNames(): void
{
$userConfig = [
@@ -665,7 +733,8 @@ public function testSetDefaultDriverAcceptsEnum(): void
protected function getApp(array $userConfig): Container
{
$app = new Container;
- $app->instance('config', new ConfigRepository($userConfig));
+ $config = new ConfigRepository($userConfig);
+ $app->instance('config', $config);
return $app;
}
diff --git a/tests/Cache/CacheMemoizedStoreTest.php b/tests/Cache/CacheMemoizedStoreTest.php
index 3581b76ed..5ef497987 100644
--- a/tests/Cache/CacheMemoizedStoreTest.php
+++ b/tests/Cache/CacheMemoizedStoreTest.php
@@ -4,19 +4,24 @@
namespace Hypervel\Tests\Cache;
+use BadMethodCallException;
use Hypervel\Cache\ArrayStore;
use Hypervel\Cache\Events\CacheHit;
use Hypervel\Cache\Events\CacheMissed;
use Hypervel\Cache\Events\RetrievingManyKeys;
use Hypervel\Cache\MemoizedStore;
use Hypervel\Cache\NullSentinel;
+use Hypervel\Cache\NullStore;
use Hypervel\Cache\Repository;
use Hypervel\Cache\StackStore;
use Hypervel\Cache\StackStoreProxy;
+use Hypervel\Contracts\Cache\CanFlushLocks;
+use Hypervel\Contracts\Cache\Store;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Support\CarbonImmutable;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use stdClass;
class CacheMemoizedStoreTest extends TestCase
{
@@ -88,6 +93,28 @@ public function testPlainFlexibleTreatsCachedSentinelAsHitThroughMemoizedStore()
$this->assertFalse($invoked);
}
+ public function testIncompleteClassHandlerRunsOnceAcrossMemoizedRepositories(): void
+ {
+ $innerRepo = new Repository(new ArrayStore);
+ $innerRepo->forever(
+ 'key',
+ unserialize(serialize(new stdClass), ['allowed_classes' => false])
+ );
+
+ $handled = [];
+
+ Repository::handleUnserializableClassUsing(function (string $key, ?string $class) use (&$handled): void {
+ $handled[] = [$key, $class];
+ });
+
+ $outerRepo = new Repository(new MemoizedStore('memoized', $innerRepo));
+
+ $outerRepo->get('key');
+ $outerRepo->get('key');
+
+ $this->assertSame([['key', 'stdClass']], $handled);
+ }
+
public function testPutManyWithEmptyInputReturnsDelegatedRepositoryResult(): void
{
$repository = m::mock(Repository::class);
@@ -190,6 +217,51 @@ public function testManyFiresCacheHitNotCacheMissedForSentinelThroughMemoizedSto
$this->assertEmpty(array_filter($captured, fn ($e) => $e instanceof CacheMissed));
}
+ public function testLockFlushCapabilityDelegatesToUnderlyingStore(): void
+ {
+ $flushable = new MemoizedStore('test', new Repository(new ArrayStore));
+ $nonFlushableStore = m::mock(Store::class);
+ $nonFlushable = new MemoizedStore('test', new Repository($nonFlushableStore));
+
+ $this->assertTrue($flushable->supportsFlushingLocks());
+ $this->assertFalse($nonFlushable->supportsFlushingLocks());
+ }
+
+ public function testFlushLocksDelegatesToUnderlyingStore(): void
+ {
+ $store = m::mock(Store::class, CanFlushLocks::class);
+ $store->shouldReceive('supportsFlushingLocks')->once()->andReturnTrue();
+ $store->shouldReceive('flushLocks')->once()->andReturnTrue();
+
+ $memoized = new MemoizedStore('test', new Repository($store));
+
+ $this->assertTrue($memoized->flushLocks());
+ }
+
+ public function testFlushLocksRejectsUnsupportedUnderlyingStore(): void
+ {
+ $store = m::mock(Store::class, CanFlushLocks::class);
+ $store->shouldReceive('supportsFlushingLocks')->once()->andReturnFalse();
+ $store->shouldNotReceive('flushLocks');
+
+ $this->expectException(BadMethodCallException::class);
+ $this->expectExceptionMessage(sprintf(
+ 'The memoized cache store\'s underlying store [%s] does not support flushing locks.',
+ $store::class
+ ));
+
+ (new MemoizedStore('test', new Repository($store)))->flushLocks();
+ }
+
+ public function testSeparateLockStoreReportingDelegatesToUnderlyingStore(): void
+ {
+ $separate = new MemoizedStore('test', new Repository(new ArrayStore));
+ $shared = new MemoizedStore('test', new Repository(new NullStore));
+
+ $this->assertTrue($separate->hasSeparateLockStore());
+ $this->assertFalse($shared->hasSeparateLockStore());
+ }
+
protected function createStackRepository(): Repository
{
return new Repository(new StackStore([
diff --git a/tests/Cache/CacheNullStoreTest.php b/tests/Cache/CacheNullStoreTest.php
index ff847a6e3..b705ca928 100644
--- a/tests/Cache/CacheNullStoreTest.php
+++ b/tests/Cache/CacheNullStoreTest.php
@@ -6,6 +6,7 @@
use Hypervel\Cache\NullStore;
use Hypervel\Cache\Repository;
+use Hypervel\Contracts\Cache\CanFlushLocks;
use Hypervel\Tests\TestCase;
class CacheNullStoreTest extends TestCase
@@ -58,4 +59,14 @@ public function testRememberNullableAlwaysReRunsCallbackOnNullStore(): void
$this->assertSame(2, $count);
}
+
+ public function testLocksCanBeFlushed(): void
+ {
+ $store = new NullStore;
+
+ $this->assertInstanceOf(CanFlushLocks::class, $store);
+ $this->assertTrue($store->supportsFlushingLocks());
+ $this->assertTrue($store->flushLocks());
+ $this->assertFalse($store->hasSeparateLockStore());
+ }
}
diff --git a/tests/Cache/CacheRateLimiterTest.php b/tests/Cache/CacheRateLimiterTest.php
index 1b8913854..c7bee2a18 100644
--- a/tests/Cache/CacheRateLimiterTest.php
+++ b/tests/Cache/CacheRateLimiterTest.php
@@ -73,6 +73,18 @@ public function testHitHasNoMemoryLeak()
$rateLimiter->hit('key', 1);
}
+ public function testIncrementWithCustomAmountHasNoMemoryLeak(): void
+ {
+ $cache = m::mock(Cache::class);
+ $cache->shouldReceive('add')->once()->with('key:timer', m::type('int'), 60)->andReturnTrue();
+ $cache->shouldReceive('add')->once()->with('key', 0, 60)->andReturnFalse();
+ $cache->shouldReceive('increment')->once()->with('key', 2)->andReturn(2);
+ $cache->shouldReceive('put')->once()->with('key', 2, 60);
+ $cache->shouldReceive('getStore')->andReturn(new ArrayStore);
+
+ (new RateLimiter($cache))->increment('key', 60, 2);
+ }
+
public function testRemainingIsNotNegative()
{
$cache = m::mock(Cache::class);
diff --git a/tests/Cache/CacheRedisStoreTest.php b/tests/Cache/CacheRedisStoreTest.php
index 6cab7305f..e019a97db 100755
--- a/tests/Cache/CacheRedisStoreTest.php
+++ b/tests/Cache/CacheRedisStoreTest.php
@@ -162,8 +162,7 @@ public function testFlushesCachedLocks()
$store = new RedisStore(
$redis,
'prefix:',
- 'default',
- $this->createPoolFactory($this->mockConnection())
+ 'default'
);
$store->setLockConnection('locks');
diff --git a/tests/Cache/CacheRepositoryTest.php b/tests/Cache/CacheRepositoryTest.php
index 05a4985ca..ec2c3b035 100644
--- a/tests/Cache/CacheRepositoryTest.php
+++ b/tests/Cache/CacheRepositoryTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Cache;
+use __PHP_Incomplete_Class;
use ArrayIterator;
use BadMethodCallException;
use DateInterval;
@@ -20,7 +21,6 @@
use Hypervel\Cache\FileStore;
use Hypervel\Cache\Lock;
use Hypervel\Cache\NullSentinel;
-use Hypervel\Cache\NullStore;
use Hypervel\Cache\RedisStore;
use Hypervel\Cache\Repository;
use Hypervel\Cache\StackStore;
@@ -36,6 +36,7 @@
use InvalidArgumentException;
use Mockery as m;
use PHPUnit\Framework\Attributes\DataProvider;
+use stdClass;
class CacheRepositoryTest extends TestCase
{
@@ -140,6 +141,55 @@ public function testRememberMethodCallsPutAndReturnsDefault()
return 'qux';
});
$this->assertSame('qux', $result);
+
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('get')->once()->andReturn(null);
+ $repo->getStore()->shouldReceive('put')->once()->with('foo', 'bar', 10);
+ $result = $repo->remember('foo', function ($value) {
+ $this->assertSame('bar', $value);
+
+ return 10;
+ }, fn () => 'bar');
+ $this->assertSame('bar', $result);
+ }
+
+ public function testRememberWithWarmthReturnsCachedValue(): void
+ {
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn('bar');
+ $repo->getStore()->shouldNotReceive('put');
+
+ $result = $repo->rememberWithWarmth('foo', 10, function () {
+ $this->fail('The cache callback should not be called.');
+ });
+
+ $this->assertSame(['bar', true], $result);
+ }
+
+ public function testRememberWithWarmthCallsPutAndReturnsColdValue(): void
+ {
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(null);
+ $repo->getStore()->shouldReceive('put')->once()->with('foo', 'bar', 10);
+
+ $result = $repo->rememberWithWarmth('foo', function ($value) {
+ $this->assertSame('bar', $value);
+
+ return 10;
+ }, fn () => 'bar');
+
+ $this->assertSame(['bar', false], $result);
+ }
+
+ public function testRememberWithWarmthTreatsNullableSentinelAsWarm(): void
+ {
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(NullSentinel::VALUE);
+ $repo->getStore()->shouldNotReceive('put');
+
+ $this->assertSame([null, true], $repo->rememberWithWarmth('foo', 10, function () {
+ $this->fail('The cache callback should not be called.');
+ }));
}
public function testRememberForeverMethodCallsForeverAndReturnsDefault()
@@ -153,6 +203,80 @@ public function testRememberForeverMethodCallsForeverAndReturnsDefault()
$this->assertSame('bar', $result);
}
+ public function testGetReturnsIncompleteClassWhenNoHandlerRegistered(): void
+ {
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(
+ unserialize(serialize(new stdClass), ['allowed_classes' => false])
+ );
+
+ $this->assertInstanceOf(__PHP_Incomplete_Class::class, $repo->get('foo'));
+ }
+
+ public function testGetCallsHandlerWithKeyAndClassForIncompleteClass(): void
+ {
+ $handled = [];
+
+ Repository::handleUnserializableClassUsing(function ($key, $class) use (&$handled): void {
+ $handled[] = [$key, $class];
+ });
+
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('get')->once()->with('foo')->andReturn(
+ unserialize(serialize(new stdClass), ['allowed_classes' => false])
+ );
+
+ $repo->get('foo');
+
+ $this->assertSame([['foo', 'stdClass']], $handled);
+ }
+
+ public function testIncompleteClassHandlerAcceptsCallableAndCanBeCleared(): void
+ {
+ $handler = new class {
+ public array $handled = [];
+
+ public function handle(string $key, ?string $class): void
+ {
+ $this->handled[] = [$key, $class];
+ }
+ };
+
+ Repository::handleUnserializableClassUsing([$handler, 'handle']);
+
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('get')->twice()->with('foo')->andReturn(
+ unserialize(serialize(new stdClass), ['allowed_classes' => false])
+ );
+
+ $repo->get('foo');
+
+ Repository::handleUnserializableClassUsing(null);
+
+ $repo->get('foo');
+
+ $this->assertSame([['foo', 'stdClass']], $handler->handled);
+ }
+
+ public function testManyCallsHandlerForEachIncompleteClass(): void
+ {
+ $handled = [];
+
+ Repository::handleUnserializableClassUsing(function ($key, $class) use (&$handled): void {
+ $handled[] = [$key, $class];
+ });
+
+ $repo = $this->getRepository();
+ $repo->getStore()->shouldReceive('many')->once()->with(['foo', 'bar'])->andReturn([
+ 'foo' => unserialize(serialize(new stdClass), ['allowed_classes' => false]),
+ 'bar' => 'baz',
+ ]);
+
+ $repo->many(['foo', 'bar']);
+
+ $this->assertSame([['foo', 'stdClass']], $handled);
+ }
+
public function testRememberNullableStoresAndReturnsNonNullValue()
{
$repo = $this->getRepository();
@@ -855,7 +979,7 @@ public function testFlushableLockRepositoryCanReportUnsupportedFlushingLocks()
public function testNonFlushableLockRepositoryDoesNotSupportFlushingLocks()
{
- $nonFlushable = m::mock(NullStore::class);
+ $nonFlushable = m::mock(Store::class);
$nonFlushableRepo = new Repository($nonFlushable);
$this->assertFalse($nonFlushableRepo->supportsFlushingLocks());
@@ -865,7 +989,7 @@ public function testItThrowsExceptionWhenStoreDoesNotSupportFlushingLocks()
{
$this->expectException(BadMethodCallException::class);
- $nonFlushable = m::mock(NullStore::class);
+ $nonFlushable = m::mock(Store::class);
$nonFlushableRepo = new Repository($nonFlushable);
$nonFlushableRepo->flushLocks();
@@ -1211,12 +1335,10 @@ public function testArrayTypedGetterReturnsDefaultWhenKeyNotFound()
$this->assertSame(['default'], $repo->array('foo', ['default']));
}
- public function testRememberFiresEventsWithPinnableStore()
+ public function testRememberFiresEventsWithRedisStore()
{
$store = m::mock(RedisStore::class);
- $store->shouldReceive('withPinnedConnection')
- ->once()
- ->andReturnUsing(fn (callable $callback) => $callback());
+ $store->shouldNotReceive('withPinnedConnection');
$store->shouldReceive('get')->once()->with('foo')->andReturn(null);
$store->shouldReceive('put')->once()->with('foo', 'bar', 10);
@@ -1232,12 +1354,10 @@ public function testRememberFiresEventsWithPinnableStore()
$this->assertSame('bar', $result);
}
- public function testRememberForeverFiresEventsWithPinnableStore()
+ public function testRememberForeverFiresEventsWithRedisStore()
{
$store = m::mock(RedisStore::class);
- $store->shouldReceive('withPinnedConnection')
- ->once()
- ->andReturnUsing(fn (callable $callback) => $callback());
+ $store->shouldNotReceive('withPinnedConnection');
$store->shouldReceive('get')->once()->with('foo')->andReturn(null);
$store->shouldReceive('forever')->once()->with('foo', 'bar');
@@ -1253,12 +1373,10 @@ public function testRememberForeverFiresEventsWithPinnableStore()
$this->assertSame('bar', $result);
}
- public function testRememberHitFiresEventsWithPinnableStore()
+ public function testRememberHitFiresEventsWithRedisStore()
{
$store = m::mock(RedisStore::class);
- $store->shouldReceive('withPinnedConnection')
- ->once()
- ->andReturnUsing(fn (callable $callback) => $callback());
+ $store->shouldNotReceive('withPinnedConnection');
$store->shouldReceive('get')->once()->with('foo')->andReturn('cached');
$events = m::mock(Dispatcher::class);
@@ -1276,9 +1394,7 @@ public function testRememberHitFiresEventsWithPinnableStore()
public function testRememberSkipsDispatchWhenCacheEventsHaveNoListeners()
{
$store = m::mock(RedisStore::class);
- $store->shouldReceive('withPinnedConnection')
- ->once()
- ->andReturnUsing(fn (callable $callback) => $callback());
+ $store->shouldNotReceive('withPinnedConnection');
$store->shouldReceive('get')->once()->with('foo')->andReturn(null);
$store->shouldReceive('put')->once()->with('foo', 'bar', 10);
@@ -1297,9 +1413,7 @@ public function testRememberSkipsDispatchWhenCacheEventsHaveNoListeners()
public function testRememberNullableFiresEventsWithNullPayloadOnCacheMiss()
{
$store = m::mock(RedisStore::class);
- $store->shouldReceive('withPinnedConnection')
- ->once()
- ->andReturnUsing(fn (callable $callback) => $callback());
+ $store->shouldNotReceive('withPinnedConnection');
$store->shouldReceive('get')->once()->with('foo')->andReturn(null);
$store->shouldReceive('put')->once()->with('foo', NullSentinel::VALUE, 10)->andReturn(true);
@@ -1334,9 +1448,7 @@ public function testRememberNullableFiresEventsWithNullPayloadOnCacheMiss()
public function testRememberForeverNullableFiresEventsWithNullPayloadOnCacheMiss()
{
$store = m::mock(RedisStore::class);
- $store->shouldReceive('withPinnedConnection')
- ->once()
- ->andReturnUsing(fn (callable $callback) => $callback());
+ $store->shouldNotReceive('withPinnedConnection');
$store->shouldReceive('get')->once()->with('foo')->andReturn(null);
$store->shouldReceive('forever')->once()->with('foo', NullSentinel::VALUE)->andReturn(true);
@@ -1417,9 +1529,7 @@ public function testFlexibleNullableFiresEventsWithNullPayloadOnCacheMiss()
public function testRememberNullableFiresHitEventWithNullPayloadOnSentinelRetrieval()
{
$store = m::mock(RedisStore::class);
- $store->shouldReceive('withPinnedConnection')
- ->once()
- ->andReturnUsing(fn (callable $callback) => $callback());
+ $store->shouldNotReceive('withPinnedConnection');
$store->shouldReceive('get')->once()->with('foo')->andReturn(NullSentinel::VALUE);
$captured = [];
diff --git a/tests/Cache/CacheSpyMemoTest.php b/tests/Cache/CacheSpyMemoTest.php
index d2c6626aa..a061d3296 100644
--- a/tests/Cache/CacheSpyMemoTest.php
+++ b/tests/Cache/CacheSpyMemoTest.php
@@ -20,6 +20,7 @@ protected function setUp(): void
{
parent::setUp();
+ // CacheManager accepts the generic container contract and must not depend on Application aliases.
$container = new Container;
$container->instance('config', new ConfigRepository([
diff --git a/tests/Cache/CacheStackStoreTagsTest.php b/tests/Cache/CacheStackStoreTagsTest.php
index 38c5de845..977cbbfef 100644
--- a/tests/Cache/CacheStackStoreTagsTest.php
+++ b/tests/Cache/CacheStackStoreTagsTest.php
@@ -4,8 +4,18 @@
namespace Hypervel\Tests\Cache;
+use __PHP_Incomplete_Class;
use BadMethodCallException;
+use Hypervel\Cache\Events\CacheHit;
+use Hypervel\Cache\Events\CacheMissed;
+use Hypervel\Cache\Events\ForgettingKey;
+use Hypervel\Cache\Events\KeyForgotten;
+use Hypervel\Cache\Events\KeyWriteFailed;
+use Hypervel\Cache\Events\KeyWritten;
+use Hypervel\Cache\Events\RetrievingKey;
+use Hypervel\Cache\Events\WritingKey;
use Hypervel\Cache\Exceptions\NotSupportedException;
+use Hypervel\Cache\Repository;
use Hypervel\Cache\StackStore;
use Hypervel\Cache\StackStoreProxy;
use Hypervel\Cache\StackTaggedCache;
@@ -14,9 +24,11 @@
use Hypervel\Cache\TagMode;
use Hypervel\Cache\TagSet;
use Hypervel\Contracts\Cache\Store;
+use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Tests\TestCase;
use Mockery as m;
use RuntimeException;
+use stdClass;
class CacheStackStoreTagsTest extends TestCase
{
@@ -263,6 +275,103 @@ public function testTaggedRememberHitReadsPlainWithoutTaggedWrite(): void
$this->assertSame('cached', $stack->tags(['tag'])->remember('key', 60, fn () => 'computed'));
}
+ public function testTaggedRememberHandlesIncompleteClassBeforeDispatchingHitEvent(): void
+ {
+ $taggable = $this->anyModeTaggableStore();
+ $taggable->shouldReceive('get')->once()->with('key')->andReturn([
+ 'value' => unserialize(serialize(new stdClass), ['allowed_classes' => false]),
+ ]);
+ $taggable->shouldNotReceive('tags');
+
+ $sequence = [];
+
+ Repository::handleUnserializableClassUsing(function (string $key, ?string $class) use (&$sequence): void {
+ $sequence[] = ['handler', $key, $class];
+ });
+
+ $cache = (new Repository(new StackStore([$taggable]), ['store' => 'stack']))->tags(['tag']);
+ $cache->setEventDispatcher($this->capturingDispatcher($sequence));
+
+ $result = $cache->remember('key', 60, function (): never {
+ $this->fail('The cache callback should not be called.');
+ });
+
+ $this->assertInstanceOf(__PHP_Incomplete_Class::class, $result);
+ $this->assertInstanceOf(RetrievingKey::class, $sequence[0]);
+ $this->assertSame(['handler', 'key', 'stdClass'], $sequence[1]);
+ $this->assertInstanceOf(CacheHit::class, $sequence[2]);
+ }
+
+ public function testTaggedRememberMissDispatchesRepositoryReadAndWriteEvents(): void
+ {
+ $taggable = $this->anyModeTaggableStore();
+ $taggedCache = m::mock(TaggedCache::class);
+
+ $taggable->shouldReceive('get')->once()->with('key')->andReturnNull();
+ $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache);
+ $taggedCache->shouldReceive('put')->once()->with('key', m::type('array'), 60)->andReturnTrue();
+
+ $captured = [];
+ $stack = new StackStore([$taggable]);
+ $cache = (new Repository($stack, ['store' => 'stack']))->tags(['tag']);
+ $cache->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertSame('computed', $cache->remember('key', 60, fn () => 'computed'));
+ $this->assertSame(
+ [RetrievingKey::class, CacheMissed::class, WritingKey::class, KeyWritten::class],
+ array_map(get_class(...), $captured)
+ );
+
+ foreach ($captured as $event) {
+ $this->assertSame('stack', $event->storeName);
+ $this->assertSame('key', $event->key);
+ $this->assertSame(['tag'], $event->tags);
+ }
+ }
+
+ public function testTaggedPutDispatchesRepositoryWriteFailureEvents(): void
+ {
+ $taggable = $this->anyModeTaggableStore();
+ $taggedCache = m::mock(TaggedCache::class);
+
+ $taggable->shouldReceive('tags')->once()->with(['tag'])->andReturn($taggedCache);
+ $taggedCache->shouldReceive('put')->once()->with('key', m::type('array'), 60)->andReturnFalse();
+
+ $captured = [];
+ $stack = new StackStore([$taggable]);
+ $cache = (new Repository($stack, ['store' => 'stack']))->tags(['tag']);
+ $cache->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertFalse($cache->put('key', 'value', 60));
+ $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured));
+
+ foreach ($captured as $event) {
+ $this->assertSame('stack', $event->storeName);
+ $this->assertSame('key', $event->key);
+ $this->assertSame(['tag'], $event->tags);
+ }
+ }
+
+ public function testTaggedPutWithExpiredTtlDispatchesRepositoryDeleteEvents(): void
+ {
+ $taggable = $this->anyModeTaggableStore();
+ $taggable->shouldReceive('forget')->once()->with('key')->andReturnTrue();
+
+ $captured = [];
+ $stack = new StackStore([$taggable]);
+ $cache = (new Repository($stack, ['store' => 'stack']))->tags(['tag']);
+ $cache->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertTrue($cache->put('key', 'value', 0));
+ $this->assertSame([ForgettingKey::class, KeyForgotten::class], array_map(get_class(...), $captured));
+
+ foreach ($captured as $event) {
+ $this->assertSame('stack', $event->storeName);
+ $this->assertSame('key', $event->key);
+ $this->assertSame(['tag'], $event->tags);
+ }
+ }
+
public function testTaggedReadAndDeleteOperationsThrow(): void
{
$cache = (new StackStore([$this->anyModeTaggableStore()]))->tags(['tag']);
@@ -320,6 +429,18 @@ private function nonTaggableStore(): Store|m\MockInterface
{
return m::mock(Store::class);
}
+
+ private function capturingDispatcher(array &$captured): Dispatcher|m\MockInterface
+ {
+ $events = m::mock(Dispatcher::class);
+ $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnTrue();
+ $events->shouldReceive('dispatch')
+ ->andReturnUsing(function (object $event) use (&$captured): void {
+ $captured[] = $event;
+ });
+
+ return $events;
+ }
}
enum StackTaggedCacheKey: int
diff --git a/tests/Cache/CacheStackStoreTest.php b/tests/Cache/CacheStackStoreTest.php
index 52f0ddd5c..8951da1c4 100644
--- a/tests/Cache/CacheStackStoreTest.php
+++ b/tests/Cache/CacheStackStoreTest.php
@@ -257,6 +257,51 @@ public function testPutCompensatesEarlierLayerWhenLowerLayerThrows(): void
}
}
+ public function testPutCompensatesCompletedLayersInReverseOrder(): void
+ {
+ $top = m::mock(ArrayStore::class);
+ $middle = m::mock(ArrayStore::class);
+ $bottom = m::mock(ArrayStore::class);
+
+ $top->shouldReceive('put')->once()->andReturnTrue();
+ $middle->shouldReceive('put')->once()->andReturnTrue();
+ $bottom->shouldReceive('put')->once()->andReturnFalse();
+ $middle->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue();
+ $top->shouldReceive('forget')->once()->with('foo')->ordered()->andReturnTrue();
+
+ $this->assertFalse((new StackStore([$top, $middle, $bottom]))->put('foo', 'bar', 60));
+ }
+
+ public function testPutPreservesFalseResultWhenCompensationThrows(): void
+ {
+ $top = m::mock(ArrayStore::class);
+ $bottom = m::mock(ArrayStore::class);
+
+ $top->shouldReceive('put')->once()->andReturnTrue();
+ $bottom->shouldReceive('put')->once()->andReturnFalse();
+ $top->shouldReceive('forget')->once()->with('foo')->andThrow(new RuntimeException('cleanup failed'));
+
+ $this->assertFalse((new StackStore([$top, $bottom]))->put('foo', 'bar', 60));
+ }
+
+ public function testPutPreservesWriteFailureWhenCompensationAlsoThrows(): void
+ {
+ $top = m::mock(ArrayStore::class);
+ $bottom = m::mock(ArrayStore::class);
+ $exception = new RuntimeException('write failed');
+
+ $top->shouldReceive('put')->once()->andReturnTrue();
+ $bottom->shouldReceive('put')->once()->andThrow($exception);
+ $top->shouldReceive('forget')->once()->with('foo')->andThrow(new RuntimeException('cleanup failed'));
+
+ try {
+ (new StackStore([$top, $bottom]))->put('foo', 'bar', 60);
+ $this->fail('Expected the write failure to be thrown.');
+ } catch (RuntimeException $throwable) {
+ $this->assertSame($exception, $throwable);
+ }
+ }
+
public function testPutDoesNotCompensateLayerWhoseWriteThrows(): void
{
$top = m::mock(ArrayStore::class);
@@ -450,7 +495,7 @@ public function testForget()
$this->swoole->shouldReceive('forget')->once()->with('foo')->andReturn(true);
$this->redis->shouldReceive('forget')->once()->with('foo')->andReturn(true);
- $this->assertTrue($this->store->forget('foo', 'bar'));
+ $this->assertTrue($this->store->forget('foo'));
}
public function testForgetFailed()
@@ -460,7 +505,26 @@ public function testForgetFailed()
$this->swoole->shouldReceive('forget')->once()->with('foo')->andReturn(false);
$this->redis->shouldReceive('forget')->once()->with('foo')->andReturn(true);
- $this->assertTrue($this->store->forget('foo', 'bar'));
+ $this->assertFalse($this->store->forget('foo'));
+ }
+
+ public function testForgetAttemptsEveryLayerAndPreservesTheEarliestFailure(): void
+ {
+ $top = m::mock(ArrayStore::class);
+ $middle = m::mock(ArrayStore::class);
+ $bottom = m::mock(ArrayStore::class);
+ $exception = new RuntimeException('first failure');
+
+ $top->shouldReceive('forget')->once()->with('foo')->andThrow($exception);
+ $middle->shouldReceive('forget')->once()->with('foo')->andReturnFalse();
+ $bottom->shouldReceive('forget')->once()->with('foo')->andThrow(new RuntimeException('second failure'));
+
+ try {
+ (new StackStore([$top, $middle, $bottom]))->forget('foo');
+ $this->fail('Expected the first forget failure to be thrown.');
+ } catch (RuntimeException $throwable) {
+ $this->assertSame($exception, $throwable);
+ }
}
public function testFlush()
@@ -470,7 +534,7 @@ public function testFlush()
$this->swoole->shouldReceive('flush')->once()->withNoArgs()->andReturn(true);
$this->redis->shouldReceive('flush')->once()->withNoArgs()->andReturn(true);
- $this->assertTrue($this->store->flush('foo', 'bar'));
+ $this->assertTrue($this->store->flush());
}
public function testFlushFailed()
@@ -480,7 +544,26 @@ public function testFlushFailed()
$this->swoole->shouldReceive('flush')->once()->withNoArgs()->andReturn(false);
$this->redis->shouldReceive('flush')->once()->withNoArgs()->andReturn(true);
- $this->assertTrue($this->store->flush('foo', 'bar'));
+ $this->assertFalse($this->store->flush());
+ }
+
+ public function testFlushAttemptsEveryLayerAndPreservesTheEarliestFailure(): void
+ {
+ $top = m::mock(ArrayStore::class);
+ $middle = m::mock(ArrayStore::class);
+ $bottom = m::mock(ArrayStore::class);
+ $exception = new RuntimeException('first failure');
+
+ $top->shouldReceive('flush')->once()->withNoArgs()->andThrow($exception);
+ $middle->shouldReceive('flush')->once()->withNoArgs()->andReturnFalse();
+ $bottom->shouldReceive('flush')->once()->withNoArgs()->andThrow(new RuntimeException('second failure'));
+
+ try {
+ (new StackStore([$top, $middle, $bottom]))->flush();
+ $this->fail('Expected the first flush failure to be thrown.');
+ } catch (RuntimeException $throwable) {
+ $this->assertSame($exception, $throwable);
+ }
}
public function testThreeStores()
diff --git a/tests/Cache/CacheStorageStoreTest.php b/tests/Cache/CacheStorageStoreTest.php
new file mode 100644
index 000000000..5994b3ec0
--- /dev/null
+++ b/tests/Cache/CacheStorageStoreTest.php
@@ -0,0 +1,149 @@
+assertTrue($store->put('foo', 'bar', 60));
+ $this->assertSame('bar', $store->get('foo'));
+ $this->assertStringStartsWith('cache/', $store->path('foo'));
+ }
+
+ public function testExpiredItemsReturnNullAndGetDeleted(): void
+ {
+ CarbonImmutable::setTestNow($now = CarbonImmutable::now());
+
+ $disk = new ArrayFilesystem;
+ $store = new StorageStore($disk, 'cache');
+
+ $store->put('foo', 'bar', 1);
+
+ CarbonImmutable::setTestNow($now->addSeconds(2));
+
+ $this->assertNull($store->get('foo'));
+ $this->assertFalse($disk->exists($store->path('foo')));
+ }
+
+ public function testAddDoesNotOverwriteExistingValues(): void
+ {
+ $store = new StorageStore(new ArrayFilesystem, 'cache');
+
+ $this->assertTrue($store->add('foo', 'bar', 60));
+ $this->assertFalse($store->add('foo', 'baz', 60));
+ $this->assertSame('bar', $store->get('foo'));
+ }
+
+ public function testIncrementAndDecrementRetainExpiration(): void
+ {
+ CarbonImmutable::setTestNow($now = CarbonImmutable::now());
+
+ $store = new StorageStore(new ArrayFilesystem, 'cache');
+ $store->put('foo', 5, 60);
+
+ $this->assertSame(7, $store->increment('foo', 2));
+ $this->assertSame(4, $store->decrement('foo', 3));
+
+ CarbonImmutable::setTestNow($now->addSeconds(61));
+
+ $this->assertNull($store->get('foo'));
+ }
+
+ public function testTouchUpdatesExpiration(): void
+ {
+ CarbonImmutable::setTestNow($now = CarbonImmutable::now());
+
+ $store = new StorageStore(new ArrayFilesystem, 'cache');
+ $store->put('foo', 'bar', 2);
+
+ CarbonImmutable::setTestNow($now->addSecond());
+
+ $this->assertTrue($store->touch('foo', 60));
+
+ CarbonImmutable::setTestNow($now->addSecond());
+
+ $this->assertSame('bar', $store->get('foo'));
+ }
+
+ public function testForgetRemovesFlexibleCreatedKeyWhenParentIsMissing(): void
+ {
+ $disk = new ArrayFilesystem;
+ $store = new StorageStore($disk, 'cache');
+
+ $store->put(Repository::FLEXIBLE_CREATED_KEY_PREFIX . 'foo', true, 60);
+
+ $this->assertTrue($store->forget('foo'));
+ $this->assertFalse($disk->exists($store->path(Repository::FLEXIBLE_CREATED_KEY_PREFIX . 'foo')));
+ }
+
+ public function testForgetPreservesFlexibleCreatedKeyWhenParentDeletionFails(): void
+ {
+ $disk = new ArrayFilesystem;
+ $store = new StorageStore($disk, 'cache');
+
+ $store->put('foo', 'bar', 60);
+ $store->put(Repository::FLEXIBLE_CREATED_KEY_PREFIX . 'foo', true, 60);
+ $disk->deleteResult = false;
+
+ $this->assertFalse($store->forget('foo'));
+ $this->assertTrue($disk->exists($store->path('foo')));
+ $this->assertTrue($disk->exists($store->path(Repository::FLEXIBLE_CREATED_KEY_PREFIX . 'foo')));
+ }
+
+ public function testFlushRemovesScopedDirectory(): void
+ {
+ $disk = new ArrayFilesystem;
+ $store = new StorageStore($disk, 'cache');
+
+ $store->put('foo', 'bar', 60);
+ $disk->put('other/file', 'value');
+
+ $this->assertTrue($store->flush());
+ $this->assertNull($store->get('foo'));
+ $this->assertTrue($disk->exists('other/file'));
+ }
+
+ public function testFlushWithEmptyRootDirectoryDoesNotIssueAnEmptyDelete(): void
+ {
+ $disk = new ArrayFilesystem;
+ $disk->deleteResult = false;
+
+ $this->assertTrue((new StorageStore($disk))->flush());
+ }
+
+ public function testExpirationHeaderRemainsTenBytesBeforeSeptember2001(): void
+ {
+ CarbonImmutable::setTestNow('2000-01-01 00:00:00');
+
+ $disk = new ArrayFilesystem;
+ $store = new StorageStore($disk, 'cache');
+
+ $this->assertTrue($store->put('foo', 'bar', 60));
+ $this->assertSame('0946684860', substr((string) $disk->get($store->path('foo')), 0, 10));
+ $this->assertSame('bar', $store->get('foo'));
+ }
+
+ public function testPutNormalizesFilesystemResults(): void
+ {
+ $disk = new ArrayFilesystem;
+ $store = new StorageStore($disk, 'cache');
+
+ $disk->putResult = 'cache/path';
+ $this->assertTrue($store->put('foo', 'bar', 60));
+
+ $disk->putResult = false;
+ $this->assertFalse($store->put('bar', 'baz', 60));
+ }
+}
diff --git a/tests/Cache/CacheSwooleStoreIntervalTest.php b/tests/Cache/CacheSwooleStoreIntervalTest.php
index c5b5237d9..31ab59f38 100644
--- a/tests/Cache/CacheSwooleStoreIntervalTest.php
+++ b/tests/Cache/CacheSwooleStoreIntervalTest.php
@@ -231,6 +231,19 @@ public function testRefresherStoreRefreshesIntervalsFromSharedIndex(): void
$this->assertSame('bar', $refresherStore->get('foo'));
}
+ public function testSerializableClassPolicyDoesNotApplyToIntervalResolvers(): void
+ {
+ $state = $this->createState();
+ $workerStore = $this->createStore($state, serializableClasses: false);
+ $refresherStore = $this->createStore($state, serializableClasses: false);
+
+ $workerStore->interval('foo', fn () => 'bar', 5);
+
+ $refresherStore->refreshIntervalCaches();
+
+ $this->assertSame('bar', $refresherStore->get('foo'));
+ }
+
public function testRefresherStoreRefreshesMultipleIntervalsFromSharedIndex(): void
{
$state = $this->createState();
@@ -528,7 +541,7 @@ public function testGenericMissDoesNotConsultSharedIntervalIndex(): void
$this->assertSame(0, $count);
}
- public function testIndexRowsAreTouchedDuringRefreshDiscovery(): void
+ public function testIndexRowsArePermanentWhenRegistered(): void
{
CarbonImmutable::setTestNow('2000-01-01 00:00:00');
@@ -537,13 +550,8 @@ public function testIndexRowsAreTouchedDuringRefreshDiscovery(): void
$store->interval('foo', fn () => 'bar', 5);
$indexKey = $this->indexKey($store, $this->metadataKey($store, 'foo'));
- $before = $state->table()->get($indexKey)['expiration'];
-
- CarbonImmutable::setTestNow('2000-01-01 00:00:10');
-
- $store->refreshIntervalCaches();
- $this->assertGreaterThan($before, $state->table()->get($indexKey)['expiration']);
+ $this->assertSame(PHP_FLOAT_MAX, $state->table()->get($indexKey)['expiration']);
}
public function testStaleCleanupAndEvictionSkipIntervalControlRows(): void
@@ -712,13 +720,15 @@ private function createStore(
?SwooleTableState $state = null,
string $policy = SwooleStore::EVICTION_POLICY_TTL,
float $memoryLimitBuffer = 0.05,
- float $evictionProportion = 0.05
+ float $evictionProportion = 0.05,
+ array|bool|null $serializableClasses = null,
): SwooleStore {
return new SwooleStore(
$state ?? $this->createState(),
$memoryLimitBuffer,
$policy,
- $evictionProportion
+ $evictionProportion,
+ $serializableClasses,
);
}
diff --git a/tests/Cache/CacheSwooleStoreTest.php b/tests/Cache/CacheSwooleStoreTest.php
index a32b6ecd1..53e0edbba 100644
--- a/tests/Cache/CacheSwooleStoreTest.php
+++ b/tests/Cache/CacheSwooleStoreTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Cache;
+use __PHP_Incomplete_Class;
use Hypervel\Cache\Exceptions\ValueTooLargeForColumnException;
use Hypervel\Cache\NullSentinel;
use Hypervel\Cache\Repository;
@@ -20,6 +21,7 @@
use InvalidArgumentException;
use Mockery as m;
use ReflectionMethod;
+use stdClass;
use TypeError;
class CacheSwooleStoreTest extends TestCase
@@ -107,6 +109,45 @@ public function testPutStoresValueInTable(): void
$this->assertEquals('bar', $store->get('foo'));
}
+ public function testPutRejectsClassesDuringUnserializationWhenConfigured(): void
+ {
+ $store = new SwooleStore(
+ $this->createState(),
+ 0.05,
+ SwooleStore::EVICTION_POLICY_TTL,
+ 0.05,
+ false,
+ );
+
+ $store->put('foo', new stdClass, 5);
+
+ $this->assertInstanceOf(__PHP_Incomplete_Class::class, $store->get('foo'));
+ }
+
+ public function testPutAllowsConfiguredClassesDuringUnserialization(): void
+ {
+ $store = new SwooleStore(
+ $this->createState(),
+ 0.05,
+ SwooleStore::EVICTION_POLICY_TTL,
+ 0.05,
+ [stdClass::class],
+ );
+
+ $store->put('foo', new stdClass, 5);
+
+ $this->assertInstanceOf(stdClass::class, $store->get('foo'));
+ }
+
+ public function testPutAllowsClassesByDefaultWhenConstructedDirectly(): void
+ {
+ $store = $this->createStore();
+
+ $store->put('foo', new stdClass, 5);
+
+ $this->assertInstanceOf(stdClass::class, $store->get('foo'));
+ }
+
public function testNullSentinelRoundTripsThroughSwooleStore(): void
{
$store = $this->createStore();
@@ -352,11 +393,16 @@ public function testIncrementAndDecrementOperationsPreserveExpiration(): void
$this->assertSame((float) $expiration, $row['expiration']);
}
- public function testIncrementCreatesMissingCounter(): void
+ public function testIncrementCreatesPermanentMissingCounter(): void
{
+ CarbonImmutable::setTestNow('2000-01-01 00:00:00');
+
$store = $this->createStore();
$this->assertSame(1, $store->increment('counter'));
+
+ CarbonImmutable::setTestNow('2002-01-01 00:00:00');
+
$this->assertSame(1, $store->get('counter'));
}
@@ -377,24 +423,30 @@ public function testIncrementDoesNotWakeObjectPayloadUnderRowLock(): void
}
}
- public function testForeverStoresValueInTable(): void
+ public function testForeverStoresValueIndefinitely(): void
{
+ CarbonImmutable::setTestNow('2000-01-01 00:00:00');
+
$store = $this->createStore();
$store->forever('foo', 'bar');
+ CarbonImmutable::setTestNow('2002-01-01 00:00:00');
+
$this->assertEquals('bar', $store->get('foo'));
}
public function testIntervalsCanBeRefreshed(): void
{
+ CarbonImmutable::setTestNow('2000-01-01 00:00:00');
+
$store = $this->createStore();
$store->interval('foo', fn () => Str::random(10), 1);
$this->assertTrue(is_string($first = $store->get('foo')));
- CarbonImmutable::setTestNow(now()->addMinutes(1));
+ CarbonImmutable::setTestNow('2002-01-01 00:00:00');
$store->refreshIntervalCaches();
@@ -862,12 +914,21 @@ public function testRefreshExtendsLiveOwnedLock(): void
$this->assertSame(20.0, $lock->getRemainingLifetime());
}
- public function testRefreshPermanentLockWithoutExplicitTtlIsNoOp(): void
+ public function testPermanentLockSurvivesAndRefreshVerifiesOwnership(): void
{
+ CarbonImmutable::setTestNow('2000-01-01 00:00:00');
+
$lock = $this->createStore()->lock('foo', 0);
$this->assertTrue($lock->acquire());
+
+ CarbonImmutable::setTestNow('2002-01-01 00:00:00');
+
$this->assertTrue($lock->refresh());
+
+ $lock->forceRelease();
+
+ $this->assertFalse($lock->refresh());
}
public function testRefreshRejectsExplicitNonPositiveTtl(): void
diff --git a/tests/Cache/ConcurrencyLimiterTest.php b/tests/Cache/ConcurrencyLimiterTest.php
index 407e8c16b..cd76b651b 100644
--- a/tests/Cache/ConcurrencyLimiterTest.php
+++ b/tests/Cache/ConcurrencyLimiterTest.php
@@ -10,13 +10,17 @@
use Error;
use Hypervel\Cache\ArrayStore;
use Hypervel\Cache\Limiters\ConcurrencyLimiter;
+use Hypervel\Cache\Lock;
use Hypervel\Cache\Repository;
+use Hypervel\Contracts\Cache\Lock as LockContract;
use Hypervel\Contracts\Cache\LockProvider;
use Hypervel\Contracts\Cache\Store;
use Hypervel\Contracts\Limiters\Lease;
use Hypervel\Contracts\Limiters\LimiterTimeoutException;
use Hypervel\Contracts\Limiters\RefreshableLease;
use Hypervel\Tests\TestCase;
+use Mockery as m;
+use RuntimeException;
use Throwable;
class ConcurrencyLimiterTest extends TestCase
@@ -162,6 +166,33 @@ public function testItReleasesIfErrorIsThrown(): void
$this->assertEquals([1], $store);
}
+ public function testBlockPreservesCallbackFailureWhenReleaseAlsoFails(): void
+ {
+ $lock = null;
+ $store = $this->failingReleaseStore($lock);
+ $limiter = new ConcurrencyLimiter($store, 'key', 1, 5);
+
+ try {
+ $limiter->block(0, fn () => throw new RuntimeException('callback failure'));
+
+ $this->fail('Expected the callback failure to be thrown.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('callback failure', $exception->getMessage());
+ }
+
+ $this->assertTrue($lock?->released);
+ }
+
+ public function testBlockPropagatesReleaseFailureAfterSuccessfulCallback(): void
+ {
+ $limiter = new ConcurrencyLimiter($this->failingReleaseStore(), 'key', 1, 5);
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('release failure');
+
+ $limiter->block(0, fn () => 'result');
+ }
+
public function testFunnelMethodOnRepository(): void
{
$store = [];
@@ -364,6 +395,39 @@ function () use (&$failureCalled) {
$this->assertFalse($failureCalled);
}
+ public function testFunnelPreservesCallbackFailureWhenReleaseAlsoFails(): void
+ {
+ $lock = null;
+ $store = $this->failingReleaseStore($lock);
+ $repository = new Repository($store);
+
+ try {
+ $repository->funnel('key')
+ ->limit(1)
+ ->block(0)
+ ->then(fn () => throw new RuntimeException('callback failure'));
+
+ $this->fail('Expected the callback failure to be thrown.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame('callback failure', $exception->getMessage());
+ }
+
+ $this->assertTrue($lock?->released);
+ }
+
+ public function testFunnelPropagatesReleaseFailureAfterSuccessfulCallback(): void
+ {
+ $repository = new Repository($this->failingReleaseStore());
+
+ $this->expectException(RuntimeException::class);
+ $this->expectExceptionMessage('release failure');
+
+ $repository->funnel('key')
+ ->limit(1)
+ ->block(0)
+ ->then(fn () => 'result');
+ }
+
public function testFunnelWithZeroLimitDoesNotRunCallback(): void
{
$called = false;
@@ -441,6 +505,20 @@ public function testReleaseAfterAcceptsDateTime(): void
$this->assertEquals([1], $store);
$this->assertSame('ok', $result);
}
+
+ /**
+ * Create a lock-capable store whose leases fail during release.
+ */
+ protected function failingReleaseStore(?LimiterFailingReleaseLock &$lock = null): Store&LockProvider
+ {
+ $store = m::mock(Store::class, LockProvider::class);
+ $store->shouldReceive('lock')
+ ->andReturnUsing(function (string $name, int $seconds, ?string $owner) use (&$lock): LockContract {
+ return $lock = new LimiterFailingReleaseLock($name, $seconds, $owner);
+ });
+
+ return $store;
+ }
}
class ConcurrencyLimiterMockThatDoesntRelease extends ConcurrencyLimiter
@@ -457,6 +535,32 @@ public function block(int $timeout, ?callable $callback = null, int $sleep = 250
}
}
+class LimiterFailingReleaseLock extends Lock
+{
+ public bool $released = false;
+
+ public function acquire(): bool
+ {
+ return true;
+ }
+
+ public function release(): bool
+ {
+ $this->released = true;
+
+ throw new RuntimeException('release failure');
+ }
+
+ public function forceRelease(): void
+ {
+ }
+
+ protected function getCurrentOwner(): ?string
+ {
+ return $this->owner;
+ }
+}
+
enum ConcurrencyLimiterBackedEnum: string
{
case TestFunnel = 'test-funnel';
diff --git a/tests/Cache/CreateSwooleTimersTest.php b/tests/Cache/CreateSwooleTimersTest.php
index 2c6377171..53067107a 100644
--- a/tests/Cache/CreateSwooleTimersTest.php
+++ b/tests/Cache/CreateSwooleTimersTest.php
@@ -13,14 +13,16 @@
use Hypervel\Core\Events\AfterWorkerStart;
use Hypervel\Tests\TestCase;
use Mockery as m;
+use RuntimeException;
use Swoole\Server as SwooleServer;
+use Throwable;
class CreateSwooleTimersTest extends TestCase
{
public function testRegistersEvictionAndIntervalRefreshTimersForEachSwooleStoreOnWorkerZero(): void
{
$config = m::mock(ConfigRepository::class);
- $config->shouldReceive('array')->once()->with('cache.stores', [])->andReturn([
+ $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([
'fast' => [
'driver' => 'swoole',
'eviction_interval' => 25000,
@@ -67,7 +69,7 @@ public function testDoesNotRegisterTimersOnTaskWorkers(): void
public function testTimerCallbacksCallTheConfiguredStore(): void
{
$config = m::mock(ConfigRepository::class);
- $config->shouldReceive('array')->once()->with('cache.stores', [])->andReturn([
+ $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([
'fast' => [
'driver' => 'swoole',
],
@@ -95,6 +97,94 @@ public function testTimerCallbacksCallTheConfiguredStore(): void
$timer->ticks[1]['callback']();
}
+ public function testRollsBackEvictionTimerWhenIntervalTimerRegistrationFails(): void
+ {
+ $config = m::mock(ConfigRepository::class);
+ $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([
+ 'fast' => [
+ 'driver' => 'swoole',
+ ],
+ ]);
+
+ $container = m::mock(Container::class);
+ $container->shouldReceive('make')->once()->with('config')->andReturn($config);
+
+ $timer = new FakeSwooleTimer([41, false]);
+
+ try {
+ (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0));
+
+ $this->fail('Expected timer registration to fail.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame(
+ 'Unable to register the Swoole cache interval refresh timer for store [fast].',
+ $exception->getMessage(),
+ );
+ }
+
+ $this->assertSame([41], $timer->cleared);
+ }
+
+ public function testRollsBackEveryEarlierTimerWhenLaterStoreRegistrationFails(): void
+ {
+ $config = m::mock(ConfigRepository::class);
+ $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([
+ 'first' => [
+ 'driver' => 'swoole',
+ ],
+ 'second' => [
+ 'driver' => 'swoole',
+ ],
+ ]);
+
+ $container = m::mock(Container::class);
+ $container->shouldReceive('make')->once()->with('config')->andReturn($config);
+
+ $timer = new FakeSwooleTimer([11, 12, 13, false]);
+
+ try {
+ (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0));
+
+ $this->fail('Expected timer registration to fail.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame(
+ 'Unable to register the Swoole cache interval refresh timer for store [second].',
+ $exception->getMessage(),
+ );
+ }
+
+ $this->assertSame([13, 12, 11], $timer->cleared);
+ }
+
+ public function testPreservesThrownRegistrationFailureWhileAttemptingEveryRollback(): void
+ {
+ $config = m::mock(ConfigRepository::class);
+ $config->shouldReceive('array')->once()->with('cache.stores')->andReturn([
+ 'first' => [
+ 'driver' => 'swoole',
+ ],
+ 'second' => [
+ 'driver' => 'swoole',
+ ],
+ ]);
+
+ $container = m::mock(Container::class);
+ $container->shouldReceive('make')->once()->with('config')->andReturn($config);
+
+ $failure = new RuntimeException('Timer registration failed.');
+ $timer = new FakeSwooleTimer([11, 12, $failure], [12]);
+
+ try {
+ (new CreateSwooleTimers($container, $timer))->handle($this->workerEvent(workerId: 0));
+
+ $this->fail('Expected timer registration to fail.');
+ } catch (RuntimeException $exception) {
+ $this->assertSame($failure, $exception);
+ }
+
+ $this->assertSame([12, 11], $timer->cleared);
+ }
+
private function workerEvent(int $workerId, bool $taskworker = false): AfterWorkerStart
{
$server = m::mock(SwooleServer::class);
@@ -111,10 +201,46 @@ class FakeSwooleTimer extends SwooleTimer
*/
public array $ticks = [];
+ /**
+ * @var list
+ */
+ public array $cleared = [];
+
+ /**
+ * @param list $results
+ * @param list $clearFailures
+ */
+ public function __construct(
+ protected array $results = [],
+ protected array $clearFailures = [],
+ ) {
+ }
+
public function tick(int $milliseconds, Closure $callback): int|false
{
$this->ticks[] = compact('milliseconds', 'callback');
+ if ($this->results !== []) {
+ $result = array_shift($this->results);
+
+ if ($result instanceof Throwable) {
+ throw $result;
+ }
+
+ return $result;
+ }
+
return array_key_last($this->ticks);
}
+
+ public function clear(int $timerId): bool
+ {
+ $this->cleared[] = $timerId;
+
+ if (in_array($timerId, $this->clearFailures, true)) {
+ throw new RuntimeException("Unable to clear timer [{$timerId}].");
+ }
+
+ return true;
+ }
}
diff --git a/tests/Cache/Fixtures/ArrayFilesystem.php b/tests/Cache/Fixtures/ArrayFilesystem.php
new file mode 100644
index 000000000..94416e0c2
--- /dev/null
+++ b/tests/Cache/Fixtures/ArrayFilesystem.php
@@ -0,0 +1,167 @@
+files) || $this->files($path) !== [];
+ }
+
+ public function get(string $path): ?string
+ {
+ return $this->files[$path] ?? null;
+ }
+
+ public function readStream(string $path): mixed
+ {
+ return null;
+ }
+
+ public function readStreamRange(string $path, ?int $start, ?int $end): mixed
+ {
+ return null;
+ }
+
+ public function put(string $path, mixed $contents, mixed $options = []): bool|string
+ {
+ if ($this->putResult === false) {
+ return false;
+ }
+
+ $this->files[$path] = $contents;
+
+ return $this->putResult;
+ }
+
+ public function putFile(
+ string|File|UploadedFile $path,
+ array|string|File|UploadedFile|null $file = null,
+ mixed $options = [],
+ ): false|string {
+ return false;
+ }
+
+ public function putFileAs(
+ string|File|UploadedFile $path,
+ array|string|File|UploadedFile|null $file,
+ array|string|null $name = null,
+ mixed $options = [],
+ ): false|string {
+ return false;
+ }
+
+ public function writeStream(string $path, mixed $resource, array $options = []): bool
+ {
+ return false;
+ }
+
+ public function getVisibility(string $path): string
+ {
+ return Filesystem::VISIBILITY_PRIVATE;
+ }
+
+ public function setVisibility(string $path, string $visibility): bool
+ {
+ return true;
+ }
+
+ public function prepend(string $path, string $data): bool
+ {
+ return false;
+ }
+
+ public function append(string $path, string $data): bool
+ {
+ return false;
+ }
+
+ public function delete(array|string $paths): bool
+ {
+ if (! $this->deleteResult) {
+ return false;
+ }
+
+ foreach ((array) $paths as $path) {
+ unset($this->files[$path]);
+ }
+
+ return true;
+ }
+
+ public function copy(string $from, string $to): bool
+ {
+ return false;
+ }
+
+ public function move(string $from, string $to): bool
+ {
+ return false;
+ }
+
+ public function size(string $path): int
+ {
+ return strlen($this->files[$path] ?? '');
+ }
+
+ public function lastModified(string $path): int
+ {
+ return 0;
+ }
+
+ public function files(?string $directory = null, bool $recursive = false): array
+ {
+ $directory = trim((string) $directory, '/');
+
+ return array_values(array_filter(array_keys($this->files), function (string $path) use ($directory): bool {
+ return $directory === '' || str_starts_with($path, $directory . '/');
+ }));
+ }
+
+ public function allFiles(?string $directory = null): array
+ {
+ return $this->files($directory, true);
+ }
+
+ public function directories(?string $directory = null, bool $recursive = false): array
+ {
+ return [];
+ }
+
+ public function allDirectories(?string $directory = null): array
+ {
+ return [];
+ }
+
+ public function makeDirectory(string $path): bool
+ {
+ return true;
+ }
+
+ public function deleteDirectory(string $directory): bool
+ {
+ foreach ($this->allFiles($directory) as $path) {
+ unset($this->files[$path]);
+ }
+
+ return true;
+ }
+}
diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php
index aa23f6e3b..9704f198a 100644
--- a/tests/Cache/Redis/AllTaggedCacheTest.php
+++ b/tests/Cache/Redis/AllTaggedCacheTest.php
@@ -4,14 +4,22 @@
namespace Hypervel\Tests\Cache\Redis;
+use Hypervel\Cache\Events\CacheFlushed;
+use Hypervel\Cache\Events\CacheFlushing;
use Hypervel\Cache\Events\CacheHit;
use Hypervel\Cache\Events\CacheMissed;
+use Hypervel\Cache\Events\KeyWriteFailed;
use Hypervel\Cache\Events\KeyWritten;
+use Hypervel\Cache\Events\RetrievingKey;
use Hypervel\Cache\Events\RetrievingManyKeys;
+use Hypervel\Cache\Events\WritingKey;
+use Hypervel\Cache\Events\WritingManyKeys;
use Hypervel\Cache\NullSentinel;
use Hypervel\Cache\Redis\AllTaggedCache;
use Hypervel\Cache\Redis\AllTagSet;
+use Hypervel\Cache\Repository;
use Hypervel\Contracts\Events\Dispatcher;
+use Hypervel\Redis\PhpRedis;
use Mockery as m;
use RuntimeException;
@@ -315,11 +323,6 @@ public function testFlush(): void
return ['key1' => 0, 'key2' => 0];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:people:entries', 0, '*', 1000)
- ->andReturnNull();
-
// Delete cache entries
$connection->shouldReceive('del')
->once()
@@ -353,10 +356,6 @@ public function testClearFlushesTaggedItems(): void
return ['key1' => 0, 'key2' => 0];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:people:entries', 0, '*', 1000)
- ->andReturnNull();
$connection->shouldReceive('del')
->once()
->with('prefix:key1', 'prefix:key2')
@@ -629,7 +628,6 @@ public function testRememberReturnsExistingValueOnCacheHit(): void
$key = hash('xxh128', '_all:tag:users:entries') . ':profile';
- // Remember operation uses connection->get() directly
$connection->shouldReceive('get')
->once()
->with("prefix:{$key}")
@@ -1137,6 +1135,191 @@ public function testFlexibleNullableReturnsNullOnFreshSentinelHit(): void
$this->assertNull($result);
$this->assertFalse($invoked);
}
+
+ public function testPutDispatchesTheRepositoryWriteEventsWithStoreNameAndTags(): void
+ {
+ $connection = $this->mockConnection();
+ $key = hash('xxh128', '_all:tag:users:entries') . ':name';
+ $score = now()->timestamp + 60;
+
+ $connection->shouldReceive('pipeline')->once()->andReturn($connection);
+ $connection->shouldReceive('zadd')
+ ->once()
+ ->with('prefix:_all:tag:users:entries', $score, $key)
+ ->andReturn($connection);
+ $connection->shouldReceive('setex')
+ ->once()
+ ->with("prefix:{$key}", 60, serialize('John'))
+ ->andReturn($connection);
+ $connection->shouldReceive('exec')->once()->andReturn([1, true]);
+
+ $captured = [];
+ $tagged = (new Repository($this->createStore($connection), ['store' => 'redis']))->tags(['users']);
+ $tagged->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertTrue($tagged->put('name', 'John', 60));
+ $this->assertSame([WritingKey::class, KeyWritten::class], array_map(get_class(...), $captured));
+
+ foreach ($captured as $event) {
+ $this->assertSame('redis', $event->storeName);
+ $this->assertSame(['users'], $event->tags);
+ $this->assertSame('name', $event->key);
+ $this->assertSame('John', $event->value);
+ $this->assertSame(60, $event->seconds);
+ }
+ }
+
+ public function testAddWithoutTtlDispatchesRepositoryReadAndWriteEvents(): void
+ {
+ $connection = $this->mockConnection();
+ $key = hash('xxh128', '_all:tag:users:entries') . ':name';
+
+ $connection->shouldReceive('get')->once()->with("prefix:{$key}")->andReturnNull();
+ $connection->shouldReceive('pipeline')->once()->andReturn($connection);
+ $connection->shouldReceive('zadd')
+ ->once()
+ ->with('prefix:_all:tag:users:entries', -1, $key)
+ ->andReturn($connection);
+ $connection->shouldReceive('set')
+ ->once()
+ ->with("prefix:{$key}", serialize('John'))
+ ->andReturn($connection);
+ $connection->shouldReceive('exec')->once()->andReturn([1, true]);
+
+ $captured = [];
+ $tagged = (new Repository($this->createStore($connection), ['store' => 'redis']))->tags(['users']);
+ $tagged->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertTrue($tagged->add('name', 'John'));
+ $this->assertSame(
+ [RetrievingKey::class, CacheMissed::class, WritingKey::class, KeyWritten::class],
+ array_map(get_class(...), $captured)
+ );
+
+ foreach ($captured as $event) {
+ $this->assertSame('redis', $event->storeName);
+ $this->assertSame(['users'], $event->tags);
+ $this->assertSame('name', $event->key);
+ }
+ }
+
+ public function testPutDispatchesTheRepositoryFailureEventWithStoreNameAndTags(): void
+ {
+ $connection = $this->mockConnection();
+ $key = hash('xxh128', '_all:tag:users:entries') . ':name';
+ $score = now()->timestamp + 60;
+
+ $connection->shouldReceive('pipeline')->once()->andReturn($connection);
+ $connection->shouldReceive('zadd')
+ ->once()
+ ->with('prefix:_all:tag:users:entries', $score, $key)
+ ->andReturn($connection);
+ $connection->shouldReceive('setex')
+ ->once()
+ ->with("prefix:{$key}", 60, serialize('John'))
+ ->andReturn($connection);
+ $connection->shouldReceive('exec')->once()->andReturn([1, false]);
+
+ $captured = [];
+ $tagged = (new Repository($this->createStore($connection), ['store' => 'redis']))->tags(['users']);
+ $tagged->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertFalse($tagged->put('name', 'John', 60));
+ $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured));
+ $this->assertSame('redis', $captured[1]->storeName);
+ $this->assertSame(['users'], $captured[1]->tags);
+ }
+
+ public function testPutManyDispatchesTheRepositoryWriteEventsWithStoreNameAndTags(): void
+ {
+ $connection = $this->mockConnection();
+ $namespace = hash('xxh128', '_all:tag:users:entries') . ':';
+ $score = now()->timestamp + 60;
+
+ $connection->shouldReceive('pipeline')->once()->andReturn($connection);
+ $connection->shouldReceive('zadd')
+ ->once()
+ ->with(
+ 'prefix:_all:tag:users:entries',
+ $score,
+ $namespace . 'name',
+ $score,
+ $namespace . 'age'
+ )
+ ->andReturn($connection);
+ $connection->shouldReceive('setex')
+ ->once()
+ ->with("prefix:{$namespace}name", 60, serialize('John'))
+ ->andReturn($connection);
+ $connection->shouldReceive('setex')
+ ->once()
+ ->with("prefix:{$namespace}age", 60, 30)
+ ->andReturn($connection);
+ $connection->shouldReceive('exec')->once()->andReturn([2, true, true]);
+
+ $captured = [];
+ $tagged = (new Repository($this->createStore($connection), ['store' => 'redis']))->tags(['users']);
+ $tagged->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertTrue($tagged->putMany(['name' => 'John', 'age' => 30], 60));
+ $this->assertSame(
+ [WritingManyKeys::class, KeyWritten::class, KeyWritten::class],
+ array_map(get_class(...), $captured)
+ );
+ $this->assertSame(['name', 'age'], $captured[0]->keys);
+ $this->assertSame(['John', 30], $captured[0]->values);
+
+ foreach ($captured as $event) {
+ $this->assertSame('redis', $event->storeName);
+ $this->assertSame(['users'], $event->tags);
+ }
+ }
+
+ public function testFlushDispatchesTheRepositoryEventsWithStoreNameAndTags(): void
+ {
+ $connection = $this->mockConnection();
+ $connection->shouldReceive('zScan')
+ ->once()
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
+ ->andReturnUsing(function ($key, &$cursor) {
+ $cursor = 0;
+
+ return [];
+ });
+ $connection->shouldReceive('del')
+ ->once()
+ ->with('prefix:_all:tag:users:entries')
+ ->andReturn(1);
+
+ $captured = [];
+ $tagged = (new Repository($this->createStore($connection), ['store' => 'redis']))->tags(['users']);
+ $tagged->setEventDispatcher($this->capturingDispatcher($captured));
+
+ $this->assertTrue($tagged->flush());
+ $this->assertSame([CacheFlushing::class, CacheFlushed::class], array_map(get_class(...), $captured));
+
+ foreach ($captured as $event) {
+ $this->assertSame('redis', $event->storeName);
+ $this->assertSame(['users'], $event->tags);
+ }
+ }
+
+ /**
+ * Create an event dispatcher that records dispatched events.
+ *
+ * @param array $captured
+ */
+ private function capturingDispatcher(array &$captured): Dispatcher
+ {
+ $dispatcher = m::mock(Dispatcher::class);
+ $dispatcher->shouldReceive('hasListeners')->withAnyArgs()->andReturnTrue();
+ $dispatcher->shouldReceive('dispatch')
+ ->andReturnUsing(function (object $event) use (&$captured): void {
+ $captured[] = $event;
+ });
+
+ return $dispatcher;
+ }
}
enum AllTaggedCacheTestKey: int
diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php
index bf13994ed..6d1ec7d0d 100644
--- a/tests/Cache/Redis/AnyTaggedCacheTest.php
+++ b/tests/Cache/Redis/AnyTaggedCacheTest.php
@@ -4,19 +4,30 @@
namespace Hypervel\Tests\Cache\Redis;
+use __PHP_Incomplete_Class;
use BadMethodCallException;
use Generator;
use Hypervel\Cache\Events\CacheHit;
use Hypervel\Cache\Events\CacheMissed;
+use Hypervel\Cache\Events\ForgettingKey;
+use Hypervel\Cache\Events\KeyForgotten;
+use Hypervel\Cache\Events\KeyWriteFailed;
use Hypervel\Cache\Events\KeyWritten;
+use Hypervel\Cache\Events\RetrievingKey;
+use Hypervel\Cache\Events\WritingKey;
use Hypervel\Cache\NullSentinel;
use Hypervel\Cache\Redis\AnyTaggedCache;
use Hypervel\Cache\Redis\AnyTagSet;
+use Hypervel\Cache\Redis\Operations\AnyTag\Put;
+use Hypervel\Cache\Redis\Operations\AnyTagOperations;
+use Hypervel\Cache\RedisStore;
+use Hypervel\Cache\Repository;
use Hypervel\Cache\TaggedCache;
use Hypervel\Contracts\Events\Dispatcher;
use Hypervel\Redis\Exceptions\LuaScriptException;
use Mockery as m;
use RuntimeException;
+use stdClass;
/**
* Tests for AnyTaggedCache behavior.
@@ -259,12 +270,27 @@ public function testPutWithZeroTtlDeletesPlainKey(): void
->once()
->andReturn(1);
+ $captured = [];
+ $events = m::mock(Dispatcher::class);
+ $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnTrue();
+ $events->shouldReceive('dispatch')
+ ->andReturnUsing(function (object $event) use (&$captured): void {
+ $captured[] = $event;
+ });
+
$store = $this->createStore($connection);
- $cache = $store->setTagMode('any')->tags(['users', 'posts']);
+ $store->setTagMode('any');
+ $cache = (new Repository($store, ['store' => 'redis']))->tags(['users', 'posts']);
+ $cache->setEventDispatcher($events);
- $result = $cache->put('mykey', 'myvalue', 0);
+ $this->assertTrue($cache->put('mykey', 'myvalue', 0));
+ $this->assertSame([ForgettingKey::class, KeyForgotten::class], array_map(get_class(...), $captured));
- $this->assertTrue($result);
+ foreach ($captured as $event) {
+ $this->assertSame('redis', $event->storeName);
+ $this->assertSame('mykey', $event->key);
+ $this->assertSame(['users', 'posts'], $event->tags);
+ }
}
/**
@@ -407,25 +433,29 @@ public function testAddStoresValueIfNotExists(): void
->once()
->andReturn(true);
+ $events = m::mock(Dispatcher::class);
+ $events->shouldNotReceive('hasListeners');
+ $events->shouldNotReceive('dispatch');
+
$store = $this->createStore($connection);
- $result = $store->setTagMode('any')->tags(['users'])->add('mykey', 'myvalue', 60);
+ $store->setTagMode('any');
+ $cache = (new Repository($store, ['store' => 'redis']))->tags(['users']);
+ $cache->setEventDispatcher($events);
- $this->assertTrue($result);
+ $this->assertTrue($cache->add('mykey', 'myvalue', 60));
}
/**
* @test
*/
- public function testAddWithNullTtlDefaultsToOneYear(): void
+ public function testAddWithNullTtlStoresPermanently(): void
{
$connection = $this->mockConnection();
- // Add with null TTL defaults to 1 year (31536000 seconds)
$connection->shouldReceive('evalWithShaCache')
->once()
- ->withArgs(function ($script, $keys, $args) {
- // Check that TTL argument is ~1 year (args[1] is ttl)
- $this->assertSame(31536000, $args[1]);
+ ->withArgs(function ($script, $keys, $args): bool {
+ $this->assertSame(0, $args[1]);
return true;
})
@@ -598,7 +628,6 @@ public function testRememberRetrievesExistingValueFromStore(): void
{
$connection = $this->mockConnection();
- // The Remember operation calls $client->get() directly
$connection->shouldReceive('get')
->once()
->with('prefix:mykey')
@@ -610,6 +639,41 @@ public function testRememberRetrievesExistingValueFromStore(): void
$this->assertSame('cached_value', $result);
}
+ public function testRememberHandlesIncompleteClassBeforeDispatchingHitEvent(): void
+ {
+ $store = m::mock(RedisStore::class);
+ $store->shouldReceive('get')->once()->with('mykey')->andReturn(
+ unserialize(serialize(new stdClass), ['allowed_classes' => false])
+ );
+
+ $sequence = [];
+
+ Repository::handleUnserializableClassUsing(function (string $key, ?string $class) use (&$sequence): void {
+ $sequence[] = ['handler', $key, $class];
+ });
+
+ $events = m::mock(Dispatcher::class);
+ $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnTrue();
+ $events->shouldReceive('dispatch')
+ ->andReturnUsing(function (object $event) use (&$sequence): void {
+ $sequence[] = $event::class;
+ });
+
+ $cache = new AnyTaggedCache($store, new AnyTagSet($store, ['users']));
+ $cache->setEventDispatcher($events);
+
+ $result = $cache->remember('mykey', 60, function (): never {
+ $this->fail('The cache callback should not be called.');
+ });
+
+ $this->assertInstanceOf(__PHP_Incomplete_Class::class, $result);
+ $this->assertSame([
+ RetrievingKey::class,
+ ['handler', 'mykey', 'stdClass'],
+ CacheHit::class,
+ ], $sequence);
+ }
+
/**
* @test
*/
@@ -617,7 +681,6 @@ public function testRememberCallsCallbackAndStoresValueWhenMiss(): void
{
$connection = $this->mockConnection();
- // Client returns null (miss) - Remember operation uses client->get() directly
$connection->shouldReceive('get')
->once()
->with('prefix:mykey')
@@ -667,20 +730,65 @@ public function testRememberNormalizesEnumKeyForRedisAndEvents(): void
$captured[] = $event;
});
- $tagged = $this->createStore($connection)->setTagMode('any')->tags(['users']);
+ $store = $this->createStore($connection);
+ $store->setTagMode('any');
+ $tagged = (new Repository($store, ['store' => 'redis']))->tags(['users']);
$tagged->setEventDispatcher($events);
$result = $tagged->remember(AnyTaggedCacheTestKey::Profile, 60, fn () => 'computed_value');
$this->assertSame('computed_value', $result);
- $cacheMissed = array_values(array_filter($captured, fn (object $event) => $event instanceof CacheMissed))[0] ?? null;
- $keyWritten = array_values(array_filter($captured, fn (object $event) => $event instanceof KeyWritten))[0] ?? null;
+ $this->assertSame(
+ [RetrievingKey::class, CacheMissed::class, WritingKey::class, KeyWritten::class],
+ array_map(get_class(...), $captured)
+ );
- $this->assertNotNull($cacheMissed);
- $this->assertSame('0', $cacheMissed->key);
- $this->assertNotNull($keyWritten);
- $this->assertSame('0', $keyWritten->key);
+ foreach ($captured as $event) {
+ $this->assertSame('redis', $event->storeName);
+ $this->assertSame('0', $event->key);
+ $this->assertSame(['users'], $event->tags);
+ }
+ }
+
+ public function testPutDispatchesTheRepositoryFailureEvent(): void
+ {
+ $put = m::mock(Put::class);
+ $put->shouldReceive('execute')
+ ->once()
+ ->with('mykey', 'myvalue', 60, ['users'])
+ ->andReturnFalse();
+
+ $operations = m::mock(AnyTagOperations::class);
+ $operations->shouldReceive('put')->once()->andReturn($put);
+
+ $store = m::mock(RedisStore::class);
+ $store->shouldReceive('anyTagOps')->once()->andReturn($operations);
+
+ $tags = m::mock(AnyTagSet::class);
+ $tags->shouldReceive('getNames')->andReturn(['users']);
+
+ $cache = new AnyTaggedCache($store, $tags);
+ $store->shouldReceive('tags')->once()->with(['users'])->andReturn($cache);
+
+ $captured = [];
+ $events = m::mock(Dispatcher::class);
+ $events->shouldReceive('hasListeners')->withAnyArgs()->andReturnTrue();
+ $events->shouldReceive('dispatch')
+ ->andReturnUsing(function (object $event) use (&$captured): void {
+ $captured[] = $event;
+ });
+
+ $tagged = (new Repository($store, ['store' => 'redis']))->tags(['users']);
+ $tagged->setEventDispatcher($events);
+
+ $this->assertFalse($tagged->put('mykey', 'myvalue', 60));
+ $this->assertSame([WritingKey::class, KeyWriteFailed::class], array_map(get_class(...), $captured));
+
+ foreach ($captured as $event) {
+ $this->assertSame('redis', $event->storeName);
+ $this->assertSame(['users'], $event->tags);
+ }
}
public function testRememberNullableStoresAndReturnsNonNullValue(): void
@@ -828,7 +936,6 @@ public function testRememberForeverRetrievesExistingValueFromStore(): void
{
$connection = $this->mockConnection();
- // RememberForever operation uses $client->get() directly
$connection->shouldReceive('get')
->once()
->with('prefix:mykey')
@@ -877,7 +984,6 @@ public function testRememberForeverCallsCallbackAndStoresValueWhenMiss(): void
{
$connection = $this->mockConnection();
- // RememberForever operation uses $client->get() directly - returns null (miss)
$connection->shouldReceive('get')
->once()
->with('prefix:mykey')
diff --git a/tests/Cache/Redis/Console/DoctorCommandTest.php b/tests/Cache/Redis/Console/DoctorCommandTest.php
index 733416cd9..2e368c9c8 100644
--- a/tests/Cache/Redis/Console/DoctorCommandTest.php
+++ b/tests/Cache/Redis/Console/DoctorCommandTest.php
@@ -167,13 +167,13 @@ public function testDoctorDetectsRedisStoreFromConfig(): void
// Set up config with a redis store
$config = m::mock(ConfigRepository::class);
$config->shouldReceive('array')
- ->with('cache.stores', [])
+ ->with('cache.stores')
->andReturn([
'file' => ['driver' => 'file'],
'redis' => ['driver' => 'redis', 'connection' => 'default'],
]);
$config->shouldReceive('string')
- ->with('cache.default', 'file')
+ ->with('cache.default')
->andReturn('file');
$config->shouldReceive('get')
->with('cache.stores.redis.connection', 'default')
@@ -230,7 +230,7 @@ public function testDoctorUsesZeroNamedStore(): void
$config = m::mock(ConfigRepository::class);
$config->shouldReceive('string')
- ->with('cache.default', 'file')
+ ->with('cache.default')
->andReturn('file');
$config->shouldReceive('get')
->with('cache.stores.0.connection', 'default')
@@ -289,7 +289,7 @@ public function testDoctorRunsChecksWhileRedisConnectionIsBorrowed(): void
{
$config = m::mock(ConfigRepository::class);
$config->shouldReceive('string')
- ->with('cache.default', 'file')
+ ->with('cache.default')
->andReturn('redis');
$this->app->instance('config', $config);
@@ -376,7 +376,7 @@ public function testDoctorDisplaysTagMode(): void
{
$config = m::mock(ConfigRepository::class);
$config->shouldReceive('string')
- ->with('cache.default', 'file')
+ ->with('cache.default')
->andReturn('redis');
$config->shouldReceive('get')
->with('cache.stores.redis.connection', 'default')
@@ -437,13 +437,13 @@ public function testDoctorFailsWhenNoRedisStoreDetected(): void
// Set up config with NO redis stores
$config = m::mock(ConfigRepository::class);
$config->shouldReceive('array')
- ->with('cache.stores', [])
+ ->with('cache.stores')
->andReturn([
'file' => ['driver' => 'file'],
'array' => ['driver' => 'array'],
]);
$config->shouldReceive('string')
- ->with('cache.default', 'file')
+ ->with('cache.default')
->andReturn('file');
$this->app->instance('config', $config);
@@ -462,12 +462,12 @@ public function testDoctorDisplaysSystemInformation(): void
{
$config = m::mock(ConfigRepository::class);
$config->shouldReceive('array')
- ->with('cache.stores', [])
+ ->with('cache.stores')
->andReturn([
'redis' => ['driver' => 'redis', 'connection' => 'default'],
]);
$config->shouldReceive('string')
- ->with('cache.default', 'file')
+ ->with('cache.default')
->andReturn('redis');
$config->shouldReceive('get')
->with('cache.stores.redis.connection', 'default')
diff --git a/tests/Cache/Redis/Operations/AllTag/FlushTest.php b/tests/Cache/Redis/Operations/AllTag/FlushTest.php
index ddcb9e129..6d60e7388 100644
--- a/tests/Cache/Redis/Operations/AllTag/FlushTest.php
+++ b/tests/Cache/Redis/Operations/AllTag/FlushTest.php
@@ -6,6 +6,10 @@
use Hypervel\Cache\Redis\Operations\AllTag\Flush;
use Hypervel\Cache\Redis\Operations\AllTag\GetEntries;
+use Hypervel\Cache\RedisStore;
+use Hypervel\Contracts\Redis\Factory as RedisFactory;
+use Hypervel\Redis\PhpRedis;
+use Hypervel\Redis\RedisProxy;
use Hypervel\Support\LazyCollection;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
use Mockery as m;
@@ -160,6 +164,72 @@ public function testFlushChunksLargeEntrySets(): void
$operation->execute(['_all:tag:users:entries'], ['users']);
}
+ public function testFlushReleasesScanConnectionsBeforeCheckingOutEachDeletionChunk(): void
+ {
+ $connection = $this->mockConnection();
+ $entries = [];
+
+ for ($i = 1; $i <= 1001; ++$i) {
+ $entries["key{$i}"] = 1;
+ }
+
+ $connection->shouldReceive('zScan')
+ ->once()
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
+ ->andReturnUsing(function ($key, &$cursor) use ($entries) {
+ $cursor = 0;
+
+ return $entries;
+ });
+
+ $firstChunk = [];
+ for ($i = 1; $i <= 1000; ++$i) {
+ $firstChunk[] = "prefix:key{$i}";
+ }
+
+ $connection->shouldReceive('del')
+ ->once()
+ ->with(...$firstChunk)
+ ->andReturn(1000);
+ $connection->shouldReceive('del')
+ ->once()
+ ->with('prefix:key1001')
+ ->andReturn(1);
+ $connection->shouldReceive('del')
+ ->once()
+ ->with('prefix:_all:tag:users:entries')
+ ->andReturn(1);
+
+ $active = false;
+ $proxy = m::mock(RedisProxy::class);
+ $proxy->shouldReceive('isCluster')->once()->andReturnFalse();
+ $proxy->shouldReceive('withConnection')
+ ->times(4)
+ ->with(m::type('callable'), false)
+ ->andReturnUsing(function (callable $callback) use ($connection, &$active) {
+ $this->assertFalse($active);
+ $active = true;
+
+ try {
+ return $callback($connection);
+ } finally {
+ $active = false;
+ }
+ });
+
+ $redis = m::mock(RedisFactory::class);
+ $redis->shouldReceive('connection')
+ ->times(5)
+ ->with('default')
+ ->andReturn($proxy);
+
+ $store = new RedisStore($redis, 'prefix:', 'default');
+ $context = $store->getContext();
+ $operation = new Flush($context, new GetEntries($context));
+
+ $operation->execute(['_all:tag:users:entries'], ['users']);
+ }
+
/**
* @test
*/
diff --git a/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php b/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php
index 45006a67c..6851b8a50 100644
--- a/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php
+++ b/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php
@@ -5,6 +5,10 @@
namespace Hypervel\Tests\Cache\Redis\Operations\AllTag;
use Hypervel\Cache\Redis\Operations\AllTag\GetEntries;
+use Hypervel\Cache\RedisStore;
+use Hypervel\Contracts\Redis\Factory as RedisFactory;
+use Hypervel\Redis\PhpRedis;
+use Hypervel\Redis\RedisProxy;
use Hypervel\Support\LazyCollection;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
use Mockery as m;
@@ -22,16 +26,12 @@ public function testGetEntriesReturnsLazyCollection(): void
$connection = $this->mockConnection();
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return ['key1' => 1, 'key2' => 2];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:users:entries', 0, '*', 1000)
- ->andReturnNull();
$store = $this->createStore($connection);
$operation = new GetEntries($store->getContext());
@@ -50,16 +50,12 @@ public function testGetEntriesWithEmptyTagReturnsEmptyCollection(): void
$connection = $this->mockConnection();
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return [];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:users:entries', 0, '*', 1000)
- ->andReturnNull();
$store = $this->createStore($connection);
$operation = new GetEntries($store->getContext());
@@ -79,30 +75,22 @@ public function testGetEntriesWithMultipleTags(): void
// First tag
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return ['user_key1' => 1, 'user_key2' => 2];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:users:entries', 0, '*', 1000)
- ->andReturnNull();
// Second tag
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:posts:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:posts:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return ['post_key1' => 1];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:posts:entries', 0, '*', 1000)
- ->andReturnNull();
$store = $this->createStore($connection);
$operation = new GetEntries($store->getContext());
@@ -121,16 +109,12 @@ public function testGetEntriesDeduplicatesWithinTag(): void
$connection = $this->mockConnection();
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return ['key1' => 1, 'key2' => 2];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:users:entries', 0, '*', 1000)
- ->andReturnNull();
$store = $this->createStore($connection);
$operation = new GetEntries($store->getContext());
@@ -150,7 +134,7 @@ public function testGetEntriesHandlesNullScanResult(): void
// zScan returns null/false when done or empty
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnNull();
$store = $this->createStore($connection);
@@ -170,7 +154,7 @@ public function testGetEntriesHandlesFalseScanResult(): void
// zScan can return false in some cases
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturn(false);
$store = $this->createStore($connection);
@@ -206,16 +190,12 @@ public function testGetEntriesUsesCorrectPrefix(): void
$connection = $this->mockConnection();
$connection->shouldReceive('zScan')
->once()
- ->with('custom_prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('custom_prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return ['key1' => 1];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('custom_prefix:_all:tag:users:entries', 0, '*', 1000)
- ->andReturnNull();
$store = $this->createStore($connection, 'custom_prefix:');
$operation = new GetEntries($store->getContext());
@@ -235,7 +215,7 @@ public function testGetEntriesHandlesPaginatedResults(): void
// First page
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 123; // Non-zero cursor indicates more data
@@ -252,12 +232,6 @@ public function testGetEntriesHandlesPaginatedResults(): void
return ['key3' => 3];
});
- // Final call with cursor 0 returns null (phpredis behavior)
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:users:entries', 0, '*', 1000)
- ->andReturnNull();
-
$store = $this->createStore($connection);
$operation = new GetEntries($store->getContext());
@@ -266,6 +240,44 @@ public function testGetEntriesHandlesPaginatedResults(): void
$this->assertSame(['key1', 'key2', 'key3'], $entries->all());
}
+ public function testGetEntriesContinuesAfterAnEmptyNonterminalPageAndChecksOutPerPage(): void
+ {
+ $connection = $this->mockConnection();
+ $connection->shouldReceive('zScan')
+ ->once()
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
+ ->andReturnUsing(function ($key, &$cursor) {
+ $cursor = 123;
+
+ return [];
+ });
+ $connection->shouldReceive('zScan')
+ ->once()
+ ->with('prefix:_all:tag:users:entries', 123, '*', 1000)
+ ->andReturnUsing(function ($key, &$cursor) {
+ $cursor = 0;
+
+ return ['key1' => 1];
+ });
+
+ $proxy = m::mock(RedisProxy::class);
+ $proxy->shouldReceive('withConnection')
+ ->twice()
+ ->with(m::type('callable'), false)
+ ->andReturnUsing(fn (callable $callback) => $callback($connection));
+
+ $redis = m::mock(RedisFactory::class);
+ $redis->shouldReceive('connection')
+ ->twice()
+ ->with('default')
+ ->andReturn($proxy);
+
+ $store = new RedisStore($redis, 'prefix:', 'default');
+ $operation = new GetEntries($store->getContext());
+
+ $this->assertSame(['key1'], $operation->execute(['_all:tag:users:entries'])->all());
+ }
+
/**
* @test
*
@@ -281,30 +293,22 @@ public function testGetEntriesDoesNotDeduplicateAcrossTags(): void
// First tag has 'shared_key'
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:users:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return ['shared_key' => 1, 'user_only' => 2];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:users:entries', 0, '*', 1000)
- ->andReturnNull();
// Second tag also has 'shared_key'
$connection->shouldReceive('zScan')
->once()
- ->with('prefix:_all:tag:posts:entries', m::any(), '*', 1000)
+ ->with('prefix:_all:tag:posts:entries', PhpRedis::initialScanCursor(), '*', 1000)
->andReturnUsing(function ($key, &$cursor) {
$cursor = 0;
return ['shared_key' => 1, 'post_only' => 2];
});
- $connection->shouldReceive('zScan')
- ->once()
- ->with('prefix:_all:tag:posts:entries', 0, '*', 1000)
- ->andReturnNull();
$store = $this->createStore($connection);
$operation = new GetEntries($store->getContext());
diff --git a/tests/Cache/Redis/Operations/AllTag/PruneTest.php b/tests/Cache/Redis/Operations/AllTag/PruneTest.php
index 4f34c9566..a22db88be 100644
--- a/tests/Cache/Redis/Operations/AllTag/PruneTest.php
+++ b/tests/Cache/Redis/Operations/AllTag/PruneTest.php
@@ -316,6 +316,38 @@ public function testPruneRemovesOrphanedEntries(): void
$this->assertContains('key2', $zRemCalls[0]['members']);
}
+ public function testPruneContinuesAfterAnEmptyNonterminalMemberPage(): void
+ {
+ $fakeClient = new FakeRedisClient(
+ scanResults: [
+ ['keys' => ['_all:tag:users:entries'], 'iterator' => 0],
+ ],
+ zRemRangeByScoreResults: [
+ '_all:tag:users:entries' => 0,
+ ],
+ zScanResults: [
+ '_all:tag:users:entries' => [
+ ['members' => [], 'iterator' => 42],
+ ['members' => ['key1' => 1234567890.0], 'iterator' => 0],
+ ],
+ ],
+ execResults: [
+ [1],
+ ],
+ zCardResults: [
+ '_all:tag:users:entries' => 1,
+ ],
+ );
+
+ $store = $this->createStoreWithFakeClient($fakeClient);
+ $operation = new Prune($store->getContext());
+
+ $result = $operation->execute();
+
+ $this->assertSame(1, $result['entries_checked']);
+ $this->assertCount(2, $fakeClient->getZScanCalls());
+ }
+
/**
* @test
*/
diff --git a/tests/Cache/Redis/Operations/AllTag/RememberForeverTest.php b/tests/Cache/Redis/Operations/AllTag/RememberForeverTest.php
deleted file mode 100644
index 0da5303fd..000000000
--- a/tests/Cache/Redis/Operations/AllTag/RememberForeverTest.php
+++ /dev/null
@@ -1,344 +0,0 @@
-mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturn(serialize('cached_value'));
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 'new_value', ['tag1:entries']);
-
- $this->assertSame('cached_value', $value);
- $this->assertTrue($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverCallsCallbackOnCacheMissUsingPipeline(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturnNull();
-
- // Pipeline mode for non-cluster
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- // ZADD for each tag with score -1 (forever marker)
- $pipeline->shouldReceive('zadd')
- ->once()
- ->withArgs(function ($key, $score, $member) {
- $this->assertSame('prefix:tag1:entries', $key);
- $this->assertSame(self::FOREVER_SCORE, $score);
- $this->assertSame('ns:foo', $member);
-
- return true;
- });
-
- // SET (not SETEX) for forever items
- $pipeline->shouldReceive('set')
- ->once()
- ->withArgs(function ($key, $value) {
- $this->assertSame('prefix:ns:foo', $key);
- $this->assertSame(serialize('computed_value'), $value);
-
- return true;
- });
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, true]);
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', function () use (&$callCount) {
- ++$callCount;
-
- return 'computed_value';
- }, ['tag1:entries']);
-
- $this->assertSame('computed_value', $value);
- $this->assertFalse($wasHit);
- $this->assertSame(1, $callCount);
- }
-
- /**
- * @test
- */
- public function testRememberForeverDoesNotCallCallbackOnCacheHit(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturn(serialize('existing_value'));
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', function () use (&$callCount) {
- ++$callCount;
-
- return 'new_value';
- }, ['tag1:entries']);
-
- $this->assertSame('existing_value', $value);
- $this->assertTrue($wasHit);
- $this->assertSame(0, $callCount, 'Callback should not be called on cache hit');
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithMultipleTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- // Should ZADD to each tag's sorted set with score -1
- $pipeline->shouldReceive('zadd')
- ->times(3)
- ->withArgs(function ($key, $score, $member) {
- $this->assertSame(self::FOREVER_SCORE, $score);
-
- return true;
- })
- ->andReturn(1);
-
- $pipeline->shouldReceive('set')
- ->once()
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, 1, 1, true]);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute(
- 'ns:foo',
- fn () => 'value',
- ['tag1:entries', 'tag2:entries', 'tag3:entries']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverPropagatesExceptionFromCallback(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Callback failed');
-
- $redis = $this->createStore($connection);
- $redis->allTagOps()->rememberForever()->execute('ns:foo', function () {
- throw new RuntimeException('Callback failed');
- }, ['tag1:entries']);
- }
-
- /**
- * @test
- */
- public function testRememberForeverUsesSequentialCommandsInClusterMode(): void
- {
- $connection = $this->mockClusterConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturnNull();
-
- // In cluster mode, should use sequential zadd calls (not pipeline)
- $connection->shouldReceive('zadd')
- ->twice()
- ->withArgs(function ($key, $score, $member) {
- // Score may be float or int depending on implementation
- $this->assertEquals(self::FOREVER_SCORE, $score);
-
- return true;
- })
- ->andReturn(1);
-
- // SET without TTL
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:ns:foo', serialize('value'))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute(
- 'ns:foo',
- fn () => 'value',
- ['tag1:entries', 'tag2:entries']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithNumericValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- $pipeline->shouldReceive('zadd')
- ->once()
- ->andReturn(1);
-
- // Numeric values are NOT serialized
- $pipeline->shouldReceive('set')
- ->once()
- ->withArgs(function ($key, $value) {
- $this->assertSame(42, $value);
-
- return true;
- })
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, true]);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 42, ['tag1:entries']);
-
- $this->assertSame(42, $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithEmptyTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- // No ZADD calls when tags are empty
- $pipeline->shouldReceive('zadd')->never();
-
- $pipeline->shouldReceive('set')
- ->once()
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([true]);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 'bar', []);
-
- $this->assertSame('bar', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverUsesNegativeOneScoreForForeverMarker(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- // Verify score is -1 (the "forever" marker that prevents cleanup)
- $capturedScore = null;
- $pipeline->shouldReceive('zadd')
- ->once()
- ->withArgs(function ($key, $score, $member) use (&$capturedScore) {
- $capturedScore = $score;
-
- return true;
- })
- ->andReturn(1);
-
- $pipeline->shouldReceive('set')
- ->once()
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, true]);
-
- $redis = $this->createStore($connection);
- $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 'bar', ['tag1:entries']);
-
- $this->assertSame(-1, $capturedScore, 'Forever items should use score -1');
- }
-}
diff --git a/tests/Cache/Redis/Operations/AllTag/RememberTest.php b/tests/Cache/Redis/Operations/AllTag/RememberTest.php
deleted file mode 100644
index 011a48394..000000000
--- a/tests/Cache/Redis/Operations/AllTag/RememberTest.php
+++ /dev/null
@@ -1,328 +0,0 @@
-mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturn(serialize('cached_value'));
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, fn () => 'new_value', ['tag1:entries']);
-
- $this->assertSame('cached_value', $value);
- $this->assertTrue($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberCallsCallbackOnCacheMissUsingPipeline(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturnNull();
-
- // Pipeline mode for non-cluster
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- // ZADD for each tag
- $pipeline->shouldReceive('zadd')
- ->once()
- ->withArgs(function ($key, $score, $member) {
- $this->assertSame('prefix:tag1:entries', $key);
- $this->assertIsInt($score);
- $this->assertSame('ns:foo', $member);
-
- return true;
- });
-
- // SETEX for the value
- $pipeline->shouldReceive('setex')
- ->once()
- ->withArgs(function ($key, $ttl, $value) {
- $this->assertSame('prefix:ns:foo', $key);
- $this->assertSame(60, $ttl);
- $this->assertSame(serialize('computed_value'), $value);
-
- return true;
- });
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, true]);
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, function () use (&$callCount) {
- ++$callCount;
-
- return 'computed_value';
- }, ['tag1:entries']);
-
- $this->assertSame('computed_value', $value);
- $this->assertFalse($wasHit);
- $this->assertSame(1, $callCount);
- }
-
- /**
- * @test
- */
- public function testRememberDoesNotCallCallbackOnCacheHit(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturn(serialize('existing_value'));
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, function () use (&$callCount) {
- ++$callCount;
-
- return 'new_value';
- }, ['tag1:entries']);
-
- $this->assertSame('existing_value', $value);
- $this->assertTrue($wasHit);
- $this->assertSame(0, $callCount, 'Callback should not be called on cache hit');
- }
-
- /**
- * @test
- */
- public function testRememberWithMultipleTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- // Should ZADD to each tag's sorted set
- $pipeline->shouldReceive('zadd')
- ->times(3)
- ->andReturn(1);
-
- $pipeline->shouldReceive('setex')
- ->once()
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, 1, 1, true]);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->remember()->execute(
- 'ns:foo',
- 60,
- fn () => 'value',
- ['tag1:entries', 'tag2:entries', 'tag3:entries']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberPropagatesExceptionFromCallback(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Callback failed');
-
- $redis = $this->createStore($connection);
- $redis->allTagOps()->remember()->execute('ns:foo', 60, function () {
- throw new RuntimeException('Callback failed');
- }, ['tag1:entries']);
- }
-
- /**
- * @test
- */
- public function testRememberEnforcesMinimumTtlOfOne(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- $pipeline->shouldReceive('zadd')
- ->once()
- ->andReturn(1);
-
- // TTL should be at least 1
- $pipeline->shouldReceive('setex')
- ->once()
- ->withArgs(function ($key, $ttl, $value) {
- $this->assertSame(1, $ttl);
-
- return true;
- })
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, true]);
-
- $redis = $this->createStore($connection);
- $redis->allTagOps()->remember()->execute('ns:foo', 0, fn () => 'bar', ['tag1:entries']);
- }
-
- /**
- * @test
- */
- public function testRememberUsesSequentialCommandsInClusterMode(): void
- {
- $connection = $this->mockClusterConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:ns:foo')
- ->andReturnNull();
-
- // In cluster mode, should use sequential zadd calls (not pipeline)
- $connection->shouldReceive('zadd')
- ->twice()
- ->andReturn(1);
-
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:ns:foo', 60, serialize('value'))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->remember()->execute(
- 'ns:foo',
- 60,
- fn () => 'value',
- ['tag1:entries', 'tag2:entries']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberWithNumericValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- $pipeline->shouldReceive('zadd')
- ->once()
- ->andReturn(1);
-
- // Numeric values are NOT serialized
- $pipeline->shouldReceive('setex')
- ->once()
- ->withArgs(function ($key, $ttl, $value) {
- $this->assertSame(42, $value);
-
- return true;
- })
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([1, true]);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, fn () => 42, ['tag1:entries']);
-
- $this->assertSame(42, $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberWithEmptyTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $pipeline = m::mock();
- $connection->shouldReceive('pipeline')
- ->once()
- ->andReturn($pipeline);
-
- // No ZADD calls when tags are empty
- $pipeline->shouldReceive('zadd')->never();
-
- $pipeline->shouldReceive('setex')
- ->once()
- ->andReturn(true);
-
- $pipeline->shouldReceive('exec')
- ->once()
- ->andReturn([true]);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, fn () => 'bar', []);
-
- $this->assertSame('bar', $value);
- $this->assertFalse($wasHit);
- }
-}
diff --git a/tests/Cache/Redis/Operations/AllTagOperationsTest.php b/tests/Cache/Redis/Operations/AllTagOperationsTest.php
index 2a81918d2..70006829c 100644
--- a/tests/Cache/Redis/Operations/AllTagOperationsTest.php
+++ b/tests/Cache/Redis/Operations/AllTagOperationsTest.php
@@ -15,18 +15,11 @@
use Hypervel\Cache\Redis\Operations\AllTag\Prune;
use Hypervel\Cache\Redis\Operations\AllTag\Put;
use Hypervel\Cache\Redis\Operations\AllTag\PutMany;
-use Hypervel\Cache\Redis\Operations\AllTag\Remember;
-use Hypervel\Cache\Redis\Operations\AllTag\RememberForever;
+use Hypervel\Cache\Redis\Operations\AllTag\Touch;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
-/**
- * Tests for the AllTagOperations container class.
- */
class AllTagOperationsTest extends RedisCacheTestCase
{
- /**
- * @test
- */
public function testAllOperationAccessorsReturnCorrectTypes(): void
{
$connection = $this->mockConnection();
@@ -39,48 +32,21 @@ public function testAllOperationAccessorsReturnCorrectTypes(): void
$this->assertInstanceOf(Forever::class, $ops->forever());
$this->assertInstanceOf(Increment::class, $ops->increment());
$this->assertInstanceOf(Decrement::class, $ops->decrement());
+ $this->assertInstanceOf(Touch::class, $ops->touch());
$this->assertInstanceOf(AddEntry::class, $ops->addEntry());
$this->assertInstanceOf(GetEntries::class, $ops->getEntries());
$this->assertInstanceOf(FlushStale::class, $ops->flushStale());
$this->assertInstanceOf(Flush::class, $ops->flush());
$this->assertInstanceOf(Prune::class, $ops->prune());
- $this->assertInstanceOf(Remember::class, $ops->remember());
- $this->assertInstanceOf(RememberForever::class, $ops->rememberForever());
}
- /**
- * @test
- */
public function testOperationInstancesAreCached(): void
{
$connection = $this->mockConnection();
$store = $this->createStore($connection);
$ops = $store->allTagOps();
- // Same instance returned on repeated calls
$this->assertSame($ops->put(), $ops->put());
- $this->assertSame($ops->remember(), $ops->remember());
$this->assertSame($ops->getEntries(), $ops->getEntries());
}
-
- /**
- * @test
- */
- public function testClearResetsAllCachedInstances(): void
- {
- $connection = $this->mockConnection();
- $store = $this->createStore($connection);
- $ops = $store->allTagOps();
-
- // Get instances before clear
- $putBefore = $ops->put();
- $rememberBefore = $ops->remember();
-
- // Clear
- $ops->clear();
-
- // Instances should be new after clear
- $this->assertNotSame($putBefore, $ops->put());
- $this->assertNotSame($rememberBefore, $ops->remember());
- }
}
diff --git a/tests/Cache/Redis/Operations/AnyTag/AddTest.php b/tests/Cache/Redis/Operations/AnyTag/AddTest.php
index 258662cbb..80b4c80be 100644
--- a/tests/Cache/Redis/Operations/AnyTag/AddTest.php
+++ b/tests/Cache/Redis/Operations/AnyTag/AddTest.php
@@ -4,11 +4,9 @@
namespace Hypervel\Tests\Cache\Redis\Operations\AnyTag;
+use Hypervel\Cache\Redis\Support\StoreContext;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
-/**
- * Tests for the Add operation (union tags).
- */
class AddTest extends RedisCacheTestCase
{
/**
@@ -24,8 +22,9 @@ public function testAddWithTagsReturnsTrueWhenKeyAdded(): void
->withArgs(function ($script, $keys, $args) {
$this->assertStringContainsString('SET', $script);
$this->assertStringContainsString('NX', $script);
- $this->assertStringContainsString('HSETEX', $script);
+ $this->assertStringContainsString("redis.call('HSETEX', tagHash, 'EX', ttl, 'FIELDS', 1, rawKey, '1')", $script);
$this->assertCount(2, $keys);
+ $this->assertSame(60, $args[1]);
return true;
})
@@ -54,4 +53,50 @@ public function testAddWithTagsReturnsFalseWhenKeyExists(): void
$result = $redis->anyTagOps()->add()->execute('foo', 'bar', 60, ['users']);
$this->assertFalse($result);
}
+
+ public function testAddWithoutTtlIsPermanentInClusterMode(): void
+ {
+ [$redis, , $connection] = $this->createClusterStore(tagMode: 'any');
+
+ $connection->shouldReceive('set')
+ ->once()
+ ->with('prefix:foo', serialize('bar'), ['NX'])
+ ->andReturn(true);
+ $connection->shouldReceive('multi')->once()->andReturnSelf();
+ $connection->shouldReceive('sadd')->once()->with('prefix:foo:_any:tags', 'users')->andReturnSelf();
+ $connection->shouldNotReceive('expire');
+ $connection->shouldReceive('exec')->once()->andReturn([]);
+ $connection->shouldReceive('hSet')
+ ->once()
+ ->with('prefix:_any:tag:users:entries', 'foo', StoreContext::TAG_FIELD_VALUE)
+ ->andReturn(1);
+ $connection->shouldNotReceive('hsetex');
+ $connection->shouldReceive('zadd')
+ ->once()
+ ->with('prefix:_any:tag:registry', ['GT'], StoreContext::MAX_EXPIRY, 'users')
+ ->andReturn(1);
+
+ $this->assertTrue($redis->anyTagOps()->add()->execute('foo', 'bar', null, ['users']));
+ }
+
+ public function testAddWithoutTtlUsesPermanentLuaBranch(): void
+ {
+ $connection = $this->mockConnection();
+ $connection->shouldReceive('evalWithShaCache')
+ ->once()
+ ->withArgs(function (string $script, array $keys, array $args): bool {
+ $this->assertStringContainsString("redis.call('SET', key, val, 'NX')", $script);
+ $this->assertStringContainsString('if not permanent then', $script);
+ $this->assertStringContainsString((string) StoreContext::MAX_EXPIRY, $script);
+ $this->assertCount(2, $keys);
+ $this->assertSame(0, $args[1]);
+
+ return true;
+ })
+ ->andReturn(true);
+
+ $redis = $this->createStore($connection, tagMode: 'any');
+
+ $this->assertTrue($redis->anyTagOps()->add()->execute('foo', 'bar', null, ['users']));
+ }
}
diff --git a/tests/Cache/Redis/Operations/AnyTag/DecrementTest.php b/tests/Cache/Redis/Operations/AnyTag/DecrementTest.php
index 761fabd9d..631a88b19 100644
--- a/tests/Cache/Redis/Operations/AnyTag/DecrementTest.php
+++ b/tests/Cache/Redis/Operations/AnyTag/DecrementTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Cache\Redis\Operations\AnyTag;
+use Hypervel\Cache\Redis\Support\StoreContext;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
/**
@@ -23,6 +24,7 @@ public function testDecrementWithTagsReturnsNewValue(): void
->withArgs(function ($script, $keys, $args) {
$this->assertStringContainsString('DECRBY', $script);
$this->assertStringContainsString('TTL', $script);
+ $this->assertStringContainsString((string) StoreContext::MAX_EXPIRY, $script);
$this->assertCount(2, $keys);
return true;
diff --git a/tests/Cache/Redis/Operations/AnyTag/ForeverTest.php b/tests/Cache/Redis/Operations/AnyTag/ForeverTest.php
index e953ce6d4..77ad18858 100644
--- a/tests/Cache/Redis/Operations/AnyTag/ForeverTest.php
+++ b/tests/Cache/Redis/Operations/AnyTag/ForeverTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Cache\Redis\Operations\AnyTag;
+use Hypervel\Cache\Redis\Support\StoreContext;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
/**
@@ -24,7 +25,7 @@ public function testForeverWithTagsUsesLuaScript(): void
// Forever uses SET (no TTL), HSET (no expiration), ZADD with max expiry
$this->assertStringContainsString("redis.call('SET'", $script);
$this->assertStringContainsString('HSET', $script);
- $this->assertStringContainsString('253402300799', $script); // MAX_EXPIRY
+ $this->assertStringContainsString((string) StoreContext::MAX_EXPIRY, $script);
$this->assertCount(2, $keys);
return true;
diff --git a/tests/Cache/Redis/Operations/AnyTag/GetTaggedKeysTest.php b/tests/Cache/Redis/Operations/AnyTag/GetTaggedKeysTest.php
index 941bc31a4..5d41878fb 100644
--- a/tests/Cache/Redis/Operations/AnyTag/GetTaggedKeysTest.php
+++ b/tests/Cache/Redis/Operations/AnyTag/GetTaggedKeysTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Cache\Redis\Operations\AnyTag;
+use Hypervel\Redis\PhpRedis;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
use Hypervel\Tests\Redis\Fixtures\FakeRedisClient;
@@ -53,6 +54,7 @@ public function testGetTaggedKeysUsesHscanForLargeHashes(): void
$connection->shouldReceive('hscan')
->once()
->withArgs(function ($key, &$iterator, $pattern, $count) {
+ $this->assertSame(PhpRedis::initialScanCursor(), $iterator);
$iterator = 0; // Done after first iteration
return true;
})
@@ -126,4 +128,25 @@ public function testGetTaggedKeysHandlesMultipleHscanBatches(): void
// Verify all 3 HSCAN batches were called
$this->assertSame(3, $fakeClient->getHScanCallCount());
}
+
+ public function testGetTaggedKeysContinuesAfterAnEmptyNonterminalPage(): void
+ {
+ $tagKey = 'prefix:_any:tag:users:entries';
+ $fakeClient = new FakeRedisClient(
+ hLenResults: [$tagKey => 5000],
+ hScanResults: [
+ $tagKey => [
+ ['fields' => [], 'iterator' => 100],
+ ['fields' => ['key1' => '1'], 'iterator' => 0],
+ ],
+ ],
+ );
+
+ $store = $this->createStoreWithFakeClient($fakeClient, tagMode: 'any');
+
+ $keys = iterator_to_array($store->anyTagOps()->getTaggedKeys()->execute('users'));
+
+ $this->assertSame(['key1'], $keys);
+ $this->assertSame(2, $fakeClient->getHScanCallCount());
+ }
}
diff --git a/tests/Cache/Redis/Operations/AnyTag/IncrementTest.php b/tests/Cache/Redis/Operations/AnyTag/IncrementTest.php
index 5476aefbe..6a28437eb 100644
--- a/tests/Cache/Redis/Operations/AnyTag/IncrementTest.php
+++ b/tests/Cache/Redis/Operations/AnyTag/IncrementTest.php
@@ -4,6 +4,7 @@
namespace Hypervel\Tests\Cache\Redis\Operations\AnyTag;
+use Hypervel\Cache\Redis\Support\StoreContext;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
/**
@@ -24,6 +25,7 @@ public function testIncrementWithTagsReturnsNewValue(): void
->withArgs(function ($script, $keys, $args) {
$this->assertStringContainsString('INCRBY', $script);
$this->assertStringContainsString('TTL', $script);
+ $this->assertStringContainsString((string) StoreContext::MAX_EXPIRY, $script);
$this->assertCount(2, $keys);
return true;
diff --git a/tests/Cache/Redis/Operations/AnyTag/PruneTest.php b/tests/Cache/Redis/Operations/AnyTag/PruneTest.php
index 929035d6a..003f92a9e 100644
--- a/tests/Cache/Redis/Operations/AnyTag/PruneTest.php
+++ b/tests/Cache/Redis/Operations/AnyTag/PruneTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Cache\Redis\Operations\AnyTag;
use Hypervel\Cache\Redis\Operations\AnyTag\Prune;
+use Hypervel\Redis\PhpRedis;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
use Hypervel\Tests\Redis\Fixtures\FakeRedisClient;
use Mockery as m;
@@ -69,6 +70,7 @@ public function testPruneRemovesOrphanedFieldsFromTagHash(): void
$connection->shouldReceive('hScan')
->once()
->andReturnUsing(function ($tagHash, &$iterator, $match, $count) {
+ $this->assertSame(PhpRedis::initialScanCursor(), $iterator);
$iterator = 0;
return [
'key1' => '1',
@@ -440,6 +442,37 @@ public function testPruneHandlesHscanWithMultipleIterations(): void
$this->assertSame(2, $result['orphans_removed']); // key2 + key3
}
+ public function testPruneContinuesAfterAnEmptyNonterminalHashPage(): void
+ {
+ $registryKey = 'prefix:_any:tag:registry';
+ $tagHashKey = 'prefix:_any:tag:users:entries';
+ $fakeClient = new FakeRedisClient(
+ execResults: [
+ [1],
+ ],
+ hScanResults: [
+ $tagHashKey => [
+ ['fields' => [], 'iterator' => 100],
+ ['fields' => ['key1' => '1'], 'iterator' => 0],
+ ],
+ ],
+ zRangeResults: [
+ $registryKey => ['users'],
+ ],
+ hLenResults: [
+ $tagHashKey => 1,
+ ],
+ );
+
+ $store = $this->createStoreWithFakeClient($fakeClient, tagMode: 'any');
+ $operation = new Prune($store->getContext());
+
+ $result = $operation->execute();
+
+ $this->assertSame(1, $result['fields_checked']);
+ $this->assertSame(2, $fakeClient->getHScanCallCount());
+ }
+
/**
* @test
*/
diff --git a/tests/Cache/Redis/Operations/AnyTag/RememberForeverTest.php b/tests/Cache/Redis/Operations/AnyTag/RememberForeverTest.php
deleted file mode 100644
index 4100b217d..000000000
--- a/tests/Cache/Redis/Operations/AnyTag/RememberForeverTest.php
+++ /dev/null
@@ -1,451 +0,0 @@
-mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('cached_value'));
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'new_value', ['users']);
-
- $this->assertSame('cached_value', $value);
- $this->assertTrue($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverCallsCallbackOnCacheMissUsingLua(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturnNull();
-
- // Uses evalWithShaCache for Lua script
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->withArgs(function ($script, $keys, $args) {
- // Verify script uses SET (not SETEX) and HSET (not HSETEX)
- $this->assertStringContainsString("redis.call('SET'", $script);
- $this->assertStringContainsString("redis.call('HSET'", $script);
- $this->assertStringContainsString('ZADD', $script);
- // Should NOT contain SETEX or HSETEX for forever items
- // Note: The word "HEXPIRE" appears in comments but not as a redis.call
- $this->assertStringNotContainsString('SETEX', $script);
- $this->assertStringNotContainsString('HSETEX', $script);
- // Verify no redis.call('HEXPIRE' - the word may appear in comments but not as actual command
- $this->assertStringNotContainsString("redis.call('HEXPIRE", $script);
- $this->assertCount(2, $keys);
-
- return true;
- })
- ->andReturn(true);
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', function () use (&$callCount) {
- ++$callCount;
-
- return 'computed_value';
- }, ['users']);
-
- $this->assertSame('computed_value', $value);
- $this->assertFalse($wasHit);
- $this->assertSame(1, $callCount);
- }
-
- /**
- * @test
- */
- public function testRememberForeverUsesEvalWithShaCacheOnMiss(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // evalWithShaCache is called
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'value', ['users']);
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverDoesNotCallCallbackOnCacheHit(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('existing_value'));
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', function () use (&$callCount) {
- ++$callCount;
-
- return 'new_value';
- }, ['users']);
-
- $this->assertSame('existing_value', $value);
- $this->assertTrue($wasHit);
- $this->assertSame(0, $callCount, 'Callback should not be called on cache hit');
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithMultipleTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Verify multiple tags are passed in the Lua script args
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->withArgs(function ($script, $keys, $args) {
- // Tags are in the args array
- $this->assertContains('users', $args);
- $this->assertContains('posts', $args);
- $this->assertContains('comments', $args);
-
- return true;
- })
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute(
- 'foo',
- fn () => 'value',
- ['users', 'posts', 'comments']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverPropagatesExceptionFromCallback(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Callback failed');
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- $redis->anyTagOps()->rememberForever()->execute('foo', function () {
- throw new RuntimeException('Callback failed');
- }, ['users']);
- }
-
- /**
- * @test
- */
- public function testRememberForeverUsesSequentialCommandsInClusterMode(): void
- {
- $connection = $this->mockClusterConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturnNull();
-
- // In cluster mode, uses sequential commands instead of Lua
-
- // Get old tags from reverse index
- $connection->shouldReceive('smembers')
- ->once()
- ->andReturn([]);
-
- // SET without TTL (not SETEX)
- $connection->shouldReceive('set')
- ->once()
- ->andReturn(true);
-
- // Multi for reverse index update (no expire call for forever) - return same connection for chaining
- $connection->shouldReceive('multi')->andReturn($connection);
- $connection->shouldReceive('del')->andReturn($connection);
- $connection->shouldReceive('sadd')->andReturn($connection);
- // No expire() call for forever items
- $connection->shouldReceive('exec')->andReturn([1, 1]);
-
- // HSET for each tag (not HSETEX, no HEXPIRE)
- $connection->shouldReceive('hset')
- ->twice()
- ->andReturn(true);
-
- // ZADD for registry with MAX_EXPIRY
- $connection->shouldReceive('zadd')
- ->once()
- ->withArgs(function ($key, $options, ...$rest) {
- $this->assertSame(['GT'], $options);
- // First score should be MAX_EXPIRY (253402300799)
- $this->assertSame(253402300799, $rest[0]);
-
- return true;
- })
- ->andReturn(2);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute(
- 'foo',
- fn () => 'value',
- ['users', 'posts']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithNumericValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 42, ['users']);
-
- $this->assertSame(42, $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverHandlesFalseReturnFromGet(): void
- {
- $connection = $this->mockConnection();
-
- // Redis returns false for non-existent keys
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(false);
-
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'computed', ['users']);
-
- $this->assertSame('computed', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithEmptyTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // With empty tags, should still use Lua script but with no tags in args
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', []);
-
- $this->assertSame('bar', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverDoesNotSetExpirationOnReverseIndex(): void
- {
- $connection = $this->mockClusterConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('smembers')
- ->once()
- ->andReturn([]);
-
- $connection->shouldReceive('set')
- ->once()
- ->andReturn(true);
-
- // Multi for reverse index - should NOT have expire call
- // Return same connection for chaining (required for RedisCluster type constraints)
- $connection->shouldReceive('multi')->andReturn($connection);
- $connection->shouldReceive('del')->andReturn($connection);
- $connection->shouldReceive('sadd')->andReturn($connection);
- // Note: We can't easily test that expire is never called with this pattern
- // because the connection mock is reused. The absence of expire in the code is
- // verified by reading the implementation.
- $connection->shouldReceive('exec')->andReturn([1, 1]);
-
- $connection->shouldReceive('hset')
- ->once()
- ->andReturn(true);
-
- $connection->shouldReceive('zadd')
- ->once()
- ->andReturn(1);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', ['users']);
- }
-
- /**
- * @test
- */
- public function testRememberForeverUsesMaxExpiryForRegistry(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Verify Lua script contains MAX_EXPIRY constant
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->withArgs(function ($script, $keys, $args) {
- // MAX_EXPIRY = 253402300799 (Year 9999)
- $this->assertStringContainsString('253402300799', $script);
-
- return true;
- })
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', ['users']);
- }
-
- /**
- * @test
- */
- public function testRememberForeverRemovesItemFromOldTagsInClusterMode(): void
- {
- $connection = $this->mockClusterConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Return old tags that should be cleaned up
- $connection->shouldReceive('smembers')
- ->once()
- ->andReturn(['old_tag', 'users']);
-
- $connection->shouldReceive('set')
- ->once()
- ->andReturn(true);
-
- // Multi for reverse index - return same connection for chaining
- $connection->shouldReceive('multi')->andReturn($connection);
- $connection->shouldReceive('del')->andReturn($connection);
- $connection->shouldReceive('sadd')->andReturn($connection);
- $connection->shouldReceive('exec')->andReturn([1, 1]);
-
- // Should HDEL from old_tag since it's not in new tags
- $connection->shouldReceive('hdel')
- ->once()
- ->withArgs(function ($hashKey, $key) {
- $this->assertStringContainsString('old_tag', $hashKey);
- $this->assertSame('foo', $key);
-
- return true;
- })
- ->andReturn(1);
-
- // HSET only for new tag 'users'
- $connection->shouldReceive('hset')
- ->once()
- ->andReturn(true);
-
- $connection->shouldReceive('zadd')
- ->once()
- ->andReturn(1);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', ['users']);
- }
-}
diff --git a/tests/Cache/Redis/Operations/AnyTag/RememberTest.php b/tests/Cache/Redis/Operations/AnyTag/RememberTest.php
deleted file mode 100644
index 8b1800f9a..000000000
--- a/tests/Cache/Redis/Operations/AnyTag/RememberTest.php
+++ /dev/null
@@ -1,324 +0,0 @@
-mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('cached_value'));
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'new_value', ['users']);
-
- $this->assertSame('cached_value', $value);
- $this->assertTrue($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberCallsCallbackOnCacheMissUsingLua(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturnNull();
-
- // Uses evalWithShaCache for Lua script
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->withArgs(function ($script, $keys, $args) {
- // Verify script contains expected commands
- $this->assertStringContainsString('SETEX', $script);
- $this->assertStringContainsString('HSETEX', $script);
- $this->assertStringContainsString('ZADD', $script);
- $this->assertCount(2, $keys);
-
- return true;
- })
- ->andReturn(true);
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, function () use (&$callCount) {
- ++$callCount;
-
- return 'computed_value';
- }, ['users']);
-
- $this->assertSame('computed_value', $value);
- $this->assertFalse($wasHit);
- $this->assertSame(1, $callCount);
- }
-
- /**
- * @test
- */
- public function testRememberUsesEvalWithShaCacheOnMiss(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // evalWithShaCache is called
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'value', ['users']);
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberDoesNotCallCallbackOnCacheHit(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('existing_value'));
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, function () use (&$callCount) {
- ++$callCount;
-
- return 'new_value';
- }, ['users']);
-
- $this->assertSame('existing_value', $value);
- $this->assertTrue($wasHit);
- $this->assertSame(0, $callCount, 'Callback should not be called on cache hit');
- }
-
- /**
- * @test
- */
- public function testRememberWithMultipleTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Verify multiple tags are passed in the Lua script args
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->withArgs(function ($script, $keys, $args) {
- // Tags are in the args array
- $this->assertContains('users', $args);
- $this->assertContains('posts', $args);
- $this->assertContains('comments', $args);
-
- return true;
- })
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute(
- 'foo',
- 60,
- fn () => 'value',
- ['users', 'posts', 'comments']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberPropagatesExceptionFromCallback(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Callback failed');
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- $redis->anyTagOps()->remember()->execute('foo', 60, function () {
- throw new RuntimeException('Callback failed');
- }, ['users']);
- }
-
- /**
- * @test
- */
- public function testRememberUsesSequentialCommandsInClusterMode(): void
- {
- $connection = $this->mockClusterConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturnNull();
-
- // In cluster mode, uses sequential commands instead of Lua
-
- // Get old tags from reverse index
- $connection->shouldReceive('smembers')
- ->once()
- ->andReturn([]);
-
- // SETEX for the value
- $connection->shouldReceive('setex')
- ->once()
- ->andReturn(true);
-
- // Multi for reverse index update - return same connection for chaining
- $connection->shouldReceive('multi')->andReturn($connection);
- $connection->shouldReceive('del')->andReturn($connection);
- $connection->shouldReceive('sadd')->andReturn($connection);
- $connection->shouldReceive('expire')->andReturn($connection);
- $connection->shouldReceive('exec')->andReturn([1, 1, 1]);
-
- // HSETEX for each tag
- $connection->shouldReceive('hsetex')
- ->twice()
- ->andReturn(true);
-
- // ZADD for registry
- $connection->shouldReceive('zadd')
- ->once()
- ->andReturn(2);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute(
- 'foo',
- 60,
- fn () => 'value',
- ['users', 'posts']
- );
-
- $this->assertSame('value', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberWithNumericValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 42, ['users']);
-
- $this->assertSame(42, $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberHandlesFalseReturnFromGet(): void
- {
- $connection = $this->mockConnection();
-
- // Redis returns false for non-existent keys
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(false);
-
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'computed', ['users']);
-
- $this->assertSame('computed', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberWithEmptyTags(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // With empty tags, should still use Lua script but with no tags in args
- $connection->shouldReceive('evalWithShaCache')
- ->once()
- ->withArgs(function ($script, $keys, $args) {
- // When tags are empty, the tags portion of args should be at the end
- // The args structure is: [value, ttl, tagPrefix, registryKey, time, rawKey, tagHashSuffix, ...tags]
- // With no tags, $args[7...] should be empty
- // We just verify the script is called; the operation handles empty tags internally
- return true;
- })
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->setTagMode('any');
- [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'bar', []);
-
- $this->assertSame('bar', $value);
- $this->assertFalse($wasHit);
- }
-}
diff --git a/tests/Cache/Redis/Operations/AnyTagOperationsTest.php b/tests/Cache/Redis/Operations/AnyTagOperationsTest.php
index 30df040c4..8dff08a36 100644
--- a/tests/Cache/Redis/Operations/AnyTagOperationsTest.php
+++ b/tests/Cache/Redis/Operations/AnyTagOperationsTest.php
@@ -8,24 +8,18 @@
use Hypervel\Cache\Redis\Operations\AnyTag\Decrement;
use Hypervel\Cache\Redis\Operations\AnyTag\Flush;
use Hypervel\Cache\Redis\Operations\AnyTag\Forever;
+use Hypervel\Cache\Redis\Operations\AnyTag\Forget;
use Hypervel\Cache\Redis\Operations\AnyTag\GetTaggedKeys;
use Hypervel\Cache\Redis\Operations\AnyTag\GetTagItems;
use Hypervel\Cache\Redis\Operations\AnyTag\Increment;
use Hypervel\Cache\Redis\Operations\AnyTag\Prune;
use Hypervel\Cache\Redis\Operations\AnyTag\Put;
use Hypervel\Cache\Redis\Operations\AnyTag\PutMany;
-use Hypervel\Cache\Redis\Operations\AnyTag\Remember;
-use Hypervel\Cache\Redis\Operations\AnyTag\RememberForever;
+use Hypervel\Cache\Redis\Operations\AnyTag\Touch;
use Hypervel\Tests\Cache\Redis\RedisCacheTestCase;
-/**
- * Tests for the AnyTagOperations container class.
- */
class AnyTagOperationsTest extends RedisCacheTestCase
{
- /**
- * @test
- */
public function testAllOperationAccessorsReturnCorrectTypes(): void
{
$connection = $this->mockConnection();
@@ -38,47 +32,21 @@ public function testAllOperationAccessorsReturnCorrectTypes(): void
$this->assertInstanceOf(Forever::class, $ops->forever());
$this->assertInstanceOf(Increment::class, $ops->increment());
$this->assertInstanceOf(Decrement::class, $ops->decrement());
+ $this->assertInstanceOf(Touch::class, $ops->touch());
+ $this->assertInstanceOf(Forget::class, $ops->forget());
$this->assertInstanceOf(GetTaggedKeys::class, $ops->getTaggedKeys());
$this->assertInstanceOf(GetTagItems::class, $ops->getTagItems());
$this->assertInstanceOf(Flush::class, $ops->flush());
$this->assertInstanceOf(Prune::class, $ops->prune());
- $this->assertInstanceOf(Remember::class, $ops->remember());
- $this->assertInstanceOf(RememberForever::class, $ops->rememberForever());
}
- /**
- * @test
- */
public function testOperationInstancesAreCached(): void
{
$connection = $this->mockConnection();
$store = $this->createStore($connection);
$ops = $store->anyTagOps();
- // Same instance returned on repeated calls
$this->assertSame($ops->put(), $ops->put());
- $this->assertSame($ops->remember(), $ops->remember());
$this->assertSame($ops->getTaggedKeys(), $ops->getTaggedKeys());
}
-
- /**
- * @test
- */
- public function testClearResetsAllCachedInstances(): void
- {
- $connection = $this->mockConnection();
- $store = $this->createStore($connection);
- $ops = $store->anyTagOps();
-
- // Get instances before clear
- $putBefore = $ops->put();
- $rememberBefore = $ops->remember();
-
- // Clear
- $ops->clear();
-
- // Instances should be new after clear
- $this->assertNotSame($putBefore, $ops->put());
- $this->assertNotSame($rememberBefore, $ops->remember());
- }
}
diff --git a/tests/Cache/Redis/Operations/RememberForeverTest.php b/tests/Cache/Redis/Operations/RememberForeverTest.php
deleted file mode 100644
index cf98eb6c4..000000000
--- a/tests/Cache/Redis/Operations/RememberForeverTest.php
+++ /dev/null
@@ -1,255 +0,0 @@
-mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('cached_value'));
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', fn () => 'new_value');
-
- $this->assertSame('cached_value', $value);
- $this->assertTrue($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverCallsCallbackOnCacheMiss(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturnNull();
-
- // Uses SET without TTL (not SETEX)
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:foo', serialize('computed_value'))
- ->andReturn(true);
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', function () use (&$callCount) {
- ++$callCount;
-
- return 'computed_value';
- });
-
- $this->assertSame('computed_value', $value);
- $this->assertFalse($wasHit);
- $this->assertSame(1, $callCount);
- }
-
- /**
- * @test
- */
- public function testRememberForeverDoesNotCallCallbackOnCacheHit(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('existing_value'));
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', function () use (&$callCount) {
- ++$callCount;
-
- return 'new_value';
- });
-
- $this->assertSame('existing_value', $value);
- $this->assertTrue($wasHit);
- $this->assertSame(0, $callCount, 'Callback should not be called on cache hit');
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithNumericValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Numeric values are NOT serialized (optimization)
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:foo', 42)
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', fn () => 42);
-
- $this->assertSame(42, $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithArrayValue(): void
- {
- $connection = $this->mockConnection();
- $arrayValue = ['key' => 'value', 'nested' => ['a', 'b']];
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:foo', serialize($arrayValue))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', fn () => $arrayValue);
-
- $this->assertSame($arrayValue, $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverPropagatesExceptionFromCallback(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Callback failed');
-
- $redis = $this->createStore($connection);
- $redis->rememberForever('foo', function () {
- throw new RuntimeException('Callback failed');
- });
- }
-
- /**
- * @test
- */
- public function testRememberForeverHandlesFalseReturnFromGet(): void
- {
- $connection = $this->mockConnection();
-
- // Redis returns false for non-existent keys
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(false);
-
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:foo', serialize('computed'))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', fn () => 'computed');
-
- $this->assertSame('computed', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithEmptyStringValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:foo', serialize(''))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', fn () => '');
-
- $this->assertSame('', $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithZeroValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Zero is numeric, not serialized
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:foo', 0)
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', fn () => 0);
-
- $this->assertSame(0, $value);
- $this->assertFalse($wasHit);
- }
-
- /**
- * @test
- */
- public function testRememberForeverWithNullReturnedFromCallback(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('set')
- ->once()
- ->with('prefix:foo', serialize(null))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- [$value, $wasHit] = $redis->rememberForever('foo', fn () => null);
-
- $this->assertNull($value);
- $this->assertFalse($wasHit);
- }
-}
diff --git a/tests/Cache/Redis/Operations/RememberTest.php b/tests/Cache/Redis/Operations/RememberTest.php
deleted file mode 100644
index 2326091ef..000000000
--- a/tests/Cache/Redis/Operations/RememberTest.php
+++ /dev/null
@@ -1,267 +0,0 @@
-mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('cached_value'));
-
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, fn () => 'new_value');
-
- $this->assertSame('cached_value', $result);
- }
-
- /**
- * @test
- */
- public function testRememberCallsCallbackOnCacheMiss(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturnNull();
-
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 60, serialize('computed_value'))
- ->andReturn(true);
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, function () use (&$callCount) {
- ++$callCount;
-
- return 'computed_value';
- });
-
- $this->assertSame('computed_value', $result);
- $this->assertSame(1, $callCount);
- }
-
- /**
- * @test
- */
- public function testRememberDoesNotCallCallbackOnCacheHit(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(serialize('existing_value'));
-
- $callCount = 0;
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, function () use (&$callCount) {
- ++$callCount;
-
- return 'new_value';
- });
-
- $this->assertSame('existing_value', $result);
- $this->assertSame(0, $callCount, 'Callback should not be called on cache hit');
- }
-
- /**
- * @test
- */
- public function testRememberEnforcesMinimumTtlOfOne(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // TTL should be at least 1 even when 0 is passed
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 1, serialize('bar'))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $redis->remember('foo', 0, fn () => 'bar');
- }
-
- /**
- * @test
- */
- public function testRememberWithNumericValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Numeric values are NOT serialized (optimization)
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 60, 42)
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, fn () => 42);
-
- $this->assertSame(42, $result);
- }
-
- /**
- * @test
- */
- public function testRememberWithArrayValue(): void
- {
- $connection = $this->mockConnection();
- $value = ['key' => 'value', 'nested' => ['a', 'b']];
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 120, serialize($value))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 120, fn () => $value);
-
- $this->assertSame($value, $result);
- }
-
- /**
- * @test
- */
- public function testRememberWithObjectValue(): void
- {
- $connection = $this->mockConnection();
- $value = (object) ['name' => 'test'];
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 60, serialize($value))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, fn () => $value);
-
- $this->assertEquals($value, $result);
- }
-
- /**
- * @test
- */
- public function testRememberPropagatesExceptionFromCallback(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $this->expectException(RuntimeException::class);
- $this->expectExceptionMessage('Callback failed');
-
- $redis = $this->createStore($connection);
- $redis->remember('foo', 60, function () {
- throw new RuntimeException('Callback failed');
- });
- }
-
- /**
- * @test
- */
- public function testRememberHandlesFalseReturnFromGet(): void
- {
- $connection = $this->mockConnection();
-
- // Redis returns false for non-existent keys
- $connection->shouldReceive('get')
- ->once()
- ->with('prefix:foo')
- ->andReturn(false);
-
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 60, serialize('computed'))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, fn () => 'computed');
-
- $this->assertSame('computed', $result);
- }
-
- /**
- * @test
- */
- public function testRememberWithEmptyStringValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 60, serialize(''))
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, fn () => '');
-
- $this->assertSame('', $result);
- }
-
- /**
- * @test
- */
- public function testRememberWithZeroValue(): void
- {
- $connection = $this->mockConnection();
-
- $connection->shouldReceive('get')
- ->once()
- ->andReturnNull();
-
- // Zero is numeric, not serialized
- $connection->shouldReceive('setex')
- ->once()
- ->with('prefix:foo', 60, 0)
- ->andReturn(true);
-
- $redis = $this->createStore($connection);
- $result = $redis->remember('foo', 60, fn () => 0);
-
- $this->assertSame(0, $result);
- }
-}
diff --git a/tests/Cache/Redis/RedisCacheTestCase.php b/tests/Cache/Redis/RedisCacheTestCase.php
index 7f5d8a453..7d1bf5ea4 100644
--- a/tests/Cache/Redis/RedisCacheTestCase.php
+++ b/tests/Cache/Redis/RedisCacheTestCase.php
@@ -8,8 +8,6 @@
use Hypervel\Contracts\Redis\Factory as RedisFactory;
use Hypervel\Redis\PhpRedisClusterConnection;
use Hypervel\Redis\PhpRedisConnection;
-use Hypervel\Redis\Pool\PoolFactory;
-use Hypervel\Redis\Pool\RedisPool;
use Hypervel\Redis\RedisConnection;
use Hypervel\Redis\RedisProxy;
use Hypervel\Support\CarbonImmutable;
@@ -154,34 +152,12 @@ protected function mockClusterConnection(): m\MockInterface|PhpRedisClusterConne
}
/**
- * Create a PoolFactory mock that returns the given connection.
+ * Create a Redis factory mock for the given connection.
*/
- protected function createPoolFactory(
+ protected function createRedisFactory(
RedisConnection $connection,
string $connectionName = 'default'
- ): m\MockInterface|PoolFactory {
- $poolFactory = m::mock(PoolFactory::class);
- $pool = m::mock(RedisPool::class);
-
- $poolFactory->shouldReceive('getPool')
- ->with($connectionName)
- ->andReturn($pool);
-
- $pool->shouldReceive('get')->andReturn($connection);
-
- return $poolFactory;
- }
-
- /**
- * Register a RedisFactory mock in the container.
- *
- * This sets up the mock that StoreContext::withConnection() uses to get
- * connections via Container::getInstance().
- */
- protected function registerRedisFactoryMock(
- RedisConnection $connection,
- string $connectionName = 'default'
- ): void {
+ ): m\MockInterface|RedisFactory {
$redisProxy = m::mock(RedisProxy::class);
$redisProxy->shouldReceive('withConnection')
->andReturnUsing(fn (callable $callback) => $callback($connection));
@@ -194,7 +170,7 @@ protected function registerRedisFactoryMock(
->with($connectionName)
->andReturn($redisProxy);
- $this->instance(RedisFactory::class, $redisFactory);
+ return $redisFactory;
}
/**
@@ -211,14 +187,10 @@ protected function createStore(
string $connectionName = 'default',
?string $tagMode = null,
): RedisStore {
- // Register RedisFactory mock for StoreContext::withConnection()
- $this->registerRedisFactoryMock($connection, $connectionName);
-
$store = new RedisStore(
- m::mock(RedisFactory::class),
+ $this->createRedisFactory($connection, $connectionName),
$prefix,
- $connectionName,
- $this->createPoolFactory($connection, $connectionName)
+ $connectionName
);
if ($tagMode !== null) {
@@ -255,14 +227,10 @@ protected function createClusterStore(
$connection = $this->mockClusterConnection();
$clusterClient = $connection->_mockClient;
- // Register RedisFactory mock for StoreContext::withConnection()
- $this->registerRedisFactoryMock($connection, $connectionName);
-
$store = new RedisStore(
- m::mock(RedisFactory::class),
+ $this->createRedisFactory($connection, $connectionName),
$prefix,
- $connectionName,
- $this->createPoolFactory($connection, $connectionName)
+ $connectionName
);
if ($tagMode !== null) {
@@ -296,14 +264,10 @@ protected function createStoreWithFakeClient(
$connection = new PhpRedisConnectionStub;
$connection->setActiveConnection($fakeClient);
- // Register RedisFactory mock for StoreContext::withConnection()
- $this->registerRedisFactoryMock($connection, $connectionName);
-
$store = new RedisStore(
- m::mock(RedisFactory::class),
+ $this->createRedisFactory($connection, $connectionName),
$prefix,
- $connectionName,
- $this->createPoolFactory($connection, $connectionName)
+ $connectionName
);
if ($tagMode !== null) {
diff --git a/tests/Cache/Redis/RedisStoreTest.php b/tests/Cache/Redis/RedisStoreTest.php
index 68e17bbbe..0dd18e92d 100644
--- a/tests/Cache/Redis/RedisStoreTest.php
+++ b/tests/Cache/Redis/RedisStoreTest.php
@@ -9,7 +9,11 @@
use Hypervel\Cache\TagMode;
use Hypervel\Contracts\Redis\Factory as Redis;
use Hypervel\Redis\RedisProxy;
+use Hypervel\Support\ClassInvoker;
use Mockery as m;
+use ReflectionClass;
+use ReflectionNamedType;
+use ReflectionProperty;
/**
* Tests for RedisStore core functionality.
@@ -45,21 +49,27 @@ public function testSetConnectionClearsCachedInstances(): void
$connection2 = $this->mockConnection();
$connection2->shouldReceive('get')->once()->with('prefix:foo')->andReturn(serialize('value2'));
- // Register Redis mocks for both connections
- $this->registerRedisFactoryMock($connection1, 'conn1');
+ $redisProxy1 = m::mock(RedisProxy::class);
+ $redisProxy1->shouldReceive('withConnection')
+ ->once()
+ ->andReturnUsing(fn (callable $callback) => $callback($connection1));
- // Create store with first connection
- $redis = $this->createStore($connection1, connectionName: 'conn1');
+ $redisProxy2 = m::mock(RedisProxy::class);
+ $redisProxy2->shouldReceive('withConnection')
+ ->once()
+ ->andReturnUsing(fn (callable $callback) => $callback($connection2));
- $this->assertSame('value1', $redis->get('foo'));
+ $redisFactory = m::mock(Redis::class);
+ $redisFactory->shouldReceive('connection')->once()->with('conn1')->andReturn($redisProxy1);
+ $redisFactory->shouldReceive('connection')->once()->with('conn2')->andReturn($redisProxy2);
- // Register second connection mock (replaces the first in container)
- $this->registerRedisFactoryMock($connection2, 'conn2');
+ $redis = new RedisStore($redisFactory, 'prefix:', 'conn1');
- // Create second store with different connection
- $redis2 = $this->createStore($connection2, connectionName: 'conn2');
+ $this->assertSame('value1', $redis->get('foo'));
- $this->assertSame('value2', $redis2->get('foo'));
+ $redis->setConnection('conn2');
+
+ $this->assertSame('value2', $redis->get('foo'));
}
/**
@@ -83,6 +93,82 @@ public function testSetPrefixClearsCachedOperations(): void
$this->assertSame('new', $redis->get('foo'));
}
+ public function testBootConfigurationMutatorsClearEveryCachedInstance(): void
+ {
+ $cachedInstances = [
+ 'context' => 'getContext',
+ 'serialization' => 'getSerialization',
+ 'getOperation' => 'getGetOperation',
+ 'manyOperation' => 'getManyOperation',
+ 'putOperation' => 'getPutOperation',
+ 'putManyOperation' => 'getPutManyOperation',
+ 'addOperation' => 'getAddOperation',
+ 'foreverOperation' => 'getForeverOperation',
+ 'forgetOperation' => 'getForgetOperation',
+ 'touchOperation' => 'getTouchOperation',
+ 'incrementOperation' => 'getIncrementOperation',
+ 'decrementOperation' => 'getDecrementOperation',
+ 'flushOperation' => 'getFlushOperation',
+ 'anyTagOperations' => 'anyTagOps',
+ 'allTagOperations' => 'allTagOps',
+ ];
+
+ $cachedProperties = [];
+
+ foreach ((new ReflectionClass(RedisStore::class))->getProperties(ReflectionProperty::IS_PRIVATE) as $property) {
+ $type = $property->getType();
+
+ if ($type instanceof ReflectionNamedType
+ && $type->allowsNull()
+ && $property->hasDefaultValue()
+ && $property->getDefaultValue() === null) {
+ $cachedProperties[] = $property->getName();
+ }
+ }
+
+ $mappedProperties = array_keys($cachedInstances);
+ sort($cachedProperties);
+ sort($mappedProperties);
+
+ $this->assertSame(
+ $cachedProperties,
+ $mappedProperties,
+ 'A new RedisStore lazy cache needs both a warming accessor here and an invalidation reset.'
+ );
+
+ // setLockConnection() is deliberately excluded: lock connections are resolved
+ // per call and are not captured by any cached instance.
+ $mutators = [
+ 'setConnection' => static function (RedisStore $store): void {
+ $store->setConnection('other');
+ },
+ 'setPrefix' => static function (RedisStore $store): void {
+ $store->setPrefix('other:');
+ },
+ 'setTagMode' => static function (RedisStore $store): void {
+ $store->setTagMode(TagMode::Any);
+ },
+ ];
+
+ foreach ($mutators as $method => $mutator) {
+ $store = $this->createStore($this->mockConnection());
+ $invoker = new ClassInvoker($store);
+
+ foreach ($cachedInstances as $accessor) {
+ $invoker->{$accessor}();
+ }
+
+ $mutator($store);
+
+ foreach (array_keys($cachedInstances) as $property) {
+ $this->assertNull(
+ $invoker->{$property},
+ "{$method} did not clear the cached {$property} instance."
+ );
+ }
+ }
+ }
+
/**
* @test
*/
@@ -257,7 +343,6 @@ public function testForgetUsesAnyTagMetadataOperationInAnyMode(): void
*/
public function testLockReturnsRedisLockInstance(): void
{
- $connection = $this->mockConnection();
$redisProxy = m::mock(RedisProxy::class);
$redisFactory = m::mock(Redis::class);
$redisFactory->shouldReceive('connection')->with('default')->andReturn($redisProxy);
@@ -265,8 +350,7 @@ public function testLockReturnsRedisLockInstance(): void
$redis = new RedisStore(
$redisFactory,
'prefix:',
- 'default',
- $this->createPoolFactory($connection)
+ 'default'
);
$lock = $redis->lock('mylock', 10);
@@ -279,7 +363,6 @@ public function testLockReturnsRedisLockInstance(): void
*/
public function testLockWithOwner(): void
{
- $connection = $this->mockConnection();
$redisProxy = m::mock(RedisProxy::class);
$redisFactory = m::mock(Redis::class);
$redisFactory->shouldReceive('connection')->with('default')->andReturn($redisProxy);
@@ -287,8 +370,7 @@ public function testLockWithOwner(): void
$redis = new RedisStore(
$redisFactory,
'prefix:',
- 'default',
- $this->createPoolFactory($connection)
+ 'default'
);
$lock = $redis->lock('mylock', 10, 'custom-owner');
@@ -301,7 +383,6 @@ public function testLockWithOwner(): void
*/
public function testRestoreLockReturnsRedisLockInstance(): void
{
- $connection = $this->mockConnection();
$redisProxy = m::mock(RedisProxy::class);
$redisFactory = m::mock(Redis::class);
$redisFactory->shouldReceive('connection')->with('default')->andReturn($redisProxy);
@@ -309,8 +390,7 @@ public function testRestoreLockReturnsRedisLockInstance(): void
$redis = new RedisStore(
$redisFactory,
'prefix:',
- 'default',
- $this->createPoolFactory($connection)
+ 'default'
);
$lock = $redis->restoreLock('mylock', 'owner-123');
@@ -336,7 +416,6 @@ public function testSetLockConnectionReturnsSelf(): void
*/
public function testLockUsesLockConnectionWhenSet(): void
{
- $connection = $this->mockConnection();
$redisProxy = m::mock(RedisProxy::class);
$lockProxy = m::mock(RedisProxy::class);
$redisFactory = m::mock(Redis::class);
@@ -346,8 +425,7 @@ public function testLockUsesLockConnectionWhenSet(): void
$redis = new RedisStore(
$redisFactory,
'prefix:',
- 'default',
- $this->createPoolFactory($connection)
+ 'default'
);
$redis->setLockConnection('locks');
@@ -361,14 +439,12 @@ public function testLockUsesLockConnectionWhenSet(): void
*/
public function testGetRedisReturnsRedis(): void
{
- $connection = $this->mockConnection();
$redisFactory = m::mock(Redis::class);
$redis = new RedisStore(
$redisFactory,
'prefix:',
- 'default',
- $this->createPoolFactory($connection)
+ 'default'
);
$this->assertSame($redisFactory, $redis->getRedis());
@@ -379,7 +455,6 @@ public function testGetRedisReturnsRedis(): void
*/
public function testConnectionReturnsRedisProxy(): void
{
- $connection = $this->mockConnection();
$redisProxy = m::mock(RedisProxy::class);
$redisFactory = m::mock(Redis::class);
$redisFactory->shouldReceive('connection')->with('default')->andReturn($redisProxy);
@@ -387,8 +462,7 @@ public function testConnectionReturnsRedisProxy(): void
$redis = new RedisStore(
$redisFactory,
'prefix:',
- 'default',
- $this->createPoolFactory($connection)
+ 'default'
);
$this->assertSame($redisProxy, $redis->connection());
diff --git a/tests/Cache/Redis/Support/StoreContextTest.php b/tests/Cache/Redis/Support/StoreContextTest.php
index 810754f1e..b0e604048 100644
--- a/tests/Cache/Redis/Support/StoreContextTest.php
+++ b/tests/Cache/Redis/Support/StoreContextTest.php
@@ -9,7 +9,7 @@
use Hypervel\Contracts\Redis\Factory as RedisFactory;
use Hypervel\Redis\PhpRedisConnection;
use Hypervel\Redis\RedisProxy;
-use Hypervel\Testbench\TestCase;
+use Hypervel\Tests\TestCase;
use Mockery as m;
use Redis;
use RuntimeException;
@@ -199,11 +199,11 @@ private function createContext(
string $prefix = 'prefix:',
TagMode $tagMode = TagMode::Any
): StoreContext {
- return new StoreContext($connectionName, $prefix, $tagMode);
+ return new StoreContext(m::mock(RedisFactory::class), $connectionName, $prefix, $tagMode);
}
/**
- * Create a context with RedisFactory mocked in the container.
+ * Create a context with a mocked Redis factory.
*/
private function createContextWithRedisFactory(
string $expectedConnectionName,
@@ -226,9 +226,6 @@ private function createContextWithRedisFactory(
->with($expectedConnectionName)
->andReturn($redisProxy);
- // Register mock in the testbench container
- $this->instance(RedisFactory::class, $redisFactory);
-
- return new StoreContext($contextConnectionName, $prefix, TagMode::Any);
+ return new StoreContext($redisFactory, $contextConnectionName, $prefix, TagMode::Any);
}
}
diff --git a/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php b/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php
index a0a94045b..ed79b0b3c 100644
--- a/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php
+++ b/tests/Integration/Auth/EloquentUserProviderCacheTagsTest.php
@@ -41,6 +41,7 @@ class EloquentUserProviderCacheTagsTest extends TestCase
protected function defineEnvironment(ApplicationContract $app): void
{
+ $app->make('config')->set('cache.serializable_classes', [User::class]);
$app->make('config')->set('cache.stores.' . self::STORE_NAME, [
'driver' => 'redis',
'connection' => 'default',
diff --git a/tests/Integration/Cache/Redis/BasicOperationsIntegrationTest.php b/tests/Integration/Cache/Redis/BasicOperationsIntegrationTest.php
index 78194c3c8..762f7fb81 100644
--- a/tests/Integration/Cache/Redis/BasicOperationsIntegrationTest.php
+++ b/tests/Integration/Cache/Redis/BasicOperationsIntegrationTest.php
@@ -5,6 +5,7 @@
namespace Hypervel\Tests\Integration\Cache\Redis;
use Hypervel\Cache\TagMode;
+use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Support\Facades\Cache;
use stdClass;
@@ -16,6 +17,13 @@
*/
class BasicOperationsIntegrationTest extends RedisCacheIntegrationTestCase
{
+ protected function defineEnvironment(ApplicationContract $app): void
+ {
+ parent::defineEnvironment($app);
+
+ $app->make('config')->set('cache.serializable_classes', [stdClass::class]);
+ }
+
// =========================================================================
// BASIC OPERATIONS (NO TAGS) - BOTH MODES
// =========================================================================
diff --git a/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php b/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php
index c271329b9..f83746612 100644
--- a/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php
+++ b/tests/Integration/Cache/Redis/ClusterFallbackIntegrationTest.php
@@ -34,6 +34,7 @@ class ClusterModeRedisStore extends RedisStore
public function getContext(): StoreContext
{
return $this->clusterContext ??= new ClusterModeStoreContext(
+ $this->getRedis(),
$this->connection,
$this->getPrefix(),
$this->getTagMode(),
diff --git a/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php b/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php
index efe6f69bd..3a145600c 100644
--- a/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php
+++ b/tests/Integration/Cache/Redis/ConnectionPinningIntegrationTest.php
@@ -4,17 +4,30 @@
namespace Hypervel\Tests\Integration\Cache\Redis;
+use Hypervel\Cache\TagMode;
+use Hypervel\Contracts\Foundation\Application as ApplicationContract;
use Hypervel\Support\Facades\Cache;
+use Redis as PhpRedis;
+
+use function Hypervel\Coroutine\parallel;
/**
* Integration tests for Redis connection pinning.
*
- * Verifies that withPinnedConnection() reuses the same pool connection
- * for multiple Redis operations, and that remember/rememberForever
- * benefit from pinning transparently.
+ * Verifies explicit connection pinning and that repository callbacks do not
+ * retain a connection while running arbitrary user code.
*/
class ConnectionPinningIntegrationTest extends RedisCacheIntegrationTestCase
{
+ protected function defineEnvironment(ApplicationContract $app): void
+ {
+ parent::defineEnvironment($app);
+
+ $app->make('config')->set('database.redis.cache.pool.min_connections', 1);
+ $app->make('config')->set('database.redis.cache.pool.max_connections', 1);
+ $app->make('config')->set('database.redis.cache.pool.wait_timeout', 0.25);
+ }
+
public function testWithPinnedConnectionReusesConnection(): void
{
$store = $this->store();
@@ -56,19 +69,25 @@ public function testWithPinnedConnectionIsReentrant(): void
$this->assertSame('inner_value', Cache::get('inner_key'));
}
- public function testRememberUsesPinnedConnectionTransparently(): void
+ public function testRememberReleasesItsConnectionBeforeInvokingTheCallback(): void
{
- // remember() should work correctly — the pinning happens inside Repository
$callCount = 0;
$result = Cache::remember('pinned_remember', 60, function () use (&$callCount) {
++$callCount;
+ [$stored] = parallel([
+ fn (): bool => Cache::put('remember_callback_key', 'callback_value', 60),
+ ]);
+
+ $this->assertTrue($stored);
+
return 'computed_value';
});
$this->assertSame('computed_value', $result);
$this->assertSame(1, $callCount);
+ $this->assertSame('callback_value', Cache::get('remember_callback_key'));
// Second call should return cached value
$result = Cache::remember('pinned_remember', 60, function () use (&$callCount) {
@@ -81,18 +100,25 @@ public function testRememberUsesPinnedConnectionTransparently(): void
$this->assertSame(1, $callCount);
}
- public function testRememberForeverUsesPinnedConnectionTransparently(): void
+ public function testRememberForeverReleasesItsConnectionBeforeInvokingTheCallback(): void
{
$callCount = 0;
$result = Cache::rememberForever('pinned_forever', function () use (&$callCount) {
++$callCount;
+ [$stored] = parallel([
+ fn (): bool => Cache::put('remember_forever_callback_key', 'callback_value', 60),
+ ]);
+
+ $this->assertTrue($stored);
+
return 'forever_value';
});
$this->assertSame('forever_value', $result);
$this->assertSame(1, $callCount);
+ $this->assertSame('callback_value', Cache::get('remember_forever_callback_key'));
// Second call should return cached value
$result = Cache::rememberForever('pinned_forever', function () use (&$callCount) {
@@ -104,4 +130,28 @@ public function testRememberForeverUsesPinnedConnectionTransparently(): void
$this->assertSame('forever_value', $result);
$this->assertSame(1, $callCount);
}
+
+ public function testAllModeFlushScansAndDeletesChunksWithOnePooledConnection(): void
+ {
+ $this->setTagMode(TagMode::All);
+
+ $redis = $this->redis();
+ $pipeline = $redis->multi(PhpRedis::PIPELINE);
+ $prefix = $this->getCachePrefix();
+ $tagKey = $this->allModeTagKey('bulk');
+ $expiresAt = time() + 60;
+
+ for ($i = 1; $i <= 1001; ++$i) {
+ $pipeline->set($prefix . "bulk:{$i}", "value:{$i}", 60);
+ $pipeline->zAdd($tagKey, $expiresAt, "bulk:{$i}");
+ }
+
+ $this->assertIsArray($pipeline->exec());
+
+ Cache::tags(['bulk'])->flush();
+
+ $this->assertSame(0, $redis->zCard($tagKey));
+ $this->assertSame(0, $redis->exists($prefix . 'bulk:1'));
+ $this->assertSame(0, $redis->exists($prefix . 'bulk:1001'));
+ }
}
diff --git a/tests/Testbench/DefaultConfigurationTest.php b/tests/Testbench/DefaultConfigurationTest.php
index d617a2007..508de7b5f 100644
--- a/tests/Testbench/DefaultConfigurationTest.php
+++ b/tests/Testbench/DefaultConfigurationTest.php
@@ -78,6 +78,9 @@ public function itFallsBackToTheTestingConnectionWhenRuntimeSqliteIsMissing(): v
public function itPopulatesExpectedCacheDefaults(): void
{
$this->assertSame(Env::has('TESTBENCH_PACKAGE_TESTER') ? 'database' : 'array', $this->app['config']['cache.default']);
+ $this->assertTrue($this->app['config']->has('cache.limiter'));
+ $this->assertNull($this->app['config']['cache.limiter']);
+ $this->assertFalse($this->app['config']['cache.serializable_classes']);
}
#[Test]