diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8346d..f24d4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -## [Unreleased] +## [3.0.0] — 2026-07-09 + +### Added + +- **Atomic cache spaces for Redis Cluster:** declare `$normCacheSpaces`, select one with `->space()`, and run cache operations in one hash slot. +- **Space-targeted flushing:** use `NormCache::flushAll('space')` or `php artisan normcache:flush --space=...`. +- **Cache-space coverage:** relations, pivot data, and table dependencies participate in cache-space invalidation. + +### Changed + +- **BREAKING:** model-declared cache spaces replace the `cluster` and `slotting` configuration. Run `php artisan normcache:flush` before deploying. +- `dependsOnTables()` supports named cache spaces. + +### Fixed + +- Cache isolation across declared spaces and database connections, including `Model::on()` queries and writes. +- Repeated `dependsOn()` calls no longer discard previously declared model dependencies. +- Stale cache entries are not written when a cache rebuild races an invalidating write. +- Table-dependency invalidation remains visible across application workers. +- Eloquent observers see invalidated cache state while a model is being saved. --- diff --git a/README.md b/README.md index 1cbc36a..50c9c4e 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,38 @@ # Laravel Normcache -**Normalized caching for Laravel Eloquent. Self-invalidating, Redis-backed.** +**Normalized, self-invalidating Redis caching for Laravel Eloquent.** [![Tests](https://github.com/kai-init/laravel-normcache/actions/workflows/tests.yml/badge.svg)](https://github.com/kai-init/laravel-normcache/actions/workflows/tests.yml) [![PHPStan](https://img.shields.io/badge/PHPStan-level%205-brightgreen.svg)](phpstan.neon) [![Latest Version on Packagist](https://img.shields.io/packagist/v/kai-init/laravel-normcache.svg)](https://packagist.org/packages/kai-init/laravel-normcache) [![License](https://img.shields.io/github/license/kai-init/laravel-normcache.svg)](LICENSE) -Most caching packages store each query result as one serialized collection. Normcache takes a different approach: a query cache only stores the matching IDs, while each model's attributes live in their own key. The same model can appear in many cached queries but is only stored once, so a single version bump invalidates everything that returned it, in O(1). +Normcache caches query results as ID lists and stores model attributes in versioned model keys. When a model changes, Normcache bumps a version key instead of scanning and deleting every query that may have returned that model. -``` -query:{posts}:v3:... → [4, 7, 12] -model:{posts}:4 → { id:4, title:..., body:... } -model:{posts}:7 → { id:7, title:..., body:... } -model:{posts}:12 → { id:12, title:..., body:... } -``` - -**Requirements:** PHP 8.2+, Laravel 12/13, Redis 4.0+ +**Requirements:** PHP 8.2+, Laravel 12/13, Redis 6.0+ ## Table of Contents - [Installation](#installation) +- [What's new in 3.0](#whats-new-in-30) - [Usage](#usage) -- [Cache Bypasses](#cache-bypasses) -- [Limitations](#limitations) +- [Invalidation](#invalidation) +- [Cache spaces](#cache-spaces) - [Configuration](#configuration) +- [Bypasses and limitations](#bypasses-and-limitations) - [Observability](#observability) -- [Redis Clustering](#redis-clustering) -- [Octane & Horizon](#octane--horizon) -- [Performance](#performance) - [License](#license) ---- - -## What's New in v2 - -Version 2 extends Normcache beyond normalized caching into a full read-path cache layer: - -- **`dependsOn([Model::class])`** and **`dependsOnTables(['table'])`** — cache cross-table queries by declaring what should invalidate them. Simple cases stay normalized; complex shapes use a versioned result cache. -- **Scalar and aggregate caching** — `count`, `sum`, `avg`, `withCount`, `withSum`, and friends are cached automatically under versioned keys. -- **Stampede protection** — waiters block on a wake channel instead of storming the database during a rebuild. -- **Redis Cluster support** — single-slot mode by default; opt into per-model slot sharding with `slotting`. -- **Tag-based flushing** — group query entries under a tag and flush them together on deploy or config change. -- **Debugbar integration** — hits, misses, and bypasses appear on the request timeline when Debugbar is installed. - ---- - ## Installation ```bash composer require kai-init/laravel-normcache ``` -Add the `Cacheable` trait to any model you want cached: +Add `Cacheable` to models you want Normcache to manage: ```php +use Illuminate\Database\Eloquent\Model; use NormCache\Traits\Cacheable; class Post extends Model @@ -63,11 +41,18 @@ class Post extends Model } ``` ---- +## What's new in 3.0 + +Redis Cluster sharding is now fully atomic within each cache space. Normcache keeps the keys for a cached operation and its valid dependencies in one hash slot, so cache reads, rebuilds, and invalidation coordination remain atomic. + +- **Cache spaces:** declare `$normCacheSpaces` on a model and select a declared space with `->space()` when needed. +- **Space-targeted flushing:** use `NormCache::flushAll('space')` or `php artisan normcache:flush --space=...`. +- **Named table dependencies:** `dependsOnTables()` works in named spaces and is invalidated with `invalidateTableVersion()`. +- **Upgrade from 2.4:** run `php artisan normcache:flush` before deploying v3 to clear legacy cache keys. ## Usage -### Basic Queries +Normal Eloquent reads are cached automatically for cacheable models: ```php Post::all(); @@ -76,247 +61,195 @@ Post::find(1); Post::paginate(20); ``` -### Bypassing the Cache +Use `withoutCache()` or `ttl()` per query: ```php Post::withoutCache()->get(); +Post::where('active', true)->ttl(600)->get(); ``` -### Cross-Table Queries - -Two shapes are inferred automatically — no `dependsOn()` needed: - -- `whereHas` / `whereDoesntHave` on a non-nested `Cacheable` relation -- plain string `join()` calls with an explicit root-table projection +### Cross-table queries -Join inference is conservative and falls back to requiring `dependsOn()`/`dependsOnTables()` for: - -- implicit aliases, e.g. `join('posts p', ...)` -- expression join targets -- raw or subquery join predicates -- other unsupported join-clause conditions +Simple `whereHas` / `whereDoesntHave` constraints on cacheable relations and plain string joins with an explicit root-table projection are inferred automatically: ```php Author::whereHas('posts', fn($q) => $q->where('published', true))->get(); Author::join('posts', 'posts.author_id', '=', 'authors.id') ->select('authors.*') - ->get(); // also works with count(), sum(), exists(), paginate() + ->get(); ``` -Everything else requires `dependsOn()`, including: - -- manual `whereExists` -- raw predicates -- nested relations -- `GROUP BY` / `DISTINCT` -- calculated columns - -Use `dependsOnTables()` when the joined table has no `Cacheable` model: +For other cross-table reads, declare dependencies explicitly: ```php +Author::query() + ->dependsOn([Post::class]) + ->get(); + Author::join('legacy_stats', 'legacy_stats.author_id', '=', 'authors.id') ->select('authors.*') ->dependsOnTables(['legacy_stats']) ->get(); ``` -> **Note:** `dependsOnTables()` declares a read dependency only. Call `NormCache::invalidateTableVersion('mysql', 'legacy_stats')` after any external write to that table. +`dependsOnTables()` declares a read dependency only. If that table is changed outside Eloquent, call `NormCache::invalidateTableVersion($connection, $table)` after the write. -Normcache chooses the best caching strategy automatically: +### Aggregates and relationships -- **Normalized Cache**: Used for simple queries on the primary table. If you add `dependsOn()`, it stays normalized but becomes versioned against the extra models too. -- **Result Cache**: Used for complex queries with `dependsOn()`. The entire result set is cached as a versioned blob. +`count`, `exists`, `value`, `pluck`, `sum`, `avg`, `min`, `max`, pagination totals, and `withCount` / `withSum` / `withAvg` / `withMin` / `withMax` / `withExists` are cached when their dependencies are safe. -Pessimistic locks always bypass the cache. +Eager-loaded `BelongsTo`, `BelongsToMany`, `MorphTo`, `MorphToMany`, `MorphedByMany`, `HasManyThrough`, and `HasOneThrough` relations are cached. `attach`, `detach`, `sync`, and `updateExistingPivot` invalidate the relevant pivot cache. -### Per-Query TTL +## Invalidation -Use `ttl()` to set a custom cache duration: +Eloquent writes on cacheable models invalidate automatically. For manual invalidation: ```php -Post::where('active', true)->ttl(600)->get(); -``` - -### Aggregates +use NormCache\Facades\NormCache; -`withCount`, `withSum`, `withAvg`, `withMin`, `withMax`, and `withExists` are cached automatically. The result set is cached as a single versioned blob and invalidated when any related model version changes. - -```php -Post::withCount('comments')->get(); -Post::withoutAggregateCache()->withCount('comments')->get(); // skip aggregate cache +NormCache::flushModel(Post::class); +NormCache::flushAll(); +NormCache::flushAll('content'); ``` -### Relationship Caching - -`BelongsTo`, `BelongsToMany`, `MorphTo`, `MorphToMany`, `MorphedByMany`, `HasManyThrough`, and `HasOneThrough` are cached for eager loads — on a warm hit no SQL is executed. `HasOne`, `HasMany`, `MorphOne`, and `MorphMany` are cached via the query cache when the related model uses `Cacheable`. - -`attach`, `detach`, `sync`, and `updateExistingPivot` automatically invalidate the relevant pivot cache. - -### Manual Flush - ```bash php artisan normcache:flush --model="App\Models\Post" php artisan normcache:flush +php artisan normcache:flush --space=content ``` -```php -NormCache::flushModel(Post::class); -NormCache::flushAll(); -``` - -If you mutate cacheable tables outside Eloquent, flush manually after the write: +If you mutate cacheable tables outside Eloquent, flush the affected model or table version yourself: ```php DB::table('posts')->update(['published' => true]); NormCache::flushModel(Post::class); ``` -### Tag-Based Flush - -Tag any query to group cache entries for manual flushing — useful for invalidation events the version system can't see (deploys, config changes, nightly rebuilds). Tags must not contain `: { } *` or whitespace. +Tags can group query entries for manual flushing: ```php -Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->get(); +Author::whereHas('posts') + ->dependsOn([Post::class]) + ->tag('homepage') + ->get(); -NormCache::flushTag(Author::class, 'homepage'); // single model — single-slot scan -NormCache::flushTagAcrossModels('homepage'); // all models — cluster-wide scan +NormCache::flushTag(Author::class, 'homepage'); +NormCache::flushTagAcrossModels('homepage'); ``` ---- - -## Cache Bypasses - -To prevent data corruption, NormCache will automatically bypass caching and fall back to the database for: - -| Query feature | Workaround | -| ---------------------------------------------------- | --------------------- | -| Pessimistic locking (`lockForUpdate` / `sharedLock`) | None — must hit DB | -| Inside a database transaction | None — must hit DB | -| Raw SQL / `DB::table(...)` | None — flush manually | -| Raw `WHERE` or `ORDER BY` clauses | Use `dependsOn()` | -| Cross-table aggregate and scalar queries | Use `dependsOn()` | -| `chunk()`, `each()`, `lazy()` | None — always hits DB | -| `sole()` | None — always hits DB | - -Everything else — `JOIN`, `GROUP BY`, `DISTINCT`, subquery `WHERE`, and calculated columns — is also cacheable with `dependsOn()`. +## Cache spaces ---- +Cache spaces are Normcache's Redis Cluster sharding boundary. Each space has a Redis hash tag, so a cached operation stays within one Cluster slot. -## Limitations +Models without a declaration use the default space (`{nc}`). Declare named spaces with `$normCacheSpaces`: -- Normcache only hooks Eloquent models that use the `Cacheable` trait. Query builder calls such as `DB::table(...)`, `DB::select()`, and `DB::statement()` are never cached. -- Writes outside Eloquent are invisible to the model version system. Flush the affected model or tag manually after imports, raw updates, maintenance jobs, or external syncs. -- Normcache caches each model's connection name and table in static properties for performance. Call `CacheKeyBuilder::reset()` after switching tenants to clear the metadata cache. -- `dependsOn()` is explicit by design. If a query reads another table, include that model class or manually flush a tag that covers the query. -- Models are expected to use standard single-column primary keys. -- Packages that replace Eloquent builders, relation classes, or hydration behavior may bypass parts of Normcache. - ---- +```php +use Illuminate\Database\Eloquent\Model; +use NormCache\Traits\Cacheable; -## Configuration +class Post extends Model +{ + use Cacheable; -Publish `config/normcache.php` to tune these options (each is also configurable via a `NORMCACHE_*` env var): - -- **`connection`** — Redis connection (from `config/database.php`) NormCache reads and writes through. Default: `cache`. -- **`enabled`** — Master switch. When `false`, every query falls through to the database. Default: `true`. -- **`ttl`** — Lifetime of individual model attribute keys. Default: 7 days. -- **`query_ttl`** — Lifetime of query, raw, pivot, and through cache keys. Default: 1 hour. -- **`key_prefix`** — String prepended to every NormCache Redis key — use to namespace shared Redis instances. Default: none. -- **`slotting`** — When `false` (default), all NormCache keys are placed on one Redis Cluster slot using the `{nc}` slot prefix. -- **`cooldown`** — Useful for write-heavy models. Version bump debounce in seconds. Manual calls to `NormCache::flushModel()` always invalidate immediately regardless of this setting. -- **`building_lock_ttl`** — How long a cache-build lock is held before it expires and another request can take over. -- **`stampede_wait_ms`** — How long a waiter blocks on a wake channel before falling back to the database. Requires Redis 6.0+ for sub-second precision. -- **`stampede_wake_tokens`** — How many list wake tokens to push when a cache build completes. Higher values wake more concurrent waiters immediately; old tokens are cleared when a new build lock is claimed. -- **`cluster`** — Enable Redis Cluster-aware key routing and multi-slot Lua calls. Default: `false`. -- **`events`** — Set to `false` to skip hit/miss event dispatches on hot paths. -- **`fallback`** — Default: `true` (fail open). When `true`, Redis exceptions disable the cache for the request/job and queries fall back to the database silently; during a Redis outage this shifts load to the database. Set to `false` to fail closed and re-throw Redis exceptions. -- **`fire_retrieved`** — When `true`, models hydrated from Redis fire Eloquent's `retrieved` event. -- **`debugbar`** — Enable the Laravel Debugbar collector (see Observability). Default: `false`. - ---- + protected static array $normCacheSpaces = ['content']; +} +``` -## Observability +How space resolution works: -### Laravel Debugbar +- If no `space()` is selected, a model uses its first declared space as its home space. +- `Post::query()->space('content')` explicitly selects a space. +- `space()` must select a space declared by the model, otherwise Normcache throws an `InvalidArgumentException`. +- A model may declare multiple spaces, up to `spaces.max_per_model`. +- Writes bump the model version in every declared space. -When [`fruitcake/laravel-debugbar`](https://github.com/fruitcake/laravel-debugbar) is installed, enable the Normcache collector: +Dependencies must belong to the active space: ```php -'debugbar' => env('NORMCACHE_DEBUGBAR', false), -``` - -This adds a **Normcache** timeline tab showing every query hit, miss, bypass, and model fetch — with key, kind, and duration — for the current request. - -### Events +class Author extends Model +{ + use Cacheable; -| Event | Fired when | Properties | -| ---------------- | ---------------------------------------- | --------------------- | -| `QueryCacheHit` | Cached query result served from Redis | `modelClass`, `key` | -| `QueryCacheMiss` | Query not cached — DB queried | `modelClass`, `key` | -| `ModelCacheHit` | Model attributes served from Redis | `modelClass`, `ids[]` | -| `ModelCacheMiss` | Model attributes not cached — DB queried | `modelClass`, `ids[]` | + protected static array $normCacheSpaces = ['content']; +} ---- +Post::query() + ->space('content') + ->dependsOn([Author::class]) + ->get(); +``` -## Redis Clustering +If a model dependency is not valid in the active space, Normcache bypasses the cache by default. Set `spaces.cross_space_behavior` to `throw` to fail loudly during development. Raw table dependencies from `dependsOnTables()` can be used in any active space and are invalidated with `invalidateTableVersion()`. -By default, Redis Cluster support uses single-slot mode. With `cluster` enabled and `slotting` disabled, every NormCache key is prefixed with `{nc}:`, so cross-model operations can keep version checks, reads, and build-lock acquisition in one single-slot Lua command. +Configure placement when you need to control Redis Cluster hash tags: ```php -'cluster' => true, -'slotting' => false, // default +'spaces' => [ + 'max_per_model' => 16, + 'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'), + 'placement' => [ + 'catalog' => ['hash_tag' => 'nc:catalog'], + ], +], ``` -Set `slotting` to `true` only when you want Redis Cluster slot sharding across model groups. In sharded mode, single-model operations keep keys on one slot via per-model hash tags (`{posts}`, `{analytics:posts}`). Cross-model operations (`dependsOn`, pivot, through, `withCount`) resolve each model's version key with separate single-slot Lua calls, then read or write on the primary model's slot. - -**Consistency note:** sharded cross-model version resolution is not atomic. A writer that bumps a dependency version between version reads may cause an outdated response before the next request uses the new version. This is the same eventually-consistent trade-off accepted by most distributed caches. - -`flushAll()` is supported. - ---- - -## Octane & Horizon - -Works out of the box. State is reset between Octane requests and queue jobs — including re-enabling the cache if a Redis error disabled it mid-job. - ---- +## Configuration -## Correctness Guarantees +Publish `config/normcache.php` if you need to customize runtime behavior: -NormCache is designed to be as transparent as possible to native Eloquent, but it operates under specific guarantees and intentional limitations: +```bash +php artisan vendor:publish --tag=normcache-config +``` -### Safe & Transparent Caching +Common options: -NormCache matches native Eloquent hydration for supported query shapes, with the following intentional limitations: +| Option | Purpose | +| ---------------------- | --------------------------------------------------------------- | +| `connection` | Redis connection name. Default: `cache`. | +| `enabled` | Master on/off switch. | +| `ttl` | Model attribute key lifetime. | +| `query_ttl` | Query/result/pivot/through key lifetime. | +| `key_prefix` | Prefix for all Normcache Redis keys. | +| `cooldown` | Debounce version bumps for write-heavy models. | +| `building_lock_ttl` | Cache rebuild lock lifetime. | +| `stampede_wait_ms` | How long waiters block for a rebuild wake signal. | +| `stampede_wake_tokens` | Number of waiters to wake after a rebuild. | +| `fallback` | Fail open to the database on Redis errors when `true`. | +| `events` | Dispatch cache hit/miss events when `true`. | +| `fire_retrieved` | Fire Eloquent `retrieved` for cached models when `true`. | +| `debugbar` | Enable Laravel Debugbar integration when installed. | +| `spaces.*` | Cache-space limits, cross-space policy, and hash-tag placement. | -- **Universal Query Patterns:** Standard model lookups, primary key fast-paths, and complex result sets across both Normalized and Result modes. -- **Full Relationship Support:** Eager-loaded relations including nested chains, pivot table attributes, and through-relations. -- **Native Model Lifecycle:** Full support for standard Eloquent behavior including global scopes and soft deletes. `retrieved` events fire only when `fire_retrieved` is enabled (see Configuration). -- **Eloquent Extensibility:** Custom casts (JSON/Enum) and custom collection classes. Cached hydration reconstructs models from raw attributes directly and does not invoke custom `newFromBuilder()` overrides. +## Bypasses and limitations -### Requires `dependsOn()` +Normcache bypasses caching for unsafe reads rather than risking stale or incorrect data. -Simple `whereHas` and plain `join()` with an explicit root-table projection are inferred automatically. Everything else — manual `whereExists`, raw predicates, expression joins, nested relations, `GROUP BY`, `DISTINCT`, and calculated columns — requires `dependsOn()` or `dependsOnTables()`. +Always bypassed: -- **Debug Warning:** If `app.debug` is true, NormCache will log a warning if it detects a query touching a table not declared in `dependsOn()`. +- pessimistic locks (`lockForUpdate`, `sharedLock`) +- reads inside a database transaction +- `DB::table(...)`, `DB::select()`, and raw SQL +- `chunk()`, `each()`, `lazy()`, and `sole()` -### Cluster Mode & Consistency +Usually require `dependsOn()` or `dependsOnTables()`: -- **Single Node / Hash Tagging:** Provides strong multi-key atomicity. -- **Slotting Mode:** Offers better distribution but weaker cross-key atomicity. Multi-dependency queries are automatically routed to the result cache in slotting mode to prevent cross-slot consistency errors. +- manual `whereExists` +- raw predicates +- nested relation constraints +- expression joins +- `GROUP BY`, `DISTINCT`, and calculated columns ---- +Other limitations: -## Performance +- Models should use standard single-column primary keys. +- Writes outside Eloquent are invisible unless you manually flush or invalidate. +- Packages that replace Eloquent builders, relation classes, or hydration behavior may bypass parts of Normcache. +- Normcache caches model connection/table metadata. Call `CacheKeyBuilder::reset()` after switching tenants dynamically. -- **Single round trip on cache hit** — version check + ID fetch + model `MGET` in one Lua `EVAL`. -- **`MGET` for bulk reads** — all model attributes for a result set in one Redis call. -- **No scanning on invalidation** — version bump makes old keys unreachable; TTL handles eviction. (Manual operations like `flushAll()` and tag flushing do use `SCAN`). -- **Stampede protection** — waiters `BRPOP` a wake channel (200ms) instead of storming the DB. Requires Redis 6.0+ for sub-second precision; both PhpRedis and Predis support this. -- **igbinary support** — smaller payloads and faster serialization when the extension is installed. +## Observability ---- +When events are enabled, Normcache dispatches query/model hit and miss events. When `fruitcake/laravel-debugbar` is installed and `normcache.debugbar` is enabled, cache hits, misses, bypasses, and model fetches appear in Debugbar. ## License diff --git a/composer.json b/composer.json index ff61fd0..9ff46e8 100644 --- a/composer.json +++ b/composer.json @@ -47,6 +47,7 @@ }, "scripts": { "test": "vendor/bin/phpunit", + "test:cluster": "REDIS_CLUSTER=true REDIS_PORT=7010 vendor/bin/phpunit", "lint": "vendor/bin/pint --test", "format": "vendor/bin/pint", "analyse": "vendor/bin/phpstan analyse --memory-limit=512M" diff --git a/config/normcache.php b/config/normcache.php index b818903..c402775 100644 --- a/config/normcache.php +++ b/config/normcache.php @@ -4,7 +4,7 @@ // Redis connection from config/database.php. 'connection' => env('NORMCACHE_CONNECTION', 'cache'), - // Master switch; false bypasses the cache. + // Boot-time master switch; false bypasses the cache. 'enabled' => env('NORMCACHE_ENABLED', true), // Model attribute payload TTL (seconds). @@ -16,10 +16,6 @@ // Prefix every NormCache key. Useful when sharing a Redis database. 'key_prefix' => env('NORMCACHE_PREFIX', ''), - // false keeps all keys in {nc}, preserving cross-model Lua atomicity on Cluster. - // true slots by model/table, spreading load but limiting atomic operations to each slot. - 'slotting' => (bool) env('NORMCACHE_SLOTTING', false), - // Debounce automatic version bumps on write-heavy models; 0 bumps immediately (seconds). 'cooldown' => (int) env('NORMCACHE_COOLDOWN', 0), @@ -32,9 +28,6 @@ // Wake tokens pushed when a cache build releases. Raise for high same-key concurrency. 'stampede_wake_tokens' => (int) env('NORMCACHE_STAMPEDE_WAKE_TOKENS', 64), - // Use Redis Cluster-safe command paths when the configured Redis connection is clustered. - 'cluster' => (bool) env('NORMCACHE_CLUSTER', false), - // Dispatch cache hit, miss, and bypass events. Enable only if something consumes them. 'events' => (bool) env('NORMCACHE_EVENTS', false), @@ -46,4 +39,17 @@ // Register the Laravel Debugbar collector for local cache inspection. 'debugbar' => env('NORMCACHE_DEBUGBAR', false), + // Redis Cluster sharding via model-declared cache spaces. + 'spaces' => [ + // Max spaces per model. Writes bump one version key per space. + 'max_per_model' => 16, + + // Cross-space dependency handling: 'bypass' or 'throw'. + 'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'), + + // Optional space => hash-tag override. Default: content => {nc:content}. + 'placement' => [ + // 'catalog' => ['hash_tag' => 'nc:catalog'], + ], + ], ]; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..49fbf7e --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +# Local Redis for the test suite. +# redis — single node on 6379 (default `composer test`) +# redis-cluster — 6-node cluster (3 masters + 3 replicas) on 7010-7015, +# matching REDIS_CLUSTER_NODES in phpunit.xml (`composer test:cluster`) +services: + redis: + image: redis:7-alpine + ports: + - "6379:6379" + command: ["redis-server", "--save", "", "--appendonly", "no"] + + redis-cluster: + image: grokzen/redis-cluster:7.0.10 + environment: + IP: 0.0.0.0 + INITIAL_PORT: 7010 + MASTERS: 3 + SLAVES_PER_MASTER: 1 + ports: + - "7010-7015:7010-7015" diff --git a/phpstan.neon b/phpstan.neon index e567727..2669e8d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -15,6 +15,9 @@ parameters: - identifier: argument.type path: src/Support/RedisStore.php + - + identifier: argument.type + path: src/Support/RedisScanner.php - identifier: trait.unused path: src/Traits/Cacheable.php diff --git a/src/Cache/ExecutionEngine.php b/src/Cache/ExecutionEngine.php index 4a032ec..fcce22e 100644 --- a/src/Cache/ExecutionEngine.php +++ b/src/Cache/ExecutionEngine.php @@ -11,42 +11,6 @@ final class ExecutionEngine { - /** - * @param callable(): ResultCacheResult $fetch - * @param callable(): (ResultCacheResult|null) $waitForBuild - * @param callable(ResultCacheResult): array{Collection, mixed} $onMiss - * @param callable(mixed, ResultCacheResult): void $onStore - * @param callable(ResultCacheResult): Collection $onHit - * @param callable(): Collection $onBuild - */ - public function runResult( - callable $fetch, - callable $waitForBuild, - callable $onMiss, - callable $onStore, - callable $onHit, - callable $onBuild, - ): Collection { - $result = $fetch(); - - if ($result->status === CacheStatus::Building) { - $result = $waitForBuild(); - - if ($result === null) { - return $onBuild(); - } - } - - if ($result->status === CacheStatus::Hit) { - return $onHit($result); - } - - [$models, $payload] = $onMiss($result); - $onStore($payload, $result); - - return $models; - } - /** * @param callable(): PivotCacheResult $fetch * @param callable(): (PivotCacheResult|null) $waitForBuild @@ -119,47 +83,15 @@ public function runScalar( } /** - * @param callable(): QueryCacheResult $fetch - * @param callable(): (QueryCacheResult|null) $waitForBuild - * @param callable(): Collection $onBuild - * @param callable(QueryCacheResult): Collection $onMiss - * @param callable(QueryCacheResult): Collection $onHit - */ - public function runNormalized( - callable $fetch, - callable $waitForBuild, - callable $onBuild, - callable $onMiss, - callable $onHit, - ): Collection { - $result = $fetch(); - - if ($result->status === CacheStatus::Building) { - $result = $waitForBuild(); - - if ($result === null) { - return $onBuild(); - } - } - - if ($result->status === CacheStatus::Miss) { - return $onMiss($result); - } - - return $onHit($result); - } - - /** - * Same control flow as {@see self::runNormalized()}, kept as a separate method so call - * sites get ThroughCacheResult-typed callables instead of QueryCacheResult. + * @template TResult of QueryCacheResult|ThroughCacheResult * - * @param callable(): ThroughCacheResult $fetch - * @param callable(): (ThroughCacheResult|null) $waitForBuild + * @param callable(): TResult $fetch + * @param callable(): (TResult|null) $waitForBuild * @param callable(): Collection $onBuild - * @param callable(ThroughCacheResult): Collection $onMiss - * @param callable(ThroughCacheResult): Collection $onHit + * @param callable(TResult): Collection $onMiss + * @param callable(TResult): Collection $onHit */ - public function runThrough( + public function runNormalized( callable $fetch, callable $waitForBuild, callable $onBuild, diff --git a/src/Cache/ModelCacheRepository.php b/src/Cache/ModelCacheRepository.php new file mode 100644 index 0000000..d763aff --- /dev/null +++ b/src/Cache/ModelCacheRepository.php @@ -0,0 +1,74 @@ +keys->activeSpace(); + $modelVersion = $this->versions->currentVersion($modelClass, $space, $connection); + + $this->storeForVersion($modelClass, $modelAttrs, $modelVersion, $space, $connection); + } + + public function storeForBuild( + string $modelClass, + array $modelAttrs, + BuildHandle $build, + ?CacheSpace $space = null, + ?string $connection = null, + ): void { + $classKey = $this->keys->classKey($modelClass, $connection); + $index = array_search($this->keys->verKey($classKey, $space), $build->versionKeys, true); + + if ($index === false || !isset($build->expectedVersions[$index])) { + return; + } + + $this->storeForVersion($modelClass, $modelAttrs, (int) $build->expectedVersions[$index], $space, $connection); + } + + public function storeForVersion( + string $modelClass, + array $modelAttrs, + int $expectedVersion, + ?CacheSpace $space = null, + ?string $connection = null, + ): void { + if (empty($modelAttrs)) { + return; + } + + $classKey = $this->keys->classKey($modelClass, $connection); + $attrsByKey = []; + + foreach ($modelAttrs as $id => $attrs) { + $attrsByKey[$this->keys->modelPrefix($classKey, $expectedVersion, $space) . $id] = $attrs; + } + + $this->store->setManyIfVersion( + $attrsByKey, + $this->config->ttl, + $this->keys->verKey($classKey, $space), + $expectedVersion, + ); + } +} diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index d2e34cb..e680335 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -5,49 +5,18 @@ use Illuminate\Database\Eloquent\Builder as EloquentBuilder; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Query\Builder as QueryBuilder; -use Illuminate\Support\Collection; use NormCache\CacheableBuilder; +use NormCache\Enums\LuaStatus; use NormCache\Support\AttributeProjector; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\CacheReporter; -use NormCache\Support\RedisScripts; +use NormCache\Support\RawAttributes; use NormCache\Support\RedisStore; +use NormCache\Values\ModelFetchContext; final class ModelHydrator { - private static ?\Closure $hydrateClosure = null; - - private static ?\Closure $setAttributeDirectClosure = null; - - private static ?\Closure $getAttributeDirectClosure = null; - - private static ?\Closure $transformScalarClosure = null; - - private static ?\Closure $transformScalarsClosure = null; - - private const STATELESS_CASTS = [ - 'array' => true, - 'bool' => true, - 'boolean' => true, - 'collection' => true, - 'custom_datetime' => true, - 'date' => true, - 'datetime' => true, - 'decimal' => true, - 'double' => true, - 'float' => true, - 'immutable_custom_datetime' => true, - 'immutable_date' => true, - 'immutable_datetime' => true, - 'int' => true, - 'integer' => true, - 'json' => true, - 'json:unicode' => true, - 'object' => true, - 'real' => true, - 'string' => true, - 'timestamp' => true, - ]; + private static array $overridesNewFromBuilder = []; public function __construct( private readonly RedisStore $store, @@ -75,63 +44,96 @@ public function getModels( $reporting = CacheReporter::active(); $debugbarStart = $reporting ? CacheReporter::beginMeasure() : null; - $containedInQueryHit = $raw !== null; + // Pre-supplied $raw defers the version GET; remember it since $raw is reassigned below. + $versionDeferred = $raw !== null; // arrays may come with sparse numeric keys, for example after array_unique(). $ids = array_values($ids); - $classKey = $this->keys->classKey($modelClass); + $connection = ($prototype ?? $missedQuery?->getModel())?->getConnectionName() + ?? $this->keys->declaredConnection($modelClass); + $classKey = $this->keys->classKey($modelClass, $connection); $projection = $columns !== null ? AttributeProjector::normalizeProjection($columns) : null; - $raw ??= $this->store->getMany($this->modelKeysFor($classKey, $ids)); + // Pre-supplied $raw skips the version GET; resolve lazily only if there are misses. + if ($raw === null) { + $modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace(), $connection); + $raw = $this->store->getMany($this->modelKeysFor($classKey, $modelVersion, $ids)); + } else { + $modelVersion = 0; // deferred + } ['hits' => $hits, 'missed' => $missed] = $this->hydrateModels($ids, $modelClass, $raw, $projection, $prototype); - $lockKey = $wakeKey = $token = null; - $version = 0; - if ($hits !== [] && $reporting) { - CacheReporter::modelHitActive($modelClass, array_keys($hits), $debugbarStart, [ - 'suppress_collector' => $containedInQueryHit, + $context = new ModelFetchContext( + modelClass: $modelClass, + classKey: $classKey, + projection: $projection, + prototype: $prototype, + missedQuery: $missedQuery, + preserveQueryShape: $preserveQueryShape, + modelVersion: $modelVersion, + ); + $context->hits = $hits; + + if ($context->hits !== [] && $reporting) { + CacheReporter::modelHitActive($modelClass, array_keys($context->hits), $debugbarStart, [ 'ids' => $ids, ]); } if ($missed === []) { - return array_values($hits); + return array_values($context->hits); + } + + if ($versionDeferred) { + $context->modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace(), $connection); } if ($reporting) { CacheReporter::modelMissActive($modelClass, $missed, $debugbarStart, [ - 'hits' => array_keys($hits), - 'partial' => $hits !== [], + 'hits' => array_keys($context->hits), + 'partial' => $context->hits !== [], ]); } - [$lockKey, $wakeKey, $token] = $this->buildLockTriple($classKey, $missed); + [$context->lockKey, $context->wakeKey, $context->token] = $this->buildLockTriple($classKey, $context->modelVersion, $missed); - [$status, $missed, $version] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $context); - if ($status === 'building' && $missed !== []) { - $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); + if ($status === LuaStatus::Building && $missed !== []) { + $this->store->brpop($context->wakeKey, $this->stampedeWaitMs / 1000.0); - [$status, $missed, $version] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $context); } - if ($status === 'miss') { + if ($status === LuaStatus::Miss) { try { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $version, $lockKey, $wakeKey, $token); + $this->fetchAndMerge($missed, $context, true); } catch (\Throwable $e) { - $this->store->releaseBuilding($lockKey, $wakeKey, $token); + $this->store->releaseBuilding($context->lockKey, $context->wakeKey, $context->token); throw $e; } - } elseif ($missed !== []) { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $version); + } elseif ($status === LuaStatus::Hit && $missed !== []) { + // Corrupt payload: Lua reported hit but deserialization failed. Only the lock owner overwrites. + if ($this->store->setNxEx($context->lockKey, $context->token, $this->buildingLockTtl)) { + try { + $this->fetchAndMerge($missed, $context, true); + } catch (\Throwable $e) { + $this->store->releaseBuilding($context->lockKey, $context->wakeKey, $context->token); + throw $e; + } + } else { + $this->fetchAndMerge($missed, $context, false); + } + } elseif ($status === LuaStatus::Building && $missed !== []) { + $this->fetchAndMerge($missed, $context, false); } $ordered = []; foreach ($ids as $id) { - if (isset($hits[$id])) { - $ordered[] = $hits[$id]; + if (isset($context->hits[$id])) { + $ordered[] = $context->hits[$id]; } } @@ -139,21 +141,22 @@ public function getModels( } // Builds the building-lock key/wake-key/token triple for a set of model ids. - private function buildLockTriple(string $classKey, array $ids): array + private function buildLockTriple(string $classKey, int $modelVersion, array $ids): array { $sorted = $ids; sort($sorted); - $lockSuffix = $this->keys->resultBuildIdentityHash('model', null, implode(',', $sorted)); - $lockKey = $this->keys->resultBuildingKey($classKey, 'model', $lockSuffix); + $segment = 'model:v' . $modelVersion; + $lockSuffix = $this->keys->resultBuildIdentityHash($segment, null, implode(',', $sorted)); + $lockKey = $this->keys->resultBuildingKey($classKey, $segment, $lockSuffix); $wakeKey = $this->keys->wakeKey($classKey, $lockSuffix); $token = $this->versions->buildLockToken(); return [$lockKey, $wakeKey, $token]; } - private function modelKeysFor(string $classKey, array $ids): array + private function modelKeysFor(string $classKey, int $modelVersion, array $ids): array { - $prefix = $this->keys->modelPrefix($classKey); + $prefix = $this->keys->modelPrefix($classKey, $modelVersion); $keys = []; foreach ($ids as $id) { $keys[] = $prefix . $id; @@ -163,67 +166,38 @@ private function modelKeysFor(string $classKey, array $ids): array } // Re-checks still-missing ids and atomically claims the build lock if anything's still missing. - private function fetchMissedStatus( - array $idsToFetch, - string $modelClass, - string $classKey, - ?array $projection, - ?Model $prototype, - string $lockKey, - string $wakeKey, - string $token, - array &$hits, - ): array { - $fetchKeys = $this->modelKeysFor($classKey, $idsToFetch); + /** @return array{0: LuaStatus, 1: list} */ + private function fetchMissedStatus(array $idsToFetch, ModelFetchContext $context): array + { + $fetchKeys = $this->modelKeysFor($context->classKey, $context->modelVersion, $idsToFetch); - $result = $this->store->script( - RedisScripts::get('fetch_model_build_status'), - [...$fetchKeys, $lockKey, $this->keys->verKey($classKey), $wakeKey], - [$token, (string) $this->buildingLockTtl] - ); + $result = $this->store->fetchBatchBuildStatus($fetchKeys, $context->lockKey, $context->wakeKey, $context->token, $this->buildingLockTtl); - $status = $result[0]; - $version = $this->versions->normalizeVersion($result[2] ?? null); $raw = $this->store->unserializeMany($result[3] ?? []); - ['hits' => $newHits, 'missed' => $stillMissed] = $this->hydrateModels($idsToFetch, $modelClass, $raw, $projection, $prototype); + ['hits' => $newHits, 'missed' => $stillMissed] = $this->hydrateModels($idsToFetch, $context->modelClass, $raw, $context->projection, $context->prototype); foreach ($newHits as $id => $hit) { - $hits[$id] = $hit; + $context->hits[$id] = $hit; } if ($stillMissed === []) { - return ['hit', [], 0]; + return [LuaStatus::Hit, []]; } - return [$status, $stillMissed, $version]; + return [LuaStatus::fromLua($result[0] ?? null), $stillMissed]; } - // Fetches still-missing ids from the database, caches them, and merges into $hits by reference. - private function fetchAndMerge( - array $missed, - string $modelClass, - string $classKey, - ?array $projection, - ?EloquentBuilder $missedQuery, - bool $preserveQueryShape, - array &$hits, - int $modelVersion, - ?string $buildingKey = null, - ?string $wakeKey = null, - ?string $token = null, - ): void { + private function fetchAndMerge(array $missed, ModelFetchContext $context, bool $writeCache): void + { if ($missed === []) { return; } - $fetched = $this->fetchFromDatabaseAndCache( - $missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, - $modelVersion, $buildingKey, $wakeKey, $token - ); + $fetched = $this->fetchFromDatabaseAndCache($missed, $context, $writeCache); foreach ($fetched as $id => $model) { - $hits[$id] = $model; + $context->hits[$id] = $model; } } @@ -232,7 +206,7 @@ public function hydrateResult(array $payload, string|Model $model, bool $cached $modelClass = $model instanceof Model ? $model::class : $model; $prototype = $model instanceof Model ? $model : CacheKeyBuilder::prototype($modelClass); $fire = $this->fireRetrieved; - $hydrate = self::hydrateClosure(); + $hydrate = RawAttributes::hydrateClosure(); $models = []; foreach ($payload as $attrs) { @@ -255,45 +229,11 @@ public function hydrateResult(array $payload, string|Model $model, bool $cached return $models; } - public static function transformScalar(mixed $value, Model $model, string $column): mixed - { - $isCast = self::resolveStatelessScalarMode($model, $column); - - if ($isCast === null) { - return $model->newFromBuilder([$column => $value])->{$column}; - } - - return self::transformScalarClosure()($model, $column, $value, $isCast); - } - - public static function transformScalars(Collection $results, Model $model, string $column): Collection - { - $isCast = self::resolveStatelessScalarMode($model, $column); - $values = $results->all(); - - if ($isCast === null) { - $template = $model->newInstance([], true); - $hydrate = self::hydrateClosure(); - - foreach ($values as $key => $value) { - $instance = clone $template; - $hydrate($instance, [$column => $value], true); - $values[$key] = $instance->{$column}; - } - - return new Collection($values); - } - - return new Collection( - self::transformScalarsClosure()($model, $column, $values, $isCast), - ); - } - private function hydrateModels(array $ids, string $modelClass, array $raw, ?array $projection, ?Model $prototype = null): array { $prototype = $prototype ?? CacheKeyBuilder::prototype($modelClass); $fire = $this->fireRetrieved; - $hydrate = self::hydrateClosure(); + $hydrate = RawAttributes::hydrateClosure(); $hits = []; $missed = []; @@ -318,278 +258,149 @@ private function hydrateModels(array $ids, string $modelClass, array $raw, ?arra return ['hits' => $hits, 'missed' => $missed]; } - private function fetchFromDatabaseAndCache( - array $missed, - string $modelClass, - string $classKey, - ?array $projection, - ?EloquentBuilder $missedQuery, - bool $preserveQueryShape, - int $modelVersion, - ?string $buildingKey = null, - ?string $wakeKey = null, - ?string $token = null, - ): array { + private function fetchFromDatabaseAndCache(array $missed, ModelFetchContext $context, bool $writeCache): array + { $query = $this->prepareMissedQuery( - $modelClass, - $missedQuery instanceof CacheableBuilder ? $missedQuery : null, - $preserveQueryShape + $context->modelClass, + $context->missedQuery instanceof CacheableBuilder ? $context->missedQuery : null, + $context->preserveQueryShape ); if ($this->overridesNewFromBuilder($query->getModel())) { - return $this->fetchAndCacheUsingEloquent( - $missed, $modelClass, $classKey, $projection, $query, $modelVersion, $buildingKey, $wakeKey, $token - ); + return $this->fetchAndCacheUsingEloquent($missed, $query, $context, $writeCache); } - return $this->fetchAndCacheUsingClosure( - $missed, $modelClass, $classKey, $projection, $query, $query->getModel(), $modelVersion, $buildingKey, $wakeKey, $token - ); + return $this->fetchAndCacheUsingClosure($missed, $query, $context, $writeCache); } private function fetchAndCacheUsingEloquent( array $missed, - string $modelClass, - string $classKey, - ?array $projection, EloquentBuilder $query, - int $modelVersion, - ?string $buildingKey = null, - ?string $wakeKey = null, - ?string $token = null, + ModelFetchContext $context, + bool $writeCache, ): array { - $prototype = CacheKeyBuilder::prototype($modelClass); + $prototype = CacheKeyBuilder::prototype($context->modelClass); $pk = $prototype->getKeyName(); - $qualifiedPk = $prototype->getQualifiedKeyName(); - $loaded = $query->whereIn($qualifiedPk, $missed) - ->get([$prototype->getTable() . '.*']) - ->keyBy($pk); - - $attrsByKey = []; - $deletedAtCol = CacheKeyBuilder::deletedAtColumn($modelClass); - $hydrate = $projection !== null ? self::hydrateClosure() : null; + $loaded = $query->whereIn($prototype->getQualifiedKeyName(), $missed) + ->get([$prototype->getTable() . '.*']); + $hydrate = $context->projection !== null ? RawAttributes::hydrateClosure() : null; - foreach ($loaded as $id => $model) { + return $this->cacheAndCollect($loaded, $context, $writeCache, function ($model) use ($pk, $hydrate, $context) { $attrs = $model->getRawOriginal(); - $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); - if (!$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; - } - if ($hydrate !== null) { - $hydrate($model, AttributeProjector::projectAttributes($attrs, $projection), false); + $hydrate($model, AttributeProjector::projectAttributes($attrs, $context->projection), false); } - } - if ($attrsByKey !== [] || $buildingKey !== null) { - $this->store->setManyTrackedIfVersion( - $attrsByKey, - $this->ttl, - $this->keys->membersKey($classKey), - $this->keys->verKey($classKey), - $modelVersion, - $buildingKey, - $wakeKey, - $token - ); - } - - return $loaded->all(); + return array_key_exists($pk, $attrs) ? [$attrs[$pk], $attrs, $model] : null; + }); } private function fetchAndCacheUsingClosure( array $missed, - string $modelClass, - string $classKey, - ?array $projection, EloquentBuilder $query, - Model $prototype, - int $modelVersion, - ?string $buildingKey = null, - ?string $wakeKey = null, - ?string $token = null, + ModelFetchContext $context, + bool $writeCache, ): array { + $prototype = $query->getModel(); $pk = $prototype->getKeyName(); - $qualifiedPk = $prototype->getQualifiedKeyName(); - $deletedAtCol = CacheKeyBuilder::deletedAtColumn($modelClass); - $hydrate = self::hydrateClosure(); - $connectionName = $query->getModel()->getConnectionName(); + $hydrate = RawAttributes::hydrateClosure(); + $connectionName = $prototype->getConnectionName(); - $rows = $query->whereIn($qualifiedPk, $missed) + $rows = $query->whereIn($prototype->getQualifiedKeyName(), $missed) ->toBase() ->get([$prototype->getTable() . '.*']); - $models = []; - $attrsByKey = []; - - foreach ($rows as $row) { + return $this->cacheAndCollect($rows, $context, $writeCache, function ($row) use ($pk, $prototype, $hydrate, $connectionName, $context) { $attrs = (array) $row; if (!array_key_exists($pk, $attrs)) { - continue; - } - - $id = $attrs[$pk]; - - $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); - if (!$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; + return null; } - $returnAttrs = $projection !== null - ? AttributeProjector::projectAttributes($attrs, $projection) + $returnAttrs = $context->projection !== null + ? AttributeProjector::projectAttributes($attrs, $context->projection) : $attrs; $instance = clone $prototype; $instance->setConnection($connectionName); $hydrate($instance, $returnAttrs, true); - $models[$id] = $instance; - } - - if ($attrsByKey !== [] || $buildingKey !== null) { - $this->store->setManyTrackedIfVersion( - $attrsByKey, - $this->ttl, - $this->keys->membersKey($classKey), - $this->keys->verKey($classKey), - $modelVersion, - $buildingKey, - $wakeKey, - $token - ); - } - - return $models; - } - - private function overridesNewFromBuilder(Model $model): bool - { - $method = new \ReflectionMethod($model, 'newFromBuilder'); - - return $method->getDeclaringClass()->getName() !== Model::class; - } - - public static function hydrateClosure(): \Closure - { - return self::$hydrateClosure ??= \Closure::bind( - static function (Model $instance, array $attrs, bool $fire): void { - $instance->attributes = $attrs; - $instance->original = $attrs; - $instance->classCastCache = []; - $instance->attributeCastCache = []; - $instance->exists = true; - if ($fire) { - $instance->fireModelEvent('retrieved', false); - } - }, - null, - Model::class - ); - } - - public static function setAttributeDirectClosure(): \Closure - { - return self::$setAttributeDirectClosure ??= \Closure::bind( - static function (Model $instance, string $key, mixed $value): void { - $instance->attributes[$key] = $value; - }, - null, - Model::class - ); - } - - public static function getAttributeDirectClosure(): \Closure - { - return self::$getAttributeDirectClosure ??= \Closure::bind( - static function (Model $instance, string $key): mixed { - return $instance->attributes[$key] ?? null; - }, - null, - Model::class - ); + return [$attrs[$pk], $attrs, $instance]; + }); } - private static function resolveStatelessScalarMode(Model $model, string $column): ?bool + /** + * Shared miss-path bookkeeping: cache-write non-trashed rows, key models by id. + * + * @param callable(mixed): (array{mixed, array, Model}|null) $each row → [id, raw attrs, model]; null skips the row + */ + private function cacheAndCollect(iterable $rows, ModelFetchContext $context, bool $writeCache, callable $each): array { - if ($model->hasAnyGetMutator($column)) { - return null; - } + $deletedAtCol = CacheKeyBuilder::deletedAtColumn($context->modelClass); + $prefix = $this->keys->modelPrefix($context->classKey, $context->modelVersion); + $attrsByKey = []; + $models = []; - $cast = $model->getCasts()[$column] ?? null; + foreach ($rows as $row) { + $result = $each($row); - if ($cast === null) { - if (!in_array($column, $model->getDates(), true)) { - return null; + if ($result === null) { + continue; } - $isCast = false; - } elseif (!is_string($cast)) { - return null; - } else { - $cast = strtolower(explode(':', $cast, 2)[0]); + [$id, $attrs, $model] = $result; - if (!isset(self::STATELESS_CASTS[$cast])) { - return null; + if ($writeCache && !($deletedAtCol && isset($attrs[$deletedAtCol]))) { + $attrsByKey[$prefix . $id] = $attrs; } - $isCast = true; + $models[$id] = $model; } - $dispatcher = $model::getEventDispatcher(); + $this->storeModelAttrs($attrsByKey, $context, $writeCache); - if ($dispatcher !== null && $dispatcher->hasListeners('eloquent.retrieved: ' . $model::class)) { - return null; - } - - return $isCast; + return $models; } - private static function transformScalarClosure(): \Closure + // Passes the lock triple only when this call owns the build lock ($writeCache), so the Lua release is owner-gated. + private function storeModelAttrs(array $attrsByKey, ModelFetchContext $context, bool $writeCache): void { - return self::$transformScalarClosure ??= \Closure::bind( - static function (Model $model, string $column, mixed $value, bool $isCast): mixed { - if ($isCast) { - return $model->castAttribute($column, $value); - } - - return $value === null ? null : $model->asDateTime($value); - }, - null, - Model::class + $this->store->setManyIfVersion( + $attrsByKey, + $this->ttl, + $this->keys->verKey($context->classKey), + $context->modelVersion, + $writeCache ? $context->lockKey : null, + $writeCache ? $context->wakeKey : null, + $writeCache ? $context->token : null, ); } - private static function transformScalarsClosure(): \Closure + public static function reset(): void { - return self::$transformScalarsClosure ??= \Closure::bind( - static function (Model $model, string $column, array $values, bool $isCast): array { - if ($isCast) { - foreach ($values as $key => $value) { - $values[$key] = $model->castAttribute($column, $value); - } - - return $values; - } + self::$overridesNewFromBuilder = []; + } - foreach ($values as $key => $value) { - $values[$key] = $value === null ? null : $model->asDateTime($value); - } + private function overridesNewFromBuilder(Model $model): bool + { + $class = $model::class; - return $values; - }, - null, - Model::class - ); + return self::$overridesNewFromBuilder[$class] ??= + (new \ReflectionMethod($model, 'newFromBuilder'))->getDeclaringClass()->getName() !== Model::class; } private function prepareMissedQuery(string $modelClass, ?CacheableBuilder $missedQuery, bool $preserveQueryShape): EloquentBuilder { if ($missedQuery === null || !$preserveQueryShape || !$this->canPreserveQueryShape($missedQuery->getQuery())) { - $builder = $modelClass::query(); + $builder = $missedQuery !== null + ? $missedQuery->getModel()->newQuery() + : $modelClass::query(); + if ($builder instanceof CacheableBuilder) { - $missedQuery?->applyRemovedScopesTo($builder); + if ($missedQuery !== null) { + $builder->withoutGlobalScopes($missedQuery->removedScopes()); + } return $builder->withoutCache(); } @@ -606,7 +417,7 @@ private function prepareMissedQuery(string $modelClass, ?CacheableBuilder $misse ->setModel($missedQuery->getModel()) ->withoutCache(); - $missedQuery->applyRemovedScopesTo($builder); + $builder->withoutGlobalScopes($missedQuery->removedScopes()); return $builder; } diff --git a/src/Cache/ModelsExecutor.php b/src/Cache/ModelsExecutor.php index 4b57284..1137c4f 100644 --- a/src/Cache/ModelsExecutor.php +++ b/src/Cache/ModelsExecutor.php @@ -8,6 +8,7 @@ use NormCache\Facades\NormCache; use NormCache\Support\CacheReporter; use NormCache\Support\QueryHasher; +use NormCache\Values\BuildHandle; use NormCache\Values\CachePlan; use NormCache\Values\PreparedQuery; @@ -22,7 +23,7 @@ public function runDirect( ): Collection { $executionBuilder = $prepared->builder; - return $executionBuilder->finalizeResult(NormCache::getModels( + return $prepared->finalizeModels(NormCache::hydrator()->getModels( $primaryKeys, $model, $selectedCols, @@ -30,7 +31,7 @@ public function runDirect( $executionBuilder, false, $prototype - ), $prepared); + )); } public function runNormalized( @@ -48,29 +49,31 @@ public function runNormalized( $hash = QueryHasher::forNormalizedQuery($executionBuilder, $base); $depClasses = $plan->dependencies->depClassesFor($model); $depTableKeys = $plan->dependencies->tables; + $connection = $prototype->getConnectionName(); return NormCache::engine()->runNormalized( - fetch: fn() => NormCache::getModelsFromQuery($model, $hash, $cacheTag, $depClasses, $depTableKeys), - waitForBuild: fn() => NormCache::waitForQueryBuild($model, $hash, $cacheTag, $depClasses, $depTableKeys), + fetch: fn() => NormCache::queries()->fetch($model, $hash, $cacheTag, $depClasses, $depTableKeys, $connection), + waitForBuild: fn() => NormCache::queries()->waitForBuild($model, $hash, $cacheTag, $depClasses, $depTableKeys, $connection), onBuild: function () use ($prepared, $executionBuilder, $base, $model, $selectedCols, $debugbarStart, $prototype) { CacheReporter::queryMiss($model, 'building:budget-exhausted', $debugbarStart, ['kind' => 'ids']); - return $executionBuilder->finalizeResult( - NormCache::getModels($this->buildIds($base, $prototype), $model, $selectedCols, null, $executionBuilder, true, $prototype), - $prepared + return $prepared->finalizeModels( + NormCache::hydrator()->getModels($this->buildIds($base, $prototype), $model, $selectedCols, null, $executionBuilder, true, $prototype) ); }, onMiss: function ($result) use ($prepared, $executionBuilder, $base, $model, $selectedCols, $debugbarStart, $queryTtl, $prototype) { CacheReporter::queryMiss($model, $result->key, $debugbarStart, ['kind' => 'ids']); $ids = $this->resolveIds( - $result->key, $base, $queryTtl, $prototype, - $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken + $result->key, + $base, + $queryTtl, + $prototype, + $result->build, ); - return $executionBuilder->finalizeResult( - NormCache::getModels($ids, $model, $selectedCols, null, $executionBuilder, true, $prototype), - $prepared + return $prepared->finalizeModels( + NormCache::hydrator()->getModels($ids, $model, $selectedCols, null, $executionBuilder, true, $prototype) ); }, onHit: function ($result) use ($prepared, $executionBuilder, $model, $selectedCols, $debugbarStart, $prototype) { @@ -80,9 +83,8 @@ public function runNormalized( 'contains_model' => $result->ids, ]); - return $executionBuilder->finalizeResult( - NormCache::getModels($result->ids, $model, $selectedCols, $result->models, $executionBuilder, true, $prototype), - $prepared + return $prepared->finalizeModels( + NormCache::hydrator()->getModels($result->ids, $model, $selectedCols, $result->models, $executionBuilder, true, $prototype) ); }, ); @@ -103,13 +105,10 @@ private function resolveIds( QueryBuilder $base, ?int $queryTtl, Model $prototype, - ?string $buildingKey = null, - array $versionKeys = [], - array $expectedVersions = [], - ?string $buildingToken = null, + BuildHandle $build, ): array { $ids = $this->buildIds($base, $prototype); - NormCache::storeQueryIds($key, $ids, $queryTtl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); + NormCache::queries()->store($key, $ids, $queryTtl, $build); return $ids; } diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php deleted file mode 100644 index d88d422..0000000 --- a/src/Cache/NormalizedCacheReader.php +++ /dev/null @@ -1,255 +0,0 @@ -keys->classKey($modelClass); - - if (empty($depClasses) && empty($depTableKeys)) { - $lockToken = $this->versions->buildLockToken(); - $result = $this->luaFetchVersionedQuery($classKey, $hash, $tag, $lockToken); - - $version = $this->versions->normalizeVersion($result[1]); - $queryKey = $this->keys->queryKey($classKey, $tag, $version, $hash); - $buildingKey = $this->keys->buildingPrefix($classKey) . $hash; - [$status, $ids] = $this->resolveIds($result, $queryKey); - - return $this->toQueryResult( - $status, $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), - [$this->keys->verKey($classKey)], - [(string) $version], - $ids, - is_array($ids) ? $this->fetchModels($classKey, $ids) : null - ); - } - - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); - $queryPrefix = $this->keys->queryPrefix($classKey, $tag); - $lockToken = $this->versions->buildLockToken(); - - if ($this->usesSlotting()) { - return $this->fetchSlotted($classKey, $hash, $queryPrefix, $versionKeys, $scheduledKeys, $lockToken); - } - - $result = $this->luaFetchMultiVersionedQuery( - $versionKeys, $scheduledKeys, $queryPrefix, - $this->keys->buildingPrefix($classKey), $classKey, - $hash, $lockToken - ); - - $seg = (string) ($result[1] ?? ''); - $queryKey = $queryPrefix . $seg . ':' . $hash; - $buildingKey = $this->keys->buildingPrefix($classKey) . $seg . ':' . $hash; - $expectedVersions = $this->keys->versionsFromSegment($seg); - [$status, $ids] = $this->resolveIds($result, $queryKey); - - return $this->toQueryResult( - $status, $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), - $versionKeys, $expectedVersions, - $ids, - is_array($ids) ? $this->fetchModels($classKey, $ids) : null - ); - } - - private function fetchSlotted( - string $classKey, string $hash, string $queryPrefix, - array $versionKeys, array $scheduledKeys, string $lockToken - ): QueryCacheResult { - [$queryKey, $buildingKey, $expectedVersions] = $this->resolveSlotKeys($classKey, $hash, $queryPrefix, $versionKeys, $scheduledKeys); - - $raw = $this->store->getRaw($queryKey); - $parsed = $raw !== null ? json_decode($raw, true) : null; - - if ($raw !== null && (!is_array($parsed) || !array_is_list($parsed))) { - $this->store->delete($queryKey); - $parsed = null; - } - - if ($parsed === null) { - if ($this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { - $this->store->delete($this->keys->buildingToWakeKey($buildingKey)); - - return new QueryCacheResult(CacheStatus::Miss, $queryKey, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions); - } - - return new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - } - - if (empty($parsed)) { - return new QueryCacheResult(CacheStatus::Empty, $queryKey, [], [], null, null, [], []); - } - - return new QueryCacheResult(CacheStatus::Hit, $queryKey, $parsed, $this->fetchModels($classKey, $parsed), null, null, [], []); - } - - private function resolveIds(array $result, string $queryKey): array - { - $status = LuaStatus::fromLua($result[0] ?? null); - - if (!$status->hasPayload()) { - return [$status, null]; - } - - if (!isset($result[2])) { - return [LuaStatus::Corrupt, null]; - } - - $ids = $this->resolveIdsPayload($result[2], $queryKey); - if ($ids === null) { - return [LuaStatus::Corrupt, null]; - } - - if (empty($ids)) { - return [LuaStatus::Empty, []]; - } - - return [$status, $ids]; - } - - private function resolveIdsPayload(mixed $payload, string $queryKey): ?array - { - $ids = is_string($payload) ? json_decode($payload, true) : $payload; - - if (!is_array($ids) || !array_is_list($ids)) { - $this->store->delete($queryKey); - - return null; - } - - return $ids; - } - - private function toQueryResult( - LuaStatus $status, - string $queryKey, - string $buildingKey, - string $lockToken, - array $versionKeys, - array $expectedVersions, - mixed $ids = null, - ?array $models = null - ): QueryCacheResult { - return match ($status) { - LuaStatus::Hit => new QueryCacheResult(CacheStatus::Hit, $queryKey, $ids, $models ?? [], null, null, [], []), - LuaStatus::Empty => new QueryCacheResult(CacheStatus::Empty, $queryKey, [], [], null, null, [], []), - LuaStatus::Miss => new QueryCacheResult(CacheStatus::Miss, $queryKey, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions), - LuaStatus::Building => new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []), - LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions), - }; - } - - private function claimMissAfterCorruptHit( - string $queryKey, - string $buildingKey, - string $lockToken, - array $versionKeys, - array $expectedVersions - ): QueryCacheResult { - if ($this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { - $this->store->delete($this->keys->buildingToWakeKey($buildingKey)); - - return new QueryCacheResult(CacheStatus::Miss, $queryKey, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions); - } - - return new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - } - - public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): void - { - $ids = array_map('strval', $ids); - $ttl ??= $this->queryTtl; - - if ($this->usesSlotting() && !empty($versionKeys)) { - $this->storeSlottingGuarded($key, json_encode($ids, JSON_THROW_ON_ERROR), $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); - - return; - } - - if (!empty($versionKeys)) { - $this->store->script( - RedisScripts::get('store_many_versioned'), - array_merge($versionKeys, [ - $key, - $buildingKey ?? '', - $buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : '', - ]), - array_merge( - [(string) count($versionKeys), '1', (string) $ttl], - $expectedVersions, - [json_encode($ids, JSON_THROW_ON_ERROR), $buildingToken ?? '', (string) $this->wakeTokenCount] - ) - ); - - return; - } - - if ($buildingKey === null) { - return; - } - - $this->store->storeRawAndRelease( - $key, - json_encode($ids, JSON_THROW_ON_ERROR), - $ttl, - $buildingKey, - $this->keys->buildingToWakeKey($buildingKey), - $buildingToken - ); - } - - public function waitForBuild(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): ?QueryCacheResult - { - $classKey = $this->keys->classKey($modelClass); - $this->store->brpop($this->keys->wakePrefix($classKey) . $hash, $this->stampedeWaitMs / 1000.0); - - $result = $this->fetch($modelClass, $hash, $tag, $depClasses, $depTableKeys); - - return $result->status === CacheStatus::Building ? null : $result; - } - - private function luaFetchVersionedQuery(string $classKey, string $hash, ?string $tag, string $lockToken): mixed - { - return $this->store->script(RedisScripts::get('fetch_versioned_query'), [ - $this->keys->verKey($classKey), - $this->keys->scheduledKey($classKey), - $this->keys->queryPrefix($classKey, $tag), - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - ], [$hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken]); - } - - private function luaFetchMultiVersionedQuery( - array $versionKeys, array $scheduledKeys, - string $queryPrefix, string $buildingPrefix, string $classKey, - string $hash, string $lockToken, - ): mixed { - return $this->store->script( - RedisScripts::get('fetch_multi_versioned_query'), - array_merge($versionKeys, $scheduledKeys, [$queryPrefix, $buildingPrefix, $this->keys->wakePrefix($classKey)]), - [$hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] - ); - } -} diff --git a/src/Cache/NormalizedCacheRepository.php b/src/Cache/NormalizedCacheRepository.php new file mode 100644 index 0000000..42638eb --- /dev/null +++ b/src/Cache/NormalizedCacheRepository.php @@ -0,0 +1,87 @@ + */ +final class NormalizedCacheRepository extends VersionedCacheRepository +{ + protected function queryPrefix(string $classKey, ?string $tag): string + { + return $this->keys->queryPrefix($classKey, $tag); + } + + protected function decodePayload(array $result, string $queryKey): array + { + $status = LuaStatus::fromLua($result[0] ?? null); + + if (!$status->hasPayload()) { + return [$status, null, null]; + } + + if (!isset($result[2])) { + return [LuaStatus::Corrupt, null, null]; + } + + $ids = $this->resolveIdsPayload($result[2], $queryKey); + if ($ids === null) { + return [LuaStatus::Corrupt, null, null]; + } + + if (empty($ids)) { + return [LuaStatus::Empty, [], null]; + } + + return [$status, $ids, null]; + } + + private function resolveIdsPayload(mixed $payload, string $queryKey): ?array + { + $ids = is_string($payload) ? json_decode($payload, true) : $payload; + + if (!is_array($ids) || !array_is_list($ids)) { + $this->store->delete($queryKey); + + return null; + } + + return $ids; + } + + protected function buildResult( + LuaStatus $status, + BuildContext $build, + ?array $ids = null, + ?array $throughKeys = null, + ?array $models = null, + ): QueryCacheResult { + return match ($status) { + LuaStatus::Hit => new QueryCacheResult(CacheStatus::Hit, $build->queryKey, $ids, $models ?? []), + LuaStatus::Empty => new QueryCacheResult(CacheStatus::Empty, $build->queryKey, [], []), + LuaStatus::Miss => new QueryCacheResult(CacheStatus::Miss, $build->queryKey, null, null, $build->handle()), + LuaStatus::Building => new QueryCacheResult(CacheStatus::Building, null, null, null), + LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($build), + }; + } + + public function store( + string $key, + array $ids, + ?int $ttl, + BuildHandle $build, + ): bool { + $ids = array_map('strval', $ids); + + return $this->storePayload( + $key, + json_encode($ids, JSON_THROW_ON_ERROR), + $ttl, + $build, + ); + } +} diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php deleted file mode 100644 index ef4fbf2..0000000 --- a/src/Cache/NormalizedThroughReader.php +++ /dev/null @@ -1,224 +0,0 @@ -keys->classKey($modelClass); - - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); - $queryPrefix = $this->keys->namespacedPrefix(CacheKeyBuilder::K_THROUGH, $classKey, $tag); - - $lockToken = $this->versions->buildLockToken(); - - if ($this->usesSlotting()) { - return $this->fetchSlotted($classKey, $hash, $queryPrefix, $versionKeys, $scheduledKeys, $lockToken); - } - - $result = $this->luaFetchMultiVersionedThrough( - $versionKeys, $scheduledKeys, $queryPrefix, - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - $hash, $lockToken - ); - - $seg = (string) ($result[1] ?? ''); - $queryKey = $queryPrefix . $seg . ':' . $hash; - $buildingKey = $this->keys->buildingPrefix($classKey) . $seg . ':' . $hash; - $expectedVersions = $this->keys->versionsFromSegment($seg); - [$status, $ids, $throughKeys] = $this->resolveIdsAndThroughKeys($result, $queryKey); - $models = $status->hasPayload() ? $this->fetchModels($classKey, $ids) : null; - - return $this->toThroughResult( - $status, $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), - $versionKeys, $expectedVersions, - $ids, $throughKeys, $models - ); - } - - private function fetchSlotted( - string $classKey, string $hash, string $queryPrefix, - array $versionKeys, array $scheduledKeys, string $lockToken - ): ThroughCacheResult { - [$queryKey, $buildingKey, $expectedVersions] = $this->resolveSlotKeys($classKey, $hash, $queryPrefix, $versionKeys, $scheduledKeys); - - $raw = $this->store->getRaw($queryKey); - $parsed = $raw !== null ? json_decode($raw, true) : null; - - if ($raw !== null && (!is_array($parsed) || !is_array($parsed['i'] ?? null) || !is_array($parsed['t'] ?? null))) { - $this->store->delete($queryKey); - $parsed = null; - } - - if ($parsed === null) { - if ($this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { - $this->store->delete($this->keys->buildingToWakeKey($buildingKey)); - - return new ThroughCacheResult(CacheStatus::Miss, $queryKey, null, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions); - } - - return new ThroughCacheResult(CacheStatus::Building, null, null, null, null, null, null, [], []); - } - - $ids = $parsed['i']; - $throughKeys = $parsed['t']; - - if (empty($ids)) { - return new ThroughCacheResult(CacheStatus::Empty, $queryKey, [], [], [], null, null, [], []); - } - - return new ThroughCacheResult(CacheStatus::Hit, $queryKey, $ids, $throughKeys, $this->fetchModels($classKey, $ids), null, null, [], []); - } - - private function resolveIdsAndThroughKeys(array $result, string $queryKey): array - { - $status = LuaStatus::fromLua($result[0] ?? null); - - if (!$status->hasPayload()) { - return [$status, null, null]; - } - - if (!isset($result[2])) { - return [LuaStatus::Corrupt, null, null]; - } - - $parsed = json_decode($result[2], true); - - if (!is_array($parsed) || !is_array($parsed['i'] ?? null) || !is_array($parsed['t'] ?? null)) { - $this->store->delete($queryKey); - - return [LuaStatus::Corrupt, null, null]; - } - - if (empty($parsed['i'])) { - return [LuaStatus::Empty, [], []]; - } - - return [LuaStatus::Hit, $parsed['i'], $parsed['t']]; - } - - private function toThroughResult( - LuaStatus $status, - string $queryKey, - string $buildingKey, - string $lockToken, - array $versionKeys, - array $expectedVersions, - ?array $ids, - ?array $throughKeys, - ?array $models - ): ThroughCacheResult { - return match ($status) { - LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $queryKey, $ids, $throughKeys, $models ?? [], null, null, [], []), - LuaStatus::Empty => new ThroughCacheResult(CacheStatus::Empty, $queryKey, [], [], [], null, null, [], []), - LuaStatus::Miss => new ThroughCacheResult(CacheStatus::Miss, $queryKey, null, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions), - LuaStatus::Building => new ThroughCacheResult(CacheStatus::Building, null, null, null, null, null, null, [], []), - LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions), - }; - } - - private function claimMissAfterCorruptHit( - string $queryKey, - string $buildingKey, - string $lockToken, - array $versionKeys, - array $expectedVersions - ): ThroughCacheResult { - if ($this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { - $this->store->delete($this->keys->buildingToWakeKey($buildingKey)); - - return new ThroughCacheResult(CacheStatus::Miss, $queryKey, null, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions); - } - - return new ThroughCacheResult(CacheStatus::Building, null, null, null, null, null, null, [], []); - } - - public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): void - { - $ids = array_map('strval', $ids); - $ttl ??= $this->queryTtl; - - $payload = json_encode(['i' => $ids, 't' => $throughKeys], JSON_THROW_ON_ERROR); - - if ($this->usesSlotting() && !empty($versionKeys)) { - $this->storeSlottingGuarded($key, $payload, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); - - return; - } - - if (!empty($versionKeys)) { - $this->store->script( - RedisScripts::get('store_many_versioned'), - array_merge($versionKeys, [ - $key, - $buildingKey ?? '', - $buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : '', - ]), - array_merge( - [(string) count($versionKeys), '1', (string) $ttl], - $expectedVersions, - [$payload, $buildingToken ?? '', (string) $this->wakeTokenCount] - ) - ); - - return; - } - - if ($buildingKey === null) { - return; - } - - $this->store->storeRawAndRelease( - $key, - $payload, - $ttl, - $buildingKey, - $this->keys->buildingToWakeKey($buildingKey), - $buildingToken - ); - } - - public function waitForBuild(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): ?ThroughCacheResult - { - $classKey = $this->keys->classKey($modelClass); - $this->store->brpop($this->keys->wakePrefix($classKey) . $hash, $this->stampedeWaitMs / 1000.0); - - $result = $this->fetch($modelClass, $hash, $tag, $depClasses, $depTableKeys); - - return $result->status === CacheStatus::Building ? null : $result; - } - - private function luaFetchMultiVersionedThrough( - array $versionKeys, array $scheduledKeys, - string $queryPrefix, string $buildingPrefix, string $wakePrefix, - string $hash, string $lockToken, - ): mixed { - return $this->store->script( - RedisScripts::get('fetch_multi_versioned_query'), - array_merge($versionKeys, $scheduledKeys, [$queryPrefix, $buildingPrefix, $wakePrefix]), - [$hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] - ); - } -} diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php deleted file mode 100644 index e4b74f0..0000000 --- a/src/Cache/ResultCacheReader.php +++ /dev/null @@ -1,361 +0,0 @@ -store->requiresSlotting($this->slotting); - } - - public function fetch( - string $modelClass, array $depClasses, string $hash, - ?string $tag, array $depTableKeys, - string $namespace = CacheKeyBuilder::K_RESULT - ): ResultCacheResult { - $classKey = $this->keys->classKey($modelClass); - $lockSuffix = $this->keys->resultBuildIdentityHash($namespace, $tag, $hash); - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); - $wakeKey = $this->keys->wakeKey($classKey, $lockSuffix); - $lockToken = $this->versions->buildLockToken(); - - if ($this->usesSlotting()) { - $resolvedVersions = $this->versions->resolveVersions($versionKeys, $scheduledKeys); - $seg = $this->keys->versionSegment($versionKeys, $resolvedVersions); - $expectedVersions = $this->versions->expectedVersions($versionKeys, $resolvedVersions); - $resultKey = $this->keys->namespacedKey($namespace, $classKey, $tag, $seg, $hash); - $buildingKey = $this->keys->resultBuildingKey($classKey, $seg, $lockSuffix); - - $payload = $this->store->get($resultKey); - $status = $payload !== null ? LuaStatus::Hit : LuaStatus::Miss; - - return $this->toResultCacheResult( - $status, $resultKey, $buildingKey, $lockToken, $wakeKey, - $versionKeys, $expectedVersions, $payload, true, false - ); - } - - $result = $this->luaFetchVersionedResult( - $versionKeys, $scheduledKeys, - $this->keys->namespacedPrefix($namespace, $classKey, $tag), - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - $hash, $lockSuffix, $lockToken - ); - - $status = LuaStatus::fromLua($result[0] ?? null); - $seg = (string) ($result[1] ?? ''); - $resultKey = $this->keys->namespacedKey($namespace, $classKey, $tag, $seg, $hash); - $buildingKey = $this->keys->resultBuildingKey($classKey, $seg, $lockSuffix); - $expectedVersions = $this->keys->versionsFromSegment($seg); - - return $this->toResultCacheResult( - $status, $resultKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), $wakeKey, - $versionKeys, $expectedVersions, $result[2] ?? null, false, $status === LuaStatus::Miss - ); - } - - private function toResultCacheResult( - LuaStatus $status, - string $resultKey, - string $buildingKey, - string $lockToken, - string $wakeKey, - array $versionKeys, - array $expectedVersions, - mixed $payload = null, - bool $payloadAlreadyUnserialized = true, - bool $alreadyClaimed = false - ): ResultCacheResult { - if ($status === LuaStatus::Hit) { - $unserialized = $payloadAlreadyUnserialized ? $payload : $this->store->unserialize($payload); - if (is_array($unserialized)) { - return new ResultCacheResult(CacheStatus::Hit, $resultKey, $unserialized, null, null, null, $versionKeys, $expectedVersions); - } - - // Corrupt payload (not an array), treat as miss. - $alreadyClaimed = false; - } - - if ($status === LuaStatus::Building) { - return new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - } - - // Standard miss or corrupt hit: attempt to claim building lock. - if ($alreadyClaimed || $this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { - if (!$alreadyClaimed) { - $this->store->delete($wakeKey); - } - - return new ResultCacheResult(CacheStatus::Miss, $resultKey, null, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions); - } - - return new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - } - - public function fetchPivot( - string $parentClass, string $relatedClass, string $relation, - array $parentIds, string $constraintHash, ?string $pivotTableKey - ): PivotCacheResult { - $parentKey = $this->keys->classKey($parentClass); - $relatedKey = $this->keys->classKey($relatedClass); - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($relatedKey, [], [$pivotTableKey ?? $parentKey]); - - if ($this->usesSlotting()) { - $resolvedVersions = $this->versions->resolveVersions($versionKeys, $scheduledKeys); - $seg = $this->keys->versionSegment($versionKeys, $resolvedVersions); - $expectedVersions = $this->versions->expectedVersions($versionKeys, $resolvedVersions); - $pivotKeys = []; - foreach ($parentIds as $id) { - $pivotKeys[] = $this->keys->pivotKey($parentKey, $relatedKey, $relation, $constraintHash, $seg, $id); - } - - return new PivotCacheResult( - $seg, - array_combine($parentIds, $this->store->getMany($pivotKeys)), - $versionKeys, - $expectedVersions, - ); - } - - $seg = $this->luaFetchVersionedPivotSegment($versionKeys, $scheduledKeys); - - $pivotKeys = []; - foreach ($parentIds as $id) { - $pivotKeys[] = $this->keys->pivotKey($parentKey, $relatedKey, $relation, $constraintHash, $seg, $id); - } - - $data = array_combine($parentIds, $this->store->getMany($pivotKeys)); - $expectedVersions = $this->keys->versionsFromSegment($seg); - $missed = array_keys(array_filter($data, fn($payload) => !is_array($payload))); - - if ($missed === []) { - return new PivotCacheResult($seg, $data, $versionKeys, $expectedVersions); - } - - [$lockKey, $wakeKey] = $this->pivotLockKeys($relatedKey, $relation, $constraintHash, $parentIds, $seg); - $token = $this->versions->buildLockToken(); - - $missedKeys = []; - foreach ($missed as $id) { - $missedKeys[] = $this->keys->pivotKey($parentKey, $relatedKey, $relation, $constraintHash, $seg, $id); - } - - $result = $this->store->script( - RedisScripts::get('fetch_pivot_build_status'), - [...$missedKeys, $lockKey, $wakeKey], - [$token, (string) $this->buildingLockTtl] - ); - - $raw = $this->store->unserializeMany($result[2] ?? []); - foreach ($missed as $i => $id) { - if (isset($raw[$i]) && is_array($raw[$i])) { - $data[$id] = $raw[$i]; - } - } - - if (array_filter($data, fn($payload) => !is_array($payload)) === []) { - return new PivotCacheResult($seg, $data, $versionKeys, $expectedVersions); - } - - if ($result[0] === 'miss') { - return new PivotCacheResult( - $seg, $data, $versionKeys, $expectedVersions, - CacheStatus::Miss, $lockKey, $token, $wakeKey - ); - } - - return new PivotCacheResult( - $seg, $data, $versionKeys, $expectedVersions, - CacheStatus::Building - ); - } - - public function waitForPivotBuild( - string $parentClass, string $relatedClass, string $relation, - array $parentIds, string $constraintHash, ?string $pivotTableKey - ): ?PivotCacheResult { - $relatedKey = $this->keys->classKey($relatedClass); - [, $wakeKey] = $this->pivotLockKeys($relatedKey, $relation, $constraintHash, $parentIds, null); - - $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); - - $result = $this->fetchPivot($parentClass, $relatedClass, $relation, $parentIds, $constraintHash, $pivotTableKey); - - return $result->status === CacheStatus::Building ? null : $result; - } - - // Lock key is segment-specific; the wake key is not, since waiters re-fetch to learn the current segment anyway. - private function pivotLockKeys(string $relatedKey, string $relation, string $constraintHash, array $parentIds, ?string $seg): array - { - $sortedIds = $parentIds; - sort($sortedIds); - $lockSuffix = $this->keys->resultBuildIdentityHash('pivot', $relation, $constraintHash . ':' . implode(',', $sortedIds)); - - return [ - $seg !== null ? $this->keys->resultBuildingKey($relatedKey, $seg, $lockSuffix) : null, - $this->keys->wakeKey($relatedKey, $lockSuffix), - ]; - } - - public function store( - string $key, mixed $payload, ?string $buildingKey, ?int $ttl, - ?string $wakeKey, array $versionKeys, array $expectedVersions, ?string $buildingToken - ): bool { - $ttl ??= $this->queryTtl; - - if ($versionKeys !== []) { - return $this->storeEntry($key, $payload, $ttl, $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken); - } - - return $this->store->storeSerializedAndRelease( - $key, $payload, $ttl, $buildingKey, - $wakeKey ?? ($buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : null), - $buildingToken - ); - } - - public function storeMany( - array $entries, int $ttl, - array $versionKeys, array $expectedVersions, - ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null - ): bool { - if (empty($entries)) { - if ($this->usesSlotting() && $buildingKey !== null) { - $this->store->releaseBuilding($buildingKey, $wakeKey ?? $this->keys->buildingToWakeKey($buildingKey), $buildingToken); - } - - return true; - } - - if ($this->usesSlotting()) { - return $this->storeSlottingGuarded( - fn() => $this->store->setMany($entries, $ttl), - $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken - ); - } - - return (bool) $this->store->script( - RedisScripts::get('store_many_versioned'), - array_merge($versionKeys, array_keys($entries), [ - $buildingKey ?? '', - $wakeKey ?? ($buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : ''), - ]), - array_merge( - [(string) count($versionKeys), (string) count($entries), (string) $ttl], - $expectedVersions, - array_map(fn($p) => $this->store->serialize($p), array_values($entries)), - [$buildingToken ?? '', (string) $this->wakeTokenCount] - ) - ); - } - - public function storeEntry( - string $key, mixed $payload, int $ttl, - array $versionKeys, array $expectedVersions, - ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null - ): bool { - if ($this->usesSlotting()) { - return $this->storeSlottingGuarded( - fn() => $this->store->set($key, $payload, $ttl), - $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken - ); - } - - return (bool) $this->store->script( - RedisScripts::get('store_many_versioned'), - array_merge($versionKeys, [ - $key, - $buildingKey ?? '', - $wakeKey ?? ($buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : ''), - ]), - array_merge( - [(string) count($versionKeys), '1', (string) $ttl], - $expectedVersions, - [$this->store->serialize($payload), $buildingToken ?? '', (string) $this->wakeTokenCount] - ) - ); - } - - private function storeSlottingGuarded( - callable $write, - array $versionKeys, array $expectedVersions, - ?string $buildingKey, ?string $wakeKey, ?string $buildingToken - ): bool { - if ($buildingKey !== null && $buildingToken !== null && $this->store->getRaw($buildingKey) !== $buildingToken) { - return false; - } - - $written = $this->versions->versionsStillMatch($versionKeys, $expectedVersions); - if ($written) { - $write(); - } - - if ($buildingKey !== null) { - $this->store->releaseBuilding( - $buildingKey, - $wakeKey ?? $this->keys->buildingToWakeKey($buildingKey), - $buildingToken - ); - } - - return $written; - } - - public function waitForBuild( - string $modelClass, array $depClasses, string $hash, - ?string $tag, array $depTableKeys, - string $namespace = CacheKeyBuilder::K_RESULT - ): ?ResultCacheResult { - $classKey = $this->keys->classKey($modelClass); - $wakeSuffix = $this->keys->resultBuildIdentityHash($namespace, $tag, $hash); - $this->store->brpop($this->keys->wakePrefix($classKey) . $wakeSuffix, $this->stampedeWaitMs / 1000.0); - - $result = $this->fetch($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace); - - return $result->status === CacheStatus::Building ? null : $result; - } - - private function luaFetchVersionedResult( - array $versionKeys, array $scheduledKeys, - string $resultPrefix, string $buildingPrefix, string $wakePrefix, - string $hash, string $lockSuffix, string $lockToken - ): array { - return $this->store->script( - RedisScripts::get('fetch_versioned_result'), - array_merge($versionKeys, $scheduledKeys, [$resultPrefix, $buildingPrefix, $wakePrefix]), - [$hash, $lockSuffix, (string) $this->buildingLockTtl, (string) (int) floor(microtime(true) * 1000), $lockToken] - ); - } - - private function luaFetchVersionedPivotSegment(array $versionKeys, array $scheduledKeys): string - { - $result = $this->store->script( - RedisScripts::get('fetch_versioned_pivot'), - array_merge($versionKeys, $scheduledKeys), - [(string) (int) floor(microtime(true) * 1000)] - ); - - return (string) ($result ?? ''); - } -} diff --git a/src/Cache/ResultCacheRepository.php b/src/Cache/ResultCacheRepository.php new file mode 100644 index 0000000..495a9ac --- /dev/null +++ b/src/Cache/ResultCacheRepository.php @@ -0,0 +1,256 @@ +keys->classKey($modelClass, $connection); + $lockSuffix = $this->keys->resultBuildIdentityHash($namespace, $tag, $hash); + [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs( + $classKey, + $depClasses, + $depTableKeys, + connection: $connection, + ); + $wakeKey = $this->keys->wakeKey($classKey, $lockSuffix); + $lockToken = $this->versions->buildLockToken(); + + $result = $this->store->fetchVersionedPayload( + $versionKeys, + $scheduledKeys, + $this->keys->namespacedPrefix($namespace, $classKey, $tag), + $this->keys->buildingPrefix($classKey), + $this->keys->wakePrefix($classKey), + $hash, + $lockSuffix, + $lockToken, + $this->buildingLockTtl, + $this->config->cooldownEnabled(), + ); + + $status = LuaStatus::fromLua($result[0] ?? null); + $seg = (string) ($result[1] ?? ''); + $resultKey = $this->keys->namespacedKey($namespace, $classKey, $tag, $seg, $hash); + $buildingKey = $this->keys->resultBuildingKey($classKey, $seg, $lockSuffix); + $expectedVersions = $this->keys->versionsFromSegment($seg); + + return $this->toResultCacheResult( + $status, $resultKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), $wakeKey, + $versionKeys, $expectedVersions, $result[2] ?? null, false, $status === LuaStatus::Miss + ); + } + + private function toResultCacheResult( + LuaStatus $status, + string $resultKey, + string $buildingKey, + string $lockToken, + string $wakeKey, + array $versionKeys, + array $expectedVersions, + mixed $payload = null, + bool $payloadAlreadyUnserialized = true, + bool $alreadyClaimed = false + ): ResultCacheResult { + if ($status === LuaStatus::Hit) { + $unserialized = $payloadAlreadyUnserialized ? $payload : $this->store->unserialize($payload); + if (is_array($unserialized)) { + return new ResultCacheResult(CacheStatus::Hit, $resultKey, $unserialized, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); + } + + $alreadyClaimed = false; + } + + if ($status === LuaStatus::Building) { + return new ResultCacheResult(CacheStatus::Building, null, null); + } + + if ($alreadyClaimed || $this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { + if (!$alreadyClaimed) { + $this->store->delete($wakeKey); + } + + return new ResultCacheResult(CacheStatus::Miss, $resultKey, null, new BuildHandle($buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions)); + } + + return new ResultCacheResult(CacheStatus::Building, null, null); + } + + public function fetchPivot( + string $parentClass, string $relatedClass, string $relation, + array $parentIds, string $constraintHash, ?string $pivotTableKey + ): PivotCacheResult { + $parentKey = $this->keys->classKey($parentClass); + $relatedKey = $this->keys->classKey($relatedClass); + [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($relatedKey, [], [$pivotTableKey ?? $parentKey]); + + $seg = $this->store->fetchVersionedPivotSegment($versionKeys, $scheduledKeys); + + $pivotKeys = []; + foreach ($parentIds as $id) { + $pivotKeys[] = $this->keys->pivotKey($parentKey, $relatedKey, $relation, $constraintHash, $seg, $id); + } + + $data = array_combine($parentIds, $this->store->getMany($pivotKeys)); + $expectedVersions = $this->keys->versionsFromSegment($seg); + $missed = array_keys(array_filter($data, fn($payload) => !is_array($payload))); + + if ($missed === []) { + return new PivotCacheResult($seg, $data, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); + } + + [$lockKey, $wakeKey] = $this->pivotLockKeys($relatedKey, $relation, $constraintHash, $parentIds, $seg); + $token = $this->versions->buildLockToken(); + + $missedKeys = []; + foreach ($missed as $id) { + $missedKeys[] = $this->keys->pivotKey($parentKey, $relatedKey, $relation, $constraintHash, $seg, $id); + } + + $result = $this->store->fetchBatchBuildStatus($missedKeys, $lockKey, $wakeKey, $token, $this->buildingLockTtl); + + $raw = $this->store->unserializeMany($result[3] ?? []); + foreach ($missed as $i => $id) { + if (isset($raw[$i]) && is_array($raw[$i])) { + $data[$id] = $raw[$i]; + } + } + + if (array_filter($data, fn($payload) => !is_array($payload)) === []) { + return new PivotCacheResult($seg, $data, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); + } + + if (LuaStatus::fromLua($result[0] ?? null) === LuaStatus::Miss) { + return new PivotCacheResult( + $seg, $data, + new BuildHandle($lockKey, $token, $wakeKey, $versionKeys, $expectedVersions), + CacheStatus::Miss + ); + } + + return new PivotCacheResult( + $seg, $data, + new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions), + CacheStatus::Building + ); + } + + public function waitForPivotBuild( + string $parentClass, string $relatedClass, string $relation, + array $parentIds, string $constraintHash, ?string $pivotTableKey + ): ?PivotCacheResult { + $relatedKey = $this->keys->classKey($relatedClass); + [, $wakeKey] = $this->pivotLockKeys($relatedKey, $relation, $constraintHash, $parentIds, null); + + $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); + + $result = $this->fetchPivot($parentClass, $relatedClass, $relation, $parentIds, $constraintHash, $pivotTableKey); + + return $result->status === CacheStatus::Building ? null : $result; + } + + private function pivotLockKeys(string $relatedKey, string $relation, string $constraintHash, array $parentIds, ?string $seg): array + { + $sortedIds = $parentIds; + sort($sortedIds); + $lockSuffix = $this->keys->resultBuildIdentityHash('pivot', $relation, $constraintHash . ':' . implode(',', $sortedIds)); + + return [ + $seg !== null ? $this->keys->resultBuildingKey($relatedKey, $seg, $lockSuffix) : null, + $this->keys->wakeKey($relatedKey, $lockSuffix), + ]; + } + + public function store( + string $key, + mixed $payload, + ?int $ttl, + BuildHandle $build, + ): bool { + $ttl ??= $this->queryTtl; + + if ($build->versionKeys !== []) { + return $this->storeEntry($key, $payload, $ttl, $build); + } + + return $this->store->storeSerializedAndRelease( + $key, + $payload, + $ttl, + $build->buildingKey, + $build->wakeKey, + $build->buildingToken, + ); + } + + public function storeMany( + array $entries, + ?int $ttl = null, + BuildHandle $build = new BuildHandle, + ): bool { + if (empty($entries)) { + return true; + } + + $ttl ??= $this->queryTtl; + + return $this->store->storeVersionedPayload( + array_map(fn($p) => $this->store->serialize($p), $entries), + $ttl, + $build->versionKeys, + $build->expectedVersions, + $build->buildingKey, + $build->wakeKey, + $build->buildingToken, + ); + } + + public function storeEntry( + string $key, + mixed $payload, + ?int $ttl = null, + BuildHandle $build = new BuildHandle, + ): bool { + return $this->storeMany([$key => $payload], $ttl, $build); + } + + public function waitForBuild( + string $modelClass, array $depClasses, string $hash, + ?string $tag, array $depTableKeys, + string $namespace = CacheKeyBuilder::K_RESULT, + ?string $connection = null, + ): ?ResultCacheResult { + $classKey = $this->keys->classKey($modelClass, $connection); + $wakeSuffix = $this->keys->resultBuildIdentityHash($namespace, $tag, $hash); + $this->store->brpop($this->keys->wakePrefix($classKey) . $wakeSuffix, $this->stampedeWaitMs / 1000.0); + + $result = $this->fetch($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace, $connection); + + return $result->status === CacheStatus::Building ? null : $result; + } +} diff --git a/src/Cache/ResultExecutor.php b/src/Cache/ResultExecutor.php index b7accce..e7cbfa7 100644 --- a/src/Cache/ResultExecutor.php +++ b/src/Cache/ResultExecutor.php @@ -4,9 +4,9 @@ use Closure; use NormCache\Enums\ResultKind; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\CacheReporter; -use NormCache\Support\FallbackHandler; use NormCache\Support\QueryHasher; use NormCache\Values\CacheConfig; use NormCache\Values\CachePlan; @@ -17,7 +17,7 @@ final class ResultExecutor { public function __construct( private readonly ExecutionEngine $engine, - private readonly ResultCacheReader $resultReader, + private readonly ResultCacheRepository $results, private readonly CacheConfig $config, ) {} @@ -29,7 +29,9 @@ public function execute( Closure $compute, ): array { $builder = $prepared->builder; - $modelClass = $builder->getModel()::class; + $model = $builder->getModel(); + $modelClass = $model::class; + $connection = $model->getConnectionName(); $tag = $builder->getCacheTag(); $ttl = $builder->getQueryTtl(); $debugbarStart = CacheReporter::beginMeasure(); @@ -40,11 +42,11 @@ public function execute( $depTableKeys = $plan->dependencies->tables; $structuredPayload = $kind === ResultKind::Collection; - $execution = FallbackHandler::rescue( + $execution = CacheFallback::rescue( $this->config, fn() => $this->engine->runScalar( - fetch: fn() => $this->resultReader->fetch($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace), - waitForBuild: fn() => $this->resultReader->waitForBuild($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace), + fetch: fn() => $this->results->fetch($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace, $connection), + waitForBuild: fn() => $this->results->waitForBuild($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace, $connection), compute: fn() => ['value' => $compute(), 'cached' => false], onStore: function ($value, $result) use ($modelClass, $ttl, $debugbarStart, $kind) { CacheReporter::queryMiss($modelClass, $result->key, $debugbarStart, ['kind' => $kind->value]); @@ -52,7 +54,7 @@ public function execute( $this->storeResult($result, $value['value'], $ttl); }, onHit: function ($result) use ($modelClass, $debugbarStart, $kind, $compute, $structuredPayload, $ttl) { - if (!is_array($result->payload) || (!$structuredPayload && !array_key_exists(0, $result->payload))) { + if (!$structuredPayload && !array_key_exists(0, $result->payload)) { $value = $compute(); $this->storeResult($result, $value, $ttl); @@ -76,15 +78,11 @@ public function execute( private function storeResult(ResultCacheResult $result, mixed $payload, ?int $ttl): void { - $this->resultReader->store( + $this->results->store( $result->key, is_array($payload) ? $payload : [$payload], - $result->buildingKey, $ttl, - $result->wakeKey, - $result->versionKeys, - $result->expectedVersions, - $result->buildingToken + $result->build, ); } @@ -102,12 +100,7 @@ private function resolveHash(PreparedQuery $prepared, ResultKind $kind, array $c $query = $prepared->base; if ($kind === ResultKind::Collection) { - if (empty($query->columns) && $columns !== ['*']) { - $query = $query->cloneWithout([]); - $query->columns = $columns; - } - - return QueryHasher::forResultQuery($prepared->builder, $query); + return QueryHasher::forResultQuery($prepared->builder, $prepared->baseWithColumns($columns)); } return match ($kind) { diff --git a/src/Cache/SlottedQueryAccess.php b/src/Cache/SlottedQueryAccess.php deleted file mode 100644 index 16cc4e7..0000000 --- a/src/Cache/SlottedQueryAccess.php +++ /dev/null @@ -1,55 +0,0 @@ -store->requiresSlotting($this->slotting); - } - - private function resolveSlotKeys( - string $classKey, string $hash, string $queryPrefix, - array $versionKeys, array $scheduledKeys - ): array { - $resolvedVersions = $this->versions->resolveVersions($versionKeys, $scheduledKeys); - $seg = $this->keys->versionSegment($versionKeys, $resolvedVersions); - $expectedVersions = $this->versions->expectedVersions($versionKeys, $resolvedVersions); - - return [ - $queryPrefix . $seg . ':' . $hash, - $this->keys->buildingPrefix($classKey) . $seg . ':' . $hash, - $expectedVersions, - ]; - } - - private function fetchModels(string $classKey, array $ids): array - { - if ($ids === []) { - return []; - } - - $modelPrefix = $this->keys->modelPrefix($classKey); - - return $this->store->getMany(array_map(static fn($id) => $modelPrefix . $id, $ids)); - } - - private function storeSlottingGuarded( - string $key, string $payload, int $ttl, - ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken - ): void { - if ($buildingKey !== null && $buildingToken !== null && $this->store->getRaw($buildingKey) !== $buildingToken) { - return; - } - - if ($this->versions->versionsStillMatch($versionKeys, $expectedVersions)) { - $this->store->setRaw($key, $payload, $ttl); - } - - if ($buildingKey !== null) { - $this->store->releaseBuilding($buildingKey, $this->keys->buildingToWakeKey($buildingKey), $buildingToken); - } - } -} diff --git a/src/Cache/ThroughCacheRepository.php b/src/Cache/ThroughCacheRepository.php new file mode 100644 index 0000000..6230cc8 --- /dev/null +++ b/src/Cache/ThroughCacheRepository.php @@ -0,0 +1,80 @@ + */ +final class ThroughCacheRepository extends VersionedCacheRepository +{ + protected function queryPrefix(string $classKey, ?string $tag): string + { + return $this->keys->namespacedPrefix(CacheKeyBuilder::K_THROUGH, $classKey, $tag); + } + + protected function decodePayload(array $result, string $queryKey): array + { + $status = LuaStatus::fromLua($result[0] ?? null); + + if (!$status->hasPayload()) { + return [$status, null, null]; + } + + if (!isset($result[2])) { + return [LuaStatus::Corrupt, null, null]; + } + + $parsed = json_decode($result[2], true); + + if (!is_array($parsed) || !is_array($parsed['i'] ?? null) || !is_array($parsed['t'] ?? null)) { + $this->store->delete($queryKey); + + return [LuaStatus::Corrupt, null, null]; + } + + if (empty($parsed['i'])) { + return [LuaStatus::Empty, [], []]; + } + + return [LuaStatus::Hit, $parsed['i'], $parsed['t']]; + } + + protected function buildResult( + LuaStatus $status, + BuildContext $build, + ?array $ids = null, + ?array $throughKeys = null, + ?array $models = null, + ): ThroughCacheResult { + return match ($status) { + LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $build->queryKey, $ids, $throughKeys, $models ?? []), + LuaStatus::Empty => new ThroughCacheResult(CacheStatus::Empty, $build->queryKey, [], [], []), + LuaStatus::Miss => new ThroughCacheResult(CacheStatus::Miss, $build->queryKey, null, null, null, $build->handle()), + LuaStatus::Building => new ThroughCacheResult(CacheStatus::Building, null, null, null, null), + LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($build), + }; + } + + public function store( + string $key, + array $ids, + array $throughKeys, + ?int $ttl, + BuildHandle $build, + ): bool { + $ids = array_map('strval', $ids); + $payload = json_encode(['i' => $ids, 't' => $throughKeys], JSON_THROW_ON_ERROR); + + return $this->storePayload( + $key, + $payload, + $ttl, + $build, + ); + } +} diff --git a/src/Cache/VersionTracker.php b/src/Cache/VersionTracker.php index acfb424..bafba59 100644 --- a/src/Cache/VersionTracker.php +++ b/src/Cache/VersionTracker.php @@ -3,8 +3,8 @@ namespace NormCache\Cache; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; +use NormCache\Values\CacheSpace; final class VersionTracker { @@ -13,54 +13,26 @@ public function __construct( private readonly CacheKeyBuilder $keys, ) {} - public function currentVersion(string $modelClass): int - { + public function currentVersion( + string $modelClass, + ?CacheSpace $space = null, + ?string $connection = null, + ): int { return $this->normalizeVersion( - $this->fetchVersionWithCooldown($this->keys->classKey($modelClass)) + $this->fetchVersionWithCooldown($this->keys->classKey($modelClass, $connection), $space) ); } - public function currentTableVersion(string $connectionName, string $table): int + public function currentTableVersion(string $connectionName, string $table, ?CacheSpace $space = null): int { return $this->normalizeVersion( - $this->fetchVersionWithCooldown($this->keys->tableKey($connectionName, $table)) + $this->fetchVersionWithCooldown($this->keys->tableKey($connectionName, $table), $space) ); } - public function resolveVersions(array $versionKeys, array $scheduledKeys): array - { - $script = RedisScripts::get('fetch_version_with_cooldown'); - $nowMs = (string) (int) floor(microtime(true) * 1000); - $map = []; - - foreach ($versionKeys as $i => $verKey) { - if (!isset($map[$verKey])) { - $map[$verKey] = (string) ($this->store->script($script, [$verKey, $scheduledKeys[$i]], [$nowMs]) ?? '0'); - } - } - - return $map; - } - - public function expectedVersions(array $versionKeys, array $resolvedVersions): array - { - return array_map(static fn($key) => $resolvedVersions[$key], $versionKeys); - } - - public function versionsStillMatch(array $versionKeys, array $expectedVersions): bool - { - foreach ($this->store->getRawMany($versionKeys) as $i => $version) { - if ((string) ($version ?? '0') !== (string) $expectedVersions[$i]) { - return false; - } - } - - return true; - } - public function buildLockToken(): string { - return hash('xxh3', microtime(true) . mt_rand()); + return bin2hex(random_bytes(16)); } public function normalizeVersion(mixed $value = null): int @@ -68,12 +40,11 @@ public function normalizeVersion(mixed $value = null): int return $value !== null ? (int) $value : 0; } - private function fetchVersionWithCooldown(string $classKey): mixed + private function fetchVersionWithCooldown(string $classKey, ?CacheSpace $space = null): mixed { - return $this->store->script( - RedisScripts::get('fetch_version_with_cooldown'), - [$this->keys->verKey($classKey), $this->keys->scheduledKey($classKey)], - [(string) (int) floor(microtime(true) * 1000)] + return $this->store->fetchVersionWithCooldown( + $this->keys->verKey($classKey, $space), + $this->keys->scheduledKey($classKey, $space) ); } } diff --git a/src/Cache/VersionedCacheRepository.php b/src/Cache/VersionedCacheRepository.php new file mode 100644 index 0000000..b5d6b16 --- /dev/null +++ b/src/Cache/VersionedCacheRepository.php @@ -0,0 +1,158 @@ +, 2: ?array} */ + abstract protected function decodePayload(array $result, string $queryKey): array; + + /** @return TResult */ + abstract protected function buildResult( + LuaStatus $status, + BuildContext $build, + ?array $ids = null, + ?array $throughKeys = null, + ?array $models = null, + ): QueryCacheResult|ThroughCacheResult; + + /** @return TResult */ + public function fetch( + string $modelClass, + string $hash, + ?string $tag, + array $depClasses, + array $depTableKeys, + ?string $connection = null, + ): QueryCacheResult|ThroughCacheResult { + $classKey = $this->keys->classKey($modelClass, $connection); + [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs( + $classKey, + $depClasses, + $depTableKeys, + connection: $connection, + ); + $queryPrefix = $this->queryPrefix($classKey, $tag); + $lockToken = $this->versions->buildLockToken(); + + $result = $this->store->fetchVersionedPayload( + $versionKeys, + $scheduledKeys, + $queryPrefix, + $this->keys->buildingPrefix($classKey), + $this->keys->wakePrefix($classKey), + $hash, + $hash, + $lockToken, + $this->buildingLockTtl, + $this->config->cooldownEnabled(), + ); + + $seg = (string) ($result[1] ?? ''); + $queryKey = $queryPrefix . $seg . ':' . $hash; + $buildingKey = $this->keys->resultBuildingKey($classKey, $seg, $hash); + $wakeKey = $this->keys->wakeKey($classKey, $hash); + $expectedVersions = $this->keys->versionsFromSegment($seg); + + [$status, $ids, $throughKeys] = $this->decodePayload($result, $queryKey); + + // Use the version Lua already resolved atomically; a separate GET would race with version bumps. + $modelVersion = is_array($ids) ? (int) ($expectedVersions[0] ?? 0) : 0; + $models = is_array($ids) ? $this->fetchModels($classKey, $modelVersion, $ids) : null; + + return $this->buildResult( + $status, + new BuildContext( + queryKey: $queryKey, + buildingKey: $buildingKey, + lockToken: (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), + wakeKey: $wakeKey, + versionKeys: $versionKeys, + expectedVersions: $expectedVersions, + ), + $ids, + $throughKeys, + $models, + ); + } + + /** @return TResult|null */ + public function waitForBuild( + string $modelClass, + string $hash, + ?string $tag, + array $depClasses, + array $depTableKeys, + ?string $connection = null, + ): QueryCacheResult|ThroughCacheResult|null { + $classKey = $this->keys->classKey($modelClass, $connection); + $this->store->brpop($this->keys->wakeKey($classKey, $hash), $this->stampedeWaitMs / 1000.0); + + $result = $this->fetch($modelClass, $hash, $tag, $depClasses, $depTableKeys, $connection); + + return $result->status === CacheStatus::Building ? null : $result; + } + + /** @return TResult */ + protected function claimMissAfterCorruptHit(BuildContext $build): QueryCacheResult|ThroughCacheResult + { + if ($this->store->setNxEx($build->buildingKey, $build->lockToken, $this->buildingLockTtl)) { + $this->store->delete($build->wakeKey); + + return $this->buildResult(LuaStatus::Miss, $build); + } + + return $this->buildResult(LuaStatus::Building, $build); + } + + protected function storePayload( + string $key, + string $payload, + ?int $ttl, + BuildHandle $build, + ): bool { + return $this->store->storeVersionedPayload( + [$key => $payload], + $ttl ?? $this->queryTtl, + $build->versionKeys, + $build->expectedVersions, + $build->buildingKey, + $build->wakeKey, + $build->buildingToken, + ); + } + + private function fetchModels(string $classKey, int $modelVersion, array $ids): array + { + if ($ids === []) { + return []; + } + + $modelPrefix = $this->keys->modelPrefix($classKey, $modelVersion); + + return $this->store->getMany(array_map(static fn($id) => $modelPrefix . $id, $ids)); + } +} diff --git a/src/CacheManager.php b/src/CacheManager.php index a044264..8d40719 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -2,33 +2,31 @@ namespace NormCache; -use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -use Illuminate\Database\Eloquent\Model; use NormCache\Cache\ExecutionEngine; +use NormCache\Cache\ModelCacheRepository; use NormCache\Cache\ModelHydrator; -use NormCache\Cache\NormalizedCacheReader; -use NormCache\Cache\NormalizedThroughReader; -use NormCache\Cache\ResultCacheReader; +use NormCache\Cache\NormalizedCacheRepository; +use NormCache\Cache\ResultCacheRepository; use NormCache\Cache\ResultExecutor; +use NormCache\Cache\ThroughCacheRepository; use NormCache\Cache\VersionTracker; +use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Spaces\CacheSpaceResolver; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\FallbackHandler; use NormCache\Support\RedisStore; use NormCache\Traits\HandlesInvalidation; use NormCache\Values\CacheConfig; -use NormCache\Values\PivotCacheResult; -use NormCache\Values\QueryCacheResult; -use NormCache\Values\ResultCacheResult; -use NormCache\Values\ThroughCacheResult; +use NormCache\Values\CacheSpace; class CacheManager { use HandlesInvalidation; public function __construct( - private readonly NormalizedCacheReader $queryReader, - private readonly ResultCacheReader $resultReader, - private readonly NormalizedThroughReader $throughReader, + private readonly NormalizedCacheRepository $queries, + private readonly ResultCacheRepository $results, + private readonly ThroughCacheRepository $through, + private readonly ModelCacheRepository $models, private readonly ResultExecutor $result, private readonly ModelHydrator $hydrator, private readonly VersionTracker $versions, @@ -36,6 +34,8 @@ public function __construct( private readonly RedisStore $store, private readonly CacheKeyBuilder $keys, private readonly CacheConfig $config, + private readonly CacheSpaceResolver $spaceResolver, + private readonly CacheSpaceRegistry $spaceRegistry, ) {} // ------------------------------------------------------------------------- @@ -52,11 +52,36 @@ public function result(): ResultExecutor return $this->result; } + public function queries(): NormalizedCacheRepository + { + return $this->queries; + } + + public function results(): ResultCacheRepository + { + return $this->results; + } + + public function through(): ThroughCacheRepository + { + return $this->through; + } + + public function models(): ModelCacheRepository + { + return $this->models; + } + public function config(): CacheConfig { return $this->config; } + public function hydrator(): ModelHydrator + { + return $this->hydrator; + } + public function isEnabled(): bool { return $this->config->enabled; @@ -72,16 +97,6 @@ public function isEventsEnabled(): bool return $this->config->dispatchEvents; } - public function isCluster(): bool - { - return $this->config->cluster; - } - - public function isSlotting(): bool - { - return $this->config->slotting; - } - public function enable(): void { $this->config->enabled = true; @@ -96,7 +111,7 @@ public function disable(): void // Accessors // ------------------------------------------------------------------------- - public function getStore(): RedisStore + public function store(): RedisStore { return $this->store; } @@ -106,153 +121,36 @@ public function keys(): CacheKeyBuilder return $this->keys; } - public function classKey(string $class): string - { - return $this->keys->classKey($class); - } - - public function tableKey(string $connectionName, string $table): string - { - return $this->keys->tableKey($connectionName, $table); - } - - public function currentVersion(string $modelClass): int - { - return $this->versions->currentVersion($modelClass); - } - - public function currentTableVersion(string $connectionName, string $table): int - { - return $this->versions->currentTableVersion($connectionName, $table); - } - - // ------------------------------------------------------------------------- - // Reads - // ------------------------------------------------------------------------- - - public function getThroughCache(string $modelClass, string $hash, ?string $tag = null, array $depClasses = [], array $depTableKeys = []): ThroughCacheResult - { - return $this->throughReader->fetch($modelClass, $hash, $tag, $depClasses, $depTableKeys); - } - - public function getModelsFromQuery(string $modelClass, string $hash, ?string $tag = null, array $depClasses = [], array $depTableKeys = []): QueryCacheResult - { - return $this->queryReader->fetch($modelClass, $hash, $tag, $depClasses, $depTableKeys); - } - - public function getPivotCache(string $parentClass, string $relatedClass, string $relation, array $parentIds, string $constraintHash = 'nc', ?string $pivotTableKey = null): PivotCacheResult + public function spaceFor(string $modelClass, ?string $explicitSpace = null): CacheSpace { - return $this->resultReader->fetchPivot($parentClass, $relatedClass, $relation, $parentIds, $constraintHash, $pivotTableKey); + return $this->spaceResolver->resolve($modelClass, $explicitSpace); } - public function waitForPivotBuild(string $parentClass, string $relatedClass, string $relation, array $parentIds, string $constraintHash, ?string $pivotTableKey): ?PivotCacheResult + public function withSpace(?CacheSpace $space, callable $callback): mixed { - return $this->resultReader->waitForPivotBuild($parentClass, $relatedClass, $relation, $parentIds, $constraintHash, $pivotTableKey); - } - - public function getResultCache(string $modelClass, array $depClasses, string $hash, ?string $tag = null, array $depTableKeys = [], string $namespace = CacheKeyBuilder::K_RESULT): ResultCacheResult - { - return $this->resultReader->fetch($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace); - } - - public function waitForQueryBuild(string $modelClass, string $hash, ?string $tag = null, array $depClasses = [], array $depTableKeys = []): ?QueryCacheResult - { - return $this->queryReader->waitForBuild($modelClass, $hash, $tag, $depClasses, $depTableKeys); - } - - public function waitForThroughBuild(string $modelClass, string $hash, ?string $tag = null, array $depClasses = [], array $depTableKeys = []): ?ThroughCacheResult - { - return $this->throughReader->waitForBuild($modelClass, $hash, $tag, $depClasses, $depTableKeys); - } - - // ------------------------------------------------------------------------- - // Loading - // ------------------------------------------------------------------------- - - public function getModels( - array $ids, - string $modelClass, - ?array $columns = null, - ?array $raw = null, - ?EloquentBuilder $missedQuery = null, - bool $preserveQueryShape = true, - ?Model $prototype = null, - ): array { - return $this->hydrator->getModels($ids, $modelClass, $columns, $raw, $missedQuery, $preserveQueryShape, $prototype); - } - - public function hydrateResult(array $payload, string|Model $model, bool $cached = true): array - { - return $this->hydrator->hydrateResult($payload, $model, $cached); - } - - // ------------------------------------------------------------------------- - // Storage - // ------------------------------------------------------------------------- - - public function storeQueryIds(string $key, array $ids, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null): void - { - $this->queryReader->store($key, $ids, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); - } - - public function storeThroughIds(string $key, array $ids, array $throughKeys, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null): void - { - $this->throughReader->store($key, $ids, $throughKeys, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); - } - - public function storeVersionedResult(string $key, mixed $payload, ?int $ttl = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null): bool - { - return $this->resultReader->storeEntry($key, $payload, $ttl ?? $this->config->queryTtl, $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken); - } - - public function storeManyVersionedResults(array $entries, ?int $ttl = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null): bool - { - return $this->resultReader->storeMany($entries, $ttl ?? $this->config->queryTtl, $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken); - } - - public function storeResultCache(string $key, array $payload, ?string $buildingKey, ?int $ttl, ?string $wakeKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null): bool - { - return $this->resultReader->store($key, $payload, $buildingKey, $ttl, $wakeKey, $versionKeys, $expectedVersions, $buildingToken); - } - - public function cacheModelAttrs(string $modelClass, array $modelAttrs): void - { - if (empty($modelAttrs)) { - return; + if ($space === null) { + return $this->keys->withSpace(null, $callback); } - $classKey = $this->keys->classKey($modelClass); - $modelVersion = $this->currentVersion($modelClass); + $active = $this->keys->activeSpace(); - $attrsByKey = []; - foreach ($modelAttrs as $id => $attrs) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; - } - - $this->store->setManyTrackedIfVersion( - $attrsByKey, - $this->config->ttl, - $this->keys->membersKey($classKey), - $this->keys->verKey($classKey), - $modelVersion - ); + return $active !== null && $active->name === $space->name + ? $callback() + : $this->keys->withSpace($space, $callback); } - // ------------------------------------------------------------------------- - // Flow - // ------------------------------------------------------------------------- - public function rescue(callable $operation, callable $fallback): mixed + public function withSpaceForModel(string $modelClass, ?string $explicitSpace, callable $callback): mixed { - return FallbackHandler::rescue($this->config, $operation, $fallback); + return $this->withSpace($this->spaceFor($modelClass, $explicitSpace), $callback); } - public function attempt(callable $operation): bool + public function currentVersion(string $modelClass, ?string $connection = null): int { - return FallbackHandler::attempt($this->config, $operation); + return $this->versions->currentVersion($modelClass, $this->modelSpaces($modelClass)[0], $connection); } - public function fallback(\Throwable $e): void + public function currentTableVersion(string $connectionName, string $table): int { - FallbackHandler::fallback($this->config, $e); + return $this->versions->currentTableVersion($connectionName, $table); } } diff --git a/src/CacheManagerFactory.php b/src/CacheManagerFactory.php new file mode 100644 index 0000000..db93621 --- /dev/null +++ b/src/CacheManagerFactory.php @@ -0,0 +1,84 @@ + $overrides */ + public function make(array $overrides = []): CacheManager + { + $connection = (string) $this->value($overrides, 'connection', 'normcache.connection'); + $ttl = (int) $this->value($overrides, 'ttl', 'normcache.ttl'); + $queryTtl = (int) $this->value($overrides, 'query_ttl', 'normcache.query_ttl'); + $keyPrefix = (string) $this->value($overrides, 'key_prefix', 'normcache.key_prefix', ''); + $cooldown = (int) $this->value($overrides, 'cooldown', 'normcache.cooldown', 0); + $enabled = (bool) $this->value($overrides, 'enabled', 'normcache.enabled', true); + $events = (bool) $this->value($overrides, 'events', 'normcache.events', false); + $fallback = (bool) $this->value($overrides, 'fallback', 'normcache.fallback', true); + $fireRetrieved = (bool) $this->value($overrides, 'fire_retrieved', 'normcache.fire_retrieved', false); + $buildingLockTtl = (int) $this->value($overrides, 'building_lock_ttl', 'normcache.building_lock_ttl', 5); + $stampedeWaitMs = (int) $this->value($overrides, 'stampede_wait_ms', 'normcache.stampede_wait_ms', 200); + $stampedeWakeTokens = (int) $this->value($overrides, 'stampede_wake_tokens', 'normcache.stampede_wake_tokens', 64); + + $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); + $store = new RedisStore($connection, $stampedeWakeTokens); + $versions = new VersionTracker($store, $keys); + $engine = new ExecutionEngine; + $config = new CacheConfig( + ttl: $ttl, + queryTtl: $queryTtl, + cooldown: $cooldown, + enabled: $enabled, + fallbackEnabled: $fallback, + dispatchEvents: $events, + stampedeWakeTokens: $stampedeWakeTokens, + ); + $queries = new NormalizedCacheRepository($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs); + $results = new ResultCacheRepository($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs); + $through = new ThroughCacheRepository($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs); + $models = new ModelCacheRepository($store, $keys, $versions, $config); + + return new CacheManager( + queries: $queries, + results: $results, + through: $through, + models: $models, + result: new ResultExecutor($engine, $results, $config), + hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), + versions: $versions, + engine: $engine, + store: $store, + keys: $keys, + config: $config, + spaceResolver: $this->spaceResolver, + spaceRegistry: $this->spaceRegistry, + ); + } + + /** @param array $overrides */ + private function value(array $overrides, string $override, string $configKey, mixed $default = null): mixed + { + return array_key_exists($override, $overrides) + ? $overrides[$override] + : config($configKey, $default); + } +} diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 471de83..0117202 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -8,19 +8,16 @@ use Illuminate\Queue\Events\Looping; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use NormCache\Cache\ExecutionEngine; -use NormCache\Cache\ModelHydrator; -use NormCache\Cache\NormalizedCacheReader; -use NormCache\Cache\NormalizedThroughReader; -use NormCache\Cache\ResultCacheReader; -use NormCache\Cache\ResultExecutor; -use NormCache\Cache\VersionTracker; +use NormCache\Cache\ModelsExecutor; use NormCache\Console\FlushCommand; use NormCache\Debug\NormCacheCollector; use NormCache\Debug\NormCacheDebugBarCollector; +use NormCache\Planning\CachePlanner; +use NormCache\Planning\CachePlanSpaceValidator; +use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Spaces\CacheSpaceResolver; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisStore; -use NormCache\Values\CacheConfig; class CacheServiceProvider extends ServiceProvider { @@ -28,54 +25,46 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/normcache.php', 'normcache'); - $this->app->singleton(CacheManager::class, function () { - $connection = config('normcache.connection'); - $ttl = (int) config('normcache.ttl'); - $queryTtl = (int) config('normcache.query_ttl'); - $keyPrefix = config('normcache.key_prefix'); - $cooldown = (int) config('normcache.cooldown'); - $cluster = (bool) config('normcache.cluster', false); - $enabled = (bool) config('normcache.enabled', true); - $events = (bool) config('normcache.events', false); - $fallback = (bool) config('normcache.fallback', true); - $fireRetrieved = (bool) config('normcache.fire_retrieved', false); - $buildingLockTtl = (int) config('normcache.building_lock_ttl', 5); - $stampedeWaitMs = (int) config('normcache.stampede_wait_ms', 200); - $stampedeWakeTokens = (int) config('normcache.stampede_wake_tokens', 64); - $slotting = (bool) config('normcache.slotting', false); - - $slottingActive = $cluster && $slotting; - $store = new RedisStore($connection, $keyPrefix, $slottingActive, $slotting ? '' : '{nc}:', $stampedeWakeTokens); - $keys = new CacheKeyBuilder; - $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens); - $engine = new ExecutionEngine; - $config = new CacheConfig( - ttl: $ttl, - queryTtl: $queryTtl, - cooldown: $cooldown, - enabled: $enabled, - fallbackEnabled: $fallback, - dispatchEvents: $events, - cluster: $cluster, - slotting: $slottingActive, - stampedeWakeTokens: $stampedeWakeTokens, + $this->app->singleton(CacheSpaceRegistry::class, function () { + $metadataStore = new RedisStore((string) config('normcache.connection'), (int) config('normcache.stampede_wake_tokens', 64)); + + return new CacheSpaceRegistry( + maxPerModel: (int) config('normcache.spaces.max_per_model', 16), + placement: (array) config('normcache.spaces.placement', []), + metadataStore: $metadataStore, + metadataKeyPrefix: (string) config('normcache.key_prefix', ''), + ); + }); + + $this->app->singleton(CacheSpaceResolver::class, function ($app) { + return new CacheSpaceResolver($app->make(CacheSpaceRegistry::class)); + }); + + $this->app->singleton(CachePlanSpaceValidator::class, function ($app) { + return new CachePlanSpaceValidator( + registry: $app->make(CacheSpaceRegistry::class), + resolver: $app->make(CacheSpaceResolver::class), + crossSpaceBehavior: (string) config('normcache.spaces.cross_space_behavior', 'bypass'), + debug: (bool) config('app.debug', false), + logger: $app->make('log'), ); + }); + + $this->app->singleton(CachePlanner::class, function ($app) { + return new CachePlanner(spaceValidator: $app->make(CachePlanSpaceValidator::class)); + }); - return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens), - resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens), - result: new ResultExecutor($engine, $resultReader, $config), - hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), - versions: $versions, - engine: $engine, - store: $store, - keys: $keys, - config: $config, + $this->app->singleton(ModelsExecutor::class); + + $this->app->singleton(CacheManagerFactory::class, function ($app) { + return new CacheManagerFactory( + $app->make(CacheSpaceRegistry::class), + $app->make(CacheSpaceResolver::class), ); }); + $this->app->scoped(CacheManager::class, fn($app) => $app->make(CacheManagerFactory::class)->make()); + $this->app->alias(CacheManager::class, 'normcache'); } @@ -94,9 +83,10 @@ public function boot(): void } }); - // Re-enable optimistically between queue jobs. If Redis is still down, fallback() will - // disable again on the first failed call — worst case is one extra Redis attempt per job. $resetManager = function () { + CacheKeyBuilder::reset(); + $this->app->make(CacheSpaceRegistry::class)->resetMetadataMemo(); + $manager = $this->app->make(CacheManager::class); $manager->discardAllPending(); $manager->enable(); @@ -105,7 +95,7 @@ public function boot(): void Event::listen(JobProcessed::class, $resetManager); Event::listen(Looping::class, $resetManager); - // Re-enable (in case fallback disabled it) between Octane requests. + // Reset request-scoped runtime state between Octane requests and tasks. foreach (['RequestReceived', 'TaskReceived'] as $event) { $octaneEvent = "Laravel\\Octane\\Events\\$event"; if (class_exists($octaneEvent)) { diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index b990049..72edbf0 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -2,6 +2,8 @@ namespace NormCache; +use Closure; +use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Model; @@ -17,9 +19,9 @@ use NormCache\Facades\NormCache; use NormCache\Planning\BypassReasons; use NormCache\Planning\CachePlanner; -use NormCache\Planning\QueryAnalyzer; use NormCache\Relations\CachesRelationAggregates; use NormCache\Relations\CachesRelationExistence; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; @@ -30,6 +32,7 @@ use NormCache\Values\CachePlanContext; use NormCache\Values\DependencySet; use NormCache\Values\PreparedQuery; +use NormCache\Values\QueryInspection; class CacheableBuilder extends Builder { @@ -37,10 +40,6 @@ class CacheableBuilder extends Builder private static array $validatedModelClasses = []; - private static ?CachePlanner $sharedPlanner = null; - - private static ?ModelsExecutor $sharedModelsExecutor = null; - private bool $skipCache = false; private ?int $queryTtl = null; @@ -51,6 +50,18 @@ class CacheableBuilder extends Builder private ?string $cacheTag = null; + private ?string $cacheSpace = null; + + private ?DependencySet $capturedDependencies = null; + + private array $capturedContextReasons = []; + + private int $capturedOpaqueJoins = 0; + + private bool $capturedOpaqueFrom = false; + + private int $capturedOpaqueWhereSubqueries = 0; + // ------------------------------------------------------------------------- // Configuration // ------------------------------------------------------------------------- @@ -87,35 +98,51 @@ public function tag(string $tag): static return $this; } + public function space(string $name): static + { + $this->cacheSpace = $name; + + return $this; + } + + public function getSpace(): ?string + { + return $this->cacheSpace; + } + public function dependsOn(array $modelClasses): static { if (empty($modelClasses)) { throw new \InvalidArgumentException('dependsOn() requires at least one model class.'); } + $existing = $this->dependsOn ?? []; + foreach ($modelClasses as $class) { if (!is_string($class)) { throw new \InvalidArgumentException('dependsOn() expects model class names, not model instances.'); } - if (isset(self::$validatedModelClasses[$class])) { - continue; - } + if (!isset(self::$validatedModelClasses[$class])) { + if (!is_a($class, Model::class, true)) { + throw new \InvalidArgumentException("dependsOn() class [{$class}] must be an Eloquent model."); + } - if (!is_a($class, Model::class, true)) { - throw new \InvalidArgumentException("dependsOn() class [{$class}] must be an Eloquent model."); - } + if (!in_array(Cacheable::class, class_uses_recursive($class), true)) { + throw new \InvalidArgumentException( + "dependsOn() class [{$class}] must use the NormCache\\Cacheable trait." + ); + } - if (!in_array(Cacheable::class, class_uses_recursive($class), true)) { - throw new \InvalidArgumentException( - "dependsOn() class [{$class}] must use the NormCache\\Cacheable trait." - ); + self::$validatedModelClasses[$class] = true; } - self::$validatedModelClasses[$class] = true; + if (!in_array($class, $existing, true)) { + $existing[] = $class; + } } - $this->dependsOn = $modelClasses; + $this->dependsOn = $existing; return $this; } @@ -141,7 +168,7 @@ public function dependsOnTables(array $tables): static $conn = $this->model->getConnection()->getName(); $this->dependsOnTables = array_values(array_unique(array_merge( $this->dependsOnTables, - array_map(fn($table) => "{$conn}:{$table}", $tables), + array_map(fn($table) => NormCache::keys()->tableKey($conn, $table), $tables), ))); return $this; @@ -162,6 +189,171 @@ public function hasExplicitDependencies(): bool return $this->dependsOn !== null || $this->dependsOnTables !== []; } + public function capturedDependencies(): DependencySet + { + return $this->capturedDependencies ?? DependencySet::empty(); + } + + public function capturedContextReasons(): array + { + return $this->capturedContextReasons; + } + + public function capturedOpaqueJoins(): int + { + return $this->capturedOpaqueJoins; + } + + public function hasCapturedOpaqueFrom(): bool + { + return $this->capturedOpaqueFrom; + } + + public function capturedOpaqueWhereSubqueries(): int + { + return $this->capturedOpaqueWhereSubqueries; + } + + public function addCapturedContextReason(string $category, string $reason): void + { + $this->capturedContextReasons[$category] = array_values(array_unique([ + ...($this->capturedContextReasons[$category] ?? []), + $reason, + ])); + } + + /** + * @param Closure(CacheableBuilder): mixed|array|string|Expression $column + */ + public function where($column, $operator = null, $value = null, $boolean = 'and'): static + { + if (!$column instanceof Closure || $operator !== null) { + return parent::where($column, $operator, $value, $boolean); + } + + $nested = $this->model->newQueryWithoutRelationships(); + + if (!$nested instanceof self) { + return parent::where($column, $operator, $value, $boolean); + } + + $column($nested); + + $this->mergeCapturedBuilderState($nested); + $this->eagerLoad = array_merge($this->eagerLoad, $nested->getEagerLoads()); + $this->withoutGlobalScopes($nested->removedScopes()); + $this->query->addNestedWhereQuery($nested->getQuery(), $boolean); + + return $this; + } + + public function selectSub($query, $as): static + { + $this->captureSubqueryDependencies($query); + $this->query->selectSub($query, $as); + + return $this; + } + + public function joinSub($query, $as, $first, $operator = null, $second = null, $type = 'inner', $where = false): static + { + $this->captureSubqueryDependencies($query); + $this->capturedOpaqueJoins++; + $this->query->joinSub($query, $as, $first, $operator, $second, $type, $where); + + return $this; + } + + public function fromSub($query, $as): static + { + $this->captureSubqueryDependencies($query); + $this->capturedOpaqueFrom = true; + $this->query->fromSub($query, $as); + + return $this; + } + + public function whereIn($column, $values, $boolean = 'and', $not = false): static + { + if ($values instanceof Closure) { + $callback = $values; + $callback($values = $this->query->newQuery()); + } + + if ($values instanceof Builder || $values instanceof QueryBuilder || $values instanceof EloquentRelation) { + $this->captureSubqueryDependencies($values); + $this->capturedOpaqueWhereSubqueries++; + } + + $this->query->whereIn($column, $values, $boolean, $not); + + return $this; + } + + public function whereNotIn($column, $values, $boolean = 'and'): static + { + return $this->whereIn($column, $values, $boolean, true); + } + + public function orWhereIn($column, $values): static + { + return $this->whereIn($column, $values, 'or'); + } + + public function orWhereNotIn($column, $values): static + { + return $this->whereIn($column, $values, 'or', true); + } + + private function captureSubqueryDependencies(mixed $query): void + { + $base = match (true) { + $query instanceof self => $query->toBase(), + $query instanceof Builder => $query->toBase(), + $query instanceof QueryBuilder => $query, + $query instanceof EloquentRelation => $query->getQuery()->toBase(), + default => null, + }; + + if ($base === null) { + $this->addCapturedContextReason('dependency', 'subquery dependency could not be inferred'); + + return; + } + + $connection = $this->model->getConnection()->getName(); + $analyzer = $this->planner()->analyzer(); + $dependencies = $analyzer->inferQueryDependencies($base, $connection); + $this->capturedDependencies = ($this->capturedDependencies ?? DependencySet::empty())->merge($dependencies); + + $table = is_string($base->from) ? CacheKeyBuilder::stripTableAlias($base->from) : $this->model->getTable(); + $reasons = BypassReasons::fromInspection(new QueryInspection( + flags: $analyzer->flags($base, $table, $base->columns), + )); + + foreach ($reasons as $category => $items) { + foreach ($items as $reason) { + $this->addCapturedContextReason($category, $reason); + } + } + } + + protected function mergeCapturedBuilderState(self $builder): void + { + $this->capturedDependencies = ($this->capturedDependencies ?? DependencySet::empty()) + ->merge($builder->capturedDependencies()); + + foreach ($builder->capturedContextReasons() as $category => $reasons) { + foreach ($reasons as $reason) { + $this->addCapturedContextReason($category, $reason); + } + } + + $this->capturedOpaqueJoins += $builder->capturedOpaqueJoins(); + $this->capturedOpaqueFrom = $this->capturedOpaqueFrom || $builder->hasCapturedOpaqueFrom(); + $this->capturedOpaqueWhereSubqueries += $builder->capturedOpaqueWhereSubqueries(); + } + public function getQueryTtl(): ?int { return $this->queryTtl; @@ -179,22 +371,19 @@ public function getCacheTag(): ?string public function explain(): string { $prepared = $this->prepareCacheExecution(); - $executionBuilder = $prepared->builder; - $base = $prepared->base; - $resolvedCols = ProjectionClassifier::resolve($base, ['*']); - $explainJoinDeps = !empty($base->joins) - ? (new QueryAnalyzer)->inferJoinDependencies($base, $executionBuilder->getModel()->getConnection()->getName()) - : DependencySet::empty(); - $plan = $executionBuilder->cachePlan($base, CachePlanContext::models( - $resolvedCols, - $executionBuilder->inferAggregateDependencies()->merge($explainJoinDeps), + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::models( + ProjectionClassifier::resolve($prepared->base, ['*']), selectAll: true, ), PlanningMode::Explain); + $space = ($plan->space !== null && $plan->space->name !== 'default') + ? ' [space: ' . $plan->space->name . ']' + : ''; + return match ($plan->strategy) { - CacheStrategy::DirectModels => 'cached: direct (primary key)', - CacheStrategy::NormalizedQuery => 'cached', - CacheStrategy::VersionedResult => $this->explainResultStrategy(), + CacheStrategy::DirectModels => 'cached: direct (primary key)' . $space, + CacheStrategy::NormalizedQuery => 'cached' . $space, + CacheStrategy::VersionedResult => $this->explainResultStrategy() . $space, CacheStrategy::LiveQuery => $this->explainBypassStrategy($plan), }; } @@ -220,7 +409,7 @@ private function explainBypassStrategy(CachePlan $plan): string public function get($columns = ['*']): Collection { if ($this->skipCache || !NormCache::isEnabled()) { - return $this->getWithoutCache($columns); + return parent::get($columns); } $debugbarStart = CacheReporter::beginMeasure(); @@ -228,37 +417,27 @@ public function get($columns = ['*']): Collection $columns = Arr::wrap($columns); $prepared = $this->prepareCacheExecution(); $model = $this->model::class; - $base = $prepared->base; - $execBuilder = $prepared->builder; - - $joinDeps = !empty($base->joins) - ? (new QueryAnalyzer)->inferJoinDependencies( - $base, - $execBuilder->getModel()->getConnection()->getName() - ) - : DependencySet::empty(); - $inferred = $execBuilder->inferAggregateDependencies()->merge($joinDeps); - - $plan = $execBuilder->cachePlan($base, CachePlanContext::models( + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::models( ProjectionClassifier::resolve($base, $columns), - $inferred, selectAll: $columns === ['*'], )); - return match ($plan->strategy) { - CacheStrategy::DirectModels => NormCache::rescue( + return NormCache::withSpace($plan->space, fn() => match ($plan->strategy) { + CacheStrategy::DirectModels => CacheFallback::rescue( + NormCache::config(), fn() => $this->modelsExecutor()->runDirect($prepared, $plan->primaryKeys, $model, $plan->columns, $this->model), - fn() => $this->getWithoutCacheFromPrepared($prepared, $columns), + fn() => $prepared->collect($columns), ), - CacheStrategy::NormalizedQuery => NormCache::rescue( + CacheStrategy::NormalizedQuery => CacheFallback::rescue( + NormCache::config(), fn() => $this->modelsExecutor()->runNormalized($prepared, $plan, $model, $plan->columns, $this->cacheTag, $this->queryTtl, $debugbarStart, $this->model), - fn() => $this->getWithoutCacheFromPrepared($prepared, $columns) + fn() => $prepared->collect($columns) ), CacheStrategy::VersionedResult => $this->executeResultQuery($prepared, $plan, $columns), CacheStrategy::LiveQuery => $this->bypassAndReturn($model, $plan->bypassReasons, $debugbarStart, $prepared, $columns), - }; + }); } private function executeResultQuery( @@ -275,21 +454,14 @@ private function executeResultQuery( $columns, function () use ($prepared, $columns) { if ($this->hasAggregateColumns()) { - $rawModels = $this->getWithoutCacheFromPrepared($prepared, $columns, false); - - return $this->resultPayloadFromEloquentModels($rawModels); - } - - $resultBase = clone $prepared->base; - if (empty($resultBase->columns) && $columns !== ['*']) { - $resultBase->columns = $columns; + return $this->resultPayloadFromEloquentModels($prepared->collect($columns, false)); } - return $this->buildResultPayloadFromQuery($resultBase); + return $prepared->baseWithColumns($columns)->get()->map(fn($row) => (array) $row)->all(); } ); - return $this->hydrateResultPayload($payload, $model, $cached, $prepared); + return $prepared->finalizeModels(NormCache::hydrator()->hydrateResult($payload, $this->model, $cached)); } private function bypassAndReturn( @@ -301,7 +473,7 @@ private function bypassAndReturn( ): Collection { CacheReporter::queryBypassed($model, $bypassReasons, $debugbarStart); - return $this->getWithoutCacheFromPrepared($prepared, $columns); + return $prepared->collect($columns); } public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null): LengthAwarePaginator @@ -313,14 +485,7 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $debugbarStart = CacheReporter::beginMeasure(); $prepared = $this->prepareCacheExecution(); - $paginateBase = $prepared->base; - $paginateBuilder = $prepared->builder; - $paginateJoinDeps = !empty($paginateBase->joins) - ? (new QueryAnalyzer)->inferJoinDependencies($paginateBase, $paginateBuilder->getModel()->getConnection()->getName()) - : DependencySet::empty(); - $plan = $paginateBuilder->cachePlan($paginateBase, CachePlanContext::paginationCount( - $paginateBuilder->inferAggregateDependencies()->merge($paginateJoinDeps) - )); + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::paginationCount()); if ($plan->strategy === CacheStrategy::LiveQuery) { CacheReporter::queryBypassed($this->model::class, $plan->bypassReasons, $debugbarStart); @@ -329,9 +494,9 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', } try { - $cachedTotal = $this->rememberPaginationTotal($prepared, $plan); + $cachedTotal = NormCache::withSpace($plan->space, fn() => $this->rememberPaginationTotal($prepared, $plan)); } catch (\Throwable $e) { - NormCache::fallback($e); + CacheFallback::fallback(NormCache::config(), $e); $cachedTotal = null; } @@ -399,39 +564,6 @@ private function bypassingCache(callable $fn): mixed // Infrastructure // ------------------------------------------------------------------------- - public function buildResultPayloadFromQuery(QueryBuilder $base): array - { - return $base->get()->map(fn($r) => (array) $r)->all(); - } - - public function hydrateResultPayload( - array $payload, - string $model, - bool $cached, - PreparedQuery $prepared, - ): Collection { - return $this->finalizeResult(NormCache::hydrateResult($payload, $this->model, $cached), $prepared); - } - - public function finalizeResult(array $models, PreparedQuery $prepared): Collection - { - if ($models && $prepared->builder->getEagerLoads()) { - $models = $prepared->builder->eagerLoadRelations($models); - } - - return $prepared->applyAfterCallbacks($this->model->newCollection($models)); - } - - public function getWithoutCache($columns): Collection - { - return parent::get($columns); - } - - public function hasAfterQueryCallbacks(): bool - { - return $this->afterQueryCallbacks !== []; - } - public function prepareCacheExecution(): PreparedQuery { return $this->prepareScopedQuery()->applyBeforeCallbacks(); @@ -445,37 +577,6 @@ public function prepareScopedQuery(): PreparedQuery return new PreparedQuery($builder, $builder->getQuery()); } - private function getWithoutCacheFromPrepared( - PreparedQuery $prepared, - array $columns, - bool $applyAfterCallbacks = true, - ): Collection { - $builder = $prepared->builder; - $models = $builder->getModels($columns); - - if (count($models) > 0) { - $models = $builder->eagerLoadRelations($models); - } - - $collection = $this->model->newCollection($models); - - return $applyAfterCallbacks - ? $prepared->applyAfterCallbacks($collection) - : $collection; - } - - public function applyRemovedScopesTo(self $target): void - { - foreach ($this->removedScopes as $scope) { - $target->withoutGlobalScope($scope); - } - } - - public function hasRemovedScopes(): bool - { - return !empty($this->removedScopes); - } - // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- @@ -488,14 +589,26 @@ public function cachePlan( return $this->planner()->plan($this, $base, $context, $planningMode); } - private function planner(): CachePlanner + /** @param Closure(): CachePlanContext $context */ + public function planPrepared( + PreparedQuery $prepared, + Closure $context, + PlanningMode $mode = PlanningMode::Hot, + ): CachePlan { + $builder = $prepared->builder; + $base = $prepared->base; + + return $builder->cachePlan($base, $context(), $mode); + } + + public function planner(): CachePlanner { - return self::$sharedPlanner ??= new CachePlanner; + return app(CachePlanner::class); } private function modelsExecutor(): ModelsExecutor { - return self::$sharedModelsExecutor ??= new ModelsExecutor; + return app(ModelsExecutor::class); } private function rememberPaginationTotal(PreparedQuery $prepared, CachePlan $plan): int diff --git a/src/Console/FlushCommand.php b/src/Console/FlushCommand.php index 8a7732f..08aecc8 100644 --- a/src/Console/FlushCommand.php +++ b/src/Console/FlushCommand.php @@ -9,7 +9,8 @@ class FlushCommand extends Command { protected $signature = 'normcache:flush - {--model= : Fully-qualified class name of the model to flush}'; + {--model= : Fully-qualified class name of the model to flush} + {--space= : Cache space to flush}'; protected $description = 'Flush the normcache. Flushes all entries unless --model is specified.'; @@ -22,9 +23,11 @@ public function handle(): int private function flushAll(): int { - $count = NormCache::flushAll(); + $space = $this->option('space'); + $count = NormCache::flushAll($space ?: null); - $this->info("Flushed {$count} NormCache key(s)."); + $target = $space ? " in space [{$space}]" : ''; + $this->info("Flushed {$count} NormCache key(s){$target}."); return Command::SUCCESS; } diff --git a/src/Enums/CacheOperation.php b/src/Enums/CacheOperation.php index c34d57a..7ce7447 100644 --- a/src/Enums/CacheOperation.php +++ b/src/Enums/CacheOperation.php @@ -7,8 +7,6 @@ enum CacheOperation case Models; case Scalar; case PaginationCount; - case BelongsToEagerLoad; - case MorphToEagerLoad; case Pivot; case Through; } diff --git a/src/Events/QueryBypassed.php b/src/Events/QueryBypassed.php index 5a7cbfb..bcac971 100644 --- a/src/Events/QueryBypassed.php +++ b/src/Events/QueryBypassed.php @@ -6,7 +6,7 @@ { /** * @param array> $reasons Bypass reasons grouped by category. - * Categories: 'dependency', 'normalization', 'safety', 'opted_out' + * Categories: 'dependency', 'normalization', 'safety', 'space', 'opted_out' */ public function __construct( public string $modelClass, diff --git a/src/Facades/NormCache.php b/src/Facades/NormCache.php index 252751e..56d379b 100644 --- a/src/Facades/NormCache.php +++ b/src/Facades/NormCache.php @@ -10,6 +10,12 @@ * * @method static \NormCache\Cache\ExecutionEngine engine() * @method static \NormCache\Cache\ResultExecutor result() + * @method static \NormCache\Cache\ModelHydrator hydrator() + * @method static \NormCache\Cache\NormalizedCacheRepository queries() + * @method static \NormCache\Cache\ResultCacheRepository results() + * @method static \NormCache\Cache\ThroughCacheRepository through() + * @method static \NormCache\Cache\ModelCacheRepository models() + * @method static \NormCache\Support\RedisStore store() * @method static \NormCache\Support\CacheKeyBuilder keys() * @method static \NormCache\Values\CacheConfig config() */ diff --git a/src/Lua/fetch_pivot_build_status.lua b/src/Lua/fetch_batch_build_status.lua similarity index 56% rename from src/Lua/fetch_pivot_build_status.lua rename to src/Lua/fetch_batch_build_status.lua index 2c4e35d..1d88f17 100644 --- a/src/Lua/fetch_pivot_build_status.lua +++ b/src/Lua/fetch_batch_build_status.lua @@ -1,14 +1,13 @@ --- Re-checks missing pivot keys and atomically claims the build lock if still missing. --- No version key needed — pivot writes are version-gated separately at write time. +-- Re-checks still-missing model/pivot keys and atomically claims the build lock if +-- anything is still missing. Used for both model attributes and pivot payloads. -- --- KEYS[1..n] = pivot keys to recheck --- KEYS[n+1] = lock key +-- KEYS[1..n] = model/pivot keys to re-check +-- KEYS[n+1] = building lock key -- KEYS[n+2] = wake key -- ARGV[1] = lock token --- ARGV[2] = lock ttl +-- ARGV[2] = lock TTL in seconds -- --- Returns: {status, lockTokenOrFalse, rawValues} — rawValues are the raw MGET results in --- KEYS[1..n] order; the caller unserializes/hydrates them. +-- Returns: {status, lockTokenOrFalse, false, rawValues} local n = #KEYS - 2 local chunkSize = 500 local values = {} @@ -34,12 +33,12 @@ for start = 1, n, chunkSize do end if allHit then - return {'hit', false, values} + return {'hit', false, false, values} end if redis.call('SET', KEYS[n + 1], ARGV[1], 'NX', 'EX', tonumber(ARGV[2])) then redis.call('DEL', KEYS[n + 2]) - return {'miss', ARGV[1], values} + return {'miss', ARGV[1], false, values} end -return {'building', false, values} +return {'building', false, false, values} diff --git a/src/Lua/fetch_model_build_status.lua b/src/Lua/fetch_model_build_status.lua deleted file mode 100644 index 5e17ae9..0000000 --- a/src/Lua/fetch_model_build_status.lua +++ /dev/null @@ -1,48 +0,0 @@ --- Re-checks still-missing model keys and atomically claims the build lock if anything's still --- missing. See ModelHydrator::fetchMissedStatus(). --- --- KEYS[1..n] = model keys to recheck --- KEYS[n+1] = lock key --- KEYS[n+2] = model-class version key --- KEYS[n+3] = wake key --- ARGV[1] = lock token --- ARGV[2] = lock ttl --- --- Returns: {status, lockTokenOrFalse, version, rawValues} — rawValues are the raw MGET results --- in KEYS[1..n] order; the caller unserializes/hydrates them. -local n = #KEYS - 3 -local chunkSize = 500 -local values = {} -local allHit = true - -for start = 1, n, chunkSize do - local stop = math.min(start + chunkSize - 1, n) - local chunk = {} - - for i = start, stop do - chunk[#chunk + 1] = KEYS[i] - end - - local chunkValues = redis.call('MGET', unpack(chunk)) - - for i = 1, #chunkValues do - values[start + i - 1] = chunkValues[i] - - if not chunkValues[i] then - allHit = false - end - end -end - -local version = redis.call('GET', KEYS[n + 2]) or '0' - -if allHit then - return {'hit', false, version, values} -end - -if redis.call('SET', KEYS[n + 1], ARGV[1], 'NX', 'EX', tonumber(ARGV[2])) then - redis.call('DEL', KEYS[n + 3]) - return {'miss', ARGV[1], version, values} -end - -return {'building', false, version, values} diff --git a/src/Lua/fetch_multi_versioned_query.lua b/src/Lua/fetch_multi_versioned_query.lua deleted file mode 100644 index 347877b..0000000 --- a/src/Lua/fetch_multi_versioned_query.lua +++ /dev/null @@ -1,52 +0,0 @@ --- Fetch a multi-versioned query result with cooldown, claiming the build lock on a miss. --- Shared with HasManyThrough — both just GET/return a raw payload at the resolved segment. --- --- KEYS[1..n] = version keys --- KEYS[n+1..2n] = scheduled keys (one per version key, same order) --- KEYS[2n+1] = query prefix --- KEYS[2n+2] = building prefix --- KEYS[2n+3] = wake prefix --- ARGV[1] = hash --- ARGV[2] = current timestamp in ms --- ARGV[3] = building lock TTL in seconds --- ARGV[4] = building lock token --- --- Returns: {status, seg, [ids_raw_or_token]} - -local n = (#KEYS - 3) / 2 -local now = tonumber(ARGV[2]) - -local vers = {} -for i = 1, n do - local ver = redis.call('GET', KEYS[i]) or '0' - local due_at = redis.call('GET', KEYS[n + i]) - if due_at then - local due_at_num = tonumber(due_at) - if due_at_num and due_at_num <= now then - redis.call('DEL', KEYS[n + i]) - ver = tostring(redis.call('INCR', KEYS[i])) - elseif not due_at_num then - redis.call('DEL', KEYS[n + i]) - end - end - vers[i] = ver -end - -local seg = 'v' .. vers[1] -for i = 2, n do seg = seg .. ':v' .. vers[i] end - -local query_key = KEYS[2 * n + 1] .. seg .. ':' .. ARGV[1] -local ids_raw = redis.call('GET', query_key) - -if not ids_raw then - local building_key = KEYS[2 * n + 2] .. seg .. ':' .. ARGV[1] - local claimed = redis.call('SET', building_key, ARGV[4], 'NX', 'EX', tonumber(ARGV[3])) - if claimed then - redis.call('DEL', KEYS[2 * n + 3] .. ARGV[1]) - return {'miss', seg, ARGV[4]} - end - - return {'building', seg} -end - -return {'hit', seg, ids_raw} diff --git a/src/Lua/fetch_versioned_payload.lua b/src/Lua/fetch_versioned_payload.lua new file mode 100644 index 0000000..325bbc6 --- /dev/null +++ b/src/Lua/fetch_versioned_payload.lua @@ -0,0 +1,57 @@ +-- Resolve versions with cooldown, fetch versioned payload, claim build lock on miss. +-- Used for: normalized query (single and multi-dep), through-relation, and result cache. +-- +-- KEYS[1..n] = version keys +-- KEYS[n+1..2n] = scheduled keys when cooldown is enabled +-- KEYS[p] = payload key prefix +-- KEYS[p+1] = building key prefix +-- KEYS[p+2] = wake prefix +-- ARGV[1] = payload hash +-- ARGV[2] = lock suffix (= hash for normalized query/through; sha1(tag+hash) for result) +-- ARGV[3] = current timestamp in ms +-- ARGV[4] = building lock TTL in seconds +-- ARGV[5] = building lock token +-- ARGV[6] = version key count +-- ARGV[7] = cooldown enabled (1/0) +-- +-- Returns: {'hit', seg, payload} | {'miss', seg, token} | {'building', seg} + +local n = tonumber(ARGV[6]) or ((#KEYS - 3) / 2) +local has_scheduled = ARGV[7] ~= '0' +local prefix_index = has_scheduled and (2 * n + 1) or (n + 1) +local now = tonumber(ARGV[3]) + +local vers = {} +for i = 1, n do + local ver = redis.call('GET', KEYS[i]) or '0' + if has_scheduled then + local scheduled_key = KEYS[n + i] + local due_at = redis.call('GET', scheduled_key) + if due_at then + local due_at_num = tonumber(due_at) + if due_at_num and due_at_num <= now then + redis.call('DEL', scheduled_key) + ver = tostring(redis.call('INCR', KEYS[i])) + elseif not due_at_num then + redis.call('DEL', scheduled_key) + end + end + end + vers[i] = ver +end + +local seg = 'v' .. vers[1] +for i = 2, n do seg = seg .. ':v' .. vers[i] end + +local data = redis.call('GET', KEYS[prefix_index] .. seg .. ':' .. ARGV[1]) +if data then + return {'hit', seg, data} +end + +local building_key = KEYS[prefix_index + 1] .. seg .. ':' .. ARGV[2] +if redis.call('SET', building_key, ARGV[5], 'NX', 'EX', tonumber(ARGV[4])) then + redis.call('DEL', KEYS[prefix_index + 2] .. ARGV[2]) + return {'miss', seg, ARGV[5]} +end + +return {'building', seg} diff --git a/src/Lua/fetch_versioned_query.lua b/src/Lua/fetch_versioned_query.lua deleted file mode 100644 index 40db72f..0000000 --- a/src/Lua/fetch_versioned_query.lua +++ /dev/null @@ -1,41 +0,0 @@ --- Fetch a versioned query result with cooldown, claiming the build lock on a miss. --- --- KEYS[1] = ver key (ver:{classKey}:) --- KEYS[2] = scheduled key (scheduled:{classKey}:) --- KEYS[3] = query prefix (query:{classKey}:v) --- KEYS[4] = building prefix (building:{classKey}:) --- KEYS[5] = wake prefix (wake:{classKey}:) --- ARGV[1] = hash --- ARGV[2] = current timestamp in ms --- ARGV[3] = building lock TTL in seconds --- ARGV[4] = building lock token --- --- Returns: {status, ver, [ids|ids_raw]} - -local now = tonumber(ARGV[2]) -local due_at = redis.call('GET', KEYS[2]) -local ver = redis.call('GET', KEYS[1]) -if not ver then ver = '0' end -if due_at then - local due_at_num = tonumber(due_at) - if due_at_num and due_at_num <= now then - redis.call('DEL', KEYS[2]) - ver = tostring(redis.call('INCR', KEYS[1])) - elseif not due_at_num then - redis.call('DEL', KEYS[2]) - end -end - -local query_key = KEYS[3] .. ver .. ':' .. ARGV[1] -local ids_raw = redis.call('GET', query_key) -if not ids_raw then - local building_key = KEYS[4] .. ARGV[1] - local claimed = redis.call('SET', building_key, ARGV[4], 'NX', 'EX', tonumber(ARGV[3])) - if claimed then - redis.call('DEL', KEYS[5] .. ARGV[1]) - return {'miss', ver, ARGV[4]} - end - return {'building', ver} -end - -return {'hit', ver, ids_raw} diff --git a/src/Lua/fetch_versioned_result.lua b/src/Lua/fetch_versioned_result.lua deleted file mode 100644 index 90f5456..0000000 --- a/src/Lua/fetch_versioned_result.lua +++ /dev/null @@ -1,55 +0,0 @@ --- Atomic fetch for result cache entries. Resolves the version segment (applying any due --- cooldown invalidation), fetches the payload, and claims the build lock on a miss. --- --- KEYS[1..n] = version keys --- KEYS[n+1..2n] = scheduled keys (one per version key, same order) --- KEYS[2n+1] = result key prefix (result:{classKey}: or result:{classKey}:tag:) --- KEYS[2n+2] = building key prefix (building:{classKey}:) --- KEYS[2n+3] = wake key prefix --- ARGV[1] = query hash (used to look up the versioned result cache entry) --- ARGV[2] = lock suffix (sha1 of tag+hash, used as building key suffix) --- ARGV[3] = building lock TTL in seconds --- ARGV[4] = current timestamp in ms --- ARGV[5] = building lock token --- --- Returns: {'hit', seg, payload} | {'miss', seg, token} | {'building', seg} - -local n = (#KEYS - 3) / 2 -local now = tonumber(ARGV[4]) - -local vers = {} -for i = 1, n do - local ver = redis.call('GET', KEYS[i]) or '0' - local due_at = redis.call('GET', KEYS[n + i]) - if due_at then - local due_at_num = tonumber(due_at) - if due_at_num and due_at_num <= now then - redis.call('DEL', KEYS[n + i]) - ver = tostring(redis.call('INCR', KEYS[i])) - elseif not due_at_num then - redis.call('DEL', KEYS[n + i]) - end - end - vers[i] = ver -end - -local seg = 'v' .. vers[1] -for i = 2, n do seg = seg .. ':v' .. vers[i] end - -local result_prefix = KEYS[2 * n + 1] -local building_prefix = KEYS[2 * n + 2] -local wake_prefix = KEYS[2 * n + 3] -local suffix = ':' .. ARGV[1] - -local data = redis.call('GET', result_prefix .. seg .. suffix) -if data then - return {'hit', seg, data} -end - -local building_key = building_prefix .. seg .. ':' .. ARGV[2] -if redis.call('SET', building_key, ARGV[5], 'NX', 'EX', tonumber(ARGV[3])) then - redis.call('DEL', wake_prefix .. ARGV[2]) - return {'miss', seg, ARGV[5]} -end - -return {'building', seg} diff --git a/src/Lua/release_building.lua b/src/Lua/release_building.lua index 2c9aef6..0464fa0 100644 --- a/src/Lua/release_building.lua +++ b/src/Lua/release_building.lua @@ -1,7 +1,7 @@ -- Release a build lock only when the caller still owns it. -- -- KEYS[1] = building lock key --- KEYS[2] = wake key +-- KEYS[2] = wake key (optional; omit when there are no waiters to signal) -- ARGV[1] = building lock token (optional; empty means release unconditionally) -- ARGV[2] = wake token count (optional; defaults to 1) local token = ARGV[1] or '' @@ -12,7 +12,7 @@ if token ~= '' and redis.call('GET', KEYS[1]) ~= token then end redis.call('DEL', KEYS[1]) -if KEYS[2] ~= '' then +if #KEYS >= 2 then for i = 1, wake_count do redis.call('LPUSH', KEYS[2], '1') end diff --git a/src/Lua/store_many_tracked_if_version.lua b/src/Lua/store_many_tracked_if_version.lua deleted file mode 100644 index 6b76843..0000000 --- a/src/Lua/store_many_tracked_if_version.lua +++ /dev/null @@ -1,63 +0,0 @@ --- Write model attribute entries only if the version key still matches, then release the --- build lock. The lock is always released, even when the write is skipped. --- --- KEYS[1] = version key (ver:{classKey}:) --- KEYS[2] = members set key --- KEYS[3] = building lock key (or '' to skip release) --- KEYS[4] = wake key (or '' to skip) --- KEYS[5..] = model attribute keys to write --- ARGV[1] = expected version --- ARGV[2] = TTL in seconds --- ARGV[3] = n (number of model keys) --- ARGV[4] = building lock token (optional; empty means release unconditionally) --- ARGV[5..n+4] = serialized attribute values --- ARGV[n+5] = wake token count (optional; defaults to 1) -local token = ARGV[4] or '' -local wake_count = tonumber(ARGV[tonumber(ARGV[3]) + 5] or '1') or 1 - -local function release_building() - if KEYS[3] == '' then return end - if token ~= '' and redis.call('GET', KEYS[3]) ~= token then return end - redis.call('DEL', KEYS[3]) - if KEYS[4] ~= '' then - for i = 1, wake_count do - redis.call('LPUSH', KEYS[4], '1') - end - redis.call('EXPIRE', KEYS[4], 10) - end -end - -if KEYS[3] ~= '' and token ~= '' and redis.call('GET', KEYS[3]) ~= token then - return 0 -end - -local current = redis.call('GET', KEYS[1]) or '0' -if current ~= ARGV[1] then - release_building() - return 0 -end - -local ttl = tonumber(ARGV[2]) -local n = tonumber(ARGV[3]) -local members = {} - -for i = 1, n do - local key = KEYS[4 + i] - redis.call('SETEX', key, ttl, ARGV[4 + i]) - members[i] = key -end - -for start = 1, n, 500 do - local stop = math.min(start + 499, n) - local batch = {} - - for i = start, stop do - batch[#batch + 1] = members[i] - end - - redis.call('SADD', KEYS[2], unpack(batch)) -end -redis.call('EXPIRE', KEYS[2], ttl) - -release_building() -return n diff --git a/src/Lua/store_model_attrs.lua b/src/Lua/store_model_attrs.lua new file mode 100644 index 0000000..6b4fb64 --- /dev/null +++ b/src/Lua/store_model_attrs.lua @@ -0,0 +1,50 @@ +-- Write model attribute entries only if the version key still matches, then release the +-- build lock. The lock is always released, even when the write is skipped. +-- +-- KEYS[1] = version key (ver:{classKey}:) +-- KEYS[2..n+1] = model attribute keys to write +-- KEYS[n+2] = building lock key (optional; omit to skip release) +-- KEYS[n+3] = wake key (optional; omit when there are no waiters) +-- ARGV[1] = expected version +-- ARGV[2] = TTL in seconds +-- ARGV[3] = n (number of model keys) +-- ARGV[4] = building lock token (optional; empty means release unconditionally) +-- ARGV[5..n+4] = serialized attribute values +-- ARGV[n+5] = wake token count (optional; defaults to 1) +local token = ARGV[4] or '' +local n = tonumber(ARGV[3]) +local wake_count = tonumber(ARGV[n + 5] or '1') or 1 +local has_lock = #KEYS > 1 + n +local has_wake = #KEYS > 1 + n + 1 + +local function release_building() + if not has_lock then return end + local lock = KEYS[n + 2] + if token ~= '' and redis.call('GET', lock) ~= token then return end + redis.call('DEL', lock) + if has_wake then + for i = 1, wake_count do + redis.call('LPUSH', KEYS[n + 3], '1') + end + redis.call('EXPIRE', KEYS[n + 3], 10) + end +end + +if has_lock and token ~= '' and redis.call('GET', KEYS[n + 2]) ~= token then + return 0 +end + +local current = redis.call('GET', KEYS[1]) or '0' +if current ~= ARGV[1] then + release_building() + return 0 +end + +local ttl = tonumber(ARGV[2]) + +for i = 1, n do + redis.call('SETEX', KEYS[1 + i], ttl, ARGV[4 + i]) +end + +release_building() +return n diff --git a/src/Lua/store_many_versioned.lua b/src/Lua/store_versioned_payload.lua similarity index 80% rename from src/Lua/store_many_versioned.lua rename to src/Lua/store_versioned_payload.lua index 82dd5e2..3120534 100644 --- a/src/Lua/store_many_versioned.lua +++ b/src/Lua/store_versioned_payload.lua @@ -4,8 +4,8 @@ -- -- KEYS[1..n] = version keys -- KEYS[n+1..n+m] = cache keys to write --- KEYS[n+m+1] = building lock key (or '' to skip) --- KEYS[n+m+2] = wake key (or '' to skip) +-- KEYS[n+m+1] = building lock key (optional; omit for a plain write) +-- KEYS[n+m+2] = wake key (optional; omit when there are no waiters) -- ARGV[1] = n (number of version keys) -- ARGV[2] = m (number of cache keys) -- ARGV[3] = TTL in seconds @@ -19,12 +19,14 @@ local m = tonumber(ARGV[2]) local ttl = tonumber(ARGV[3]) local token = ARGV[n + m + 4] or '' local wake_count = tonumber(ARGV[n + m + 5] or '1') or 1 +local has_lock = #KEYS > n + m +local has_wake = #KEYS > n + m + 1 local function release_building() - if KEYS[n + m + 1] == '' then return end + if not has_lock then return end if token ~= '' and redis.call('GET', KEYS[n + m + 1]) ~= token then return end redis.call('DEL', KEYS[n + m + 1]) - if KEYS[n + m + 2] ~= '' then + if has_wake then for i = 1, wake_count do redis.call('LPUSH', KEYS[n + m + 2], '1') end @@ -32,7 +34,7 @@ local function release_building() end end -if KEYS[n + m + 1] ~= '' and token ~= '' and redis.call('GET', KEYS[n + m + 1]) ~= token then +if has_lock and token ~= '' and redis.call('GET', KEYS[n + m + 1]) ~= token then return 0 end diff --git a/src/Planning/BypassReasons.php b/src/Planning/BypassReasons.php index b2cc5cf..9273cf7 100644 --- a/src/Planning/BypassReasons.php +++ b/src/Planning/BypassReasons.php @@ -12,6 +12,7 @@ final class BypassReasons * dependency — dependency tracking can't safely cover this query * normalization — result can't be decomposed into model cache keys * safety — bypassed for query correctness; no caching workaround + * space — cache-space membership or registration prevents caching * * @param array|null $resolvedColumns null skips the calculated-column check * @return array> @@ -40,10 +41,6 @@ public static function fromInspection(QueryInspection $inspection): array $dependency[] = 'raw WHERE expression'; } - if ($inspection->has(QueryInspection::SUBQUERY_WHERE | QueryInspection::EXISTS_WHERE)) { - $dependency[] = 'subquery WHERE (whereHas/whereExists)'; - } - if ($inspection->has(QueryInspection::NON_CANONICAL_FROM)) { $normalization[] = 'non-standard FROM (subquery or raw expression)'; } @@ -80,11 +77,11 @@ public static function fromInspection(QueryInspection $inspection): array $normalization[] = 'calculated or raw SELECT expressions'; } - return array_filter([ + return self::merge([ 'dependency' => $dependency, 'normalization' => $normalization, 'safety' => $safety, - ]); + ], $inspection->contextReasons); } public static function labels(): array @@ -93,6 +90,7 @@ public static function labels(): array 'dependency' => "can't infer cache dependency", 'normalization' => "result can't be normalized into model keys", 'safety' => 'bypassed for query correctness', + 'space' => 'cross-space dependencies', 'opted_out' => 'explicitly disabled', ]; } diff --git a/src/Planning/CachePlanSpaceValidator.php b/src/Planning/CachePlanSpaceValidator.php new file mode 100644 index 0000000..41d5548 --- /dev/null +++ b/src/Planning/CachePlanSpaceValidator.php @@ -0,0 +1,89 @@ +isCacheable()) { + return $plan; + } + + $space = $this->resolver->resolve($model::class, $builder->getSpace()); + + if ($this->registry->dependenciesAreOnlyModel( + $model::class, + $plan->dependencies->models, + $plan->dependencies->tables, + )) { + return $plan->withSpace($space); + } + + $validation = $this->registry->validateDependencies( + $space, + $plan->dependencies->models, + $plan->dependencies->tables, + includeDependenciesBySpace: $explain, + ); + + if ($validation->isValid) { + if (!$explain && !$this->registry->registerTableDependencies($space, $plan->dependencies->tables)) { + return CachePlan::bypass( + operation: $plan->operation, + dependencies: $plan->dependencies, + bypassReasons: ['space' => ['failed to register table-space dependencies']], + )->withSpace($space); + } + + return $plan->withSpace($space); + } + + $offending = implode(', ', $validation->invalidModels); + $reason = 'cross-space dependencies for space [' . $space->name . ']: ' . $offending; + + if (!$explain && $this->debug) { + $modelClass = $model::class; + $this->logger?->warning( + "NormCache: query for [{$modelClass}] in space [{$space->name}] depends on [{$offending}] " + . 'which are not in that space; the query will not cache. Add them to the space or drop the dependency.' + ); + } + + if (!$explain && $this->crossSpaceBehavior === 'throw') { + throw new \RuntimeException('NormCache: ' . $reason); + } + + return CachePlan::bypass( + operation: $plan->operation, + dependencies: $plan->dependencies, + bypassReasons: ['space' => [$reason]], + )->withSpace($space); + } +} diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 0c7ec94..8ced440 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -3,8 +3,8 @@ namespace NormCache\Planning; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Query\Builder as QueryBuilder; -use Illuminate\Support\Facades\Log; use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; use NormCache\Enums\PlanningMode; @@ -16,7 +16,7 @@ final class CachePlanner { - private const SIMPLE_RESULT_BYPASS_FLAGS = QueryInspection::RAW_ORDER + private const SIMPLE_RESULT_FAST_PATH_BLOCKERS = QueryInspection::RAW_ORDER | QueryInspection::RAW_WHERE | QueryInspection::SUBQUERY_WHERE | QueryInspection::EXISTS_WHERE @@ -26,7 +26,7 @@ final class CachePlanner | QueryInspection::UNION; // Raw ORDER is allowed: getCountForPagination() ignores ordering entirely. - private const SIMPLE_PAGINATION_BYPASS_FLAGS = QueryInspection::RAW_WHERE + private const SIMPLE_PAGINATION_FAST_PATH_BLOCKERS = QueryInspection::RAW_WHERE | QueryInspection::SUBQUERY_WHERE | QueryInspection::EXISTS_WHERE | QueryInspection::LOCK @@ -38,8 +38,15 @@ final class CachePlanner public function __construct( private readonly QueryAnalyzer $analyzer = new QueryAnalyzer, + private readonly DependencyResolver $dependencies = new DependencyResolver, + private readonly ?CachePlanSpaceValidator $spaceValidator = null, ) {} + public function analyzer(): QueryAnalyzer + { + return $this->analyzer; + } + public function plan( CacheableBuilder $builder, QueryBuilder $base, @@ -61,15 +68,37 @@ public function plan( return $this->transactionBypass($builder, $model, $context); } - return match ($context->operation) { - CacheOperation::Scalar => $this->planScalarLike($builder, $model, $base, $context, $insideTransaction, $explain, self::SIMPLE_RESULT_BYPASS_FLAGS), - CacheOperation::PaginationCount => $this->planScalarLike($builder, $model, $base, $context, $insideTransaction, $explain, self::SIMPLE_PAGINATION_BYPASS_FLAGS), - CacheOperation::Pivot, - CacheOperation::Through => $this->planRelationResult($builder, $model, $base, $context, $insideTransaction, $explain), - CacheOperation::Models, - CacheOperation::BelongsToEagerLoad, - CacheOperation::MorphToEagerLoad => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), - }; + $inspection = $this->analyze($builder, $model, $base, $context); + + $plan = isset($inspection->contextReasons['opted_out']) + ? CachePlan::bypass( + operation: $context->operation, + dependencies: $this->dependencies->resolveBase($builder, $model, $context), + bypassReasons: ['opted_out' => $inspection->contextReasons['opted_out']], + ) + : match ($context->operation) { + CacheOperation::Scalar => $this->planInspectedResult($builder, $base, $context, $inspection, $insideTransaction, $explain, self::SIMPLE_RESULT_FAST_PATH_BLOCKERS), + CacheOperation::PaginationCount => $this->planInspectedResult($builder, $base, $context, $inspection, $insideTransaction, $explain, self::SIMPLE_PAGINATION_FAST_PATH_BLOCKERS), + CacheOperation::Pivot, + CacheOperation::Through => $this->planInspectedResult($builder, $base, $context, $inspection, $insideTransaction, $explain, strictRelation: true), + CacheOperation::Models => $this->planModels($builder, $model, $base, $context, $inspection, $insideTransaction, $explain), + }; + + return $this->spaceValidator()->validate($plan, $builder, $model, $explain); + } + + public function applySpaceValidation( + CachePlan $plan, + CacheableBuilder $builder, + Model $model, + bool $explain = false, + ): CachePlan { + return $this->spaceValidator()->validate($plan, $builder, $model, $explain); + } + + private function spaceValidator(): CachePlanSpaceValidator + { + return $this->spaceValidator ?? CachePlanSpaceValidator::standalone(); } private function globalBypass( @@ -88,8 +117,7 @@ private function globalBypass( return CachePlan::bypass( operation: $context->operation, - dependencies: $this->baseDependencies($builder, $model, $context), - reasons: $reasons, + dependencies: $this->dependencies->resolveBase($builder, $model, $context), bypassReasons: ['opted_out' => $reasons], ); } @@ -103,94 +131,27 @@ private function transactionBypass( return CachePlan::bypass( operation: $context->operation, - dependencies: $this->baseDependencies($builder, $model, $context), - reasons: $reasons, + dependencies: $this->dependencies->resolveBase($builder, $model, $context), bypassReasons: ['safety' => $reasons], ); } - private function planScalarLike( - CacheableBuilder $builder, - Model $model, - QueryBuilder $base, - CachePlanContext $context, - bool $insideTransaction, - bool $explain, - int $simpleBypassFlags, - ): CachePlan { - $explicitModels = $builder->explicitDependencies(); - $explicitTables = $builder->explicitTableDependencies(); - $hasExplicit = $builder->hasExplicitDependencies(); - - if (!$explain && !$insideTransaction && !$hasExplicit && $context->contextReasons === []) { - $plan = $this->trySimpleResultPlan($model, $base, $context, $simpleBypassFlags); - - if ($plan !== null) { - return $plan; - } - } - - return $this->planInspectedResult( - model: $model, - base: $base, - context: $context, - explicitModels: $explicitModels, - explicitTables: $explicitTables, - hasExplicit: $hasExplicit, - insideTransaction: $insideTransaction, - explain: $explain, - scalarLike: true, - ); - } - - private function planRelationResult( - CacheableBuilder $builder, - Model $model, - QueryBuilder $base, - CachePlanContext $context, - bool $insideTransaction, - bool $explain, - ): CachePlan { - $explicitModels = $builder->explicitDependencies(); - $explicitTables = $builder->explicitTableDependencies(); - $hasExplicit = $builder->hasExplicitDependencies(); - - return $this->planInspectedResult( - model: $model, - base: $base, - context: $context, - explicitModels: $explicitModels, - explicitTables: $explicitTables, - hasExplicit: $hasExplicit, - insideTransaction: $insideTransaction, - explain: $explain, - strictRelation: true, - ); - } - private function planModels( CacheableBuilder $builder, Model $model, QueryBuilder $base, CachePlanContext $context, + QueryInspection $inspection, bool $insideTransaction, bool $explain, ): CachePlan { $modelClass = $model::class; $modelTable = $model->getTable(); - $inferred = $context->inferredDependencies; + $inferred = $inspection->dependencies; $explicitModels = $builder->explicitDependencies(); $explicitTables = $builder->explicitTableDependencies(); $hasExplicit = $builder->hasExplicitDependencies(); - $inspection = $this->analyzer->inspect( - $base, - $modelTable, - $context->columns, - [$model->getKeyName(), $model->getQualifiedKeyName()], - includeTables: $explain, - ); - if ($this->qualifiesForDirectModels($explain, $insideTransaction, $hasExplicit, $context, $inferred, $inspection)) { return CachePlan::direct( operation: $context->operation, @@ -201,11 +162,11 @@ private function planModels( } $hasDependencyBypass = $inspection->hasDependencyBypass(); - $hasContextDependencyBypass = isset($context->contextReasons['dependency']); + $hasContextDependencyBypass = isset($inspection->contextReasons['dependency']); $dependencies = $this->dependsOnPrimaryModelOnly($hasExplicit, $inferred, $hasDependencyBypass, $hasContextDependencyBypass) ? DependencySet::singleModel($modelClass) - : $this->resolveDependencies( + : $this->dependencies->resolve( $modelClass, $context, $inspection, @@ -215,78 +176,68 @@ private function planModels( ); if ($insideTransaction - || $inspection->hasSafetyBypass() - || isset($context->contextReasons['safety'])) { + || $inspection->hasSafetyBypass()) { return $this->safetyBypass($context, $inspection, $dependencies, $insideTransaction); } - $normalizable = !$hasDependencyBypass + $normalizable = $inferred->hasNoDependencies() + && !$hasDependencyBypass && !$hasContextDependencyBypass && $inspection->normalizationFlags() === 0 - && !isset($context->contextReasons['normalization']); - $requiresPrimaryKeys = false; - - if ($context->operation === CacheOperation::BelongsToEagerLoad - || $context->operation === CacheOperation::MorphToEagerLoad) { - $requiresPrimaryKeys = $inspection->primaryKeys === null; - $normalizable = $normalizable && !$requiresPrimaryKeys; - } + && !isset($inspection->contextReasons['normalization']); if ($normalizable && $dependencies->safe) { - $isMultiDependency = count($dependencies->models) + count($dependencies->tables) > 1; - - if (!NormCache::isSlotting() || !$isMultiDependency) { - return CachePlan::normalized( - operation: $context->operation, - dependencies: $dependencies, - columns: $context->columns, - primaryKeys: $inspection->primaryKeys, - ); - } + return CachePlan::normalized( + operation: $context->operation, + dependencies: $dependencies, + columns: $context->columns, + primaryKeys: $inspection->primaryKeys, + ); } - if ($dependencies->safe && $this->hasResultDependencies($context, $hasExplicit)) { + if ($dependencies->safe && $this->hasResultDependencies($inspection, $hasExplicit)) { if ($this->requiresExplicitSelectForJoinResult($builder, $base, $context)) { - $this->warnUnderDeclaredDependencies($modelTable, $base, $inspection, $dependencies); - $reasons = ['join_result_requires_explicit_select']; - return CachePlan::bypass( operation: $context->operation, dependencies: $dependencies, - reasons: $reasons, - bypassReasons: ['normalization' => $reasons], + bypassReasons: ['normalization' => ['join_result_requires_explicit_select']], ); } - return $this->resultPlan($modelTable, $base, $context, $inspection, $dependencies, $normalizable); + return $this->resultPlan($context, $inspection, $dependencies); } - return $this->bypassPlan( - $context, - $inspection, - $dependencies, - requiresPrimaryKeys: $requiresPrimaryKeys, - ); + return $this->bypassPlan($context, $inspection, $dependencies); } private function planInspectedResult( - Model $model, + CacheableBuilder $builder, QueryBuilder $base, CachePlanContext $context, - ?array $explicitModels, - array $explicitTables, - bool $hasExplicit, + QueryInspection $inspection, bool $insideTransaction, bool $explain, - bool $scalarLike = false, + ?int $simpleBypassFlags = null, bool $strictRelation = false, ): CachePlan { + $model = $builder->getModel(); $modelClass = $model::class; $modelTable = $model->getTable(); - $inferred = $context->inferredDependencies; + $inferred = $inspection->dependencies; + $explicitModels = $builder->explicitDependencies(); + $explicitTables = $builder->explicitTableDependencies(); + $hasExplicit = $builder->hasExplicitDependencies(); - $inspection = $this->inspect($model, $base, $context, collectTables: $explain); - $dependencies = $this->resolveDependencies( + if ($simpleBypassFlags !== null + && !$explain + && !$insideTransaction + && !$hasExplicit + && $inspection->contextReasons === [] + && ($plan = $this->trySimpleResultPlan($model, $context, $inspection, $simpleBypassFlags)) !== null) { + return $plan; + } + + $dependencies = $this->dependencies->resolve( $modelClass, $context, $inspection, @@ -299,7 +250,7 @@ private function planInspectedResult( return $bypass; } - if ($scalarLike + if ($simpleBypassFlags !== null && !$hasExplicit && $inferred->hasNoDependencies() && $dependencies->safe @@ -312,7 +263,7 @@ private function planInspectedResult( } $normalizationFlags = $inspection->normalizationFlags(); - $hasContextNormalizationBypass = isset($context->contextReasons['normalization']); + $hasContextNormalizationBypass = isset($inspection->contextReasons['normalization']); if ($strictRelation && $normalizationFlags === QueryInspection::JOIN @@ -322,7 +273,7 @@ private function planInspectedResult( if ($dependencies->safe && (!$strictRelation || ($normalizationFlags === 0 && !$hasContextNormalizationBypass))) { - return $this->resultPlan($modelTable, $base, $context, $inspection, $dependencies); + return $this->resultPlan($context, $inspection, $dependencies); } return $this->bypassPlan( @@ -337,17 +288,15 @@ private function planInspectedResult( private function trySimpleResultPlan( Model $model, - QueryBuilder $base, CachePlanContext $context, + QueryInspection $inspection, int $bypassFlags, ): ?CachePlan { - $inferred = $context->inferredDependencies; - - if (!$inferred->safe || !$inferred->hasNoDependencies()) { + if (!$inspection->dependencies->safe || !$inspection->dependencies->hasNoDependencies()) { return null; } - if (($this->analyzer->flags($base, $model->getTable(), null) & $bypassFlags) !== 0) { + if (($inspection->flags & $bypassFlags) !== 0) { return null; } @@ -358,22 +307,44 @@ private function trySimpleResultPlan( ); } - // Touched tables are only collected for explain/debug output, so $collectTables receives $explain. - private function inspect( + private function analyze( + CacheableBuilder $builder, Model $model, QueryBuilder $base, CachePlanContext $context, - bool $collectTables, ): QueryInspection { + $primaryKeys = $context->operation === CacheOperation::Models + ? [$model->getKeyName(), $model->getQualifiedKeyName()] + : []; + return $this->analyzer->inspect( $base, $model->getTable(), $context->columns, - [], - $collectTables, + $primaryKeys, + $context->operation === CacheOperation::Models + ? $this->activeSoftDeleteScopeColumn($builder, $model) + : null, + $model->getConnection()->getName(), + $builder->capturedDependencies(), + BypassReasons::merge($context->contextReasons, $builder->capturedContextReasons()), + $builder->capturedOpaqueJoins(), + $builder->hasCapturedOpaqueFrom(), + $builder->capturedOpaqueWhereSubqueries(), ); } + private function activeSoftDeleteScopeColumn(CacheableBuilder $builder, Model $model): ?string + { + if (!$model::hasGlobalScope(SoftDeletingScope::class) + || in_array(SoftDeletingScope::class, $builder->removedScopes(), true) + || !method_exists($model, 'getQualifiedDeletedAtColumn')) { + return null; + } + + return $model->getQualifiedDeletedAtColumn(); + } + private function qualifiesForDirectModels( bool $explain, bool $insideTransaction, @@ -385,7 +356,7 @@ private function qualifiesForDirectModels( return !$explain && !$insideTransaction && !$hasExplicit - && $context->contextReasons === [] + && $inspection->contextReasons === [] && $inferred->safe && $inferred->hasNoDependencies() && $inspection->primaryKeys !== null @@ -406,71 +377,6 @@ private function dependsOnPrimaryModelOnly( && !$hasContextDependencyBypass; } - private function resolveDependencies( - string $modelClass, - CachePlanContext $context, - ?QueryInspection $inspection, - ?array $explicitModels, - array $explicitTables, - bool $hasExplicit, - ): DependencySet { - $inferred = $context->inferredDependencies; - - if ($hasExplicit) { - return new DependencySet( - models: array_keys(array_flip([ - $modelClass, - ...$inferred->models, - ...($explicitModels ?? []), - ])), - tables: array_values(array_unique([...$inferred->tables, ...$explicitTables])), - ); - } - - $hasDependencyBypass = $inspection !== null && $inspection->hasDependencyBypass(); - - // EXISTS_WHERE-only bypasses are exempt if inferred dependencies are safe and non-empty. - $exempt = $hasDependencyBypass - && $inspection->hasOnlyExistsDependencyBypass() - && $inferred->safe - && !$inferred->hasNoDependencies(); - - if (($hasDependencyBypass && !$exempt) - || isset($context->contextReasons['dependency']) - || !$inferred->safe) { - return DependencySet::unsafe(array_values(array_unique([ - ...($inspection !== null ? BypassReasons::fromInspection($inspection)['dependency'] ?? [] : []), - ...($context->contextReasons['dependency'] ?? []), - ...$inferred->reasons, - ]))); - } - - if ($inferred->hasNoDependencies()) { - return DependencySet::singleModel($modelClass); - } - - return new DependencySet( - models: array_keys(array_flip([$modelClass, ...$inferred->models])), - tables: $inferred->tables, - ); - } - - // Dependencies for plans made without query inspection (global/transaction bypasses). - private function baseDependencies( - CacheableBuilder $builder, - Model $model, - CachePlanContext $context, - ): DependencySet { - return $this->resolveDependencies( - $model::class, - $context, - null, - $builder->explicitDependencies(), - $builder->explicitTableDependencies(), - $builder->hasExplicitDependencies(), - ); - } - private function safetyBypass( CachePlanContext $context, QueryInspection $inspection, @@ -478,19 +384,16 @@ private function safetyBypass( bool $insideTransaction, ): ?CachePlan { if (!$insideTransaction - && !$inspection->hasSafetyBypass() - && !isset($context->contextReasons['safety'])) { + && !$inspection->hasSafetyBypass()) { return null; } - $bypassReasons = $this->mergedBypassReasons($context, $inspection, $insideTransaction); - $reasons = $bypassReasons['safety'] ?? []; + $bypassReasons = $this->mergedBypassReasons($inspection, $insideTransaction); return CachePlan::bypass( operation: $context->operation, dependencies: $dependencies, - reasons: $reasons, - bypassReasons: ['safety' => $reasons], + bypassReasons: ['safety' => $bypassReasons['safety'] ?? []], ); } @@ -506,26 +409,19 @@ private function requiresExplicitSelectForJoinResult( && !$builder->hasAggregateColumns(); } - private function hasResultDependencies(CachePlanContext $context, bool $hasExplicit): bool + private function hasResultDependencies(QueryInspection $inspection, bool $hasExplicit): bool { - return $hasExplicit - || !$context->inferredDependencies->hasNoDependencies(); + return $hasExplicit || !$inspection->dependencies->hasNoDependencies(); } private function resultPlan( - string $modelTable, - QueryBuilder $base, CachePlanContext $context, QueryInspection $inspection, DependencySet $dependencies, - bool $normalizable = false, ): CachePlan { - $this->warnUnderDeclaredDependencies($modelTable, $base, $inspection, $dependencies); - return CachePlan::result( operation: $context->operation, dependencies: $dependencies, - normalizable: $normalizable, columns: $context->columns, primaryKeys: $inspection->primaryKeys, ); @@ -535,14 +431,9 @@ private function bypassPlan( CachePlanContext $context, QueryInspection $inspection, DependencySet $dependencies, - bool $requiresPrimaryKeys = false, bool $relaxedRelationNormalization = false, ): CachePlan { - $bypassReasons = $this->mergedBypassReasons($context, $inspection); - - if ($requiresPrimaryKeys) { - $bypassReasons['normalization'][] = 'eager load requires primary key lookup'; - } + $bypassReasons = $this->mergedBypassReasons($inspection); if ($relaxedRelationNormalization) { unset($bypassReasons['normalization']); @@ -558,78 +449,16 @@ private function bypassPlan( return CachePlan::bypass( operation: $context->operation, dependencies: $dependencies, - reasons: array_values(array_unique([ - ...$dependencies->reasons, - ...($bypassReasons['dependency'] ?? []), - ...($bypassReasons['normalization'] ?? []), - ])), bypassReasons: $bypassReasons, ); } - private function warnUnderDeclaredDependencies( - string $modelTable, - QueryBuilder $base, - QueryInspection $inspection, - DependencySet $dependencies, - ): void { - if (!config('app.debug', false)) { - return; - } - - if ($inspection->has(QueryInspection::EXISTS_WHERE | QueryInspection::SUBQUERY_WHERE)) { - Log::warning( - 'NormCache Warning: Query contains subquery/exists predicates. NormCache cannot verify all touched tables; ensure dependsOn()/dependsOnTables() includes every table read by the subquery.' - ); - } - - $this->checkDependencyCompleteness( - $inspection->tables ?? $this->analyzer->extractTables($base, $modelTable), - $dependencies, - $modelTable, - ); - } - - private function checkDependencyCompleteness(array $queryTables, DependencySet $dependencies, string $baseTable): void - { - // Strip connection prefix from table keys ("conn:table" → "table"). - $declaredTables = array_map( - fn($key) => str_contains($key, ':') ? substr($key, strpos($key, ':') + 1) : $key, - $dependencies->tables - ); - - // Map declared models to their tables. - foreach ($dependencies->models as $modelClass) { - if (class_exists($modelClass) && is_subclass_of($modelClass, Model::class)) { - $declaredTables[] = (new $modelClass)->getTable(); - } - } - - // Add the base table to the declared list so it doesn't get flagged as missing. - $declaredTables[] = $baseTable; - - $missing = array_diff($queryTables, $declaredTables); - - if (!empty($missing)) { - $tablesStr = implode(', ', $missing); - Log::warning( - "NormCache Warning: Query touches tables ({$tablesStr}) that are not present in dependsOn()/dependsOnTables(). This is an under-declared dependency and can lead to outdated cache reads." - ); - } - } - private function mergedBypassReasons( - CachePlanContext $context, QueryInspection $inspection, bool $insideTransaction = false, ): array { return BypassReasons::merge( - $this->resolveContextReasons( - $context->contextReasons, - cacheSkipped: false, - cacheDisabled: false, - insideTransaction: $insideTransaction, - ), + $insideTransaction ? ['safety' => ['inside a database transaction']] : [], BypassReasons::fromInspection($inspection), ); } diff --git a/src/Planning/DependencyResolver.php b/src/Planning/DependencyResolver.php new file mode 100644 index 0000000..55f90bf --- /dev/null +++ b/src/Planning/DependencyResolver.php @@ -0,0 +1,92 @@ +dependencies; + $required = $context->requiredDependencies; + + if ($hasExplicit) { + return new DependencySet( + models: array_values(array_unique([ + $modelClass, + ...$inferred->models, + ...$required->models, + ...($explicitModels ?? []), + ])), + tables: array_values(array_unique([ + ...$inferred->tables, + ...$required->tables, + ...$explicitTables, + ])), + safe: $required->safe, + reasons: $required->reasons, + ); + } + + if ($inspection->hasDependencyBypass() + || isset($inspection->contextReasons['dependency']) + || !$inferred->safe + || !$required->safe) { + return DependencySet::unsafe(array_values(array_unique([ + ...(BypassReasons::fromInspection($inspection)['dependency'] ?? []), + ...($inspection->contextReasons['dependency'] ?? []), + ...$inferred->reasons, + ...$required->reasons, + ]))); + } + + if ($inferred->hasNoDependencies() && $required->hasNoDependencies()) { + return DependencySet::singleModel($modelClass); + } + + return new DependencySet( + models: array_values(array_unique([ + $modelClass, + ...$inferred->models, + ...$required->models, + ])), + tables: array_values(array_unique([ + ...$inferred->tables, + ...$required->tables, + ])), + ); + } + + public function resolveBase( + CacheableBuilder $builder, + Model $model, + CachePlanContext $context, + ): DependencySet { + $required = $context->requiredDependencies; + + return new DependencySet( + models: array_values(array_unique([ + $model::class, + ...$required->models, + ...($builder->explicitDependencies() ?? []), + ])), + tables: array_values(array_unique([ + ...$required->tables, + ...$builder->explicitTableDependencies(), + ])), + safe: $required->safe, + reasons: $required->reasons, + ); + } +} diff --git a/src/Planning/QueryAnalyzer.php b/src/Planning/QueryAnalyzer.php index a9ca7d8..e2b56a2 100644 --- a/src/Planning/QueryAnalyzer.php +++ b/src/Planning/QueryAnalyzer.php @@ -4,13 +4,17 @@ use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Query\Builder as QueryBuilder; -use NormCache\Facades\NormCache; +use NormCache\Support\CacheKeyBuilder; use NormCache\Support\ProjectionClassifier; use NormCache\Values\DependencySet; use NormCache\Values\QueryInspection; final class QueryAnalyzer { + public function __construct( + private readonly CacheKeyBuilder $keys = new CacheKeyBuilder, + ) {} + public const EXISTS_WHERE_TYPES = [ 'Exists' => true, 'NotExists' => true, @@ -27,14 +31,32 @@ public function inspect( string $table, ?array $resolvedColumns, array $primaryKeyIdentifiers = [], - bool $includeTables = false, + ?string $softDeleteScopeColumn = null, + ?string $connection = null, + ?DependencySet $capturedDependencies = null, + array $contextReasons = [], + int $capturedOpaqueJoins = 0, + bool $capturedOpaqueFrom = false, + int $capturedOpaqueWhereSubqueries = 0, ): QueryInspection { + $dependencies = $connection === null + ? ($capturedDependencies ?? DependencySet::empty()) + : $this->inferQueryDependencies( + $base, + $connection, + $table, + $capturedOpaqueJoins, + $capturedOpaqueFrom, + $capturedOpaqueWhereSubqueries, + )->merge($capturedDependencies ?? DependencySet::empty()); + return new QueryInspection( flags: $this->flags($base, $table, $resolvedColumns), primaryKeys: $primaryKeyIdentifiers === [] ? null - : self::extractPrimaryKeys($base, $primaryKeyIdentifiers), - tables: $includeTables ? $this->extractTables($base, $table) : null, + : self::extractPrimaryKeys($base, $primaryKeyIdentifiers, $softDeleteScopeColumn), + dependencies: $dependencies, + contextReasons: $contextReasons, ); } @@ -103,7 +125,7 @@ public function extractTables(QueryBuilder $base, string $table): array foreach ((array) $base->joins as $join) { if (is_string($join->table)) { - $tables[] = self::stripAlias($join->table); + $tables[] = CacheKeyBuilder::stripTableAlias($join->table); } } @@ -111,31 +133,150 @@ public function extractTables(QueryBuilder $base, string $table): array } /** @param string $connection Eloquent connection name, e.g. $model->getConnection()->getName() */ - public function inferJoinDependencies(QueryBuilder $base, string $connection): DependencySet - { - if (empty($base->joins)) { - return DependencySet::empty(); + public function inferQueryDependencies( + QueryBuilder $base, + string $connection, + ?string $primaryTable = null, + int $capturedOpaqueJoins = 0, + bool $capturedOpaqueFrom = false, + int $capturedOpaqueWhereSubqueries = 0, + ): DependencySet { + $connection = $this->connectionName($base, $connection); + $tables = []; + $unsafe = $this->collectQueryTables( + $base, + $connection, + $tables, + $capturedOpaqueJoins, + $capturedOpaqueFrom, + ); + + if ($unsafe !== null) { + return DependencySet::unsafe($unsafe); } - $tables = []; + if ($this->countOpaqueWhereSubqueries($base) > $capturedOpaqueWhereSubqueries) { + return DependencySet::unsafe('subquery predicate dependency could not be inferred'); + } - foreach ($base->joins as $join) { - if (!is_string($join->table)) { - return DependencySet::empty(); + if ($primaryTable !== null) { + unset($tables[$this->keys->tableKey($connection, $primaryTable)]); + } + + return new DependencySet(tables: array_keys($tables)); + } + + /** @param array $tables */ + private function collectQueryTables( + QueryBuilder $base, + string $connection, + array &$tables, + int $capturedOpaqueJoins = 0, + bool $capturedOpaqueFrom = false, + ): ?string { + $connection = $this->connectionName($base, $connection); + + if (is_string($base->from)) { + if ($this->joinTableHasImplicitAlias($base->from)) { + return 'query source dependency could not be inferred'; } + $tables[$this->keys->tableKey($connection, $base->from)] = true; + } elseif (!$capturedOpaqueFrom) { + return 'query source dependency could not be inferred'; + } + + $opaqueJoins = 0; + foreach ($base->joins ?? [] as $join) { if ($this->joinClauseHasComplexWheres($join->wheres ?? [])) { - return DependencySet::unsafe('join clause dependency could not be inferred'); + return 'join clause dependency could not be inferred'; } - if ($this->joinTableHasImplicitAlias($join->table)) { - return DependencySet::unsafe('join table alias could not be inferred'); + if (is_string($join->table)) { + if ($this->joinTableHasImplicitAlias($join->table)) { + return 'join table alias could not be inferred'; + } + + $tables[$this->keys->tableKey($connection, $join->table)] = true; + } else { + $opaqueJoins++; } + } - $tables[] = NormCache::tableKey($connection, self::stripAlias($join->table)); + if ($opaqueJoins > $capturedOpaqueJoins) { + return 'joined subquery dependency could not be inferred'; } - return new DependencySet(tables: array_values(array_unique($tables))); + foreach ($base->wheres as $where) { + $query = $where['query'] ?? null; + + if ($query instanceof QueryBuilder + && ($reason = $this->collectQueryTables($query, $connection, $tables))) { + return $reason; + } + } + + foreach ($base->unions ?? [] as $union) { + $query = $union['query'] ?? null; + + if (!$query instanceof QueryBuilder) { + return 'union dependency could not be inferred'; + } + + if ($reason = $this->collectQueryTables($query, $connection, $tables)) { + return $reason; + } + } + + return null; + } + + private function countOpaqueWhereSubqueries(QueryBuilder $base): int + { + $count = 0; + + foreach ($base->wheres as $where) { + $type = $where['type'] ?? null; + + if ($type === 'Basic' && ($where['column'] ?? null) instanceof Expression) { + $count++; + } + + if (($type === 'In' || $type === 'NotIn') && isset($where['values'])) { + foreach ((array) $where['values'] as $value) { + if ($value instanceof Expression) { + $count++; + } + } + } + + if (($where['query'] ?? null) instanceof QueryBuilder) { + $count += $this->countOpaqueWhereSubqueries($where['query']); + } + } + + return $count; + } + + private function connectionName(QueryBuilder $base, string $fallback): string + { + $connection = $base->getConnection(); + + return method_exists($connection, 'getName') + ? ($connection->getName() ?? $fallback) + : $fallback; + } + + /** @deprecated Use inferQueryDependencies(). */ + public function inferJoinDependencies(QueryBuilder $base, string $connection): DependencySet + { + $dependencies = $this->inferQueryDependencies($base, $connection, is_string($base->from) ? $base->from : null); + + return new DependencySet( + tables: $dependencies->tables, + safe: $dependencies->safe, + reasons: $dependencies->reasons, + ); } private function joinTableHasImplicitAlias(string $table): bool @@ -154,13 +295,11 @@ private function joinClauseHasComplexWheres(array $wheres): bool return false; } - private static function stripAlias(string $table): string - { - return preg_replace('/\s+as\s+\S+$/i', '', $table); - } - - public static function extractPrimaryKeys(QueryBuilder $base, array $primaryKeyIdentifiers): ?array - { + public static function extractPrimaryKeys( + QueryBuilder $base, + array $primaryKeyIdentifiers, + ?string $softDeleteScopeColumn = null, + ): ?array { if ($base->offset > 0) { return null; } @@ -169,11 +308,16 @@ public static function extractPrimaryKeys(QueryBuilder $base, array $primaryKeyI return []; } - if (count($base->wheres) !== 1) { + $wheres = array_values(array_filter( + $base->wheres, + static fn(array $where): bool => !self::isSoftDeleteScopeConstraint($where, $softDeleteScopeColumn), + )); + + if (count($wheres) !== 1) { return null; } - $where = $base->wheres[0]; + $where = $wheres[0]; $column = $where['column'] ?? null; if (!in_array($column, $primaryKeyIdentifiers, true)) { @@ -205,6 +349,15 @@ public static function extractPrimaryKeys(QueryBuilder $base, array $primaryKeyI return null; } + private static function isSoftDeleteScopeConstraint(array $where, ?string $column): bool + { + return $column !== null + && ($where['type'] ?? null) === 'Null' + && ($where['boolean'] ?? 'and') === 'and' + && !($where['not'] ?? false) + && ($where['column'] ?? null) === $column; + } + private function inspectWheres(array $wheres): int { $flags = 0; @@ -212,7 +365,7 @@ private function inspectWheres(array $wheres): int foreach ($wheres as $where) { $type = $where['type'] ?? ''; - if ($type === 'Raw' || $type === 'raw') { + if ($type === 'raw') { $flags |= QueryInspection::RAW_WHERE; } @@ -224,7 +377,13 @@ private function inspectWheres(array $wheres): int $flags |= QueryInspection::SUBQUERY_WHERE; } elseif ($type === 'Basic' && ($where['column'] ?? null) instanceof Expression) { $flags |= QueryInspection::SUBQUERY_WHERE; - } elseif ($type === 'Nested' && isset($where['query'])) { + } + + if (($where['query'] ?? null) instanceof QueryBuilder) { + if ($where['query']->lock !== null) { + $flags |= QueryInspection::LOCK; + } + $flags |= $this->inspectWheres((array) $where['query']->wheres); } diff --git a/src/Relations/CacheableBelongsTo.php b/src/Relations/CacheableBelongsTo.php deleted file mode 100644 index 11f7865..0000000 --- a/src/Relations/CacheableBelongsTo.php +++ /dev/null @@ -1,123 +0,0 @@ -eagerKeys = $this->getEagerModelKeys($models); - } - - public function getEager() - { - if (!$this->query instanceof CacheableBuilder) { - return parent::getEager(); - } - - $prepared = $this->query->prepareCacheExecution(); - $builder = $prepared->builder; - $base = $prepared->base; - $columns = ProjectionClassifier::resolve($base, null); - - if (!$this->shouldUseCacheForEagerLoad($columns, $base, $builder)) { - return $this->getFromPreparedBuilder($prepared); - } - - $models = NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false); - - if ($builder->getEagerLoads() !== []) { - $models = $builder->eagerLoadRelations($models); - } - - return $prepared->applyAfterCallbacks($this->related->newCollection($models)); - } - - private function shouldUseCacheForEagerLoad( - ?array $columns, - QueryBuilder $base, - CacheableBuilder $builder, - ): bool { - $ownerKey = $this->getOwnerKeyName(); - - if ($this->eagerKeys === [] - || $ownerKey !== $this->related->getKeyName()) { - return false; - } - - if ($columns !== null) { - foreach ($columns as $col) { - if (!is_string($col)) { - return false; - } - } - if (!isset(AttributeProjector::normalizeProjection($columns)[$ownerKey])) { - return false; - } - } - - if ($this->isSimplePkEagerLoad($base, $builder)) { - return true; - } - - $plan = $builder->cachePlan($base, CachePlanContext::belongsToEagerLoad($columns ?? [])); - - return $plan->isNormalized(); - } - - private function isSimplePkEagerLoad(QueryBuilder $base, CacheableBuilder $builder): bool - { - if (RelationCacheGuards::blocksBypass($builder, $base) - || RelationCacheGuards::hasOrderingOrJoins($base)) { - return false; - } - - $whereCount = count($base->wheres); - - if ($whereCount === 0 || $whereCount > 2) { - return false; - } - - // First where must be the In/InRaw FK constraint added by addEagerConstraints. - $fkWhere = $base->wheres[0]; - if (($fkWhere['type'] ?? null) !== 'In' && ($fkWhere['type'] ?? null) !== 'InRaw') { - return false; - } - if (($fkWhere['column'] ?? null) !== $this->getQualifiedOwnerKeyName()) { - return false; - } - - // Optional second where must be a soft-delete Null constraint. - if ($whereCount === 2 && ($base->wheres[1]['type'] ?? null) !== 'Null') { - return false; - } - - return true; - } - - private function getFromPreparedBuilder(PreparedQuery $prepared): Collection - { - if ($this->eagerKeys === []) { - return $this->related->newCollection(); - } - - return $this->collectFromPreparedBuilder($prepared); - } -} diff --git a/src/Relations/CacheableMorphTo.php b/src/Relations/CacheableMorphTo.php deleted file mode 100644 index 28f0afd..0000000 --- a/src/Relations/CacheableMorphTo.php +++ /dev/null @@ -1,152 +0,0 @@ -query->toBase(); - $columns = ProjectionClassifier::resolve($base, null); - - foreach ($this->dictionary as $type => $_) { - $instance = $this->cacheableInstanceForType($type); - $results = $instance !== null - ? $this->getResultsFromCache($type, $instance, $columns) - : $this->getResultsByType($type); - - $this->matchToMorphParents($type, $results); - } - - return $this->models; - } - - protected function getResultsByType($type) - { - if (!empty($this->macroBuffer)) { - $instance = $this->createModelByType($type); - $ownerKey = $this->ownerKey ?? $instance->getKeyName(); - $query = $this->replayMacros($instance->newQuery()); - - if ($query instanceof CacheableBuilder) { - $query->withoutCache(); - } - - $relationQuery = $this->getQuery(); - $query = $query->mergeConstraintsFrom($relationQuery) - ->with(array_merge( - $relationQuery->getEagerLoads(), - (array) ($this->morphableEagerLoads[get_class($instance)] ?? []) - )) - ->withCount( - (array) ($this->morphableEagerLoadCounts[get_class($instance)] ?? []) - ); - - if ($callback = ($this->morphableConstraints[get_class($instance)] ?? null)) { - $callback($query); - } - - $whereIn = $this->whereInMethod($instance, $ownerKey); - - return $query->{$whereIn}( - $instance->qualifyColumn($ownerKey), $this->gatherKeysByType($type, $instance->getKeyType()) - )->get(); - } - - return parent::getResultsByType($type); - } - - private function cacheableInstanceForType(string $type): ?Model - { - $class = Model::getActualClassNameForMorph($type); - - if (isset($this->morphableConstraints[$class]) || isset($this->morphableEagerLoadCounts[$class])) { - return null; - } - - $instance = $this->createModelByType($type); - $query = $instance->newQuery(); - - if (!empty($this->macroBuffer)) { - $query = $this->replayMacros($query); - } - - if ($this->ownerKey !== null && $this->ownerKey !== $instance->getKeyName()) { - return null; - } - - if (!$query instanceof CacheableBuilder) { - return null; - } - - $prepared = $query->prepareCacheExecution(); - $builder = $prepared->builder; - $base = $prepared->base; - - if ($this->isSimpleMorphBase($base, $builder)) { - return $instance; - } - - $plan = $builder->cachePlan($base, CachePlanContext::morphToEagerLoad($type)); - - return $plan->isNormalized() ? $instance : null; - } - - private function isSimpleMorphBase(QueryBuilder $base, CacheableBuilder $builder): bool - { - // At this point only global scopes (e.g. soft-delete) have been applied — no FK constraints yet. - // Accept only an optional soft-delete Null where; reject everything else. - if (RelationCacheGuards::blocksBypass($builder, $base) - || RelationCacheGuards::hasOrderingOrJoins($base)) { - return false; - } - - $whereCount = count($base->wheres); - - if ($whereCount > 1) { - return false; - } - - if ($whereCount === 1 && ($base->wheres[0]['type'] ?? null) !== 'Null') { - return false; - } - - return true; - } - - private function getResultsFromCache(string $type, Model $instance, ?array $columns): EloquentCollection - { - $class = $instance::class; - $ids = array_values($this->gatherKeysByType($type, $instance->getKeyType())); - - $missedQuery = $this->query; - if (!empty($this->macroBuffer)) { - $queryWithMacros = $this->replayMacros($instance->newQuery()); - if ($queryWithMacros instanceof CacheableBuilder) { - $queryWithMacros->withoutCache(); - } - $missedQuery = $queryWithMacros->mergeConstraintsFrom($this->query); - } - - $models = NormCache::getModels($ids, $class, $columns, null, $missedQuery, false); - $collection = $instance->newCollection($models); - - $eagerLoads = $this->morphableEagerLoads[$class] ?? []; - - if (!empty($eagerLoads)) { - $collection->load($eagerLoads); - } - - return $collection; - } -} diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 2800cd4..1907b7d 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -4,14 +4,15 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Query\Builder; -use NormCache\Cache\ModelHydrator; use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; use NormCache\Facades\NormCache; use NormCache\Planning\QueryAnalyzer; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; use NormCache\Support\QueryHasher; +use NormCache\Support\RawAttributes; use NormCache\Support\RelationCacheGuards; use NormCache\Values\CachePlan; use NormCache\Values\CachePlanContext; @@ -21,8 +22,6 @@ trait CachesOneOrManyThrough { - use CollectsRelatedModels; - public function get($columns = ['*']): Collection { if (!$this->query instanceof CacheableBuilder) { @@ -69,10 +68,11 @@ public function get($columns = ['*']): Collection $tag = $builder->getCacheTag(); $ttl = $builder->getQueryTtl(); - return NormCache::rescue( - fn() => NormCache::engine()->runThrough( - fetch: fn() => NormCache::getThroughCache($relatedClass, $hash, $tag, $depClasses, $depTableKeys), - waitForBuild: fn() => NormCache::waitForThroughBuild( + $runThrough = fn() => CacheFallback::rescue( + NormCache::config(), + fn() => NormCache::engine()->runNormalized( + fetch: fn() => NormCache::through()->fetch($relatedClass, $hash, $tag, $depClasses, $depTableKeys), + waitForBuild: fn() => NormCache::through()->waitForBuild( $relatedClass, $hash, $tag, $depClasses, $depTableKeys ), onBuild: fn() => $this->getFromPreparedBuilder($prepared), @@ -91,14 +91,29 @@ public function get($columns = ['*']): Collection } $cachePayload = $this->cachePayloadFromResult($rawModels); - - NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs) { - NormCache::storeThroughIds( - $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, - $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken - ); - NormCache::cacheModelAttrs($relatedClass, $modelAttrs); - }); + $space = NormCache::keys()->activeSpace(); + + CacheFallback::attempt( + NormCache::config(), + function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $space) { + $stored = NormCache::through()->store( + $result->key, + $cachePayload['ids'], + $cachePayload['throughKeys'], + $ttl, + $result->build, + ); + + if ($stored) { + NormCache::models()->storeForBuild( + $relatedClass, + $modelAttrs, + $result->build, + $space, + ); + } + }, + ); return $prepared->applyAfterCallbacks($rawModels); }, @@ -118,6 +133,8 @@ public function get($columns = ['*']): Collection ), fn() => $this->getFromPreparedBuilder($prepared) ); + + return NormCache::withSpace($plan->space, $runThrough); } private function applyOneOfManyDependency(CacheableBuilder $query): void @@ -130,17 +147,27 @@ private function applyOneOfManyDependency(CacheableBuilder $query): void private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?CachePlan { if ($this->isSimpleThroughQuery($base, $builder)) { - return CachePlan::result( + $space = NormCache::spaceFor($this->related::class, $builder->getSpace()); + $dependencies = DependencySet::singleModel($this->throughParent::class); + $plan = CachePlan::result( operation: CacheOperation::Through, - dependencies: new DependencySet(models: [$this->throughParent::class]), + dependencies: $dependencies, + )->withSpace($space); + + $plan = $builder->planner()->applySpaceValidation( + $plan, + $builder, + $this->related, ); + + return $plan->usesResultCache() ? $plan : null; } $projection = ProjectionClassifier::resolve($base, null); $plan = $builder->cachePlan($base, CachePlanContext::through( $projection ?? [], - $builder->inferAggregateDependencies() + DependencySet::singleModel($this->throughParent::class), )); return $plan->usesResultCache() ? $plan : null; @@ -185,11 +212,11 @@ private function hydrateFromIds( ?array $raw = null, ): Collection { $builder = $prepared->builder; - $models = NormCache::getModels($ids, $relatedClass, $selectedColumns, $raw, $builder, false, $this->related); + $models = NormCache::hydrator()->getModels($ids, $relatedClass, $selectedColumns, $raw, $builder, false, $this->related); if ($throughKeys !== []) { - $getAttribute = ModelHydrator::getAttributeDirectClosure(); - $setAttribute = ModelHydrator::setAttributeDirectClosure(); + $getAttribute = RawAttributes::getAttributeClosure(); + $setAttribute = RawAttributes::setAttributeClosure(); $keyName = $this->related->getKeyName(); foreach ($models as $model) { $id = $getAttribute($model, $keyName); @@ -208,6 +235,6 @@ private function hydrateFromIds( private function getFromPreparedBuilder(PreparedQuery $prepared, bool $applyAfterCallbacks = true): Collection { - return $this->collectFromPreparedBuilder($prepared, $applyAfterCallbacks); + return $prepared->collect(applyAfterCallbacks: $applyAfterCallbacks); } } diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index bbd9232..7b90db9 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -6,20 +6,21 @@ use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Arr; -use NormCache\Cache\ModelHydrator; use NormCache\CacheableBuilder; use NormCache\Facades\NormCache; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; use NormCache\Support\QueryHasher; +use NormCache\Support\RawAttributes; +use NormCache\Values\BuildHandle; use NormCache\Values\CachePlanContext; +use NormCache\Values\CacheSpace; use NormCache\Values\PreparedQuery; /** @mixin BelongsToMany */ trait CachesPivotRelation { - use CollectsRelatedModels; - private array $eagerParentIds = []; private bool $inEagerLoad = false; @@ -91,11 +92,12 @@ public function get($columns = ['*']): Collection $parentClass = $this->parent::class; $relatedClass = $this->related::class; - $parentClassKey = NormCache::classKey($parentClass); + $parentClassKey = NormCache::keys()->classKey($parentClass); - $results = NormCache::rescue( + $runPivot = fn() => CacheFallback::rescue( + NormCache::config(), fn() => NormCache::engine()->runPivot( - fetch: fn() => NormCache::getPivotCache( + fetch: fn() => NormCache::results()->fetchPivot( $parentClass, $relatedClass, $this->relationName, @@ -103,7 +105,7 @@ public function get($columns = ['*']): Collection $constraintHash, $this->pivotTableKey() ), - waitForBuild: fn() => NormCache::waitForPivotBuild( + waitForBuild: fn() => NormCache::results()->waitForPivotBuild( $parentClass, $relatedClass, $this->relationName, $cacheParentIds, $constraintHash, $this->pivotTableKey() ), onBuild: fn() => $this->getFromPreparedPivotBuilder($prepared), @@ -119,21 +121,30 @@ public function get($columns = ['*']): Collection ]; }, onStore: function ($models, $pivotResult) use ($cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $shouldCacheRelatedModels, $ttl) { - NormCache::attempt(function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $pivotResult, $shouldCacheRelatedModels, $ttl) { - $relatedKey = NormCache::classKey($relatedClass); - $keyMap = []; - foreach ($cacheParentIds as $parentId) { - $keyMap[$parentId] = NormCache::keys()->pivotKey( - $parentClassKey, $relatedKey, $this->relationName, - $constraintHash, $pivotResult->seg, $parentId + $space = NormCache::keys()->activeSpace(); + + CacheFallback::attempt( + NormCache::config(), + function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $pivotResult, $shouldCacheRelatedModels, $ttl, $space) { + $relatedKey = NormCache::keys()->classKey($relatedClass); + $keyMap = []; + foreach ($cacheParentIds as $parentId) { + $keyMap[$parentId] = NormCache::keys()->pivotKey( + $parentClassKey, $relatedKey, $this->relationName, + $constraintHash, $pivotResult->seg, $parentId + ); + } + $this->populatePivotCache( + $models, + $keyMap, + $relatedClass, + $shouldCacheRelatedModels, + $ttl, + $pivotResult->build, + $space, ); - } - $this->populatePivotCache( - $models, $keyMap, $relatedClass, $shouldCacheRelatedModels, - $pivotResult->versionKeys, $pivotResult->expectedVersions, $ttl, - $pivotResult->buildingKey, $pivotResult->wakeKey, $pivotResult->buildingToken - ); - }); + }, + ); }, onHit: function ($pivotResult) use ($relatedClass, $selectedRelatedColumns, $parentClass, $parentClassKey, $cacheParentIds, $debugbarStart, $prepared) { CacheReporter::queryHit($parentClass, "pivot:{$parentClassKey}:{$this->relationName}", @@ -150,7 +161,7 @@ public function get($columns = ['*']): Collection fn() => $this->getFromPreparedPivotBuilder($prepared) ); - return $results; + return NormCache::withSpaceForModel($relatedClass, $builder->getSpace(), $runPivot); } private function shouldUsePivotCache( @@ -171,10 +182,7 @@ private function shouldUsePivotCache( return false; } - $plan = $builder->cachePlan($base, CachePlanContext::pivot( - $resolvedColumns ?? [], - $builder->inferAggregateDependencies() - )); + $plan = $builder->cachePlan($base, CachePlanContext::pivot($resolvedColumns ?? [])); if (!$plan->usesResultCache()) { return false; @@ -192,7 +200,7 @@ private function shouldUsePivotCache( private function pivotTableKey(): string { - return NormCache::tableKey( + return NormCache::keys()->tableKey( $this->parent->getConnection()->getName(), $this->table ); @@ -212,9 +220,13 @@ private function getCacheParentIds(): array } private function populatePivotCache( - Collection $results, array $keyMap, string $relatedClass, bool $cacheRelatedModels, - array $versionKeys, array $expectedVersions, ?int $ttl = null, - ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null, + Collection $results, + array $keyMap, + string $relatedClass, + bool $cacheRelatedModels, + ?int $ttl, + BuildHandle $build, + ?CacheSpace $space = null, ): void { $pivotMap = array_fill_keys(array_keys($keyMap), []); $modelAttrs = []; @@ -248,12 +260,20 @@ private function populatePivotCache( $pivotEntriesByKey[$keyMap[$parentId]] = $entries; } - NormCache::storeManyVersionedResults( - $pivotEntriesByKey, ttl: $ttl, versionKeys: $versionKeys, expectedVersions: $expectedVersions, - buildingKey: $buildingKey, wakeKey: $wakeKey, buildingToken: $buildingToken, + $stored = NormCache::results()->storeMany( + $pivotEntriesByKey, + $ttl, + $build, ); - NormCache::cacheModelAttrs($relatedClass, $modelAttrs); + if ($stored) { + NormCache::models()->storeForBuild( + $relatedClass, + $modelAttrs, + $build, + $space, + ); + } } private function hydrateFromPivotCache( @@ -270,9 +290,9 @@ private function hydrateFromPivotCache( } $modelsById = []; - $getAttribute = ModelHydrator::getAttributeDirectClosure(); + $getAttribute = RawAttributes::getAttributeClosure(); $keyName = $this->related->getKeyName(); - foreach (NormCache::getModels(array_keys($uniqueRelatedIds), $relatedClass, $selectedRelatedColumns) as $model) { + foreach (NormCache::hydrator()->getModels(array_keys($uniqueRelatedIds), $relatedClass, $selectedRelatedColumns) as $model) { $modelsById[$getAttribute($model, $keyName)] = $model; } @@ -289,7 +309,7 @@ private function hydrateFromPivotCache( $model = clone $modelsById[$entry['id']]; $pivot = clone $templatePivot; - ModelHydrator::hydrateClosure()($pivot, $entry['pivot'], false); + RawAttributes::hydrateClosure()($pivot, $entry['pivot'], false); $model->setRelation($this->accessor, $pivot); @@ -311,10 +331,9 @@ private function getFromPreparedPivotBuilder( PreparedQuery $prepared, bool $applyAfterCallbacks = true, ): Collection { - return $this->collectFromPreparedBuilder( - $prepared, - $applyAfterCallbacks, - fn(array $models) => $this->hydratePivotRelation($models), + return $prepared->collect( + applyAfterCallbacks: $applyAfterCallbacks, + beforeEagerLoad: fn(array $models) => $this->hydratePivotRelation($models), ); } @@ -329,7 +348,7 @@ protected function hydratePivotRelation(array $models) $pivot = $template = $this->newExistingPivot($values); } else { $pivot = clone $template; - ModelHydrator::hydrateClosure()($pivot, $values, false); + RawAttributes::hydrateClosure()($pivot, $values, false); } $model->setRelation($this->accessor, $pivot); diff --git a/src/Relations/CachesRelationAggregates.php b/src/Relations/CachesRelationAggregates.php index 3c54552..e35713c 100644 --- a/src/Relations/CachesRelationAggregates.php +++ b/src/Relations/CachesRelationAggregates.php @@ -5,41 +5,41 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Arr; use Illuminate\Support\Str; -use NormCache\Values\DependencySet; trait CachesRelationAggregates { - private bool $cacheAggregates = true; + private bool $aggregateCaching = true; - private bool $aggregateInferenceFailed = false; - - private array $aggregateDependencies = []; - - private array $aggregateTableDependencies = []; + private bool $aggregateRequested = false; private array $aggregateAliases = []; public function withoutAggregateCache(): static { - $this->clearAggregateTracking(); + $this->aggregateCaching = false; + $this->aggregateAliases = []; + + if ($this->aggregateRequested) { + $this->addCapturedContextReason('opted_out', 'withoutAggregateCache() was called explicitly'); + } return $this; } public function withAggregate($relations, $column, $function = null): static { - if (!$this->cacheAggregates) { + $this->aggregateRequested = true; + + if (!$this->aggregateCaching) { + $this->addCapturedContextReason('opted_out', 'withoutAggregateCache() was called explicitly'); + return parent::withAggregate($relations, $column, $function); } - $dependencies = []; - $tableDependencies = []; $names = []; - foreach (Arr::wrap($relations) as $name => $constraint) { if (is_numeric($name)) { $name = $constraint; - $constraint = null; } $segments = explode(' ', (string) $name); @@ -49,32 +49,18 @@ public function withAggregate($relations, $column, $function = null): static $names[] = $name; - $entry = $this->classifyAggregate($name, $constraint); - - if ($entry === null) { - $result = parent::withAggregate($relations, $column, $function); - $this->clearAggregateTracking(true); - - return $result; - } - - $dependencies[] = $entry['relatedClass']; - - if ($entry['throughClass'] ?? null) { - $dependencies[] = $entry['throughClass']; - } - - if ($entry['tableKey'] ?? null) { - $tableDependencies[] = $entry['tableKey']; + if (str_contains($name, '.')) { + $this->addCapturedContextReason('dependency', 'nested aggregate relation semantics could not be fully verified'); + } else { + $this->captureRelationSemantics($name); } + } - array_push($dependencies, ...$entry['constraintModels']); - array_push($tableDependencies, ...$entry['constraintTables']); + if ($function === 'exists') { + $this->addCapturedContextReason('dependency', 'withExists() compiles its relation subquery to a raw select'); } $result = parent::withAggregate($relations, $column, $function); - // Slice from the end: the first withAggregate() call on a bare query also injects a - // "table.*" wildcard column, which would shift a from-the-front slice by one. $newColumns = array_slice($result->getQuery()->columns ?? [], -count($names)); $aliases = []; @@ -82,15 +68,11 @@ public function withAggregate($relations, $column, $function = null): static $aliases[] = $this->resolveAlias($col, $names[$i] ?? null, $function, $column); } - $this->aggregateDependencies = array_values(array_unique([...$this->aggregateDependencies, ...$dependencies])); - $this->aggregateTableDependencies = array_values(array_unique([...$this->aggregateTableDependencies, ...$tableDependencies])); $this->aggregateAliases = array_values(array_unique([...$this->aggregateAliases, ...$aliases])); return $result; } - // Eloquent assigns this alias internally but exposes no way to read it back; parse it out of - // the rendered SQL, falling back to predicting it the way Eloquent itself does if unmatched. private function resolveAlias(mixed $column, ?string $name, ?string $function, mixed $columnArg): string { $grammar = $this->getQuery()->getGrammar(); @@ -106,41 +88,6 @@ private function resolveAlias(mixed $column, ?string $name, ?string $function, m return Str::snake(preg_replace('/[^[:alnum:][:space:]_]/u', '', "{$name} {$lowerFunction} {$colValue}")); } - private function clearAggregateTracking(bool $failed = false): void - { - $this->cacheAggregates = false; - $this->aggregateInferenceFailed = $failed; - $this->aggregateDependencies = []; - $this->aggregateTableDependencies = []; - $this->aggregateAliases = []; - } - - private function classifyAggregate( - string $name, - ?callable $constraint, - ): ?array { - if (str_contains($name, '.')) { - return null; - } - - return (new RelationDependencyClassifier)->classify($this->model->{$name}(), $constraint); - } - - public function inferAggregateDependencies(): DependencySet - { - $aggregate = match (true) { - !$this->cacheAggregates && $this->aggregateInferenceFailed => DependencySet::unsafe('Aggregate dependencies could not be inferred.'), - !$this->cacheAggregates => DependencySet::empty(), - $this->aggregateDependencies === [] && $this->aggregateTableDependencies === [] => DependencySet::empty(), - default => new DependencySet( - models: $this->aggregateDependencies, - tables: $this->aggregateTableDependencies, - ), - }; - - return $aggregate->merge($this->inferExistenceDependencies()); - } - public function hasAggregateColumns(): bool { return $this->aggregateAliases !== []; @@ -148,12 +95,10 @@ public function hasAggregateColumns(): bool public function resultPayloadFromEloquentModels(Collection $models): array { - $aliases = $this->aggregateAliases; - $payload = []; foreach ($models as $model) { $attributes = $model->getRawOriginal(); - foreach ($aliases as $alias) { + foreach ($this->aggregateAliases as $alias) { $attributes[$alias] = $model->getAttribute($alias); } $payload[] = $attributes; diff --git a/src/Relations/CachesRelationExistence.php b/src/Relations/CachesRelationExistence.php index dbd7ee8..fc7df80 100644 --- a/src/Relations/CachesRelationExistence.php +++ b/src/Relations/CachesRelationExistence.php @@ -3,88 +3,70 @@ namespace NormCache\Relations; use Closure; -use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; -use Illuminate\Database\Eloquent\Relations\HasOneOrMany; -use Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough; use Illuminate\Database\Eloquent\Relations\MorphTo; use NormCache\CacheableBuilder; -use NormCache\Values\DependencySet; +use NormCache\Traits\Cacheable; -/** - * @mixin CacheableBuilder - */ +/** @mixin CacheableBuilder */ trait CachesRelationExistence { - private int $totalHasCalls = 0; - - private int $simpleHasCalls = 0; - - private array $existenceDependencies = []; - - private array $existenceTableDependencies = []; - public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null): static { - // whereHasMorph/nested has('a.b') run their inner has() calls on a cloned - // builder, so they never touch this trait's state — the outer call sees - // no dependency and bypasses safely. - $this->totalHasCalls++; - - if (is_string($relation) && !str_contains($relation, '.')) { - $entry = $this->classifyExistenceRelation($relation, $callback); - - if ($entry !== null) { - $this->simpleHasCalls++; - $this->existenceDependencies[] = $entry['relatedClass']; + if (!is_string($relation) || str_contains($relation, '.')) { + $this->addCapturedContextReason('dependency', 'whereHas/has relation semantics could not be fully verified'); + } else { + $this->captureRelationSemantics($relation); + } - if ($entry['throughClass'] ?? null) { - $this->existenceDependencies[] = $entry['throughClass']; - } + if (!($operator === '>=' && $count === 1) + && !($operator === '<' && $count === 1)) { + $this->addCapturedContextReason('dependency', 'has() count threshold requires explicit dependencies'); + } - if ($entry['tableKey'] ?? null) { - $this->existenceTableDependencies[] = $entry['tableKey']; - } + if ($callback !== null) { + $constraint = $callback; + $callback = function ($query) use ($constraint) { + $result = $constraint($query); + $this->captureConstrainedRelationQuery($query); - array_push($this->existenceDependencies, ...$entry['constraintModels']); - array_push($this->existenceTableDependencies, ...$entry['constraintTables']); - } + return $result; + }; } return parent::has($relation, $operator, $count, $boolean, $callback); } - public function inferExistenceDependencies(): DependencySet + protected function captureRelationSemantics(string $name): void { - if ($this->totalHasCalls === 0) { - return DependencySet::empty(); - } + try { + $relation = $this->getRelationWithoutConstraints($name); + $related = $relation->getRelated(); + $query = $relation->getQuery(); - if ($this->totalHasCalls !== $this->simpleHasCalls) { - return DependencySet::unsafe('whereHas/has dependencies could not be fully inferred.'); - } + if ($relation instanceof MorphTo) { + $this->addCapturedContextReason('dependency', 'polymorphic relation dependency could not be fully inferred'); + } + + if (!in_array(Cacheable::class, class_uses_recursive($related::class), true)) { + $this->addCapturedContextReason('dependency', 'related model does not provide automatic table invalidation'); + } - return new DependencySet( - models: array_values(array_unique($this->existenceDependencies)), - tables: array_values(array_unique($this->existenceTableDependencies)), - ); + if ($query->toBase()->lock !== null) { + $this->addCapturedContextReason('safety', 'relation query uses a lock'); + } + } catch (\Throwable) { + $this->addCapturedContextReason('dependency', 'relation semantics could not be inspected'); + } } - private function classifyExistenceRelation(string $name, ?callable $constraint): ?array + private function captureConstrainedRelationQuery(mixed $query): void { - $relation = $this->getRelationWithoutConstraints($name); - - if ($relation instanceof MorphTo) { - return null; + if ($query instanceof CacheableBuilder) { + $this->mergeCapturedBuilderState($query); } - if (!($relation instanceof HasOneOrMany - || $relation instanceof BelongsTo - || $relation instanceof BelongsToMany - || $relation instanceof HasOneOrManyThrough)) { - return null; + if (method_exists($query, 'toBase') && $query->toBase()->lock !== null) { + $this->addCapturedContextReason('safety', 'relation query uses a lock'); } - - return (new RelationDependencyClassifier)->classify($relation, $constraint); } } diff --git a/src/Relations/CachesRelationships.php b/src/Relations/CachesRelationships.php index d01b21f..7d7b1dc 100644 --- a/src/Relations/CachesRelationships.php +++ b/src/Relations/CachesRelationships.php @@ -8,11 +8,6 @@ trait CachesRelationships { - protected function newBelongsTo(Builder $query, Model $child, $foreignKey, $ownerKey, $relation) - { - return new CacheableBelongsTo($query, $child, $foreignKey, $ownerKey, $relation); - } - protected function newHasOne(Builder $query, Model $parent, $foreignKey, $localKey) { return new CacheableHasOne($query, $parent, $foreignKey, $localKey); @@ -79,15 +74,4 @@ protected function newBelongsToMany( $relatedPivotKey, $parentKey, $relatedKey, $relationName, ); } - - protected function newMorphTo( - Builder $query, - Model $parent, - $foreignKey, - $ownerKey, - $type, - $relation, - ) { - return new CacheableMorphTo($query, $parent, $foreignKey, $ownerKey, $type, $relation); - } } diff --git a/src/Relations/CollectsRelatedModels.php b/src/Relations/CollectsRelatedModels.php deleted file mode 100644 index a8c6e89..0000000 --- a/src/Relations/CollectsRelatedModels.php +++ /dev/null @@ -1,45 +0,0 @@ -builder; - $models = $builder->getModels(); - - if ($beforeEagerLoad !== null) { - $beforeEagerLoad($models); - } - - if (count($models) > 0) { - $models = $builder->eagerLoadRelations($models); - } - - $collection = $this->related->newCollection($models); - - return $applyAfterCallbacks - ? $prepared->applyAfterCallbacks($collection) - : $collection; - } -} diff --git a/src/Relations/InvalidatesPivotCache.php b/src/Relations/InvalidatesPivotCache.php index f493a05..4730d09 100644 --- a/src/Relations/InvalidatesPivotCache.php +++ b/src/Relations/InvalidatesPivotCache.php @@ -54,6 +54,6 @@ public function sync($ids, $detaching = true): array private function invalidatePivotCache(): void { $conn = $this->parent->getConnection()->getName(); - NormCache::invalidateTableVersion($conn, $this->table); + NormCache::invalidatePivotTableVersion($conn, $this->table, [$this->parent::class, $this->related::class]); } } diff --git a/src/Relations/RelationDependencyClassifier.php b/src/Relations/RelationDependencyClassifier.php deleted file mode 100644 index be5714a..0000000 --- a/src/Relations/RelationDependencyClassifier.php +++ /dev/null @@ -1,111 +0,0 @@ - $relation - */ - public function classify(Relation $relation, ?callable $constraint): ?array - { - $relatedClass = $relation->getRelated()::class; - - if (!self::relatedIsCacheable($relatedClass)) { - return null; - } - - $relationExtraTables = []; - $relationQuery = $relation->getQuery(); - - if ($relationQuery instanceof CacheableBuilder) { - if ($relationQuery->isCacheSkipped()) { - return null; - } - - $relationBase = $relationQuery->toBase(); - $inspection = (new QueryAnalyzer)->inspect($relationBase, $relation->getRelated()->getTable(), null); - - if ($inspection->hasDependencyBypass() || $inspection->hasSafetyBypass()) { - return null; - } - - $joinDeps = (new QueryAnalyzer)->inferJoinDependencies( - $relationBase, - $relationQuery->getModel()->getConnection()->getName() - ); - - if (!$joinDeps->safe || (!empty($relationBase->joins) && $joinDeps->tables === [])) { - return null; - } - - $relationExtraTables = $joinDeps->tables; - } - - $constraintModels = []; - $constraintTables = []; - - if ($constraint !== null) { - try { - $testBuilder = ($relatedClass)::query(); - $constraint($testBuilder); - - if (!$testBuilder instanceof CacheableBuilder) { - return null; - } - - $prepared = $testBuilder->prepareCacheExecution(); - $plan = $prepared->builder->cachePlan($prepared->base, CachePlanContext::models()); - - if (!$plan->isCacheable()) { - return null; - } - - $constraintModels = $plan->dependencies->models; - $constraintTables = $plan->dependencies->tables; - } catch (\Throwable) { - return null; - } - } - - $throughClass = null; - if ($relation instanceof HasOneOrManyThrough) { - $through = (new \ReflectionProperty($relation, 'throughParent'))->getValue($relation)::class; - if (self::relatedIsCacheable($through)) { - $throughClass = $through; - } - } - - $tableKey = null; - if ($relation instanceof BelongsToMany) { - $tableKey = NormCache::tableKey( - $relation->getParent()->getConnection()->getName(), - $relation->getTable(), - ); - } - - return [ - 'relatedClass' => $relatedClass, - 'throughClass' => $throughClass, - 'tableKey' => $tableKey, - 'constraintModels' => $constraintModels, - 'constraintTables' => array_values(array_unique([...$constraintTables, ...$relationExtraTables])), - ]; - } - - private static function relatedIsCacheable(string $class): bool - { - static $cache = []; - - return $cache[$class] ??= in_array(Cacheable::class, class_uses_recursive($class), true); - } -} diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php new file mode 100644 index 0000000..e706e69 --- /dev/null +++ b/src/Spaces/CacheSpaceRegistry.php @@ -0,0 +1,400 @@ + name => space (memoized) */ + private array $spaces = []; + + /** @var array> model class => spaces (memoized, validated) */ + private array $modelSpaces = []; + + /** @var array> table key => spaces (memoized, validated) */ + private array $tableSpaces = []; + + /** @var list|null */ + private ?array $metadataSpaceNames = null; + + /** @var array> table key => persisted space names */ + private array $metadataTableSpaceNames = []; + + /** @var array hash tag => logical space name */ + private array $hashTagOwners = []; + + /** + * @param array $placement per-space hash-tag overrides + */ + public function __construct( + private readonly int $maxPerModel = 16, + private readonly array $placement = [], + private readonly ?RedisStore $metadataStore = null, + private readonly string $metadataKeyPrefix = '', + ) {} + + public function defaultSpace(): CacheSpace + { + return $this->space(self::DEFAULT_SPACE); + } + + public function space(string $name): CacheSpace + { + return $this->materializeSpace($name); + } + + /** @return list */ + public function knownSpaces(): array + { + $names = array_values(array_unique([ + self::DEFAULT_SPACE, + ...array_keys($this->placement), + ...$this->metadataSpaces(), + ...array_keys($this->spaces), + ])); + + return array_map(fn(string $name) => $this->materializeSpace($name, remember: false), $names); + } + + /** @return list */ + public function spacesForModel(string $modelClass): array + { + return $this->modelSpaces[$modelClass] ??= $this->resolveModelSpaces($modelClass); + } + + /** @return list */ + public function spacesForTable(string $table): array + { + return $this->mergeTableSpaces($table, $this->resolveTableSpaces($table)); + } + + /** @return list */ + public function freshSpacesForTable(string $table): array + { + return $this->mergeTableSpaces($table, $this->resolveTableSpaces($table, fresh: true)); + } + + public function resetMetadataMemo(): void + { + $this->metadataSpaceNames = null; + $this->metadataTableSpaceNames = []; + } + + public function modelAllowedInSpace(string $modelClass, CacheSpace|string $space): bool + { + return $this->isAllowed($this->spacesForModel($modelClass), $space); + } + + public function dependenciesAreOnlyModel(string $modelClass, array $models, array $tables): bool + { + return $models === [$modelClass] && $tables === []; + } + + /** + * Validate operation dependencies against the active space. + * + * @param list $models + * @param list $tables + */ + public function validateDependencies( + CacheSpace $space, + array $models, + array $tables, + bool $includeDependenciesBySpace = false, + ): SpaceValidationResult { + $invalidModels = []; + $dependencySpaces = []; + + foreach ($models as $modelClass) { + $spaces = $this->spacesForModel($modelClass); + $dependencySpaces[$modelClass] = $spaces; + + if (!$this->isAllowed($spaces, $space)) { + $invalidModels[] = $modelClass; + } + } + + foreach ($tables as $table) { + $spaces = $this->spacesForTable($table); + $validatedSpaces = $this->isAllowed($spaces, $space) + ? $spaces + : [...$spaces, $space]; + + $dependencySpaces[$table] = $validatedSpaces; + } + + $ok = $invalidModels === []; + $dependenciesBySpace = $ok && !$includeDependenciesBySpace + ? [] + : $this->dependencySpaceNames($dependencySpaces); + + return new SpaceValidationResult( + isValid: $ok, + space: $space, + invalidModels: $invalidModels, + dependenciesBySpace: $dependenciesBySpace, + ); + } + + /** @return list */ + private function resolveModelSpaces(string $modelClass): array + { + $names = array_values(array_unique( + method_exists($modelClass, 'normCacheSpaces') ? $modelClass::normCacheSpaces() : [] + )); + + if ($names === []) { + return [$this->defaultSpace()]; + } + + if (count($names) > $this->maxPerModel) { + throw new \InvalidArgumentException( + "NormCache model [{$modelClass}] declares " . count($names) . " spaces, exceeding max_per_model ({$this->maxPerModel})." + ); + } + + return array_map(fn($name) => $this->space($name), $names); + } + + /** @return list */ + private function resolveTableSpaces(string $table, bool $fresh = false): array + { + $names = array_values(array_unique([ + self::DEFAULT_SPACE, + ...$this->metadataTableSpaces($table, $fresh), + ])); + + return array_map(fn(string $name) => $this->materializeSpace($name, remember: false), $names); + } + + /** + * @param list $resolved + * @return list + */ + private function mergeTableSpaces(string $table, array $resolved): array + { + $spaces = $this->tableSpaces[$table] ?? [$this->defaultSpace()]; + + foreach ($resolved as $space) { + if (!$this->isAllowed($spaces, $space)) { + $spaces[] = $space; + } + } + + return $this->tableSpaces[$table] = $spaces; + } + + private function materializeSpace(string $name, bool $remember = true): CacheSpace + { + if (!isset($this->spaces[$name])) { + $hashTag = $this->hashTagFor($name); + $owner = $this->hashTagOwners[$hashTag] ?? null; + + if ($owner !== null && $owner !== $name) { + throw new \InvalidArgumentException( + "Cache spaces [{$owner}] and [{$name}] cannot share hash tag [{$hashTag}]." + ); + } + + $this->hashTagOwners[$hashTag] = $name; + $this->spaces[$name] = new CacheSpace($name, $hashTag); + + if ($remember) { + $this->rememberSpace($name); + } + } + + return $this->spaces[$name]; + } + + private function hashTagFor(string $name): string + { + if (!$this->validSpaceName($name)) { + throw new \InvalidArgumentException( + "Invalid cache space name [{$name}]: must be non-empty and contain no ':', '{', '}', or whitespace." + ); + } + + // Placement override; otherwise use the standard hash-tag convention. + $override = $this->placement[$name]['hash_tag'] ?? null; + + if ($override !== null) { + if ($override === '' || preg_match('/[{}*?\[\]\\\\]/', $override)) { + throw new \InvalidArgumentException( + "Invalid hash_tag override [{$override}] for space [{$name}]: must be non-empty and contain no Redis pattern characters." + ); + } + + return $override; + } + + return $name === self::DEFAULT_SPACE + ? self::DEFAULT_HASH_TAG + : self::DEFAULT_HASH_TAG . ':' . $name; + } + + private function validSpaceName(string $name): bool + { + return $name !== '' && !preg_match('/[:{}\s*?\[\]\\\\]/', $name); + } + + /** @return list */ + private function metadataSpaces(): array + { + if ($this->metadataStore === null) { + return []; + } + + if ($this->metadataSpaceNames !== null) { + return $this->metadataSpaceNames; + } + + try { + return $this->metadataSpaceNames = array_values(array_filter( + $this->metadataStore->setMembers($this->metadataSpacesKey()), + fn(string $name) => $this->validSpaceName($name), + )); + } catch (\Throwable) { + return $this->metadataSpaceNames = []; + } + } + + /** @return list */ + private function metadataTableSpaces(string $table, bool $fresh = false): array + { + if ($this->metadataStore === null) { + return []; + } + + if (!$fresh && array_key_exists($table, $this->metadataTableSpaceNames)) { + return $this->metadataTableSpaceNames[$table]; + } + + try { + return $this->metadataTableSpaceNames[$table] = array_values(array_filter( + $this->metadataStore->setMembers($this->metadataTableSpacesKey($table)), + fn(string $name) => $this->validSpaceName($name), + )); + } catch (\Throwable $e) { + report($e); + + return []; + } + } + + private function rememberSpace(string $name): void + { + if ($this->metadataStore === null || $name === self::DEFAULT_SPACE) { + return; + } + + try { + $this->metadataStore->addToSet($this->metadataSpacesKey(), [$name]); + } catch (\Throwable $e) { + report($e); + } + } + + public function registerTableDependencies(CacheSpace $space, array $tables): bool + { + foreach ($tables as $table) { + if (!$this->rememberTableSpace($table, $space)) { + return false; + } + } + + return true; + } + + private function rememberTableSpace(string $table, CacheSpace $space): bool + { + $spaces = $this->spacesForTable($table); + + if ($this->isAllowed($spaces, $space)) { + return true; + } + + if (!$this->persistTableSpace($table, $space)) { + return false; + } + + $spaces[] = $space; + $this->tableSpaces[$table] = $spaces; + + return true; + } + + private function persistTableSpace(string $table, CacheSpace $space): bool + { + if ($this->metadataStore === null || $space->name === self::DEFAULT_SPACE) { + return true; + } + + try { + $this->metadataStore->addToSet($this->metadataTableSpacesKey($table), [$space->name]); + + return true; + } catch (\Throwable $e) { + report($e); + + return false; + } + } + + private function metadataSpacesKey(): string + { + return '{nc:meta}:' . $this->metadataKeyPrefix . 'spaces'; + } + + private function metadataTableSpacesKey(string $table): string + { + return '{nc:meta}:' . $this->metadataKeyPrefix . 'table-spaces:' . sha1($table); + } + + /** @param list $allowed */ + private function isAllowed(array $allowed, CacheSpace|string $space): bool + { + $name = $space instanceof CacheSpace ? $space->name : $space; + + foreach ($allowed as $candidate) { + if ($candidate->name === $name) { + return true; + } + } + + return false; + } + + /** + * @param list $spaces + * @return list + */ + private function spaceNames(array $spaces): array + { + return array_map(fn(CacheSpace $s) => $s->name, $spaces); + } + + /** + * @param array> $dependencies + * @return array> + */ + private function dependencySpaceNames(array $dependencies): array + { + $names = []; + + foreach ($dependencies as $dependency => $spaces) { + $names[$dependency] = $this->spaceNames($spaces); + } + + return $names; + } +} diff --git a/src/Spaces/CacheSpaceResolver.php b/src/Spaces/CacheSpaceResolver.php new file mode 100644 index 0000000..a01c253 --- /dev/null +++ b/src/Spaces/CacheSpaceResolver.php @@ -0,0 +1,28 @@ +space(), else the model's first +// declared space, else the default. +final class CacheSpaceResolver +{ + public function __construct(private readonly CacheSpaceRegistry $registry) {} + + public function resolve(string $modelClass, ?string $explicitSpace): CacheSpace + { + if ($explicitSpace !== null) { + if (!$this->registry->modelAllowedInSpace($modelClass, $explicitSpace)) { + throw new \InvalidArgumentException( + "NormCache: model [{$modelClass}] is not a member of space [{$explicitSpace}]; declare it in \$normCacheSpaces or remove ->space()." + ); + } + + return $this->registry->space($explicitSpace); + } + + // [0] is the home space: declared order, or [default] when undeclared. + return $this->registry->spacesForModel($modelClass)[0]; + } +} diff --git a/src/Support/CacheFallback.php b/src/Support/CacheFallback.php new file mode 100644 index 0000000..e15f955 --- /dev/null +++ b/src/Support/CacheFallback.php @@ -0,0 +1,49 @@ +fallbackEnabled) { + throw $e; + } + + report($e); + $config->enabled = false; + } +} diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 788a877..09f944e 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -4,6 +4,8 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use NormCache\Cache\ModelHydrator; +use NormCache\Values\CacheSpace; class CacheKeyBuilder { @@ -17,8 +19,6 @@ class CacheKeyBuilder public const K_BUILDING = 'building'; - public const K_MEMBERS = 'members:model'; - public const K_COUNT = 'count'; public const K_SCALAR = 'scalar'; @@ -37,54 +37,106 @@ class CacheKeyBuilder private static array $deletedAtColumns = []; + private static array $singleDepPairs = []; + + private ?CacheSpace $activeSpace = null; + + public function __construct( + private readonly string $hashTagPrefix = '{nc}:', + private readonly string $keyPrefix = '', + ) {} + + // Scope an operation to a space: every key built inside $callback uses its tag. + public function withSpace(?CacheSpace $space, callable $callback): mixed + { + $previous = $this->activeSpace; + $this->activeSpace = $space; + + try { + return $callback(); + } finally { + $this->activeSpace = $previous; + } + } + + public function activeSpace(): ?CacheSpace + { + return $this->activeSpace; + } + + private function full(string $body, ?CacheSpace $space = null): string + { + return $this->tagPrefix($space) . $this->keyPrefix . $body; + } + + // Hash-tag prefix: explicit space, else the active operation's space, else default. + private function tagPrefix(?CacheSpace $space): string + { + $space ??= $this->activeSpace; + + return $space === null ? $this->hashTagPrefix : '{' . $space->hashTag . '}:'; + } + + public function prefixed(string $pattern, ?CacheSpace $space = null): string + { + return $this->full($pattern, $space); + } + // ------------------------------------------------------------------------- // Prefixes // ------------------------------------------------------------------------- - public function modelPrefix(string $classKey): string + public function modelPrefix(string $classKey, int|string $version, ?CacheSpace $space = null): string { - return self::K_MODEL . ':{' . $classKey . '}:'; + return $this->full(self::K_MODEL . ':' . $classKey . ':v' . $version . ':', $space); } - public function queryPrefix(string $classKey, ?string $tag = null): string + public function queryPrefix(string $classKey, ?string $tag = null, ?CacheSpace $space = null): string { - $base = self::K_QUERY . ':{' . $classKey . '}:'; + $base = self::K_QUERY . ':' . $classKey . ':'; - return $tag !== null ? $base . $tag . ':v' : $base . 'v'; + return $this->full($tag !== null ? $base . $tag . ':' : $base, $space); } - public function namespacedPrefix(string $namespace, string $classKey, ?string $tag = null): string + public function namespacedPrefix(string $namespace, string $classKey, ?string $tag = null, ?CacheSpace $space = null): string { - return "{$namespace}:{{$classKey}}:" . $this->tagSegment($tag); + return $this->full("{$namespace}:{$classKey}:" . $this->tagSegment($tag), $space); } - public function pivotBasePrefix(string $parentKey, string $relatedKey): string + public function pivotBasePrefix(string $parentKey, string $relatedKey, ?CacheSpace $space = null): string { - return self::K_PIVOT . ':{' . $parentKey . '}:' . $relatedKey . ':'; + return $this->full(self::K_PIVOT . ':' . $parentKey . ':' . $relatedKey . ':', $space); } - public function pivotPrefix(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg): string + public function pivotPrefix(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg, ?CacheSpace $space = null): string { - return $this->pivotBasePrefix($parentKey, $relatedKey) . $relation . ':' . $constraintHash . ':' . $seg . ':'; + return $this->pivotBasePrefix($parentKey, $relatedKey, $space) . $relation . ':' . $constraintHash . ':' . $seg . ':'; } - public function buildingPrefix(string $classKey): string + public function buildingPrefix(string $classKey, ?CacheSpace $space = null): string { - return self::K_BUILDING . ':{' . $classKey . '}:'; + return $this->full(self::K_BUILDING . ':' . $classKey . ':', $space); } - public function wakePrefix(string $classKey): string + public function wakePrefix(string $classKey, ?CacheSpace $space = null): string { - return self::K_WAKE . ':{' . $classKey . '}:'; + return $this->full(self::K_WAKE . ':' . $classKey . ':', $space); } // ------------------------------------------------------------------------- // High-level resolution // ------------------------------------------------------------------------- - public function classKey(string $class): string + public function classKey(string $class, ?string $connection = null): string { - return self::$classKeys[$class] ??= $this->resolveClassKey($class); + $connection ??= $this->declaredConnection($class); + + return self::$classKeys[$connection][$class] ??= $this->resolveClassKey($class, $connection); + } + + public function declaredConnection(string $class): string + { + return self::prototype($class)->getConnectionName() ?? DB::getDefaultConnection(); } // Clear all static metadata caches. Call this after switching tenant connections. @@ -93,6 +145,9 @@ public static function reset(): void self::$classKeys = []; self::$prototypes = []; self::$deletedAtColumns = []; + self::$singleDepPairs = []; + + ModelHydrator::reset(); } public static function prototype(string $class): Model @@ -109,56 +164,51 @@ public static function deletedAtColumn(string $class): ?string public function tableKey(string $connectionName, string $table): string { - return "{$connectionName}:{$table}"; + return "{$connectionName}:" . self::stripTableAlias($table); } - public function verKey(string $classKey): string + public static function stripTableAlias(string $table): string { - return self::K_VER . ':{' . $classKey . '}:'; + return preg_replace('/\s+as\s+\S+$/i', '', $table); } - public function scheduledKey(string $classKey): string + public function verKey(string $classKey, ?CacheSpace $space = null): string { - return self::K_SCHEDULED . ':{' . $classKey . '}:'; + return $this->full(self::K_VER . ':' . $classKey . ':', $space); } - public function membersKey(string $classKey): string + public function scheduledKey(string $classKey, ?CacheSpace $space = null): string { - return self::K_MEMBERS . ':{' . $classKey . '}'; + return $this->full(self::K_SCHEDULED . ':' . $classKey . ':', $space); } - public function wakeKey(string $classKey, string $lockSuffix): string + public function wakeKey(string $classKey, string $lockSuffix, ?CacheSpace $space = null): string { - return self::K_WAKE . ':{' . $classKey . '}:' . $lockSuffix; + return $this->full(self::K_WAKE . ':' . $classKey . ':' . $lockSuffix, $space); } // ------------------------------------------------------------------------- // Specific Keys // ------------------------------------------------------------------------- - public function queryKey(string $classKey, ?string $tag, int|string $version, string $hash): string + public function queryKey(string $classKey, ?string $tag, int|string $version, string $hash, ?CacheSpace $space = null): string { - return $this->queryPrefix($classKey, $tag) . $version . ':' . $hash; + return $this->queryPrefix($classKey, $tag, $space) . 'v' . $version . ':' . $hash; } - public function namespacedKey(string $namespace, string $classKey, ?string $tag, string $seg, string $hash): string + public function namespacedKey(string $namespace, string $classKey, ?string $tag, string $seg, string $hash, ?CacheSpace $space = null): string { - return $this->namespacedPrefix($namespace, $classKey, $tag) . $seg . ':' . $hash; + return $this->namespacedPrefix($namespace, $classKey, $tag, $space) . $seg . ':' . $hash; } - public function resultBuildingKey(string $classKey, string $seg, string $lockSuffix): string + public function resultBuildingKey(string $classKey, string $seg, string $lockSuffix, ?CacheSpace $space = null): string { - return $this->buildingPrefix($classKey) . $seg . ':' . $lockSuffix; + return $this->buildingPrefix($classKey, $space) . $seg . ':' . $lockSuffix; } - public function pivotKey(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg, mixed $parentId): string + public function pivotKey(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg, mixed $parentId, ?CacheSpace $space = null): string { - return $this->pivotPrefix($parentKey, $relatedKey, $relation, $constraintHash, $seg) . $parentId; - } - - public function modelKey(string $modelClass, string $id): string - { - return $this->modelPrefix($this->classKey($modelClass)) . $id; + return $this->pivotPrefix($parentKey, $relatedKey, $relation, $constraintHash, $seg, $space) . $parentId; } // ------------------------------------------------------------------------- @@ -187,15 +237,6 @@ public function versionsFromSegment(string $seg): array return $parts; } - public function buildingToWakeKey(string $buildingKey): string - { - $classKeyEnd = strpos($buildingKey, '}:') + 2; - - return self::K_WAKE - . substr($buildingKey, strlen(self::K_BUILDING), $classKeyEnd - strlen(self::K_BUILDING)) - . substr(strrchr($buildingKey, ':'), 1); - } - // ------------------------------------------------------------------------- // Dependency Resolvers // ------------------------------------------------------------------------- @@ -203,12 +244,21 @@ public function buildingToWakeKey(string $buildingKey): string /** * @return array{0: list, 1: list} [versionKeys, scheduledKeys] */ - public function depKeyPairs(string $classKey, array $depClasses, array $depTableKeys = []): array - { + public function depKeyPairs( + string $classKey, + array $depClasses, + array $depTableKeys = [], + ?CacheSpace $space = null, + ?string $connection = null, + ): array { + $space ??= $this->activeSpace; + if ($depClasses === [] && $depTableKeys === []) { - return [ - [$this->verKey($classKey)], - [$this->scheduledKey($classKey)], + $cacheKey = $this->singleDepPairCacheKey($classKey, $space); + + return self::$singleDepPairs[$cacheKey] ??= [ + [$this->verKey($classKey, $space)], + [$this->scheduledKey($classKey, $space)], ]; } @@ -218,8 +268,8 @@ public function depKeyPairs(string $classKey, array $depClasses, array $depTable $seen[$classKey] = true; $all[] = $classKey; - foreach ($this->sortClassesByKey($depClasses) as $class) { - $key = $this->classKey($class); + foreach ($this->sortClassesByKey($depClasses, $connection) as $class) { + $key = $this->classKey($class, $connection); if (!isset($seen[$key])) { $seen[$key] = true; @@ -238,8 +288,8 @@ public function depKeyPairs(string $classKey, array $depClasses, array $depTable $scheduledKeys = []; foreach ($all as $key) { - $versionKeys[] = $this->verKey($key); - $scheduledKeys[] = $this->scheduledKey($key); + $versionKeys[] = $this->verKey($key, $space); + $scheduledKeys[] = $this->scheduledKey($key, $space); } return [$versionKeys, $scheduledKeys]; @@ -266,24 +316,34 @@ public static function assertValidTag(string $tag): void public function resultBuildIdentityHash(string $namespace, ?string $tag, string $hash): string { - return hash('xxh3', $namespace . ':' . $this->tagSegment($tag) . $hash); + return hash('xxh128', $namespace . ':' . $this->tagSegment($tag) . $hash); } // ------------------------------------------------------------------------- // Private implementation // ------------------------------------------------------------------------- - private function resolveClassKey(string $class): string + private function singleDepPairCacheKey(string $classKey, ?CacheSpace $space): string + { + return $this->keyPrefix . '|' . $this->tagPrefix($space) . '|' . $classKey; + } + + private function resolveClassKey(string $class, string $connection): string { $model = self::prototype($class); - $connection = $model->getConnectionName() ?? DB::getDefaultConnection(); + + if (str_contains($connection, ':')) { + throw new \InvalidArgumentException( + "NormCache connection name [{$connection}] must not contain a colon; the class key is colon-delimited." + ); + } return "{$connection}:{$model->getTable()}"; } - private function sortClassesByKey(array $classes): array + private function sortClassesByKey(array $classes, ?string $connection = null): array { - usort($classes, fn($a, $b) => strcmp($this->classKey($a), $this->classKey($b))); + usort($classes, fn($a, $b) => strcmp($this->classKey($a, $connection), $this->classKey($b, $connection))); return $classes; } diff --git a/src/Support/FallbackHandler.php b/src/Support/FallbackHandler.php deleted file mode 100644 index 307aa64..0000000 --- a/src/Support/FallbackHandler.php +++ /dev/null @@ -1,43 +0,0 @@ -fallbackEnabled) { - throw $e; - } - - report($e); - $config->enabled = false; - } -} diff --git a/src/Support/ProjectionClassifier.php b/src/Support/ProjectionClassifier.php index 0091390..5122efa 100644 --- a/src/Support/ProjectionClassifier.php +++ b/src/Support/ProjectionClassifier.php @@ -49,21 +49,6 @@ public static function isExactFullModelProjection(?array $columns, string $table return true; } - public static function containsWildcard(?array $columns): bool - { - if ($columns === null) { - return false; - } - - foreach ($columns as $column) { - if (is_string($column) && str_ends_with($column, '*')) { - return true; - } - } - - return false; - } - public static function hasRequiredKey(array $columns, string $table, string $key): bool { $qualified = "{$table}.{$key}"; diff --git a/src/Support/QueryHasher.php b/src/Support/QueryHasher.php index 36f308b..e5881f5 100644 --- a/src/Support/QueryHasher.php +++ b/src/Support/QueryHasher.php @@ -107,7 +107,7 @@ public static function fromQuery(QueryBuilder $query): string public static function hash(string $data): string { - return hash('xxh3', $data); + return hash('xxh128', $data); } public static function normalizeValueForHash(mixed $value, ?QueryBuilder $base = null): mixed diff --git a/src/Support/RawAttributes.php b/src/Support/RawAttributes.php new file mode 100644 index 0000000..068401b --- /dev/null +++ b/src/Support/RawAttributes.php @@ -0,0 +1,55 @@ +attributes = $attrs; + $instance->original = $attrs; + $instance->classCastCache = []; + $instance->attributeCastCache = []; + $instance->exists = true; + if ($fire) { + $instance->fireModelEvent('retrieved', false); + } + }, + null, + Model::class + ); + } + + public static function setAttributeClosure(): \Closure + { + return self::$setAttributeClosure ??= \Closure::bind( + static function (Model $instance, string $key, mixed $value): void { + $instance->attributes[$key] = $value; + }, + null, + Model::class + ); + } + + public static function getAttributeClosure(): \Closure + { + return self::$getAttributeClosure ??= \Closure::bind( + static function (Model $instance, string $key): mixed { + return $instance->attributes[$key] ?? null; + }, + null, + Model::class + ); + } +} diff --git a/src/Support/RedisScanner.php b/src/Support/RedisScanner.php new file mode 100644 index 0000000..b0257e7 --- /dev/null +++ b/src/Support/RedisScanner.php @@ -0,0 +1,275 @@ +connection instanceof PhpRedisClusterConnection) { + $keys = $this->scanPhpRedisClusterKeys($pattern); + } elseif ($this->connection instanceof PredisClusterConnection) { + $keys = $this->scanPredisClusterKeys($pattern); + } else { + $keys = $this->scanKeys($pattern); + } + + $connectionPrefix = $this->connectionPrefix(); + + if ($connectionPrefix === '') { + return $keys; + } + + return array_map( + static fn($k) => str_starts_with($k, $connectionPrefix) ? substr($k, strlen($connectionPrefix)) : $k, + $keys + ); + } + + public function scanPatterns(array $patterns): array + { + $patterns = array_values(array_unique(array_filter($patterns, 'is_string'))); + + if ($patterns === []) { + return []; + } + + $matched = []; + + foreach ($this->groupPatternsByHashTag($patterns) as $group) { + $scanPatterns = count($group) === 1 + ? $group + : [$this->commonScanPattern($group)]; + + if ($scanPatterns === ['*']) { + $scanPatterns = $group; + } + + foreach ($scanPatterns as $scanPattern) { + foreach ($this->scanPattern($scanPattern) as $key) { + foreach ($group as $pattern) { + if (fnmatch($pattern, $key)) { + $matched[$key] = true; + break; + } + } + } + } + } + + return array_keys($matched); + } + + private function scanKeys(string $pattern): array + { + $keys = []; + $connectionPrefix = $this->connectionPrefix(); + + $this->executeScan( + function (&$cursor) use ($pattern, $connectionPrefix) { + $p = $connectionPrefix . $pattern; + + return $this->isPhpRedis() + ? $this->connection->client()->scan($cursor, $p, 1000) + : $this->connection->scan($cursor, ['match' => $p, 'count' => 1000]); + }, + function ($chunk) use (&$keys) { + array_push($keys, ...$chunk); + } + ); + + return $keys; + } + + private function scanPredisClusterKeys(string $pattern): array + { + $targeted = $this->scanPredisClusterSlotKeys($pattern); + if ($targeted !== null) { + return $targeted; + } + + $keys = []; + $connectionPrefix = $this->connectionPrefix(); + + foreach ($this->connection->client() as $node) { + $this->executeScan( + fn($cursor) => $node->scan($cursor, ['match' => $connectionPrefix . $pattern, 'count' => 1000]), + function ($chunk) use (&$keys) { + array_push($keys, ...$chunk); + } + ); + } + + return array_values(array_unique($keys)); + } + + private function scanPredisClusterSlotKeys(string $pattern): ?array + { + $hashTag = $this->concreteHashTag($pattern); + if ($hashTag === null) { + return null; + } + + try { + $cluster = $this->connection->client()->getConnection(); + if (!method_exists($cluster, 'getConnectionBySlot')) { + return null; + } + + $slot = (new RedisStrategy)->getSlotByKey('{' . $hashTag . '}'); + $node = $cluster->getConnectionBySlot($slot); + if (!is_object($node) || !method_exists($node, 'scan')) { + return null; + } + } catch (\Throwable) { + return null; + } + + $keys = []; + $connectionPrefix = $this->connectionPrefix(); + + $this->executeScan( + fn($cursor) => $node->scan($cursor, ['match' => $connectionPrefix . $pattern, 'count' => 1000]), + function ($chunk) use (&$keys) { + array_push($keys, ...$chunk); + } + ); + + return array_values(array_unique($keys)); + } + + private function scanPhpRedisClusterKeys(string $pattern): array + { + $keys = []; + $client = $this->connection->client(); + $connectionPrefix = $this->connectionPrefix(); + + foreach ($client->_masters() as $node) { + $this->executeScan( + function (&$cursor) use ($client, $node, $pattern, $connectionPrefix) { + return $client->scan($cursor, $node, $connectionPrefix . $pattern, 1000); + }, + function ($chunk) use (&$keys) { + array_push($keys, ...$chunk); + } + ); + } + + return $keys; + } + + /** @return list> */ + private function groupPatternsByHashTag(array $patterns): array + { + $groups = []; + + foreach ($patterns as $pattern) { + $group = preg_match('/^\{[^{}]+\}:/', $pattern, $matches) === 1 + ? $matches[0] + : ''; + $groups[$group][] = $pattern; + } + + return array_values($groups); + } + + private function commonScanPattern(array $patterns): string + { + $prefix = array_shift($patterns); + + foreach ($patterns as $pattern) { + $length = min(strlen($prefix), strlen($pattern)); + $i = 0; + + while ($i < $length && $prefix[$i] === $pattern[$i]) { + $i++; + } + + $prefix = substr($prefix, 0, $i); + } + + return $prefix . '*'; + } + + private function concreteHashTag(string $pattern): ?string + { + if (!preg_match('/\{([^{}]+)\}/', $pattern, $matches)) { + return null; + } + + $tag = $matches[1]; + + return str_contains($tag, '*') || str_contains($tag, '?') + ? null + : $tag; + } + + private function isPhpRedis(): bool + { + // PhpRedisClusterConnection extends PhpRedisConnection, so this covers both. + return $this->connection instanceof PhpRedisConnection; + } + + private function connectionPrefix(): string + { + // *ClusterConnection extends *Connection, so these cover both cluster and standalone. + if ($this->connection instanceof PhpRedisConnection) { + return (string) $this->connection->client()->getOption(\Redis::OPT_PREFIX); + } + + if ($this->connection instanceof PredisConnection) { + $prefix = $this->connection->client()->getOptions()->prefix ?? null; + if (is_object($prefix) && method_exists($prefix, 'getPrefix')) { + return (string) $prefix->getPrefix(); + } + } + + return ''; + } + + /** + * @param \Closure(mixed &): mixed $scanner + * @param \Closure(array): void $processor + */ + private function executeScan(\Closure $scanner, \Closure $processor): void + { + if ($this->isPhpRedis()) { + // phpredis 6.x SCAN/SSCAN require null to start; updates cursor by reference. + $cursor = null; + while (true) { + $chunk = $scanner($cursor); + if (!empty($chunk)) { + $processor($chunk); + } + if (!$cursor) { + break; + } + } + + return; + } + + // Predis returns [$cursor, $keys]; '0' signals completion. + $cursor = '0'; + do { + $result = $scanner($cursor); + if (!is_array($result) || !isset($result[1])) { + break; + } + [$cursor, $chunk] = $result; + if (!empty($chunk)) { + $processor($chunk); + } + } while ($cursor !== '0'); + } +} diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 55a9966..0b2e8e7 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -16,14 +16,13 @@ final class RedisStore private CacheSerializer $serializer; + private ?RedisScanner $scanner = null; + /** @var array SHA1 cache — populated on first use of each script */ private static array $shas = []; public function __construct( string $redisConnection, - private string $keyPrefix, - private bool $slotting, - private string $slotPrefix = '', private int $wakeTokenCount = 64, ) { $this->serializer = new CacheSerializer; @@ -34,15 +33,9 @@ public function __construct( // Operations — singular // ------------------------------------------------------------------------- - public function prefix(string $key): string - { - // Follow the expected order: slotPrefix then keyPrefix. - return $this->slotPrefix . $this->keyPrefix . $key; - } - public function get(string $key): mixed { - $value = $this->connection->get($this->prefix($key)); + $value = $this->connection->get($key); return ($value !== null && $value !== false) ? $this->unserialize($value) : null; } @@ -50,7 +43,7 @@ public function get(string $key): mixed // Returns the raw string value without deserialization (for JSON-encoded entries). public function getRaw(string $key): ?string { - $value = $this->connection->get($this->prefix($key)); + $value = $this->connection->get($key); return ($value !== null && $value !== false) ? $value : null; } @@ -63,13 +56,35 @@ public function getRawMany(array $keys): array public function set(string $key, mixed $value, int $ttl): void { - $this->connection->setex($this->prefix($key), $ttl, $this->serialize($value)); + $this->connection->setex($key, $ttl, $this->serialize($value)); } // Set a raw string value without serialization. public function setRaw(string $key, string $value, int $ttl): void { - $this->connection->setex($this->prefix($key), $ttl, $value); + $this->connection->setex($key, $ttl, $value); + } + + /** @param list $values */ + public function addToSet(string $key, array $values): int + { + if ($values === []) { + return 0; + } + + return (int) $this->connection->command('sadd', [$key, ...$values]); + } + + /** @return list */ + public function setMembers(string $key): array + { + $members = $this->connection->command('smembers', [$key]); + + if (!is_array($members)) { + return []; + } + + return array_values(array_filter($members, 'is_string')); } // SET NX EX — returns true if the lock was claimed. @@ -86,21 +101,23 @@ public function setNxEx(string $key, string $value, int $ttl): bool public function delete(string|array $keys): void { - $keys = (array) $keys; - if (empty($keys)) { + $keys = array_values(array_filter((array) $keys, fn($k) => $k !== '')); + + if ($keys === []) { return; } - $prefixed = array_map(fn($k) => $this->prefix($k), $keys); - $this->del($prefixed); + $this->del($keys); } // DEL building key + LPUSH/EXPIRE wake key atomically when the token still owns the lock. public function releaseBuilding(string $buildingKey, string $wakeKey, ?string $token = null): bool { + $keys = $wakeKey !== '' ? [$buildingKey, $wakeKey] : [$buildingKey]; + return (bool) $this->script( RedisScripts::get('release_building'), - [$buildingKey, $wakeKey], + $keys, [$token ?? '', (string) $this->wakeTokenCount] ); } @@ -113,21 +130,103 @@ public function storeSerializedAndRelease(string $key, mixed $value, int $ttl, ? public function storeRawAndRelease(string $key, string $value, int $ttl, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null): bool { if ($buildingKey === null) { - $this->connection->setex($this->prefix($key), $ttl, $value); + $this->connection->setex($key, $ttl, $value); return true; } + return $this->storeVersionedPayload([$key => $value], $ttl, [], [], $buildingKey, $wakeKey, $token); + } + + /** @param array $entries key => pre-encoded payload */ + public function storeVersionedPayload( + array $entries, + int $ttl, + array $versionKeys, + array $expectedVersions, + ?string $buildingKey = null, + ?string $wakeKey = null, + ?string $token = null, + ): bool { + $keys = array_merge($versionKeys, array_keys($entries)); + if ($buildingKey !== null) { + $keys[] = $buildingKey; + if ($wakeKey !== null && $wakeKey !== '') { + $keys[] = $wakeKey; + } + } + return (bool) $this->script( - RedisScripts::get('store_many_versioned'), - [$key, $buildingKey, $wakeKey ?? ''], - ['0', '1', (string) $ttl, $value, $token ?? '', (string) $this->wakeTokenCount] + RedisScripts::get('store_versioned_payload'), + $keys, + array_merge( + [(string) count($versionKeys), (string) count($entries), (string) $ttl], + $expectedVersions, + array_values($entries), + [$token ?? '', (string) $this->wakeTokenCount] + ) + ); + } + + public function fetchVersionedPayload( + array $versionKeys, + array $scheduledKeys, + string $payloadPrefix, + string $buildingPrefix, + string $wakePrefix, + string $hash, + string $lockSuffix, + string $lockToken, + int $lockTtl, + bool $cooldown, + ): array { + return (array) $this->script( + RedisScripts::get('fetch_versioned_payload'), + array_merge($versionKeys, $cooldown ? $scheduledKeys : [], [$payloadPrefix, $buildingPrefix, $wakePrefix]), + [ + $hash, + $lockSuffix, + (int) floor(microtime(true) * 1000), + $lockTtl, + $lockToken, + (string) count($versionKeys), + $cooldown ? '1' : '0', + ] + ); + } + + public function fetchVersionedPivotSegment(array $versionKeys, array $scheduledKeys): string + { + $result = $this->script( + RedisScripts::get('fetch_versioned_pivot'), + array_merge($versionKeys, $scheduledKeys), + [(string) (int) floor(microtime(true) * 1000)] + ); + + return (string) ($result ?? ''); + } + + public function fetchBatchBuildStatus(array $keys, string $lockKey, string $wakeKey, string $token, int $lockTtl): array + { + return (array) $this->script( + RedisScripts::get('fetch_batch_build_status'), + [...$keys, $lockKey, $wakeKey], + [$token, (string) $lockTtl] + ); + } + + public function fetchVersionWithCooldown(string $verKey, string $scheduledKey): mixed + { + return $this->script( + RedisScripts::get('fetch_version_with_cooldown'), + [$verKey, $scheduledKey], + [(string) (int) floor(microtime(true) * 1000)] ); } public function increment(string $key): int { - return (int) $this->connection->incr($this->prefix($key)); + return (int) $this->connection->incr($key); } public function incrementAndExpire(string $key, int $ttl): int @@ -143,7 +242,7 @@ public function incrementAndExpire(string $key, int $ttl): int * Requires Redis 6.0+ for sub-second precision; older Redis rounds the timeout up to 1s. */ public function brpop(string $key, float $timeoutSeconds): bool { - $result = $this->connection->brpop($this->prefix($key), $timeoutSeconds); + $result = $this->connection->brpop($key, $timeoutSeconds); return $result !== null && $result !== false; } @@ -157,51 +256,22 @@ public function getMany(array $keys): array return $this->mgetValues($keys, unserialize: true); } - // MGET in input order, with null for missing keys; groups by hash tag when slotting or on a real cluster. private function mgetValues(array $keys, bool $unserialize): array { if (empty($keys)) { return []; } - if (!$this->slotting && !$this->isCluster()) { - $prefixed = []; - foreach ($keys as $key) { - $prefixed[] = $this->prefix($key); - } - - $raw = $this->connection->mget($prefixed); - $values = []; - - foreach ($raw as $i => $value) { - $values[$i] = $this->mgetValue($value, $unserialize); - } - - return $values; - } - - $results = []; - - foreach ($this->groupByTag($keys) as $groupKeys) { - $prefixed = []; - foreach ($groupKeys as $key) { - $prefixed[] = $this->prefix($key); - } - - $raw = $this->connection->mget($prefixed); - - $idx = 0; - foreach ($groupKeys as $key) { - $results[$key] = $this->mgetValue($raw[$idx++], $unserialize); - } - } + $raw = $this->connection instanceof PredisClusterConnection + ? $this->connection->command('mget', $keys) + : $this->connection->mget($keys); + $values = []; - $ordered = []; - foreach ($keys as $key) { - $ordered[] = $results[$key]; + foreach ($raw as $i => $value) { + $values[$i] = $this->mgetValue($value, $unserialize); } - return $ordered; + return $values; } private function mgetValue(mixed $value, bool $unserialize): mixed @@ -213,32 +283,10 @@ private function mgetValue(mixed $value, bool $unserialize): mixed return $unserialize ? $this->unserialize($value) : $value; } - public function setMany(array $pairs, int $ttl): void - { - if (empty($pairs)) { - return; - } - - if ($this->isCluster()) { - foreach ($pairs as $key => $value) { - $this->connection->setex($this->prefix($key), $ttl, $this->serialize($value)); - } - - return; - } - - $this->connection->pipeline(function ($pipe) use ($pairs, $ttl) { - foreach ($pairs as $key => $value) { - $pipe->setex($this->prefix($key), $ttl, $this->serialize($value)); - } - }); - } - // CAS write of model attribute entries; releases the build lock as part of the write when given. - public function setManyTrackedIfVersion( + public function setManyIfVersion( array $attrsByKey, int $ttl, - string $memberKey, string $versionKey, int $expectedVersion, ?string $buildingKey = null, @@ -253,19 +301,26 @@ public function setManyTrackedIfVersion( return; } - $script = RedisScripts::get('store_many_tracked_if_version'); + $script = RedisScripts::get('store_model_attrs'); $chunks = array_chunk($attrsByKey, 500, true); $lastChunk = array_key_last($chunks); foreach ($chunks as $i => $chunk) { $isLast = $i === $lastChunk; + // Only the last chunk releases the build lock; trailing lock/wake keys are + // present (and removed below if absent) only on that chunk. + $keys = array_merge([$versionKey], array_keys($chunk)); + if ($isLast && $buildingKey !== null) { + $keys[] = $buildingKey; + if ($wakeKey !== null && $wakeKey !== '') { + $keys[] = $wakeKey; + } + } + $this->script( $script, - array_merge( - [$versionKey, $memberKey, $isLast ? ($buildingKey ?? '') : '', $isLast ? ($wakeKey ?? '') : ''], - array_keys($chunk) - ), + $keys, array_merge( [(string) $expectedVersion, (string) $ttl, (string) count($chunk), $isLast ? ($token ?? '') : ''], array_map(fn($attrs) => $this->serialize($attrs), array_values($chunk)), @@ -275,64 +330,28 @@ public function setManyTrackedIfVersion( } } - // Delete a key and remove it from a tracking set in one atomic operation. - public function deleteFromSet(string $key, string $memberKey): void - { - $this->script( - "redis.call('DEL', KEYS[1]); redis.call('SREM', KEYS[2], KEYS[1])", - [$key, $memberKey] - ); - } - - public function sscanAndFlushSet(string $prefixedMemberKey): void - { - // SSCAN members already include the connection prefix; strip it before asyncDel adds it again. - $connectionPrefix = $this->connectionPrefix(); - - $this->executeScan( - function (&$cursor) use ($prefixedMemberKey) { - return $this->isPhpRedis() - ? $this->connection->client()->sscan($prefixedMemberKey, $cursor, '*', 1000) - : $this->connection->sscan($prefixedMemberKey, $cursor, ['match' => '*', 'count' => 1000]); - }, - function ($members) use ($connectionPrefix) { - if ($connectionPrefix !== '') { - $members = array_map( - static fn($k) => str_starts_with($k, $connectionPrefix) ? substr($k, strlen($connectionPrefix)) : $k, - $members - ); - } - $this->asyncDel($members); - } - ); - - $this->connection->del($prefixedMemberKey); - } - public function asyncDel(array $prefixedKeys): void { if (empty($prefixedKeys)) { return; } - if (!$this->slotting) { - foreach (array_chunk($prefixedKeys, 1000) as $chunk) { - $this->del($chunk); - } - - return; - } - - foreach ($this->groupByTag($prefixedKeys) as $keys) { - foreach (array_chunk($keys, 1000) as $chunk) { - $this->del($chunk); - } + foreach (array_chunk($prefixedKeys, 1000) as $chunk) { + $this->del($chunk); } } private function del(array $keys): void { - // PredisClusterConnection extends PredisConnection, so this covers both. + if ($this->connection instanceof PredisClusterConnection) { + foreach ($this->groupByHashTag($keys) as $group) { + $this->connection->command('del', $group); + } + + return; + } + + // Standalone Predis accepts Laravel's array form. if ($this->connection instanceof PredisConnection) { $this->connection->del($keys); @@ -342,51 +361,41 @@ private function del(array $keys): void $this->connection->unlink($keys); } - public function flushByPatterns(array $patterns): int + /** @return list> */ + private function groupByHashTag(array $keys): array { - $total = 0; - - foreach ($patterns as $pattern) { - $keys = $this->keysForPattern($pattern); + $groups = []; - if (!empty($keys)) { - $total += count($keys); - $this->asyncDel($keys); + foreach ($keys as $key) { + if (preg_match('/\{([^{}]+)\}/', $key, $matches) === 1) { + $groups['tag:' . $matches[1]][] = $key; + } else { + $groups['key:' . $key][] = $key; } } - return $total; + return array_values($groups); } - // Prefixes $keys before passing them to EVALSHA, falling back to EVAL on NOSCRIPT. - public function script(string $script, array $keys, array $args = []): mixed + public function flushByPatterns(array $patterns): int { - $prefixedKeys = []; - foreach ($keys as $key) { - $prefixedKeys[] = $key === '' ? '' : $this->prefix($key); + $keys = ($this->scanner ??= new RedisScanner($this->connection))->scanPatterns($patterns); + + if ($keys === []) { + return 0; } - if ($this->isCluster()) { - $tag = null; - foreach ($prefixedKeys as $pk) { - $hashTag = $this->hashTag($pk, includeBraces: true); - if ($hashTag !== null) { - $tag = $hashTag; - break; - } - } + $this->asyncDel($keys); - if ($tag !== null) { - foreach ($prefixedKeys as $i => $prefixedKey) { - if ($prefixedKey === '') { - $prefixedKeys[$i] = "{$tag}:null"; - } - } - } - } + return count($keys); + } - $n = count($prefixedKeys); - $allArgs = array_merge($prefixedKeys, $args); + // Runs a Lua script via EVALSHA, falling back to EVAL on NOSCRIPT. All KEYS must + // share one hash slot (cluster); optional slots are omitted, never passed empty. + public function script(string $script, array $keys, array $args = []): mixed + { + $n = count($keys); + $allArgs = array_merge($keys, $args); $sha = self::$shas[$script] ??= sha1($script); @@ -450,183 +459,8 @@ public function isCluster(): bool || $this->connection instanceof PredisClusterConnection; } - public function requiresSlotting(bool $slottingEnabled): bool - { - return $slottingEnabled || ($this->isCluster() && $this->slotPrefix === ''); - } - - private function isPhpRedis(): bool - { - // PhpRedisClusterConnection extends PhpRedisConnection, so this covers both. - return $this->connection instanceof PhpRedisConnection; - } - - private function groupByTag(array $keys): array - { - $groups = []; - - foreach ($keys as $key) { - $tag = $this->hashTag($key) ?? $key; - $groups[$tag][] = $key; - } - - return $groups; - } - - private function hashTag(string $key, bool $includeBraces = false): ?string - { - $start = strpos($key, '{'); - - if ($start === false) { - return null; - } - - $end = strpos($key, '}', $start + 1); - - if ($end === false || $end === $start + 1) { - return null; - } - - return $includeBraces - ? substr($key, $start, $end - $start + 1) - : substr($key, $start + 1, $end - $start - 1); - } - - private function connectionPrefix(): string - { - // *ClusterConnection extends *Connection, so these cover both cluster and standalone. - if ($this->connection instanceof PhpRedisConnection) { - return (string) $this->connection->client()->getOption(\Redis::OPT_PREFIX); - } - - if ($this->connection instanceof PredisConnection) { - $prefix = $this->connection->client()->getOptions()->prefix ?? null; - if (is_object($prefix) && method_exists($prefix, 'getPrefix')) { - return (string) $prefix->getPrefix(); - } - } - - return ''; - } - public function scanPattern(string $pattern): array { - if ($this->connection instanceof PhpRedisClusterConnection) { - $keys = $this->scanPhpRedisClusterKeys($pattern); - } elseif ($this->connection instanceof PredisClusterConnection) { - $keys = $this->scanPredisClusterKeys($pattern); - } else { - $keys = $this->scanKeys($pattern); - } - - $connectionPrefix = $this->connectionPrefix(); - - if ($connectionPrefix === '') { - return $keys; - } - - return array_map( - static fn($k) => str_starts_with($k, $connectionPrefix) ? substr($k, strlen($connectionPrefix)) : $k, - $keys - ); - } - - private function keysForPattern(string $pattern): array - { - return $this->scanPattern($this->prefix($pattern)); - } - - private function scanKeys(string $pattern): array - { - $keys = []; - $connectionPrefix = $this->connectionPrefix(); - - $this->executeScan( - function (&$cursor) use ($pattern, $connectionPrefix) { - $p = $connectionPrefix . $pattern; - - return $this->isPhpRedis() - ? $this->connection->client()->scan($cursor, $p, 1000) - : $this->connection->scan($cursor, ['match' => $p, 'count' => 1000]); - }, - function ($chunk) use (&$keys) { - array_push($keys, ...$chunk); - } - ); - - return $keys; - } - - private function scanPredisClusterKeys(string $pattern): array - { - $keys = []; - $connectionPrefix = $this->connectionPrefix(); - - foreach ($this->connection->client() as $node) { - $this->executeScan( - fn($cursor) => $node->scan($cursor, ['match' => $connectionPrefix . $pattern, 'count' => 1000]), - function ($chunk) use (&$keys) { - array_push($keys, ...$chunk); - } - ); - } - - return array_values(array_unique($keys)); - } - - private function scanPhpRedisClusterKeys(string $pattern): array - { - $keys = []; - $client = $this->connection->client(); - $connectionPrefix = $this->connectionPrefix(); - - foreach ($client->_masters() as $node) { - $this->executeScan( - function (&$cursor) use ($client, $node, $pattern, $connectionPrefix) { - return $client->scan($cursor, $node, $connectionPrefix . $pattern, 1000); - }, - function ($chunk) use (&$keys) { - array_push($keys, ...$chunk); - } - ); - } - - return $keys; - } - - /** - * @param \Closure(mixed &): mixed $scanner - * @param \Closure(array): void $processor - */ - private function executeScan(\Closure $scanner, \Closure $processor): void - { - if ($this->isPhpRedis()) { - // phpredis 6.x SCAN/SSCAN require null to start; updates cursor by reference. - $cursor = null; - while (true) { - $chunk = $scanner($cursor); - if (!empty($chunk)) { - $processor($chunk); - } - if (!$cursor) { - break; - } - } - - return; - } - - // Predis returns [$cursor, $keys]; '0' signals completion. - $cursor = '0'; - do { - $result = $scanner($cursor); - if (!is_array($result) || !isset($result[1])) { - break; - } - [$cursor, $chunk] = $result; - if (!empty($chunk)) { - $processor($chunk); - } - } while ($cursor !== '0'); + return ($this->scanner ??= new RedisScanner($this->connection))->scanPattern($pattern); } } diff --git a/src/Support/ScalarTransformer.php b/src/Support/ScalarTransformer.php new file mode 100644 index 0000000..a05663c --- /dev/null +++ b/src/Support/ScalarTransformer.php @@ -0,0 +1,145 @@ + true, + 'bool' => true, + 'boolean' => true, + 'collection' => true, + 'custom_datetime' => true, + 'date' => true, + 'datetime' => true, + 'decimal' => true, + 'double' => true, + 'float' => true, + 'immutable_custom_datetime' => true, + 'immutable_date' => true, + 'immutable_datetime' => true, + 'int' => true, + 'integer' => true, + 'json' => true, + 'json:unicode' => true, + 'object' => true, + 'real' => true, + 'string' => true, + 'timestamp' => true, + ]; + + public static function transformScalar(mixed $value, Model $model, string $column): mixed + { + $isCast = self::resolveStatelessScalarMode($model, $column); + + if ($isCast === null) { + return $model->newFromBuilder([$column => $value])->{$column}; + } + + return self::transformScalarClosure()($model, $column, $value, $isCast); + } + + public static function transformScalars(Collection $results, Model $model, string $column): Collection + { + $isCast = self::resolveStatelessScalarMode($model, $column); + $values = $results->all(); + + if ($isCast === null) { + $template = $model->newInstance([], true); + $hydrate = RawAttributes::hydrateClosure(); + + foreach ($values as $key => $value) { + $instance = clone $template; + $hydrate($instance, [$column => $value], true); + $values[$key] = $instance->{$column}; + } + + return new Collection($values); + } + + return new Collection( + self::transformScalarsClosure()($model, $column, $values, $isCast), + ); + } + + private static function resolveStatelessScalarMode(Model $model, string $column): ?bool + { + if ($model->hasAnyGetMutator($column)) { + return null; + } + + $cast = $model->getCasts()[$column] ?? null; + + if ($cast === null) { + if (!in_array($column, $model->getDates(), true)) { + return null; + } + + $isCast = false; + } elseif (!is_string($cast)) { + return null; + } else { + $cast = strtolower(explode(':', $cast, 2)[0]); + + if (!isset(self::STATELESS_CASTS[$cast])) { + return null; + } + + $isCast = true; + } + + $dispatcher = $model::getEventDispatcher(); + + if ($dispatcher !== null && $dispatcher->hasListeners('eloquent.retrieved: ' . $model::class)) { + return null; + } + + return $isCast; + } + + private static function transformScalarClosure(): \Closure + { + return self::$transformScalarClosure ??= \Closure::bind( + static function (Model $model, string $column, mixed $value, bool $isCast): mixed { + if ($isCast) { + return $model->castAttribute($column, $value); + } + + return $value === null ? null : $model->asDateTime($value); + }, + null, + Model::class + ); + } + + private static function transformScalarsClosure(): \Closure + { + return self::$transformScalarsClosure ??= \Closure::bind( + static function (Model $model, string $column, array $values, bool $isCast): array { + if ($isCast) { + foreach ($values as $key => $value) { + $values[$key] = $model->castAttribute($column, $value); + } + + return $values; + } + + foreach ($values as $key => $value) { + $values[$key] = $value === null ? null : $model->asDateTime($value); + } + + return $values; + }, + null, + Model::class + ); + } +} diff --git a/src/Traits/Cacheable.php b/src/Traits/Cacheable.php index 099225c..323a1e1 100644 --- a/src/Traits/Cacheable.php +++ b/src/Traits/Cacheable.php @@ -22,6 +22,13 @@ public function flush(): void NormCache::flushInstance($this); } + // Cache spaces this model belongs to (empty = default), via $normCacheSpaces. + /** @return list */ + public static function normCacheSpaces(): array + { + return property_exists(static::class, 'normCacheSpaces') ? (array) static::$normCacheSpaces : []; + } + // ------------------------------------------------------------------------- // Public overrides // ------------------------------------------------------------------------- @@ -55,6 +62,13 @@ public function fresh($with = []): ?static public function save(array $options = []): bool { + if ($this->exists && $this->isDirty() + && $this->getConnection()->transactionLevel() === 0 + && !$this->isPendingRestoreSave() + ) { + NormCache::flushInstance($this); + } + return $this->saveWithCacheInvalidation(fn() => parent::save($options)); } @@ -90,8 +104,6 @@ private function saveWithCacheInvalidation(callable $save): bool $existsBefore = $this->exists; $originalKey = $existsBefore ? $this->getOriginal($this->getKeyName()) : null; - $this->evictBeforeSaveForObservers($existsBefore, $originalKey); - $result = $save(); if ($result) { @@ -119,37 +131,18 @@ private function invalidateAfterSave(bool $existsBefore, mixed $originalKey = nu return; } - if ($originalKey !== null && $originalKey !== $this->getKey()) { - NormCache::evictModelKey(static::class, $originalKey); - } - NormCache::flushInstance($this); } - // Evict before save so observers do not read an outdated model payload. - private function evictBeforeSaveForObservers(bool $existsBefore, mixed $originalKey = null): void + private function isPendingRestoreSave(): bool { - if (!$existsBefore) { - return; - } - - if (!$this->isDirty()) { - return; - } - - if ($this->isPendingRestoreSave()) { - return; - } - - if (!NormCache::isEnabled()) { - return; + if (!method_exists($this, 'getDeletedAtColumn')) { + return false; } - if ($this->getConnection()->transactionLevel() !== 0) { - return; - } + $col = $this->getDeletedAtColumn(); - NormCache::evictModelKey(static::class, $originalKey ?? $this->getKey()); + return $this->isDirty($col) && $this->getAttribute($col) === null; } private function isRestoreSave(bool $existsBefore): bool @@ -163,17 +156,6 @@ private function isRestoreSave(bool $existsBefore): bool return $this->wasChanged($deletedAtColumn) && $this->{$deletedAtColumn} === null; } - private function isPendingRestoreSave(): bool - { - if (!method_exists($this, 'getDeletedAtColumn')) { - return false; - } - - $deletedAtColumn = $this->getDeletedAtColumn(); - - return $this->isDirty($deletedAtColumn) && $this->{$deletedAtColumn} === null; - } - private function runWithoutCache(callable $callback) { $previous = $this->withoutCacheNext; diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index 46bc002..f71f147 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -5,13 +5,12 @@ use Illuminate\Contracts\Database\Query\Expression; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Support\Str; -use NormCache\Cache\ModelHydrator; use NormCache\CacheableBuilder; use NormCache\Enums\ResultKind; use NormCache\Facades\NormCache; -use NormCache\Planning\QueryAnalyzer; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; +use NormCache\Support\ScalarTransformer; use NormCache\Values\CachePlanContext; /** @@ -123,28 +122,24 @@ private function cacheScalar( array $columns = [], ?\Closure $compute = null, ): mixed { + if ($this->isCacheSkipped() || !NormCache::isEnabled()) { + return $fallback(); + } + if (ProjectionClassifier::hasCalculatedColumns($columns)) { return $fallback(); } - if (($kind === ResultKind::Pluck || $kind === ResultKind::Value) && $this->hasAfterQueryCallbacks()) { + if (($kind === ResultKind::Pluck || $kind === ResultKind::Value) && $this->afterQueryCallbacks !== []) { return $fallback(); } $prepared = $this->prepareCacheExecution(); - $executionBuilder = $prepared->builder; $base = $prepared->base; $computeValue = $compute === null ? $fallback : fn() => $compute($base); - $inferredDependencies = $executionBuilder->inferAggregateDependencies()->merge( - (new QueryAnalyzer)->inferJoinDependencies($base, $executionBuilder->getModel()->getConnection()->getName()) - ); - $plan = $executionBuilder->cachePlan($base, CachePlanContext::scalar( - $kind->value, - $columns, - $inferredDependencies, - )); + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::scalar($columns)); if (!$plan->isCacheable()) { if (!$plan->hasBypassReason('opted_out')) { @@ -154,13 +149,13 @@ private function cacheScalar( return $computeValue(); } - $result = NormCache::result()->execute( + $result = NormCache::withSpace($plan->space, fn() => NormCache::result()->execute( $prepared, $plan, $kind, $columns, $computeValue - ); + )); return $result[0]; } @@ -177,7 +172,7 @@ private function pluckFromPreparedBase(QueryBuilder $base, mixed $column, mixed return $results; } - return ModelHydrator::transformScalars($results, $this->model, $column); + return ScalarTransformer::transformScalars($results, $this->model, $column); } private function valueFromPreparedBase(QueryBuilder $base, mixed $column): mixed @@ -199,6 +194,6 @@ private function valueFromPreparedBase(QueryBuilder $base, mixed $column): mixed return $value; } - return ModelHydrator::transformScalar($value, $this->model, $column); + return ScalarTransformer::transformScalar($value, $this->model, $column); } } diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 59e68a6..93e467c 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -5,8 +5,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use NormCache\CacheManager; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; +use NormCache\Values\CacheSpace; /** * @phpstan-require-extends CacheManager @@ -16,11 +17,46 @@ trait HandlesInvalidation /** @var array> */ private array $flushQueue = []; - /** @var array> */ + /** @var array>> conn => classKey => spaces */ private array $versionQueue = []; - /** @var array> model class, model key, members key */ - private array $instanceEvictQueue = []; + /** @var array, true>> */ + private array $modelVersionQueue = []; + + /** @var array> conn => table classKey => true */ + private array $tableVersionQueue = []; + + /** @return list the spaces a model/table version key lives in */ + private function modelSpaces( + string $modelClass, + ?string $connectionName = null, + bool $freshTableSpaces = false, + ): array { + $connectionName ??= $this->keys->declaredConnection($modelClass); + $tableKey = $this->keys->classKey($modelClass, $connectionName); + $spaces = []; + $tableSpaces = $freshTableSpaces + ? $this->spaceRegistry->freshSpacesForTable($tableKey) + : $this->spaceRegistry->spacesForTable($tableKey); + + foreach ([...$this->spaceRegistry->spacesForModel($modelClass), ...$tableSpaces] as $space) { + $spaces[$space->name] = $space; + } + + return array_values($spaces); + } + + /** @return list the spaces a table's version key lives in */ + private function tableInvalidationSpaces(string $tableKey): array + { + return $this->spaceRegistry->freshSpacesForTable($tableKey); + } + + /** @return list */ + private function knownSpaces(): array + { + return $this->spaceRegistry->knownSpaces(); + } // Invalidation @@ -34,8 +70,8 @@ public function invalidateVersion(Model $model): void $this->queueOrRun( $conn, - fn() => $this->queueVersionFlush($conn, $this->keys->classKey($model::class)), - fn() => $this->doInvalidateVersion($model::class), + fn() => $this->modelVersionQueue[$conn][$model::class] = true, + fn() => $this->doInvalidateVersion($model::class, $conn), ); } @@ -53,32 +89,14 @@ public function flushModel(Model|string $model): void $this->queueOrRun( $conn, fn() => $this->queueModelFlush($conn, $modelClass), - fn() => $this->forceFlushModel($modelClass), + fn() => $this->forceFlushModel($modelClass, $conn), ); } + /** Alias of invalidateVersion(); kept as the model-instance-facing entry point. */ public function flushInstance(Model $model): void { - if (!$this->isEnabled()) { - return; - } - - $conn = $model->getConnection()->getName(); - $class = $model::class; - $key = $this->keys->modelKey($class, $model->getKey()); - $classKey = $this->keys->classKey($class); - - $this->queueOrRun( - $conn, - function () use ($conn, $class, $key, $classKey) { - $this->queueVersionFlush($conn, $classKey); - $this->queueInstanceEvict($conn, $class, $key, $this->keys->membersKey($classKey)); - }, - function () use ($class, $key, $classKey) { - $this->doInvalidateVersion($class); - $this->store->deleteFromSet($key, $this->keys->membersKey($classKey)); - }, - ); + $this->invalidateVersion($model); } public function invalidateTableVersion(string $connectionName, string $table): void @@ -91,41 +109,52 @@ public function invalidateTableVersion(string $connectionName, string $table): v $this->queueOrRun( $connectionName, - fn() => $this->queueVersionFlush($connectionName, $classKey), - fn() => $this->doInvalidateKey($classKey), + fn() => $this->queueTableVersionFlush($connectionName, $classKey), + fn() => $this->doInvalidateTable($classKey), ); } - public function evictModelKey(string $modelClass, mixed $id): void + /** Bump the pivot table version only in the spaces belonging to the involved models. */ + public function invalidatePivotTableVersion(string $connectionName, string $table, array $modelClasses): void { if (!$this->isEnabled()) { return; } - $this->attempt(function () use ($modelClass, $id) { - $classKey = $this->keys->classKey($modelClass); - $key = $this->keys->modelPrefix($classKey) . $id; - $this->store->deleteFromSet( - $key, - $this->keys->membersKey($classKey) - ); - }); + $classKey = $this->keys->tableKey($connectionName, $table); + $spaces = []; + foreach ($modelClasses as $modelClass) { + foreach ($this->modelSpaces($modelClass, freshTableSpaces: true) as $space) { + $spaces[$space->name] = $space; + } + } + $spaces = array_values($spaces); + + $this->queueOrRun( + $connectionName, + fn() => $this->queueVersionFlush($connectionName, $classKey, $spaces), + function () use ($classKey, $spaces) { + foreach ($spaces as $space) { + $this->doInvalidateKey($classKey, $space); + } + }, + ); } - public function forceFlushModel(string $modelClass): void + public function forceFlushModel(string $modelClass, ?string $connectionName = null): void { - $classKey = $this->keys->classKey($modelClass); - $this->store->incrementAndExpire($this->keys->verKey($classKey), $this->versionTtl()); // bypass cooldown + $classKey = $this->keys->classKey($modelClass, $connectionName); - $this->store->sscanAndFlushSet($this->store->prefix($this->keys->membersKey($classKey))); + foreach ($this->modelSpaces($modelClass, $connectionName, freshTableSpaces: true) as $space) { + $this->store->incrementAndExpire($this->keys->verKey($classKey, $space), $this->versionTtl()); // bypass cooldown + } } - public function flushAll(): int + public function flushAll(CacheSpace|string|null $space = null): int { - return $this->store->flushByPatterns([ + $patterns = [ CacheKeyBuilder::K_QUERY . ':*', CacheKeyBuilder::K_MODEL . ':*', - CacheKeyBuilder::K_MEMBERS . ':*', CacheKeyBuilder::K_VER . ':*', CacheKeyBuilder::K_COUNT . ':*', CacheKeyBuilder::K_SCALAR . ':*', @@ -135,7 +164,19 @@ public function flushAll(): int CacheKeyBuilder::K_BUILDING . ':*', CacheKeyBuilder::K_WAKE . ':*', CacheKeyBuilder::K_RESULT . ':*', - ]); + ]; + + if (!$this->store->isCluster()) { + if ($space === null) { + return $this->store->flushByPatterns($this->prefixedForAnySpace($patterns)); + } + } + + $spaces = $space === null + ? $this->knownSpaces() + : [is_string($space) ? $this->spaceRegistry->space($space) : $space]; + + return $this->store->flushByPatterns($this->prefixedForSpaces($patterns, $spaces)); } public function flushTag(string $modelClass, string $tag): int @@ -144,98 +185,149 @@ public function flushTag(string $modelClass, string $tag): int $classKey = $this->keys->classKey($modelClass); - return $this->store->flushByPatterns([ - CacheKeyBuilder::K_RESULT . ':{' . $classKey . '}:' . $tag . ':*', - CacheKeyBuilder::K_QUERY . ':{' . $classKey . '}:' . $tag . ':*', - CacheKeyBuilder::K_COUNT . ':{' . $classKey . '}:' . $tag . ':*', - CacheKeyBuilder::K_SCALAR . ':{' . $classKey . '}:' . $tag . ':*', - CacheKeyBuilder::K_THROUGH . ':{' . $classKey . '}:' . $tag . ':*', - ]); + return $this->store->flushByPatterns($this->prefixedForSpaces([ + CacheKeyBuilder::K_RESULT . ':' . $classKey . ':' . $tag . ':*', + CacheKeyBuilder::K_QUERY . ':' . $classKey . ':' . $tag . ':*', + CacheKeyBuilder::K_COUNT . ':' . $classKey . ':' . $tag . ':*', + CacheKeyBuilder::K_SCALAR . ':' . $classKey . ':' . $tag . ':*', + CacheKeyBuilder::K_THROUGH . ':' . $classKey . ':' . $tag . ':*', + ], $this->modelSpaces($modelClass, freshTableSpaces: true))); } public function flushTagAcrossModels(string $tag): int { CacheKeyBuilder::assertValidTag($tag); - return $this->store->flushByPatterns([ + $patterns = [ CacheKeyBuilder::K_RESULT . ':*:' . $tag . ':*', CacheKeyBuilder::K_QUERY . ':*:' . $tag . ':*', CacheKeyBuilder::K_COUNT . ':*:' . $tag . ':*', CacheKeyBuilder::K_SCALAR . ':*:' . $tag . ':*', CacheKeyBuilder::K_THROUGH . ':*:' . $tag . ':*', - ]); + ]; + + if (!$this->store->isCluster()) { + return $this->store->flushByPatterns($this->prefixedForAnySpace($patterns)); + } + + return $this->store->flushByPatterns($this->prefixedForSpaces($patterns, $this->knownSpaces())); } public function invalidateMultipleVersions(array $modelClasses, ?string $connectionName = null): void { - if (!$this->isEnabled()) { + if (!$this->isEnabled() || $modelClasses === []) { return; } - $this->queueOrRun( - $connectionName, - function () use ($connectionName, $modelClasses) { - foreach ($modelClasses as $modelClass) { - $this->queueVersionFlush($connectionName, $this->keys->classKey($modelClass)); - } - }, - function () use ($modelClasses) { - foreach ($modelClasses as $modelClass) { - $this->doInvalidateVersion($modelClass); - } - }, - ); + $groups = []; + + foreach ($modelClasses as $modelClass) { + $conn = $connectionName + ?? ($this->keys->prototype($modelClass)->getConnectionName() ?? DB::getDefaultConnection()); + $groups[$conn][] = $modelClass; + } + + foreach ($groups as $conn => $classes) { + $this->queueOrRun( + $conn, + function () use ($conn, $classes) { + foreach ($classes as $modelClass) { + $this->modelVersionQueue[$conn][$modelClass] = true; + } + }, + function () use ($classes, $conn) { + foreach ($classes as $modelClass) { + $this->doInvalidateVersion($modelClass, $conn); + } + }, + ); + } } public function commitPending(string $connectionName): void { $flushes = array_keys($this->flushQueue[$connectionName] ?? []); - $versions = array_keys($this->versionQueue[$connectionName] ?? []); - $evicts = $this->instanceEvictQueue[$connectionName] ?? []; - - unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName], $this->instanceEvictQueue[$connectionName]); + $models = array_keys($this->modelVersionQueue[$connectionName] ?? []); + $versions = $this->versionQueue[$connectionName] ?? []; + $tables = array_keys($this->tableVersionQueue[$connectionName] ?? []); + + unset( + $this->flushQueue[$connectionName], + $this->modelVersionQueue[$connectionName], + $this->versionQueue[$connectionName], + $this->tableVersionQueue[$connectionName], + ); - if ((empty($flushes) && empty($versions) && empty($evicts)) || !$this->isEnabled()) { + if ((empty($flushes) && empty($models) && empty($versions) && empty($tables)) || !$this->isEnabled()) { return; } - $this->attempt(function () use ($flushes, $versions, $evicts) { - foreach ($flushes as $modelClass) { - $this->forceFlushModel($modelClass); - } + CacheFallback::attempt( + $this->config, + function () use ($connectionName, $flushes, $models, $versions, $tables) { + /** @var array> $invalidated class key => space name => true */ + $invalidated = []; + /** @var array> $pending class key => space name => space */ + $pending = []; + + $queue = function (string $classKey, array $spaces) use (&$pending, &$invalidated): void { + foreach ($spaces as $space) { + if (!isset($invalidated[$classKey][$space->name])) { + $pending[$classKey][$space->name] = $space; + } + } + }; + + foreach ($flushes as $modelClass) { + $classKey = $this->keys->classKey($modelClass, $connectionName); + $spaces = $this->modelSpaces($modelClass, $connectionName, freshTableSpaces: true); + $this->forceFlushModel($modelClass, $connectionName); + + foreach ($spaces as $space) { + $invalidated[$classKey][$space->name] = true; + } + } - $flushClassKeys = array_map(fn($class) => $this->keys->classKey($class), $flushes); - $evictClasses = array_unique(array_column($evicts, 0)); - $evictClassKeys = array_map(fn($class) => $this->keys->classKey($class), $evictClasses); + foreach ($models as $modelClass) { + $queue( + $this->keys->classKey($modelClass, $connectionName), + $this->modelSpaces($modelClass, $connectionName, freshTableSpaces: true), + ); + } - foreach ($versions as $classKey) { - if (!in_array($classKey, $evictClassKeys, true) && !in_array($classKey, $flushClassKeys, true)) { - $this->doInvalidateKey($classKey); + foreach ($versions as $classKey => $spaces) { + $queue($classKey, $spaces); } - } - foreach ($evictClasses as $class) { - if (!in_array($this->keys->classKey($class), $flushClassKeys, true)) { - $this->doInvalidateVersion($class); + foreach ($tables as $classKey) { + $queue($classKey, $this->tableInvalidationSpaces($classKey)); } - } - foreach ($evicts as [, $key, $membersKey]) { - $this->store->deleteFromSet($key, $membersKey); - } - }); + foreach ($pending as $classKey => $spaces) { + foreach ($spaces as $space) { + $this->doInvalidateKey($classKey, $space); + } + } + }, + ); } public function discardPending(string $connectionName): void { - unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName], $this->instanceEvictQueue[$connectionName]); + unset( + $this->flushQueue[$connectionName], + $this->modelVersionQueue[$connectionName], + $this->versionQueue[$connectionName], + $this->tableVersionQueue[$connectionName], + ); } public function discardAllPending(): void { $this->flushQueue = []; + $this->modelVersionQueue = []; $this->versionQueue = []; - $this->instanceEvictQueue = []; + $this->tableVersionQueue = []; } // Private — invalidation internals @@ -247,24 +339,35 @@ private function queueOrRun(?string $connectionName, callable $queue, callable $ return; } - $this->attempt($immediate); + CacheFallback::attempt($this->config, $immediate); } - private function doInvalidateVersion(string $modelClass): void + private function doInvalidateVersion(string $modelClass, ?string $connectionName = null): void { - $this->doInvalidateKey($this->keys->classKey($modelClass)); + $classKey = $this->keys->classKey($modelClass, $connectionName); + + foreach ($this->modelSpaces($modelClass, $connectionName) as $space) { + $this->doInvalidateKey($classKey, $space); + } + } + + private function doInvalidateTable(string $classKey): void + { + foreach ($this->tableInvalidationSpaces($classKey) as $space) { + $this->doInvalidateKey($classKey, $space); + } } - private function doInvalidateKey(string $classKey): void + private function doInvalidateKey(string $classKey, ?CacheSpace $space = null): void { if ($this->config->cooldown <= 0) { - $this->store->incrementAndExpire($this->keys->verKey($classKey), $this->versionTtl()); + $this->store->incrementAndExpire($this->keys->verKey($classKey, $space), $this->versionTtl()); return; } - $this->resolveCurrentVersion($classKey); - $this->scheduleInvalidation($classKey); + $this->resolveCurrentVersion($classKey, $space); + $this->scheduleInvalidation($classKey, $space); } private function versionTtl(): int @@ -272,24 +375,23 @@ private function versionTtl(): int return max($this->config->ttl, $this->config->queryTtl) * 2; } - private function resolveCurrentVersion(string $classKey): string|int|null + private function resolveCurrentVersion(string $classKey, ?CacheSpace $space = null): string|int|null { if ($this->config->cooldown <= 0) { - return $this->store->getRaw($this->keys->verKey($classKey)); + return $this->store->getRaw($this->keys->verKey($classKey, $space)); } - return $this->store->script( - RedisScripts::get('fetch_version_with_cooldown'), - [$this->keys->verKey($classKey), $this->keys->scheduledKey($classKey)], - [(string) (int) floor(microtime(true) * 1000)] + return $this->store->fetchVersionWithCooldown( + $this->keys->verKey($classKey, $space), + $this->keys->scheduledKey($classKey, $space) ); } - private function scheduleInvalidation(string $classKey): void + private function scheduleInvalidation(string $classKey, ?CacheSpace $space = null): void { $dueAtMs = (int) floor(microtime(true) * 1000) + ($this->config->cooldown * 1000); - $this->store->setNxEx($this->keys->scheduledKey($classKey), (string) $dueAtMs, $this->config->cooldown + $this->versionTtl()); + $this->store->setNxEx($this->keys->scheduledKey($classKey, $space), (string) $dueAtMs, $this->config->cooldown + $this->versionTtl()); } private function queueModelFlush(string $connectionName, string $modelClass): void @@ -297,13 +399,54 @@ private function queueModelFlush(string $connectionName, string $modelClass): vo $this->flushQueue[$connectionName][$modelClass] = true; } - private function queueVersionFlush(string $connectionName, string $classKey): void + /** + * @param list $patterns + * @param list $spaces + * @return list + */ + private function prefixedForSpaces(array $patterns, array $spaces): array + { + $prefixed = []; + + foreach ($spaces as $space) { + foreach ($patterns as $pattern) { + $prefixed[] = $this->keys->prefixed($pattern, $space); + } + } + + return $prefixed; + } + + /** + * @param list $patterns + * @return list + */ + private function prefixedForAnySpace(array $patterns): array + { + return array_map( + fn(string $pattern) => preg_replace('/^\{[^}]+\}:/', '{*}:', $this->keys->prefixed($pattern)), + $patterns, + ); + } + + /** @param list $spaces */ + private function queueVersionFlush(string $connectionName, string $classKey, array $spaces): void { - $this->versionQueue[$connectionName][$classKey] = true; + $queued = []; + + foreach ($this->versionQueue[$connectionName][$classKey] ?? [] as $space) { + $queued[$space->name] = $space; + } + + foreach ($spaces as $space) { + $queued[$space->name] = $space; + } + + $this->versionQueue[$connectionName][$classKey] = array_values($queued); } - private function queueInstanceEvict(string $connectionName, string $modelClass, string $key, string $membersKey): void + private function queueTableVersionFlush(string $connectionName, string $classKey): void { - $this->instanceEvictQueue[$connectionName][] = [$modelClass, $key, $membersKey]; + $this->tableVersionQueue[$connectionName][$classKey] = true; } } diff --git a/src/Values/BuildContext.php b/src/Values/BuildContext.php new file mode 100644 index 0000000..122118b --- /dev/null +++ b/src/Values/BuildContext.php @@ -0,0 +1,20 @@ +buildingKey, $this->lockToken, $this->wakeKey, $this->versionKeys, $this->expectedVersions); + } +} diff --git a/src/Values/BuildHandle.php b/src/Values/BuildHandle.php new file mode 100644 index 0000000..25f217d --- /dev/null +++ b/src/Values/BuildHandle.php @@ -0,0 +1,15 @@ +cooldown > 0; + } } diff --git a/src/Values/CachePlan.php b/src/Values/CachePlan.php index 6c05713..c1c166a 100644 --- a/src/Values/CachePlan.php +++ b/src/Values/CachePlan.php @@ -8,20 +8,31 @@ final readonly class CachePlan { /** - * @param list $reasons * @param array> $bypassReasons */ public function __construct( public CacheStrategy $strategy, public CacheOperation $operation, public DependencySet $dependencies, - public bool $normalizable = false, public ?array $columns = null, public ?array $primaryKeys = null, - public array $reasons = [], public array $bypassReasons = [], + public ?CacheSpace $space = null, ) {} + public function withSpace(CacheSpace $space): self + { + return new self( + strategy: $this->strategy, + operation: $this->operation, + dependencies: $this->dependencies, + columns: $this->columns, + primaryKeys: $this->primaryKeys, + bypassReasons: $this->bypassReasons, + space: $space, + ); + } + public static function normalized( CacheOperation $operation, DependencySet $dependencies, @@ -32,7 +43,6 @@ public static function normalized( strategy: CacheStrategy::NormalizedQuery, operation: $operation, dependencies: $dependencies, - normalizable: true, columns: $columns, primaryKeys: $primaryKeys, ); @@ -41,7 +51,6 @@ public static function normalized( public static function result( CacheOperation $operation, DependencySet $dependencies, - bool $normalizable = false, ?array $columns = null, ?array $primaryKeys = null, ): self { @@ -49,7 +58,6 @@ public static function result( strategy: CacheStrategy::VersionedResult, operation: $operation, dependencies: $dependencies, - normalizable: $normalizable, columns: $columns, primaryKeys: $primaryKeys, ); @@ -58,14 +66,12 @@ public static function result( public static function bypass( CacheOperation $operation, DependencySet $dependencies, - array $reasons = [], array $bypassReasons = [], ): self { return new self( strategy: CacheStrategy::LiveQuery, operation: $operation, dependencies: $dependencies, - reasons: $reasons, bypassReasons: $bypassReasons, ); } @@ -80,7 +86,6 @@ public static function direct( strategy: CacheStrategy::DirectModels, operation: $operation, dependencies: $dependencies, - normalizable: true, columns: $columns, primaryKeys: $primaryKeys, ); @@ -91,6 +96,12 @@ public function hasBypassReason(string $category): bool return isset($this->bypassReasons[$category]) && !empty($this->bypassReasons[$category]); } + /** @return list all bypass reasons, category order preserved */ + public function flatReasons(): array + { + return array_values(array_unique(array_merge(...array_values($this->bypassReasons ?: [[]])))); + } + public function isCacheable(): bool { return $this->strategy !== CacheStrategy::LiveQuery; diff --git a/src/Values/CachePlanContext.php b/src/Values/CachePlanContext.php index e4033ab..73fde21 100644 --- a/src/Values/CachePlanContext.php +++ b/src/Values/CachePlanContext.php @@ -6,52 +6,45 @@ final readonly class CachePlanContext { - public DependencySet $inferredDependencies; + public DependencySet $requiredDependencies; public function __construct( public CacheOperation $operation, public ?array $columns = null, - ?DependencySet $inferredDependencies = null, public array $contextReasons = [], - public ?string $kind = null, public bool $selectAll = false, + ?DependencySet $requiredDependencies = null, ) { - $this->inferredDependencies = $inferredDependencies ?? DependencySet::empty(); + $this->requiredDependencies = $requiredDependencies ?? DependencySet::empty(); } /** @param bool $selectAll the caller requested the default ['*'] projection */ - public static function models(?array $columns = null, ?DependencySet $inferred = null, bool $selectAll = false): self + public static function models(?array $columns = null, bool $selectAll = false): self { - return new self(CacheOperation::Models, $columns, $inferred, selectAll: $selectAll); + return new self(CacheOperation::Models, $columns, selectAll: $selectAll); } - public static function scalar(string $kind, array $columns = [], ?DependencySet $inferred = null, array $contextReasons = []): self + public static function scalar(array $columns = [], array $contextReasons = []): self { - return new self(CacheOperation::Scalar, $columns, $inferred, $contextReasons, $kind); + return new self(CacheOperation::Scalar, $columns, $contextReasons); } - public static function paginationCount(?DependencySet $inferred = null): self + public static function paginationCount(): self { - return new self(CacheOperation::PaginationCount, null, $inferred, kind: 'pagination_count'); + return new self(CacheOperation::PaginationCount); } - public static function belongsToEagerLoad(array $columns = []): self + public static function pivot(array $columns = []): self { - return new self(CacheOperation::BelongsToEagerLoad, $columns); + return new self(CacheOperation::Pivot, $columns); } - public static function morphToEagerLoad(string $type): self + public static function through(array $columns = [], ?DependencySet $required = null): self { - return new self(CacheOperation::MorphToEagerLoad, kind: $type); - } - - public static function pivot(array $columns = [], ?DependencySet $inferred = null): self - { - return new self(CacheOperation::Pivot, $columns, $inferred); - } - - public static function through(array $columns = [], ?DependencySet $inferred = null): self - { - return new self(CacheOperation::Through, $columns, $inferred); + return new self( + CacheOperation::Through, + $columns, + requiredDependencies: $required, + ); } } diff --git a/src/Values/CacheSpace.php b/src/Values/CacheSpace.php new file mode 100644 index 0000000..27104f4 --- /dev/null +++ b/src/Values/CacheSpace.php @@ -0,0 +1,12 @@ + id => hydrated model */ + public array $hits = []; + + public ?string $lockKey = null; + + public ?string $wakeKey = null; + + public ?string $token = null; + + public function __construct( + public readonly string $modelClass, + public readonly string $classKey, + public readonly ?array $projection, + public readonly ?Model $prototype, + public readonly ?EloquentBuilder $missedQuery, + public readonly bool $preserveQueryShape, + public int $modelVersion, + ) {} +} diff --git a/src/Values/PivotCacheResult.php b/src/Values/PivotCacheResult.php index bb46307..acc60f9 100644 --- a/src/Values/PivotCacheResult.php +++ b/src/Values/PivotCacheResult.php @@ -12,12 +12,8 @@ public function __construct( public string $seg, public array $data, - public array $versionKeys, - public array $expectedVersions, + public BuildHandle $build = new BuildHandle, public CacheStatus $status = CacheStatus::Hit, - public ?string $buildingKey = null, - public ?string $buildingToken = null, - public ?string $wakeKey = null, ) {} public function missedIds(): array diff --git a/src/Values/PreparedQuery.php b/src/Values/PreparedQuery.php index fcd04e2..8d70527 100644 --- a/src/Values/PreparedQuery.php +++ b/src/Values/PreparedQuery.php @@ -2,6 +2,8 @@ namespace NormCache\Values; +use Closure; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Query\Builder as QueryBuilder; use NormCache\CacheableBuilder; @@ -14,6 +16,18 @@ public function __construct( public readonly QueryBuilder $base, ) {} + /** Clone of base with the caller's projection injected when the query itself selects none. */ + public function baseWithColumns(array $columns): QueryBuilder + { + $base = clone $this->base; + + if (empty($base->columns) && $columns !== ['*']) { + $base->columns = $columns; + } + + return $base; + } + public function applyBeforeCallbacks(): self { if (!$this->beforeCallbacksApplied) { @@ -24,6 +38,30 @@ public function applyBeforeCallbacks(): self return $this; } + public function collect( + array $columns = ['*'], + bool $applyAfterCallbacks = true, + ?Closure $beforeEagerLoad = null, + ): Collection { + $models = $this->builder->getModels($columns); + $beforeEagerLoad?->__invoke($models); + + return $this->finalizeModels($models, $applyAfterCallbacks); + } + + public function finalizeModels(array $models, bool $applyAfterCallbacks = true): Collection + { + if ($models !== [] && $this->builder->getEagerLoads() !== []) { + $models = $this->builder->eagerLoadRelations($models); + } + + $collection = $this->builder->getModel()->newCollection($models); + + return $applyAfterCallbacks + ? $this->builder->applyAfterQueryCallbacks($collection) + : $collection; + } + public function applyAfterCallbacks(mixed $result): mixed { return $this->builder->applyAfterQueryCallbacks($result); diff --git a/src/Values/QueryCacheResult.php b/src/Values/QueryCacheResult.php index e482c20..bbdd6f4 100644 --- a/src/Values/QueryCacheResult.php +++ b/src/Values/QueryCacheResult.php @@ -11,9 +11,6 @@ public function __construct( public ?string $key, public ?array $ids, public ?array $models, - public ?string $buildingKey, - public ?string $buildingToken, - public array $versionKeys, - public array $expectedVersions, + public BuildHandle $build = new BuildHandle, ) {} } diff --git a/src/Values/QueryInspection.php b/src/Values/QueryInspection.php index c787159..c899052 100644 --- a/src/Values/QueryInspection.php +++ b/src/Values/QueryInspection.php @@ -30,10 +30,7 @@ public const EXISTS_WHERE = 1 << 12; - private const DEPENDENCY_BYPASS = self::RAW_ORDER - | self::RAW_WHERE - | self::SUBQUERY_WHERE - | self::EXISTS_WHERE; + private const DEPENDENCY_BYPASS = self::RAW_ORDER | self::RAW_WHERE; private const NORMALIZATION_BYPASS = self::NON_CANONICAL_FROM | self::JOIN @@ -44,11 +41,16 @@ | self::DISTINCT | self::CALCULATED_COLUMNS; + public DependencySet $dependencies; + public function __construct( public int $flags = 0, public ?array $primaryKeys = null, - public ?array $tables = null, - ) {} + ?DependencySet $dependencies = null, + public array $contextReasons = [], + ) { + $this->dependencies = $dependencies ?? DependencySet::empty(); + } public function has(int $flags): bool { @@ -60,17 +62,6 @@ public function hasDependencyBypass(): bool return $this->has(self::DEPENDENCY_BYPASS); } - // True only if EXISTS_WHERE is the sole reason hasDependencyBypass() is true. - public function hasOnlyExistsDependencyBypass(): bool - { - return ($this->flags & self::DEPENDENCY_BYPASS) === self::EXISTS_WHERE; - } - - public function hasNormalizationBypass(): bool - { - return $this->has(self::NORMALIZATION_BYPASS); - } - public function normalizationFlags(): int { return $this->flags & self::NORMALIZATION_BYPASS; @@ -78,6 +69,6 @@ public function normalizationFlags(): int public function hasSafetyBypass(): bool { - return $this->has(self::LOCK); + return $this->has(self::LOCK) || isset($this->contextReasons['safety']); } } diff --git a/src/Values/ResultCacheResult.php b/src/Values/ResultCacheResult.php index d72af5f..9ed7a74 100644 --- a/src/Values/ResultCacheResult.php +++ b/src/Values/ResultCacheResult.php @@ -10,10 +10,6 @@ public function __construct( public CacheStatus $status, public ?string $key, public mixed $payload, - public ?string $buildingKey, - public ?string $buildingToken, - public ?string $wakeKey, - public array $versionKeys, - public array $expectedVersions, + public BuildHandle $build = new BuildHandle, ) {} } diff --git a/src/Values/SpaceValidationResult.php b/src/Values/SpaceValidationResult.php new file mode 100644 index 0000000..651ce35 --- /dev/null +++ b/src/Values/SpaceValidationResult.php @@ -0,0 +1,19 @@ + $invalidModels + * @param array> $dependenciesBySpace + */ + public function __construct( + public bool $isValid, + public CacheSpace $space, + public array $invalidModels = [], + public array $dependenciesBySpace = [], + ) {} +} diff --git a/src/Values/ThroughCacheResult.php b/src/Values/ThroughCacheResult.php index cf14193..b916792 100644 --- a/src/Values/ThroughCacheResult.php +++ b/src/Values/ThroughCacheResult.php @@ -12,9 +12,6 @@ public function __construct( public ?array $ids, public ?array $throughKeys, public ?array $models, - public ?string $buildingKey, - public ?string $buildingToken, - public array $versionKeys, - public array $expectedVersions, + public BuildHandle $build = new BuildHandle, ) {} } diff --git a/tests/Fixtures/Models/CatalogTag.php b/tests/Fixtures/Models/CatalogTag.php new file mode 100644 index 0000000..c6167ac --- /dev/null +++ b/tests/Fixtures/Models/CatalogTag.php @@ -0,0 +1,18 @@ +hasManyThrough(SpacedPost::class, SpacedAuthor::class, 'country_id', 'author_id'); + } + + public function crossSpacePosts(): HasManyThrough + { + return $this->hasManyThrough(SpacedPost::class, ReportingAuthor::class, 'country_id', 'author_id'); + } +} diff --git a/tests/Fixtures/Models/SpacedAuthor.php b/tests/Fixtures/Models/SpacedAuthor.php new file mode 100644 index 0000000..a04545d --- /dev/null +++ b/tests/Fixtures/Models/SpacedAuthor.php @@ -0,0 +1,18 @@ +belongsTo(SpacedAuthor::class, 'author_id'); + } + + public function catalogTags(): MorphToMany + { + return $this->morphToMany(CatalogTag::class, 'taggable', 'taggables', 'taggable_id', 'tag_id'); + } +} diff --git a/tests/Integration/Cache/CacheAccuracyTest.php b/tests/Integration/Cache/CacheAccuracyTest.php index fbcd479..49972b2 100644 --- a/tests/Integration/Cache/CacheAccuracyTest.php +++ b/tests/Integration/Cache/CacheAccuracyTest.php @@ -274,7 +274,7 @@ public function test_subclass_get_deleted_at_column_override_is_respected(): voi $author = Author::create(['name' => 'Alice']); $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); - app('normcache')->getModels([$post->id], Post::class); + app('normcache')->hydrator()->getModels([$post->id], Post::class); $resolved = (new ReflectionProperty(CacheKeyBuilder::class, 'deletedAtColumns'))->getValue(); $this->assertSame('deleted_at', $resolved[Post::class] ?? null); @@ -307,8 +307,8 @@ public function test_same_table_models_on_different_connections_do_not_share_cac $secondary = SecondaryConnectionAuthor::find(1); $this->assertSame('Secondary Alice', $secondary->name); - $this->assertSame(DB::getDefaultConnection() . ':authors', app('normcache')->classKey(Author::class)); - $this->assertSame('secondary_testing:authors', app('normcache')->classKey(SecondaryConnectionAuthor::class)); + $this->assertSame(DB::getDefaultConnection() . ':authors', app('normcache')->keys()->classKey(Author::class)); + $this->assertSame('secondary_testing:authors', app('normcache')->keys()->classKey(SecondaryConnectionAuthor::class)); } } diff --git a/tests/Integration/Cache/CacheableBuilderTest.php b/tests/Integration/Cache/CacheableBuilderTest.php index 1bdd5bc..16e6fa8 100644 --- a/tests/Integration/Cache/CacheableBuilderTest.php +++ b/tests/Integration/Cache/CacheableBuilderTest.php @@ -12,7 +12,6 @@ use NormCache\Facades\NormCache; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; -use NormCache\Tests\Fixtures\Models\UncachedAuthor; use NormCache\Tests\Fixtures\Models\UncachedPost; use NormCache\Tests\TestCase; @@ -23,30 +22,12 @@ */ class CacheableBuilderTest extends TestCase { - public function test_get_writes_query_cache_key(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotEmpty($this->redisKeys('test:query:*')); - } - public function test_without_cache_writes_no_query_keys(): void { Author::create(['name' => 'Alice']); Author::withoutCache()->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); - } - - public function test_uncached_get_accepts_string_columns(): void - { - Author::create(['name' => 'Alice']); - - $authors = Author::withoutCache()->get('id'); - - $this->assertCount(1, $authors); - $this->assertSame(['id'], array_keys($authors->first()->getAttributes())); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_ttl_uses_custom_ttl(): void @@ -54,28 +35,18 @@ public function test_ttl_uses_custom_ttl(): void Author::create(['name' => 'Alice']); Author::query()->ttl(9999)->get(); - $queryKey = collect($this->redisKeys('test:query:*'))->first(); + $queryKey = collect($this->redisKeys('query:*'))->first(); $this->assertNotNull($queryKey); $this->assertGreaterThan(9000, Redis::connection('normcache-test')->ttl($queryKey)); } - public function test_query_with_join_bypasses_cache(): void - { - Author::create(['name' => 'Alice']); - Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->get(); - - $this->assertEmpty($this->redisKeys('test:query:*')); - } - public function test_query_with_group_by_bypasses_cache(): void { Author::create(['name' => 'Alice']); Author::query()->groupBy('name')->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_query_from_subquery_bypasses_cache(): void @@ -84,7 +55,7 @@ public function test_query_from_subquery_bypasses_cache(): void Author::fromSub(Author::query()->select('id', 'name'), 'authors')->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_query_with_raw_select_expression_bypasses_cache(): void @@ -92,24 +63,7 @@ public function test_query_with_raw_select_expression_bypasses_cache(): void Author::create(['name' => 'Alice']); Author::query()->selectRaw('id, name, 1 + 1 as computed')->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); - } - - public function test_subquery_where_has_reflects_related_model_writes(): void - { - $author = Author::create(['name' => 'Alice']); - $post = Post::create(['title' => 'Hello', 'author_id' => $author->id, 'published' => true]); - - $warm = Author::whereHas('posts', fn($q) => $q->where('published', true))->get(); - $this->assertCount(1, $warm); - - $post->update(['published' => false]); - - $live = UncachedAuthor::whereHas('posts', fn($q) => $q->where('published', true))->get(); - $this->assertCount(0, $live); - - $cached = Author::whereHas('posts', fn($q) => $q->where('published', true))->get(); - $this->assertCount(0, $cached); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_bulk_update_invalidates_version(): void @@ -179,6 +133,26 @@ public function test_with_count_result_is_cached_and_invalidated_on_version_bump $this->assertSame(2, Author::withCount('posts')->find($author->id)->posts_count); } + public function test_without_aggregate_cache_before_with_count_uses_live_queries(): void + { + $author = Author::create(['name' => 'Alice']); + Post::create(['title' => 'Hello', 'author_id' => $author->id]); + + $this->assertAggregateCacheOptOutRunsLive( + fn() => Author::withoutAggregateCache()->withCount('posts')->get(), + ); + } + + public function test_without_aggregate_cache_after_with_count_uses_live_queries(): void + { + $author = Author::create(['name' => 'Alice']); + Post::create(['title' => 'Hello', 'author_id' => $author->id]); + + $this->assertAggregateCacheOptOutRunsLive( + fn() => Author::withCount('posts')->withoutAggregateCache()->get(), + ); + } + public function test_flush_model_invalidates_aggregate_blob_cache(): void { $author = Author::create(['name' => 'Alice']); @@ -200,7 +174,7 @@ public function test_with_count_on_non_cacheable_relation_falls_through_to_eloqu $result = Author::withCount('uncachedPosts')->get()->firstWhere('id', $author->id); $this->assertSame(2, (int) $result->uncached_posts_count); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_belongs_to_warm_hit_runs_after_query_callbacks(): void @@ -254,7 +228,7 @@ public function test_in_random_order_bypasses_cache(): void Author::create(['name' => 'Alice']); Author::inRandomOrder()->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_primary_key_query_with_limit_uses_model_cache_without_query_cache(): void @@ -265,18 +239,7 @@ public function test_primary_key_query_with_limit_uses_model_cache_without_query $this->assertCount(1, $authors); $this->assertSame('Alice', $authors->first()->name); - $this->assertEmpty($this->redisKeys('test:query:*')); - } - - public function test_single_primary_key_query_with_order_uses_model_cache_without_query_cache(): void - { - $author = Author::create(['name' => 'Alice']); - - $authors = Author::whereKey($author->id)->orderBy('name')->get(); - - $this->assertCount(1, $authors); - $this->assertSame('Alice', $authors->first()->name); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_primary_key_query_with_zero_limit_returns_empty_without_query_cache(): void @@ -286,7 +249,7 @@ public function test_primary_key_query_with_zero_limit_returns_empty_without_que $authors = Author::whereKey($author->id)->limit(0)->get(); $this->assertCount(0, $authors); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_increment_invalidates_version(): void @@ -321,7 +284,7 @@ public function test_query_inside_transaction_bypasses_cache(): void Author::all(); }); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_refresh_issues_a_db_query_not_a_cache_read(): void @@ -399,7 +362,7 @@ public function test_paginate_count_cache_is_select_independent(): void Author::query()->paginate(10); Author::query()->select('name')->paginate(10); - $this->assertCount(1, $this->redisKeys('test:count:*')); + $this->assertCount(1, $this->redisKeys('count:*')); } public function test_raw_builder_insert_invalidates_version(): void @@ -517,13 +480,11 @@ public function test_explain_groups_join_as_normalization(): void $this->assertStringContainsString('join_result_requires_explicit_select', $result); } - public function test_explain_groups_non_standard_from_as_normalization(): void + public function test_explain_uses_result_cache_for_inferable_from_subquery(): void { - $result = Author::fromSub(Author::query()->select('id', 'name'), 'authors') - ->explain(); + $result = Author::fromSub(Author::query()->select('id', 'name'), 'authors')->explain(); - $this->assertStringContainsString("can't be normalized", $result); - $this->assertStringContainsString('non-standard FROM', $result); + $this->assertSame('cached: result', $result); } public function test_explain_groups_group_by_as_normalization(): void @@ -565,7 +526,7 @@ public function test_get_fires_query_bypassed_event_with_dependency_category_for Event::assertDispatched(QueryBypassed::class, function (QueryBypassed $e) { return $e->modelClass === Author::class && isset($e->reasons['dependency']) - && collect($e->reasons['dependency'])->contains(fn($r) => str_contains($r, 'subquery WHERE')); + && collect($e->reasons['dependency'])->contains(fn($r) => str_contains($r, 'raw WHERE')); }); } @@ -726,8 +687,8 @@ public function test_complex_query_without_depends_on_bypasses(): void Author::whereHas('posts.comments')->get(); Event::assertDispatched(QueryBypassed::class); - $this->assertEmpty($this->redisKeys('test:query:*')); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertEmpty($this->redisKeys('query:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_complex_aggregate_without_explicit_dependencies_bypasses(): void @@ -748,7 +709,7 @@ public function test_corrupt_query_cache_ids_are_treated_as_miss(): void Author::create(['name' => 'Alice']); Author::all(); // warm - $queryKey = collect($this->redisKeys('test:query:*'))->first(); + $queryKey = collect($this->redisKeys('query:*'))->first(); $this->assertNotNull($queryKey); Redis::connection('normcache-test')->set($queryKey, 'NOT_JSON'); @@ -766,7 +727,7 @@ public function test_malformed_count_cache_payload_is_recomputed_and_repaired(): $this->assertSame(2, Author::count()); - $countKey = collect($this->redisKeys('test:count:*'))->first(); + $countKey = collect($this->redisKeys('count:*'))->first(); $this->assertNotNull($countKey); $this->corruptResultCacheEntry($countKey); @@ -789,7 +750,7 @@ public function test_malformed_exists_cache_payload_is_recomputed_and_repaired() $this->assertTrue(Author::exists()); - $scalarKey = collect($this->redisKeys('test:scalar:*'))->first(); + $scalarKey = collect($this->redisKeys('scalar:*'))->first(); $this->assertNotNull($scalarKey); $this->corruptResultCacheEntry($scalarKey); @@ -813,7 +774,7 @@ public function test_malformed_scalar_cache_payload_is_recomputed_and_repaired() $this->assertSame(10, Post::sum('views')); - $scalarKey = collect($this->redisKeys('test:scalar:*'))->first(); + $scalarKey = collect($this->redisKeys('scalar:*'))->first(); $this->assertNotNull($scalarKey); $this->corruptResultCacheEntry($scalarKey); @@ -830,10 +791,27 @@ public function test_malformed_scalar_cache_payload_is_recomputed_and_repaired() $this->assertSame(0, $queryCount, 'Malformed entry must be repaired so the next read is a clean hit'); } + private function assertAggregateCacheOptOutRunsLive(callable $query): void + { + $query(); + + DB::flushQueryLog(); + DB::enableQueryLog(); + try { + $query(); + $queries = DB::getQueryLog(); + } finally { + DB::disableQueryLog(); + } + + $this->assertEmpty($this->redisKeys('result:*')); + $this->assertNotEmpty($queries, 'withoutAggregateCache() must not serve a result-cache hit.'); + } + // Overwrites a result-cache entry with a serialized [] — the wrong shape for any scalar/count cache. private function corruptResultCacheEntry(string $key): void { - $serialized = $this->cacheManager()->getStore()->serialize([]); + $serialized = $this->cacheManager()->store()->serialize([]); Redis::connection('normcache-test')->set($key, $serialized); } @@ -848,4 +826,28 @@ public function test_normalized_cache_preserves_wildcard_plus_alias_projection() $this->assertSame('Alice', $second->first()->name); $this->assertSame('Alice', $second->first()->display_name); } + + public function test_depends_on_merges_previous_model_dependencies(): void + { + $builder = Post::query() + ->dependsOn([Post::class]) + ->dependsOn([Author::class]); + + $this->assertContains(Post::class, $builder->explicitDependencies()); + $this->assertContains(Author::class, $builder->explicitDependencies()); + $this->assertCount(2, $builder->explicitDependencies()); + } + + public function test_depends_on_tables_merges_previous_table_dependencies(): void + { + $conn = (new Post)->getConnection()->getName(); + + $builder = Post::query() + ->dependsOnTables(['posts']) + ->dependsOnTables(['authors']); + + $this->assertContains("{$conn}:posts", $builder->explicitTableDependencies()); + $this->assertContains("{$conn}:authors", $builder->explicitTableDependencies()); + $this->assertCount(2, $builder->explicitTableDependencies()); + } } diff --git a/tests/Integration/Cache/CastAttributeTest.php b/tests/Integration/Cache/CastAttributeTest.php index 586f7bf..89db1d1 100644 --- a/tests/Integration/Cache/CastAttributeTest.php +++ b/tests/Integration/Cache/CastAttributeTest.php @@ -104,7 +104,7 @@ public function test_pluck_preserves_retrieved_events_for_casted_values(): void public function test_pluck_fires_retrieved_once_per_row_not_once_per_batch(): void { - // ModelHydrator::transformScalars() clones a template pivot-like instance per + // ScalarTransformer::transformScalars() clones a template pivot-like instance per // row when a retrieved listener forces the slow path. Guard against the clone // optimization collapsing N rows into a single fired event. $author = Author::create(['name' => 'Alice']); diff --git a/tests/Integration/Cache/ConnectionAwareCachingTest.php b/tests/Integration/Cache/ConnectionAwareCachingTest.php new file mode 100644 index 0000000..32a0c2a --- /dev/null +++ b/tests/Integration/Cache/ConnectionAwareCachingTest.php @@ -0,0 +1,152 @@ +seedDifferentAuthorsOnTwoConnections(); + + $primary = Author::find(1); + $secondaryBuilder = Author::on('secondary_testing'); + $this->assertSame('secondary_testing', $secondaryBuilder->getModel()->getConnectionName()); + $secondary = $secondaryBuilder->find(1); + + $this->assertSame('Primary Alice', $primary?->name); + $this->assertSame('Secondary Alice', $secondary?->name); + $this->assertSame('secondary_testing', $secondary?->getConnectionName()); + + $manager = $this->cacheManager(); + $defaultKey = $manager->keys()->classKey(Author::class); + $secondaryKey = $manager->keys()->classKey(Author::class, 'secondary_testing'); + $defaultVersion = $manager->currentVersion(Author::class); + $secondaryVersion = $manager->currentVersion(Author::class, 'secondary_testing'); + + $this->assertSame( + 'Primary Alice', + $manager->store()->get($manager->keys()->modelPrefix($defaultKey, $defaultVersion) . '1')['name'] ?? null, + ); + $this->assertSame( + 'Secondary Alice', + $manager->store()->get($manager->keys()->modelPrefix($secondaryKey, $secondaryVersion) . '1')['name'] ?? null, + ); + + DB::connection('secondary_testing')->flushQueryLog(); + DB::connection('secondary_testing')->enableQueryLog(); + + try { + $this->assertSame('Secondary Alice', Author::on('secondary_testing')->find(1)?->name); + $this->assertSame([], DB::connection('secondary_testing')->getQueryLog()); + } finally { + DB::connection('secondary_testing')->disableQueryLog(); + } + } + + public function test_on_connection_query_does_not_poison_default_cache_namespace(): void + { + $this->seedDifferentAuthorsOnTwoConnections(); + + $this->assertSame('Secondary Alice', Author::on('secondary_testing')->find(1)?->name); + $this->assertSame('Primary Alice', Author::find(1)?->name); + } + + public function test_normalized_and_scalar_caches_are_connection_scoped(): void + { + $this->seedDifferentAuthorsOnTwoConnections(); + DB::connection('secondary_testing')->table('authors')->insert([ + 'id' => 2, + 'name' => 'Secondary Bob', + 'country_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $this->assertSame(['Primary Alice'], Author::orderBy('id')->get()->pluck('name')->all()); + $this->assertSame( + ['Secondary Alice', 'Secondary Bob'], + Author::on('secondary_testing')->orderBy('id')->get()->pluck('name')->all(), + ); + $this->assertSame(1, Author::count()); + $this->assertSame(2, Author::on('secondary_testing')->count()); + + DB::connection('secondary_testing')->flushQueryLog(); + DB::connection('secondary_testing')->enableQueryLog(); + + try { + $this->assertSame( + ['Secondary Alice', 'Secondary Bob'], + Author::on('secondary_testing')->orderBy('id')->get()->pluck('name')->all(), + ); + $this->assertSame(2, Author::on('secondary_testing')->count()); + $this->assertSame([], DB::connection('secondary_testing')->getQueryLog()); + } finally { + DB::connection('secondary_testing')->disableQueryLog(); + } + } + + public function test_secondary_model_write_invalidates_only_secondary_namespace(): void + { + $this->seedDifferentAuthorsOnTwoConnections(); + + Author::find(1); + $secondary = Author::on('secondary_testing')->find(1); + $manager = $this->cacheManager(); + $defaultBefore = $manager->currentVersion(Author::class); + $secondaryBefore = $manager->currentVersion(Author::class, 'secondary_testing'); + + $secondary?->update(['name' => 'Secondary Alicia']); + + $this->assertSame($defaultBefore, $manager->currentVersion(Author::class)); + $this->assertGreaterThan( + $secondaryBefore, + $manager->currentVersion(Author::class, 'secondary_testing'), + ); + $this->assertSame('Primary Alice', Author::find(1)?->name); + $this->assertSame('Secondary Alicia', Author::on('secondary_testing')->find(1)?->name); + } + + public function test_on_connection_query_explain_reports_cached_strategy(): void + { + $this->seedDifferentAuthorsOnTwoConnections(); + + $this->assertSame( + 'cached', + Author::on('secondary_testing')->whereKey(1)->explain(), + ); + } + + private function seedDifferentAuthorsOnTwoConnections(): void + { + config()->set('database.connections.secondary_testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + DB::purge('secondary_testing'); + + Schema::connection('secondary_testing')->create('authors', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->foreignId('country_id')->nullable(); + $table->timestamps(); + }); + + Author::create(['id' => 1, 'name' => 'Primary Alice']); + + DB::connection('secondary_testing')->table('authors')->insert([ + 'id' => 1, + 'name' => 'Secondary Alice', + 'country_id' => null, + 'created_at' => now(), + 'updated_at' => now(), + ]); + } +} diff --git a/tests/Integration/Cache/CrossTableDependencySafetyTest.php b/tests/Integration/Cache/CrossTableDependencySafetyTest.php index 3430f86..b184f94 100644 --- a/tests/Integration/Cache/CrossTableDependencySafetyTest.php +++ b/tests/Integration/Cache/CrossTableDependencySafetyTest.php @@ -21,7 +21,7 @@ public function test_join_count_without_depends_on_infers_join_dependency_and_ca ->join('posts', 'posts.author_id', '=', 'authors.id') ->count(); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); } public function test_join_count_with_depends_on_caches(): void @@ -34,7 +34,7 @@ public function test_join_count_with_depends_on_caches(): void ->dependsOn([Post::class]) ->count(); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); } public function test_join_count_with_depends_on_invalidates_on_dep_change(): void @@ -65,7 +65,7 @@ public function test_join_paginate_without_depends_on_infers_join_dependency_and ->select('authors.*') ->paginate(10); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); } public function test_join_paginate_with_depends_on_caches_count(): void @@ -79,7 +79,7 @@ public function test_join_paginate_with_depends_on_caches_count(): void ->dependsOn([Post::class]) ->paginate(10); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); } public function test_simple_count_without_join_still_caches(): void @@ -88,7 +88,7 @@ public function test_simple_count_without_join_still_caches(): void Author::query()->count(); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); } public function test_count_with_group_by_single_table_still_caches(): void @@ -97,10 +97,10 @@ public function test_count_with_group_by_single_table_still_caches(): void Author::query()->groupBy('name')->count('name'); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); } - public function test_aggregate_constraint_with_join_disables_tracking_and_bypasses(): void + public function test_aggregate_constraint_with_join_uses_inferred_table_dependencies(): void { $author = Author::create(['name' => 'Alice']); Post::create(['title' => 'Hello', 'author_id' => $author->id]); @@ -109,7 +109,7 @@ public function test_aggregate_constraint_with_join_disables_tracking_and_bypass 'posts' => fn($q) => $q->join('authors', 'authors.id', '=', 'posts.author_id'), ])->get(); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_simple_aggregate_constraint_still_infers_dependencies(): void @@ -119,10 +119,10 @@ public function test_simple_aggregate_constraint_still_infers_dependencies(): vo Author::withCount(['posts' => fn($q) => $q->where('title', 'Hello')])->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } - public function test_aggregate_constraint_with_wherehas_disables_tracking(): void + public function test_aggregate_constraint_with_wherehas_uses_recursive_dependencies(): void { $author = Author::create(['name' => 'Alice']); Post::create(['title' => 'Hello', 'author_id' => $author->id]); @@ -131,7 +131,7 @@ public function test_aggregate_constraint_with_wherehas_disables_tracking(): voi 'posts' => fn($q) => $q->whereHas('author'), ])->get(); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_aliased_from_query_bypasses_normalized_cache(): void @@ -140,7 +140,7 @@ public function test_aliased_from_query_bypasses_normalized_cache(): void Author::from('authors as a')->where('a.name', 'Alice')->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_aliased_from_with_depends_on_uses_result_cache(): void @@ -152,7 +152,7 @@ public function test_aliased_from_with_depends_on_uses_result_cache(): void ->dependsOn([Author::class]) ->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_canonical_from_still_uses_normalized_cache(): void @@ -161,7 +161,7 @@ public function test_canonical_from_still_uses_normalized_cache(): void Author::from('authors')->get(); - $this->assertNotEmpty($this->redisKeys('test:query:*')); + $this->assertNotEmpty($this->redisKeys('query:*')); } public function test_pluck_with_falsey_key_hashes_differently_from_no_key(): void @@ -170,11 +170,11 @@ public function test_pluck_with_falsey_key_hashes_differently_from_no_key(): voi // Warm cache for pluck without a key Author::query()->pluck('name'); - $noKeyCache = $this->redisKeys('test:scalar:*'); + $noKeyCache = $this->redisKeys('scalar:*'); // Pluck with an integer key (falsey: 0) Author::query()->pluck('name', 'id'); - $withKeyCache = $this->redisKeys('test:scalar:*'); + $withKeyCache = $this->redisKeys('scalar:*'); // Should have two separate cache entries, not share one $this->assertCount(2, $withKeyCache); @@ -186,10 +186,10 @@ public function test_pluck_with_null_key_behaves_same_as_no_key(): void Author::create(['name' => 'Alice']); Author::query()->pluck('name', null); - $nullKeyCache = $this->redisKeys('test:scalar:*'); + $nullKeyCache = $this->redisKeys('scalar:*'); Author::query()->pluck('name'); - $noKeyCache = $this->redisKeys('test:scalar:*'); + $noKeyCache = $this->redisKeys('scalar:*'); // null key should hash the same as no key (both use only [$column]) $this->assertCount(1, $noKeyCache); diff --git a/tests/Integration/Cache/DependsOnTest.php b/tests/Integration/Cache/DependsOnTest.php index b688812..e05587d 100644 --- a/tests/Integration/Cache/DependsOnTest.php +++ b/tests/Integration/Cache/DependsOnTest.php @@ -19,16 +19,12 @@ class DependsOnTest extends TestCase { public function test_simple_depends_on_query_uses_normalized_query_cache(): void { - if ($this->cacheManager()->isSlotting()) { - $this->markTestSkipped('In slotting mode, multi-dependency queries route to result cache — see ClusterModeTest'); - } - Author::create(['name' => 'Alice']); Author::query()->dependsOn([Post::class])->get(); - $this->assertNotEmpty($this->redisKeys('test:query:*')); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('query:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_depends_on_invalidates_on_primary_model_version_bump(): void @@ -84,11 +80,11 @@ public function test_depends_on_dep_order_does_not_affect_key(): void Post::create(['title' => 'Hello', 'author_id' => $author->id]); Author::whereHas('posts')->dependsOn([Post::class, Author::class])->get(); - $keysAB = $this->redisKeys('test:query:*'); + $keysAB = $this->redisKeys('query:*'); // dep order is sorted before hashing, so reversed order must hit the same key Author::whereHas('posts')->dependsOn([Author::class, Post::class])->get(); - $keysBA = $this->redisKeys('test:query:*'); + $keysBA = $this->redisKeys('query:*'); $this->assertSame( array_map(fn($k) => str_replace('test:', '', $k), $keysAB), @@ -103,16 +99,16 @@ public function test_depends_on_paginate_caches_count_with_dep_versions(): void Author::whereHas('posts')->dependsOn([Post::class])->paginate(10); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); // inserting bumps Post's version; the old count key becomes unreachable but is not deleted Post::create(['title' => 'World', 'author_id' => $author->id]); - $firstKeys = $this->redisKeys('test:count:*'); + $firstKeys = $this->redisKeys('count:*'); Author::whereHas('posts')->dependsOn([Post::class])->paginate(10); - $secondKeys = $this->redisKeys('test:count:*'); + $secondKeys = $this->redisKeys('count:*'); // two distinct versioned count keys: the orphaned (old) one and the new one $this->assertCount(2, $secondKeys); @@ -135,7 +131,7 @@ public function test_join_with_depends_on_paginate_caches_count(): void $this->assertSame(2, $pageOne->total()); $this->assertCount(1, $pageOne->items()); $this->assertSame('Alice', $pageOne->first()->name); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); $pageTwo = Author::query() ->join('posts', 'posts.author_id', '=', 'authors.id') @@ -176,7 +172,7 @@ public function test_join_with_depends_on_paginate_count_invalidates_on_dep_mode ->dependsOn([Post::class]) ->paginate(10); - $firstKeys = $this->redisKeys('test:count:*'); + $firstKeys = $this->redisKeys('count:*'); Post::create(['title' => 'World', 'author_id' => $author->id]); @@ -186,7 +182,7 @@ public function test_join_with_depends_on_paginate_count_invalidates_on_dep_mode ->dependsOn([Post::class]) ->paginate(10); - $secondKeys = $this->redisKeys('test:count:*'); + $secondKeys = $this->redisKeys('count:*'); $this->assertCount(2, $secondKeys); $this->assertNotEmpty(array_diff($secondKeys, $firstKeys)); @@ -234,7 +230,7 @@ public function test_join_count_with_depends_on_caches_as_scalar_result(): void $this->assertSame(3, $second); $this->assertEmpty($queries); - $this->assertNotEmpty($this->redisKeys('test:count:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); } public function test_distinct_count_with_depends_on_caches_as_scalar_result(): void @@ -295,7 +291,7 @@ public function test_from_subquery_with_depends_on_caches_as_blob(): void ->dependsOn([Author::class]) ->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_raw_order_with_depends_on_can_cache(): void @@ -307,7 +303,7 @@ public function test_raw_order_with_depends_on_can_cache(): void ->dependsOn([Author::class]) ->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_where_in_subquery_requires_depends_on(): void @@ -319,7 +315,7 @@ public function test_where_in_subquery_requires_depends_on(): void ->whereIn('id', Post::query()->select('author_id')) ->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_where_in_subquery_with_depends_on_can_cache(): void @@ -332,7 +328,7 @@ public function test_where_in_subquery_with_depends_on_can_cache(): void ->dependsOn([Post::class]) ->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_distinct_with_depends_on_preserves_distinct_semantics(): void @@ -402,7 +398,7 @@ public function test_complex_aggregate_with_explicit_dependencies_uses_result_ca 'posts' => fn($q) => $q->whereRaw('1=1'), ])->dependsOn([Post::class])->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*'), 'Complex aggregate with dependsOn should use result cache'); + $this->assertNotEmpty($this->redisKeys('result:*'), 'Complex aggregate with dependsOn should use result cache'); } public function test_scalar_count_with_depends_on_caches_as_result(): void @@ -411,7 +407,7 @@ public function test_scalar_count_with_depends_on_caches_as_result(): void Author::where('name', 'Alice')->dependsOn([Post::class])->count(); - $this->assertNotEmpty($this->redisKeys('test:count:*'), 'Scalar count with dependsOn should use count namespace'); + $this->assertNotEmpty($this->redisKeys('count:*'), 'Scalar count with dependsOn should use count namespace'); } // tag() — manual flush grouping @@ -423,8 +419,8 @@ public function test_tag_is_embedded_in_computed_key(): void Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*:homepage:*')); - $this->assertEmpty($this->redisKeys('test:result:*[^:]homepage*')); + $this->assertNotEmpty($this->redisKeys('result:*:homepage:*')); + $this->assertEmpty($this->redisKeys('result:*[^:]homepage*')); } public function test_tagged_keys_are_isolated_from_untagged_keys(): void @@ -435,8 +431,8 @@ public function test_tagged_keys_are_isolated_from_untagged_keys(): void Author::whereHas('posts')->dependsOn([Post::class])->get(); Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->get(); - $all = $this->redisKeys('test:result:*'); - $tagged = $this->redisKeys('test:result:*:homepage:*'); + $all = $this->redisKeys('result:*'); + $tagged = $this->redisKeys('result:*:homepage:*'); $this->assertCount(2, $all); $this->assertCount(1, $tagged); @@ -453,8 +449,8 @@ public function test_flush_tag_removes_only_matching_keys(): void $removed = NormCache::flushTag(Author::class, 'homepage'); $this->assertSame(1, $removed); - $this->assertNotEmpty($this->redisKeys('test:result:*')); - $this->assertEmpty($this->redisKeys('test:result:*:homepage:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); + $this->assertEmpty($this->redisKeys('result:*:homepage:*')); } public function test_flush_tag_across_models_removes_all_matching(): void @@ -468,7 +464,7 @@ public function test_flush_tag_across_models_removes_all_matching(): void $removed = NormCache::flushTagAcrossModels('deploy'); $this->assertSame(2, $removed); - $this->assertEmpty($this->redisKeys('test:result:*:deploy:*')); + $this->assertEmpty($this->redisKeys('result:*:deploy:*')); } public function test_flush_tag_removes_tagged_paginate_count_key(): void @@ -478,12 +474,12 @@ public function test_flush_tag_removes_tagged_paginate_count_key(): void Author::whereHas('posts')->dependsOn([Post::class])->tag('homepage')->paginate(10); - $this->assertNotEmpty($this->redisKeys('test:count:*:homepage:*')); + $this->assertNotEmpty($this->redisKeys('count:*:homepage:*')); $removed = NormCache::flushTag(Author::class, 'homepage'); $this->assertGreaterThan(0, $removed); - $this->assertEmpty($this->redisKeys('test:count:*:homepage:*')); + $this->assertEmpty($this->redisKeys('count:*:homepage:*')); } public function test_tagged_result_cache_invalidates_on_dep_version_bump(): void @@ -608,7 +604,7 @@ public function test_depends_on_tables_caches_query(): void Author::whereHas('tags')->dependsOnTables(['author_tag'])->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_depends_on_tables_invalidates_when_pivot_table_version_bumps(): void @@ -649,7 +645,7 @@ public function test_depends_on_tables_alone_without_depends_on(): void // No dependsOn() — just table dep. Should still use result cache. Author::whereHas('tags')->dependsOnTables(['author_tag'])->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*'), 'dependsOnTables() alone should trigger result cache'); + $this->assertNotEmpty($this->redisKeys('result:*'), 'dependsOnTables() alone should trigger result cache'); } public function test_depends_on_tables_rejects_empty_array(): void diff --git a/tests/Integration/Cache/JoinResultCacheTest.php b/tests/Integration/Cache/JoinResultCacheTest.php index 2987931..1436a0e 100644 --- a/tests/Integration/Cache/JoinResultCacheTest.php +++ b/tests/Integration/Cache/JoinResultCacheTest.php @@ -27,20 +27,7 @@ public function test_join_with_depends_on_and_no_explicit_select_bypasses_cache( ->dependsOn([Post::class]) ->get(); - $this->assertEmpty($this->redisKeys('test:result:*')); - } - - public function test_join_with_depends_on_and_no_explicit_select_returns_correct_results(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); - - $results = Author::query() - ->join('posts', 'posts.author_id', '=', 'authors.id') - ->dependsOn([Post::class]) - ->get(); - - $this->assertCount(1, $results); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_join_with_depends_on_and_explicit_root_select_caches_as_result(): void @@ -54,7 +41,7 @@ public function test_join_with_depends_on_and_explicit_root_select_caches_as_res ->dependsOn([Post::class]) ->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_join_with_explicit_select_serves_subsequent_calls_from_cache(): void @@ -94,7 +81,7 @@ public function test_plain_join_without_depends_on_infers_table_dependency_and_c ->select('authors.*') ->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_plain_join_without_explicit_root_select_bypasses_despite_inferred_tables(): void @@ -106,8 +93,8 @@ public function test_plain_join_without_explicit_root_select_bypasses_despite_in ->join('posts', 'posts.author_id', '=', 'authors.id') ->get(); - $this->assertEmpty($this->redisKeys('test:result:*')); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('result:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_inferred_join_invalidates_when_joined_table_is_written(): void @@ -142,7 +129,7 @@ public function test_join_get_star_string_without_explicit_root_select_bypasses_ ->get('*'); $this->assertCount(1, $results); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_expression_join_bypasses_inferred_dependency(): void @@ -155,7 +142,7 @@ public function test_expression_join_bypasses_inferred_dependency(): void ->select('authors.*') ->get(); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_join_with_where_exists_clause_bypasses_auto_inference(): void @@ -181,7 +168,7 @@ public function test_join_with_where_exists_clause_bypasses_auto_inference(): vo ->select('authors.*') ->get(); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_join_with_raw_clause_bypasses_auto_inference(): void @@ -197,7 +184,7 @@ public function test_join_with_raw_clause_bypasses_auto_inference(): void ->select('authors.*') ->get(); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_implicit_join_alias_bypasses_auto_inference(): void @@ -229,7 +216,7 @@ public function test_multiple_joins_all_table_deps_collected_and_invalidated(): ->get(); $this->assertCount(1, $first); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); $country->update(['name' => 'NZ']); @@ -296,7 +283,7 @@ public function test_join_to_non_cacheable_table_infers_table_dep_and_caches(): ->select('authors.*') ->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_corrupt_result_cache_payload_is_treated_as_miss(): void @@ -307,7 +294,7 @@ public function test_corrupt_result_cache_payload_is_treated_as_miss(): void $query = Author::query()->whereHas('posts')->dependsOn([Post::class]); $query->get(); // warm - $resultKey = collect($this->redisKeys('test:result:*'))->first(); + $resultKey = collect($this->redisKeys('result:*'))->first(); Redis::connection('normcache-test')->set($resultKey, 'CORRUPT'); $results = $query->get(); diff --git a/tests/Integration/Cache/ModelCachingTest.php b/tests/Integration/Cache/ModelCachingTest.php index 8243b17..0dbd2f5 100644 --- a/tests/Integration/Cache/ModelCachingTest.php +++ b/tests/Integration/Cache/ModelCachingTest.php @@ -2,7 +2,6 @@ namespace NormCache\Tests\Integration\Cache; -use Illuminate\Support\Facades\DB; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; @@ -20,7 +19,7 @@ public function test_cache_disabled_globally_skips_caching(): void Author::create(['name' => 'Alice']); Author::all(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_flush_command_without_model_flushes_all_keys(): void @@ -28,26 +27,25 @@ public function test_flush_command_without_model_flushes_all_keys(): void Author::create(['name' => 'Alice']); Author::all(); - $this->assertNotEmpty($this->redisKeys('test:*')); + $this->assertNotEmpty($this->redisKeys('*')); $this->artisan('normcache:flush')->assertSuccessful(); - $this->assertEmpty($this->redisKeys('test:*')); + $this->assertEmpty($this->redisKeys('*')); } public function test_flush_command_with_model_flushes_only_that_model(): void { $author = Author::create(['name' => 'Alice']); Author::all(); - Post::create(['title' => 'Hello', 'author_id' => $author->id]); + $post = Post::create(['title' => 'Hello', 'author_id' => $author->id]); Post::all(); $this->artisan('normcache:flush', ['--model' => Author::class])->assertSuccessful(); - $default = DB::getDefaultConnection(); - - $this->assertEmpty($this->redisKeys('test:model:{' . $default . ':authors}:*')); - $this->assertNotEmpty($this->redisKeys('test:model:{' . $default . ':posts}:*')); + // Author entries are unreachable after the version bump; Post cache is unaffected. + $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); + $this->assertNotNull($this->modelCacheEntry(Post::class, $post->id)); } public function test_flush_command_rejects_nonexistent_class(): void diff --git a/tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php b/tests/Integration/Cache/ModelHydratorColdHydrationTest.php similarity index 60% rename from tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php rename to tests/Integration/Cache/ModelHydratorColdHydrationTest.php index bf91794..259a0a1 100644 --- a/tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php +++ b/tests/Integration/Cache/ModelHydratorColdHydrationTest.php @@ -2,21 +2,17 @@ namespace NormCache\Tests\Integration\Cache; -use Illuminate\Database\Eloquent\Builder as EloquentBuilder; -use Illuminate\Database\Query\Expression; -use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use NormCache\Cache\ModelHydrator; use NormCache\Cache\VersionTracker; use NormCache\Support\CacheKeyBuilder; use NormCache\Tests\Fixtures\Models\Author; -use NormCache\Tests\Fixtures\Models\Comment; use NormCache\Tests\Fixtures\Models\InstrumentedPost; use NormCache\Tests\Fixtures\Models\NewFromBuilderOverridingPost; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; -class ModelHydratorClosureColdHydrationTest extends TestCase +class ModelHydratorColdHydrationTest extends TestCase { protected function tearDown(): void { @@ -27,90 +23,13 @@ protected function tearDown(): void private function makeHydrator(): ModelHydrator { - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $keys = new CacheKeyBuilder; $versions = new VersionTracker($store, $keys); return new ModelHydrator($store, $keys, $versions, 3600, true, 5, 200); } - private function invokeGuard(ModelHydrator $hydrator, EloquentBuilder $query): bool - { - $method = new \ReflectionMethod($hydrator, 'overridesNewFromBuilder'); - - return !$method->invoke($hydrator, $query->getModel()); - } - - public function test_guard_allows_simple_model_table_lookup(): void - { - $hydrator = $this->makeHydrator(); - $query = Post::query()->withoutCache(); - - $this->assertTrue($this->invokeGuard($hydrator, $query)); - } - - public function test_guard_allows_query_with_joins(): void - { - $hydrator = $this->makeHydrator(); - $query = Post::query()->withoutCache() - ->join('authors', 'authors.id', '=', 'posts.author_id'); - - $this->assertTrue($this->invokeGuard($hydrator, $query)); - } - - public function test_guard_allows_query_with_unions(): void - { - $hydrator = $this->makeHydrator(); - $query = Post::query()->withoutCache(); - $query->getQuery()->unions = [['query' => Post::query()->getQuery(), 'all' => false]]; - - $this->assertTrue($this->invokeGuard($hydrator, $query)); - } - - public function test_guard_allows_query_with_groups_and_havings(): void - { - $hydrator = $this->makeHydrator(); - $query = Post::query()->withoutCache() - ->groupBy('author_id') - ->havingRaw('count(*) > 0'); - - $this->assertTrue($this->invokeGuard($hydrator, $query)); - } - - public function test_guard_allows_query_with_aggregate(): void - { - $hydrator = $this->makeHydrator(); - $query = Post::query()->withoutCache(); - $query->getQuery()->aggregate = ['function' => 'count', 'columns' => ['*']]; - - $this->assertTrue($this->invokeGuard($hydrator, $query)); - } - - public function test_guard_allows_query_with_custom_select_columns(): void - { - $hydrator = $this->makeHydrator(); - $query = Post::query()->withoutCache()->select('id', 'title'); - - $this->assertTrue($this->invokeGuard($hydrator, $query)); - } - - public function test_guard_allows_non_canonical_from(): void - { - $hydrator = $this->makeHydrator(); - $query = Post::query()->withoutCache(); - $query->getQuery()->from = new Expression('(select * from posts) as p'); - - $this->assertTrue($this->invokeGuard($hydrator, $query)); - } - - public function test_guard_rejects_model_overriding_new_from_builder(): void - { - $hydrator = $this->makeHydrator(); - $query = NewFromBuilderOverridingPost::query()->withoutCache(); - - $this->assertFalse($this->invokeGuard($hydrator, $query)); - } - public function test_simple_cold_miss_uses_closure_hydration_without_calling_set_raw_attributes(): void { $author = Author::create(['name' => 'Dana']); @@ -119,7 +38,7 @@ public function test_simple_cold_miss_uses_closure_hydration_without_calling_set $this->evictModelCache(InstrumentedPost::class, $post->id); $manager = $this->buildManager(); - $models = $manager->getModels([$post->id], InstrumentedPost::class); + $models = $manager->hydrator()->getModels([$post->id], InstrumentedPost::class); $this->assertCount(1, $models); $this->assertSame('Hello', $models[0]->title); @@ -135,7 +54,7 @@ public function test_custom_new_from_builder_override_uses_eloquent_fallback(): $this->evictModelCache(NewFromBuilderOverridingPost::class, $post->id); $manager = $this->buildManager(); - $models = $manager->getModels([$post->id], NewFromBuilderOverridingPost::class); + $models = $manager->hydrator()->getModels([$post->id], NewFromBuilderOverridingPost::class); $this->assertCount(1, $models); $this->assertSame('Custom', $models[0]->title); @@ -178,7 +97,7 @@ public function test_cold_miss_closure_hydration_fires_retrieved_event_regardles }); $manager = $this->buildManager(fireRetrieved: false); - $models = $manager->getModels([$post->id], Post::class); + $models = $manager->hydrator()->getModels([$post->id], Post::class); $this->assertCount(1, $models); $this->assertSame(1, $calls, 'Cold-miss closure hydration must fire retrieved exactly once, matching the previous Eloquent-hydration behavior'); @@ -191,7 +110,7 @@ public function test_connection_name_matches_native_eloquent_after_cold_miss(): $this->evictModelCache(InstrumentedPost::class, $post->id); $manager = $this->buildManager(); - $models = $manager->getModels([$post->id], InstrumentedPost::class); + $models = $manager->hydrator()->getModels([$post->id], InstrumentedPost::class); $native = InstrumentedPost::find($post->id); @@ -199,24 +118,6 @@ public function test_connection_name_matches_native_eloquent_after_cold_miss(): $this->assertSame('testing', $models[0]->getConnectionName()); } - public function test_eager_loading_happens_only_during_finalization_not_inside_optimized_fetch(): void - { - $author = Author::create(['name' => 'Ivy']); - $post = Post::create(['title' => 'WithComments', 'author_id' => $author->id]); - Comment::create(['body' => 'Nice post', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); - - $this->evictModelCache(Post::class, $post->id); - - $this->contract( - cached: fn() => Post::with('comments')->whereKey($post->id)->get(), - native: fn() => Post::withoutCache()->with('comments')->whereKey($post->id)->get(), - ); - - $loaded = Post::with('comments')->whereKey($post->id)->get()->first(); - $this->assertTrue($loaded->relationLoaded('comments')); - $this->assertCount(1, $loaded->comments); - } - public function test_after_query_callback_runs_exactly_once_per_call_on_cold_miss(): void { $author = Author::create(['name' => 'Jack']); @@ -242,40 +143,6 @@ public function test_after_query_callback_runs_exactly_once_per_call_on_cold_mis $this->assertSame(2, $calls, 'callback should run exactly once per get() call, not once per fetched row'); } - public function test_soft_deleted_row_is_not_cached_as_active_model_payload(): void - { - $author = Author::create(['name' => 'Kim']); - $post = Post::create(['title' => 'Trashed', 'author_id' => $author->id]); - $post->delete(); - - $this->evictModelCache(Post::class, $post->id); - - $this->contract( - cached: fn() => Post::withTrashed()->whereKey($post->id)->get(), - native: fn() => Post::withoutCache()->withTrashed()->whereKey($post->id)->get(), - ); - - $this->assertNull($this->modelCacheEntry(Post::class, $post->id), 'soft-deleted rows must not be cached as an active model payload'); - } - - public function test_query_with_join_and_explicit_select_returns_correct_models(): void - { - $author = Author::create(['name' => 'Lyle']); - $post = Post::create(['title' => 'Joined', 'author_id' => $author->id]); - $this->evictModelCache(Post::class, $post->id); - - $this->contract( - cached: fn() => Post::query()->join('authors', 'authors.id', '=', 'posts.author_id') - ->select('posts.*') - ->whereKey($post->id) - ->get(), - native: fn() => Post::withoutCache()->join('authors', 'authors.id', '=', 'posts.author_id') - ->select('posts.*') - ->whereKey($post->id) - ->get(), - ); - } - public function test_query_with_join_and_explicit_select_uses_closure_hydration(): void { $author = Author::create(['name' => 'Nora']); @@ -304,7 +171,7 @@ public function test_cold_miss_with_joined_missed_query_uses_closure_hydration_a $joinedQuery = InstrumentedPost::query()->withoutCache() ->join('authors', 'authors.id', '=', 'posts.author_id'); - $models = $manager->getModels([$post->id], InstrumentedPost::class, null, null, $joinedQuery, true); + $models = $manager->hydrator()->getModels([$post->id], InstrumentedPost::class, null, null, $joinedQuery, true); $this->assertCount(1, $models); $this->assertSame('JoinAmbiguous', $models[0]->title); @@ -325,7 +192,7 @@ public function test_cold_miss_with_grouped_missed_query_returns_one_row_per_req // whereIn(pk, [post1, post2]) on top of this GROUP BY would collapse to one row. $groupedQuery = InstrumentedPost::query()->withoutCache()->groupBy('author_id'); - $models = $manager->getModels([$post1->id, $post2->id], InstrumentedPost::class, null, null, $groupedQuery, true); + $models = $manager->hydrator()->getModels([$post1->id, $post2->id], InstrumentedPost::class, null, null, $groupedQuery, true); $this->assertCount(2, $models, 'Both requested ids must be resolved, not collapsed by the original query\'s GROUP BY'); $titles = array_map(fn($m) => $m->title, $models); @@ -356,7 +223,7 @@ public function test_cold_miss_with_unioned_missed_query_does_not_run_the_union_ ); DB::enableQueryLog(); - $models = $manager->getModels([$wanted->id], InstrumentedPost::class, null, null, $unionedQuery, true); + $models = $manager->hydrator()->getModels([$wanted->id], InstrumentedPost::class, null, null, $unionedQuery, true); $queries = DB::getQueryLog(); DB::disableQueryLog(); @@ -366,21 +233,4 @@ public function test_cold_miss_with_unioned_missed_query_does_not_run_the_union_ $refetchRanAUnion = array_filter($queries, fn($q) => str_contains(strtolower($q['query']), 'union')); $this->assertSame([], $refetchRanAUnion, 'The original union must not be reused for the by-id refetch — it leaves the second arm unconstrained by the requested ids'); } - - public function test_warm_cold_parity_for_casts_dates_json_booleans_and_accessors(): void - { - $author = Author::create(['name' => 'Mona']); - Post::create(['title' => 'Parity', 'author_id' => $author->id, 'published' => true, 'metadata' => ['k' => 'v']]); - - $this->contract( - cached: fn() => Post::where('published', true)->get(), - native: fn() => Post::withoutCache()->where('published', true)->get(), - ); - - $post = Post::where('published', true)->first(); - $this->assertIsBool($post->published); - $this->assertSame(['k' => 'v'], $post->metadata); - $this->assertSame('calculated_value', $post->calculated_field); - $this->assertInstanceOf(Carbon::class, $post->created_at); - } } diff --git a/tests/Integration/Cache/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index 596eae4..0f77f7a 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -5,26 +5,30 @@ use Illuminate\Support\Facades\DB; use NormCache\Cache\ModelHydrator; use NormCache\Cache\VersionTracker; +use NormCache\Enums\LuaStatus; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisScripts; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\TestCase; +use NormCache\Values\ModelFetchContext; class ModelHydratorStampedeTest extends TestCase { public function test_acquires_lock_fetches_once_caches_and_releases_lock_on_miss(): void { - $manager = $this->buildManager(buildingLockTtl: 5, stampedeWaitMs: 50, slotting: true); + $manager = $this->buildManager(buildingLockTtl: 5, stampedeWaitMs: 50); $author = Author::create(['name' => 'Alice']); $this->evictModelCache(Author::class, $author->id); - $keys = new CacheKeyBuilder; + $keys = $manager->keys(); $classKey = $keys->classKey(Author::class); - $lockSuffix = $keys->resultBuildIdentityHash('model', null, (string) $author->id); - $lockKey = $keys->resultBuildingKey($classKey, 'model', $lockSuffix); + $modelVersion = $manager->currentVersion(Author::class); + $lockSegment = 'model:v' . $modelVersion; + $lockSuffix = $keys->resultBuildIdentityHash($lockSegment, null, (string) $author->id); + $lockKey = $keys->resultBuildingKey($classKey, $lockSegment, $lockSuffix); DB::enableQueryLog(); - $models = $manager->getModels([$author->id], Author::class); + $models = $manager->hydrator()->getModels([$author->id], Author::class); $queries = DB::getQueryLog(); DB::disableQueryLog(); @@ -33,24 +37,26 @@ public function test_acquires_lock_fetches_once_caches_and_releases_lock_on_miss $this->assertCount(1, $queries, 'Expected exactly one DB query to hydrate the missed model'); $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertNull($manager->getStore()->getRaw($lockKey), 'Building lock must be released after a miss'); + $this->assertNull($manager->store()->getRaw($lockKey), 'Building lock must be released after a miss'); } public function test_falls_back_to_database_without_releasing_someone_elses_lock(): void { - $manager = $this->buildManager(buildingLockTtl: 5, stampedeWaitMs: 50, slotting: true); + $manager = $this->buildManager(buildingLockTtl: 5, stampedeWaitMs: 50); $author = Author::create(['name' => 'Bob']); $this->evictModelCache(Author::class, $author->id); - $keys = new CacheKeyBuilder; + $keys = $manager->keys(); $classKey = $keys->classKey(Author::class); - $lockSuffix = $keys->resultBuildIdentityHash('model', null, (string) $author->id); - $lockKey = $keys->resultBuildingKey($classKey, 'model', $lockSuffix); + $modelVersion = $manager->currentVersion(Author::class); + $lockSegment = 'model:v' . $modelVersion; + $lockSuffix = $keys->resultBuildIdentityHash($lockSegment, null, (string) $author->id); + $lockKey = $keys->resultBuildingKey($classKey, $lockSegment, $lockSuffix); - $store = $manager->getStore(); + $store = $manager->store(); $this->assertTrue($store->setNxEx($lockKey, 'other-token', 5)); - $models = $manager->getModels([$author->id], Author::class); + $models = $manager->hydrator()->getModels([$author->id], Author::class); $this->assertCount(1, $models); $this->assertSame('Bob', $models[0]->name); @@ -58,23 +64,23 @@ public function test_falls_back_to_database_without_releasing_someone_elses_lock // The lock is held by another process — we must not release it. $this->assertSame('other-token', $store->getRaw($lockKey)); - // Our DB fallback still populates the cache for future requests. - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); + // Non-owner waiters read from DB but must not write cache — that is the lock owner's job. + $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); } public function test_build_status_script_claims_lock_when_unheld(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $classKey = $keys->classKey(Author::class); $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $modelKey = $keys->modelPrefix($classKey) . 'missing-id'; + $modelKey = $keys->modelPrefix($classKey, 0) . 'missing-id'; $result = $store->script( - RedisScripts::get('fetch_model_build_status'), - [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + RedisScripts::get('fetch_batch_build_status'), + [$modelKey, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -86,17 +92,17 @@ public function test_build_status_script_claims_lock_when_unheld(): void public function test_build_status_script_reports_building_when_lock_already_held(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $classKey = $keys->classKey(Author::class); $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $modelKey = $keys->modelPrefix($classKey) . 'missing-id'; + $modelKey = $keys->modelPrefix($classKey, 0) . 'missing-id'; $store->setNxEx($lockKey, 'other-token', 5); $result = $store->script( - RedisScripts::get('fetch_model_build_status'), - [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + RedisScripts::get('fetch_batch_build_status'), + [$modelKey, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -109,17 +115,17 @@ public function test_build_status_script_reports_building_when_lock_already_held public function test_build_status_script_reports_hit_without_claiming_lock_when_recheck_resolves(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $classKey = $keys->classKey(Author::class); $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $modelKey = $keys->modelPrefix($classKey) . 'present-id'; + $modelKey = $keys->modelPrefix($classKey, 0) . 'present-id'; $store->set($modelKey, ['id' => 1, 'name' => 'Present'], 60); $result = $store->script( - RedisScripts::get('fetch_model_build_status'), - [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + RedisScripts::get('fetch_batch_build_status'), + [$modelKey, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -131,34 +137,46 @@ public function test_build_status_script_reports_hit_without_claiming_lock_when_ public function test_fetch_missed_status_resolves_via_retry_mget_without_claiming_lock(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $versions = new VersionTracker($store, $keys); $hydrator = new ModelHydrator($store, $keys, $versions, 3600, false, 5, 200); $author = Author::create(['name' => 'Carol']); $classKey = $keys->classKey(Author::class); - $modelKey = $keys->modelPrefix($classKey) . $author->id; + $version = $versions->normalizeVersion($store->getRaw($keys->verKey($classKey))); + $modelKey = $keys->modelPrefix($classKey, $version) . $author->id; $store->set($modelKey, $author->getRawOriginal(), 3600); $lockKey = $keys->resultBuildingKey($classKey, 'model', 'test-lock'); - $hits = []; + + $context = new ModelFetchContext( + modelClass: Author::class, + classKey: $classKey, + projection: null, + prototype: null, + missedQuery: null, + preserveQueryShape: true, + modelVersion: $version, + ); + $context->lockKey = $lockKey; + $context->wakeKey = $keys->wakeKey($classKey, 'test-lock'); + $context->token = 'token'; $method = new \ReflectionMethod($hydrator, 'fetchMissedStatus'); - $args = [[$author->id], Author::class, $classKey, null, null, $lockKey, $keys->wakeKey($classKey, 'test-lock'), 'token', &$hits]; - [$status, $missed] = $method->invokeArgs($hydrator, $args); + [$status, $missed] = $method->invokeArgs($hydrator, [[$author->id], $context]); - $this->assertSame('hit', $status); + $this->assertSame(LuaStatus::Hit, $status); $this->assertSame([], $missed); - $this->assertArrayHasKey($author->id, $hits); - $this->assertSame('Carol', $hits[$author->id]->name); + $this->assertArrayHasKey($author->id, $context->hits); + $this->assertSame('Carol', $context->hits[$author->id]->name); $this->assertNull($store->getRaw($lockKey), 'Must not claim the build lock when the retry MGET already resolves the miss'); } public function test_build_status_script_all_hit_across_multiple_mget_chunks(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $classKey = $keys->classKey(Author::class); @@ -168,14 +186,14 @@ public function test_build_status_script_all_hit_across_multiple_mget_chunks(): $count = 1200; $modelKeys = []; for ($i = 0; $i < $count; $i++) { - $modelKey = $keys->modelPrefix($classKey) . "present-{$i}"; + $modelKey = $keys->modelPrefix($classKey, 0) . "present-{$i}"; $store->set($modelKey, ['id' => $i], 60); $modelKeys[] = $modelKey; } $result = $store->script( - RedisScripts::get('fetch_model_build_status'), - [...$modelKeys, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + RedisScripts::get('fetch_batch_build_status'), + [...$modelKeys, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -193,7 +211,7 @@ public function test_build_status_script_all_hit_across_multiple_mget_chunks(): public function test_build_status_script_partial_miss_across_chunk_boundary(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $classKey = $keys->classKey(Author::class); @@ -205,7 +223,7 @@ public function test_build_status_script_partial_miss_across_chunk_boundary(): v $missingIndex = 500; $modelKeys = []; for ($i = 0; $i < $count; $i++) { - $modelKey = $keys->modelPrefix($classKey) . "key-{$i}"; + $modelKey = $keys->modelPrefix($classKey, 0) . "key-{$i}"; if ($i !== $missingIndex) { $store->set($modelKey, ['id' => $i], 60); } @@ -213,8 +231,8 @@ public function test_build_status_script_partial_miss_across_chunk_boundary(): v } $result = $store->script( - RedisScripts::get('fetch_model_build_status'), - [...$modelKeys, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + RedisScripts::get('fetch_batch_build_status'), + [...$modelKeys, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); diff --git a/tests/Integration/Cache/OptimizationsTest.php b/tests/Integration/Cache/OptimizationsTest.php index 62d12d3..22d0d93 100644 --- a/tests/Integration/Cache/OptimizationsTest.php +++ b/tests/Integration/Cache/OptimizationsTest.php @@ -57,9 +57,9 @@ public function test_lua_retrieval_stores_json_and_fetches_in_one_go() Author::where('name', 'Lua Author')->get(); $redis = Redis::connection(config('normcache.connection')); - $prefix = config('normcache.key_prefix'); + $manager = app('normcache'); - $keys = $redis->keys($prefix . 'query:*'); + $keys = $redis->keys($manager->keys()->prefixed('query:*')); $this->assertNotEmpty($keys); $value = $redis->get($keys[0]); @@ -81,7 +81,7 @@ public function test_query_hit_fast_path_returns_model_payload_arrays(): void $query = Author::where('name', 'Payload Author'); $hash = QueryHasher::forNormalizedQuery($query, $query->toBase()); - $result = app('normcache')->getModelsFromQuery(Author::class, $hash); + $result = app('normcache')->queries()->fetch(Author::class, $hash, null, [], []); $this->assertSame(CacheStatus::Hit, $result->status); $this->assertSame([(string) $author->id], $result->ids); @@ -98,11 +98,14 @@ public function test_corrupt_query_cache_payload_degrades_to_miss_and_repairs(): $query->get(); $hash = QueryHasher::forNormalizedQuery($query, $query->toBase()); - $classKey = app('normcache')->classKey(Author::class); + $classKey = app('normcache')->keys()->classKey(Author::class); $version = app('normcache')->currentVersion(Author::class); + $manager = app('normcache'); + $store = $manager->store(); + $fullQueryKey = $manager->keys()->prefixed("query:{$classKey}:v{$version}:{$hash}"); Redis::connection(config('normcache.connection'))->set( - config('normcache.key_prefix') . "query:{{$classKey}}:v{$version}:{$hash}", + $fullQueryKey, '{not-json' ); @@ -113,20 +116,11 @@ public function test_corrupt_query_cache_payload_degrades_to_miss_and_repairs(): $this->assertCount(1, $found); Event::assertDispatched(QueryCacheMiss::class); - $raw = app('normcache')->getStore()->getRaw("query:{{$classKey}}:v{$version}:{$hash}"); + $raw = $store->getRaw($fullQueryKey); $repaired = $raw !== null ? json_decode($raw, true) : null; $this->assertSame([(string) $found->first()->id], $repaired); } - public function test_empty_query_result_warm_hit_stays_empty(): void - { - $first = Author::where('name', 'Missing Author')->get(); - $second = Author::where('name', 'Missing Author')->get(); - - $this->assertCount(0, $first); - $this->assertCount(0, $second); - } - public function test_multi_dependency_query_corrupt_payload_degrades_to_miss_and_repairs(): void { $this->setClusterMode(false); @@ -135,7 +129,7 @@ public function test_multi_dependency_query_corrupt_payload_degrades_to_miss_and Author::query()->dependsOn([Post::class])->get(); - $queryKey = collect($this->redisKeys('test:query:*'))->first(); + $queryKey = collect($this->redisKeys('query:*'))->first(); $this->assertNotNull($queryKey); Redis::connection(config('normcache.connection'))->set($queryKey, '{not-json'); @@ -147,9 +141,8 @@ public function test_multi_dependency_query_corrupt_payload_degrades_to_miss_and $this->assertCount(1, $found); Event::assertDispatched(QueryCacheMiss::class); - $raw = app('normcache')->getStore()->getRaw( - str_replace(config('normcache.key_prefix'), '', $queryKey) - ); + $store = app('normcache')->store(); + $raw = $store->getRaw($queryKey); $repaired = $raw !== null ? json_decode($raw, true) : null; $this->assertSame([(string) $found->first()->id], $repaired); } @@ -244,10 +237,6 @@ public function test_where_key_ignores_fast_path_when_extra_dependencies_exist() $builder = Author::whereKey($a1->id)->dependsOn([Post::class]); $builder->get(); - if ($this->cacheManager()->isSlotting()) { - $this->assertNotEmpty($this->redisKeys('test:result:*')); - } else { - $this->assertNotEmpty($this->redisKeys('test:query:*')); - } + $this->assertNotEmpty($this->redisKeys('query:*')); } } diff --git a/tests/Integration/Cache/PivotCacheTest.php b/tests/Integration/Cache/PivotCacheTest.php index 5b5a7d0..2ce669a 100644 --- a/tests/Integration/Cache/PivotCacheTest.php +++ b/tests/Integration/Cache/PivotCacheTest.php @@ -93,7 +93,7 @@ public function test_eager_load_populates_pivot_cache(): void Author::with('tags')->get(); - $this->assertNotEmpty($this->redisKeys('test:pivot:*')); + $this->assertNotEmpty($this->redisKeys('pivot:*')); } public function test_belongs_to_many_warm_hit_zero_sql(): void @@ -114,20 +114,16 @@ public function test_belongs_to_many_warm_hit_zero_sql(): void public function test_pivot_eager_load_falls_back_to_database_when_build_lock_is_held_elsewhere(): void { - if ($this->cacheManager()->isSlotting()) { - $this->markTestSkipped('Pivot caching does not claim a build lock in slotting mode — see ClusterModeTest'); - } - $author = Author::create(['name' => 'Alice']); $tag = Tag::create(['name' => 'Fiction']); $author->tags()->attach($tag->id); $constraintHash = $this->callConstraintHash($author->tags()); - $pivotTableKey = NormCache::tableKey($author->getConnection()->getName(), 'author_tag'); + $pivotTableKey = NormCache::keys()->tableKey($author->getConnection()->getName(), 'author_tag'); // Claim the same build lock another concurrent request would, before the eager load runs. - $claimed = NormCache::getPivotCache(Author::class, Tag::class, 'tags', [$author->id], $constraintHash, $pivotTableKey); - $this->assertNotNull($claimed->buildingKey); + $claimed = NormCache::results()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], $constraintHash, $pivotTableKey); + $this->assertNotNull($claimed->build->buildingKey); DB::enableQueryLog(); $authors = Author::with('tags')->get(); @@ -138,8 +134,8 @@ public function test_pivot_eager_load_falls_back_to_database_when_build_lock_is_ $this->assertCount(1, $pivotQueries, 'Should fall back to a single direct DB query for the pivot relation while its build lock is held elsewhere'); $this->assertSame(['Fiction'], $authors->first()->tags->pluck('name')->all()); $this->assertSame( - $claimed->buildingToken, - NormCache::getStore()->getRaw($claimed->buildingKey), + $claimed->build->buildingToken, + NormCache::store()->getRaw($claimed->build->buildingKey), 'The foreign build lock must remain untouched' ); } @@ -150,7 +146,7 @@ public function test_empty_relationship_is_cached(): void Author::with('tags')->get(); - $this->assertNotEmpty($this->redisKeys('test:pivot:*')); + $this->assertNotEmpty($this->redisKeys('pivot:*')); $authors = Author::with('tags')->get(); @@ -240,7 +236,7 @@ public function test_parent_version_bump_does_not_invalidate_pivot_membership_ca $author->tags()->get(); - $keyCountAfterWarm = count($this->redisKeys('test:pivot:*')); + $keyCountAfterWarm = count($this->redisKeys('pivot:*')); $author->update(['name' => 'Alice Updated']); @@ -250,7 +246,7 @@ public function test_parent_version_bump_does_not_invalidate_pivot_membership_ca DB::disableQueryLog(); $this->assertEmpty($queries); - $this->assertSame($keyCountAfterWarm, count($this->redisKeys('test:pivot:*'))); + $this->assertSame($keyCountAfterWarm, count($this->redisKeys('pivot:*'))); $this->assertSame(['Fiction'], $tags->pluck('name')->all()); } @@ -321,7 +317,7 @@ public function test_pivot_relation_with_extra_join_delegates_to_eloquent(): voi ->get(); $this->assertSame([$tag->id], $tags->modelKeys()); - $this->assertEmpty($this->redisKeys('test:pivot:*')); + $this->assertEmpty($this->redisKeys('pivot:*')); } public function test_pivot_cache_ordered_eager_loads_do_not_collide(): void @@ -538,10 +534,40 @@ public function test_pivot_cache_used_when_projection_is_table_wildcard(): void DB::disableQueryLog(); $this->assertSame([$tag->id], $tags->modelKeys()); - $this->assertNotEmpty($this->redisKeys('test:pivot:*'), 'pivot cache should be used for table.* projection'); + $this->assertNotEmpty($this->redisKeys('pivot:*'), 'pivot cache should be used for table.* projection'); $this->assertEmpty(array_filter($queries, fn($q) => str_contains($q['query'], 'author_tag')), 'pivot query should be cached'); } + public function test_empty_parent_has_relation_loaded_on_warm_hit(): void + { + $alice = Author::create(['name' => 'Alice']); + $bob = Author::create(['name' => 'Bob']); + $tag = Tag::create(['name' => 'Fiction']); + $alice->tags()->attach($tag->id); + + // Warm: both authors loaded; Bob has no tags. + Author::with('tags')->get(); + + DB::enableQueryLog(); + $authors = Author::with('tags')->get(); + $queries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertEmpty($queries, 'warm hit should issue no SQL'); + + $bobLoaded = $authors->firstWhere('name', 'Bob'); + $this->assertTrue($bobLoaded->relationLoaded('tags'), 'empty-parent relation must be marked loaded'); + $this->assertCount(0, $bobLoaded->tags); + + // Accessing the relation must not trigger a lazy load. + DB::enableQueryLog(); + $_ = $bobLoaded->tags->all(); + $lazyQueries = DB::getQueryLog(); + DB::disableQueryLog(); + + $this->assertEmpty($lazyQueries, 'accessing loaded empty relation must not issue SQL'); + } + public function test_belongs_to_many_remains_fast_path(): void { $author = Author::create(['name' => 'Alice']); @@ -571,7 +597,7 @@ public function test_pivot_table_wildcard_plus_extra_column_does_not_pollute_mod $author->tags()->select('tags.*')->selectRaw('1 as polluted')->get(); // The model cache should NOT contain 'polluted' - $cached = NormCache::getModels([$tag->id], Tag::class); + $cached = NormCache::hydrator()->getModels([$tag->id], Tag::class); $this->assertArrayNotHasKey('polluted', collect($cached)->first()->getRawOriginal()); } @@ -583,7 +609,7 @@ public function test_corrupt_pivot_cache_entries_are_treated_as_miss(): void Author::with('tags')->get(); // warm pivot cache - $keys = $this->redisKeys('test:pivot:*'); + $keys = $this->redisKeys('pivot:*'); $this->assertNotEmpty($keys); $pivotKey = $keys[0]; @@ -617,6 +643,6 @@ public function test_pivot_with_explicit_dependencies_bypasses_pivot_cache(): vo $author->tags()->dependsOn([Post::class])->get(); - $this->assertEmpty($this->redisKeys('test:pivot:*')); + $this->assertEmpty($this->redisKeys('pivot:*')); } } diff --git a/tests/Integration/Cache/PivotStampedeTest.php b/tests/Integration/Cache/PivotStampedeTest.php index 6ece698..bf8c023 100644 --- a/tests/Integration/Cache/PivotStampedeTest.php +++ b/tests/Integration/Cache/PivotStampedeTest.php @@ -17,11 +17,11 @@ public function test_concurrent_pivot_miss_second_caller_sees_building_status(): $author = Author::create(['name' => 'Alice']); Tag::create(['name' => 'Fiction']); - $first = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); - $second = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); + $first = $manager->results()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', null); + $second = $manager->results()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', null); $this->assertSame(CacheStatus::Miss, $first->status); - $this->assertNotNull($first->buildingKey); + $this->assertNotNull($first->build->buildingKey); $this->assertSame(CacheStatus::Building, $second->status); } @@ -31,24 +31,20 @@ public function test_pivot_build_lock_is_released_after_store_and_visible_to_nex $author = Author::create(['name' => 'Alice']); $tag = Tag::create(['name' => 'Fiction']); - $miss = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); + $miss = $manager->results()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', null); $this->assertSame(CacheStatus::Miss, $miss->status); - $keys = new CacheKeyBuilder; + $keys = $manager->keys(); $pivotKey = $keys->pivotKey($keys->classKey(Author::class), $keys->classKey(Tag::class), 'tags', 'nc', $miss->seg, $author->id); - $manager->storeManyVersionedResults( + $manager->results()->storeMany( [$pivotKey => [['id' => $tag->id, 'pivot' => ['author_id' => $author->id, 'tag_id' => $tag->id]]]], - versionKeys: $miss->versionKeys, - expectedVersions: $miss->expectedVersions, - buildingKey: $miss->buildingKey, - wakeKey: $miss->wakeKey, - buildingToken: $miss->buildingToken, + build: $miss->build, ); - $this->assertNull($manager->getStore()->getRaw($miss->buildingKey), 'Building lock must be released after store'); + $this->assertNull($manager->store()->getRaw($miss->build->buildingKey), 'Building lock must be released after store'); - $hit = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); + $hit = $manager->results()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', null); $this->assertSame(CacheStatus::Hit, $hit->status); $this->assertSame([$tag->id], array_column($hit->data[$author->id], 'id')); } @@ -59,32 +55,32 @@ public function test_pivot_falls_back_to_database_without_releasing_someone_else $author = Author::create(['name' => 'Bob']); Tag::create(['name' => 'Fiction']); - $miss = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); + $miss = $manager->results()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], 'nc', null); $this->assertSame(CacheStatus::Miss, $miss->status); - $store = $manager->getStore(); + $store = $manager->store(); // Someone else now holds the lock under a different token. - $store->delete($miss->buildingKey); - $this->assertTrue($store->setNxEx($miss->buildingKey, 'other-token', 5)); + $store->delete($miss->build->buildingKey); + $this->assertTrue($store->setNxEx($miss->build->buildingKey, 'other-token', 5)); - $waited = $manager->waitForPivotBuild(Author::class, Tag::class, 'tags', [$author->id], 'nc', null); + $waited = $manager->results()->waitForPivotBuild(Author::class, Tag::class, 'tags', [$author->id], 'nc', null); $this->assertNull($waited, 'Waiting must time out and report null while the lock is held by someone else'); - $this->assertSame('other-token', $store->getRaw($miss->buildingKey), 'Must not release a lock held by another process'); + $this->assertSame('other-token', $store->getRaw($miss->build->buildingKey), 'Must not release a lock held by another process'); } public function test_pivot_build_status_script_claims_lock_when_unheld(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $lockKey = $keys->resultBuildingKey('cls', 'v1', 'test-lock'); $wakeKey = $keys->wakeKey('cls', 'test-lock'); - $pivotKey = 'pivot:missing:1'; + $pivotKey = $keys->prefixed('pivot:missing:1'); $result = $store->script( - RedisScripts::get('fetch_pivot_build_status'), + RedisScripts::get('fetch_batch_build_status'), [$pivotKey, $lockKey, $wakeKey], ['token', '5'] ); @@ -97,16 +93,16 @@ public function test_pivot_build_status_script_claims_lock_when_unheld(): void public function test_pivot_build_status_script_reports_building_when_lock_already_held(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $lockKey = $keys->resultBuildingKey('cls', 'v1', 'test-lock'); $wakeKey = $keys->wakeKey('cls', 'test-lock'); - $pivotKey = 'pivot:missing:1'; + $pivotKey = $keys->prefixed('pivot:missing:1'); $store->setNxEx($lockKey, 'other-token', 5); $result = $store->script( - RedisScripts::get('fetch_pivot_build_status'), + RedisScripts::get('fetch_batch_build_status'), [$pivotKey, $lockKey, $wakeKey], ['token', '5'] ); @@ -119,16 +115,16 @@ public function test_pivot_build_status_script_reports_building_when_lock_alread public function test_pivot_build_status_script_reports_hit_without_claiming_lock_when_recheck_resolves(): void { $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $keys = new CacheKeyBuilder; $lockKey = $keys->resultBuildingKey('cls', 'v1', 'test-lock'); $wakeKey = $keys->wakeKey('cls', 'test-lock'); - $pivotKey = 'pivot:present:1'; + $pivotKey = $keys->prefixed('pivot:present:1'); $store->setRaw($pivotKey, $store->serialize([['id' => 1]]), 60); $result = $store->script( - RedisScripts::get('fetch_pivot_build_status'), + RedisScripts::get('fetch_batch_build_status'), [$pivotKey, $lockKey, $wakeKey], ['token', '5'] ); diff --git a/tests/Integration/Cache/ProjectionBypassTest.php b/tests/Integration/Cache/ProjectionBypassTest.php index 1d8ce8f..64988c7 100644 --- a/tests/Integration/Cache/ProjectionBypassTest.php +++ b/tests/Integration/Cache/ProjectionBypassTest.php @@ -62,7 +62,7 @@ public function test_belongs_to_bypasses_when_owner_key_absent_from_projection() ->map(fn($p) => $p->author?->name)->all(); $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('test:query:{authors}:*'), 'bypassed BelongsTo must not write query cache'); + $this->assertEmpty($this->redisKeys('query:testing:authors:*'), 'bypassed BelongsTo must not write query cache'); } public function test_belongs_to_bypasses_when_owner_key_is_aliased(): void @@ -80,7 +80,7 @@ public function test_belongs_to_bypasses_when_owner_key_is_aliased(): void ->get()->map(fn($p) => $p->author?->name)->all(); $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('test:query:{authors}:*')); + $this->assertEmpty($this->redisKeys('query:testing:authors:*')); } // ── BelongsToMany ───────────────────────────────────────────────────────── @@ -99,7 +99,7 @@ public function test_pivot_bypasses_when_related_pk_absent_from_projection(): vo ->map(fn($a) => $a->tags->pluck('name')->all())->all(); $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('test:pivot:*'), 'bypassed pivot must not write pivot cache'); + $this->assertEmpty($this->redisKeys('pivot:*'), 'bypassed pivot must not write pivot cache'); } public function test_pivot_uses_cache_when_qualified_related_pk_present(): void @@ -115,7 +115,7 @@ public function test_pivot_uses_cache_when_qualified_related_pk_present(): void $cold = Author::with(['tags' => fn($q) => $q->select('tags.id', 'name')])->get() ->map(fn($a) => $a->tags->pluck('name')->all())->all(); - $this->assertNotEmpty($this->redisKeys('test:pivot:*'), 'qualified PK projection must populate pivot cache'); + $this->assertNotEmpty($this->redisKeys('pivot:*'), 'qualified PK projection must populate pivot cache'); $this->assertSame($native, $cold); $warm = Author::with(['tags' => fn($q) => $q->select('tags.id', 'name')])->get() @@ -140,7 +140,7 @@ public function test_through_bypasses_when_related_pk_absent_from_projection(): ->map(fn($c) => $c->posts->pluck('title')->all())->all(); $this->assertSame($native, $result); - $this->assertEmpty($this->redisKeys('test:through:*'), 'bypassed through must not write through cache'); + $this->assertEmpty($this->redisKeys('through:*'), 'bypassed through must not write through cache'); } public function test_through_uses_cache_when_qualified_related_pk_present(): void @@ -156,7 +156,7 @@ public function test_through_uses_cache_when_qualified_related_pk_present(): voi $cold = Country::with(['posts' => fn($q) => $q->select('posts.id', 'posts.title')])->get() ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - $this->assertNotEmpty($this->redisKeys('test:through:*'), 'qualified PK projection must populate through cache'); + $this->assertNotEmpty($this->redisKeys('through:*'), 'qualified PK projection must populate through cache'); $this->assertSame($native, $cold); $warm = Country::with(['posts' => fn($q) => $q->select('posts.id', 'posts.title')])->get() @@ -178,7 +178,7 @@ public function test_through_uses_cache_when_projection_is_table_wildcard(): voi $cold = Country::with(['posts' => fn($q) => $q->select('posts.*')])->get() ->map(fn($c) => $c->posts->pluck('title')->sort()->values()->all())->all(); - $this->assertNotEmpty($this->redisKeys('test:through:*'), 'table.* projection must populate the through-relation cache'); + $this->assertNotEmpty($this->redisKeys('through:*'), 'table.* projection must populate the through-relation cache'); $this->assertSame($native, $cold); $warm = Country::with(['posts' => fn($q) => $q->select('posts.*')])->get() diff --git a/tests/Integration/Cache/ScalarCollisionTest.php b/tests/Integration/Cache/ScalarCollisionTest.php index 934ea21..12c5054 100644 --- a/tests/Integration/Cache/ScalarCollisionTest.php +++ b/tests/Integration/Cache/ScalarCollisionTest.php @@ -73,7 +73,7 @@ public function test_raw_expression_scalar_bypasses_cache(): void $sum = Post::sum(DB::raw('views + 1')); $this->assertEquals(11, $sum); - $this->assertEmpty($this->redisKeys('test:scalar:*')); + $this->assertEmpty($this->redisKeys('scalar:*')); } public function test_sum_and_avg_same_column_do_not_share_cache_entry(): void diff --git a/tests/Integration/Cache/SimplificationTest.php b/tests/Integration/Cache/SimplificationTest.php index 908406e..ae762c0 100644 --- a/tests/Integration/Cache/SimplificationTest.php +++ b/tests/Integration/Cache/SimplificationTest.php @@ -20,8 +20,8 @@ public function test_simple_query_uses_normalized_cache(): void // First call - miss Author::query()->get(); - $this->assertNotEmpty($this->redisKeys('test:query:*'), 'Query key should be created'); - $this->assertEmpty($this->redisKeys('test:result:*'), 'Result key should not be created'); + $this->assertNotEmpty($this->redisKeys('query:*'), 'Query key should be created'); + $this->assertEmpty($this->redisKeys('result:*'), 'Result key should not be created'); // Second call - hit DB::enableQueryLog(); @@ -34,17 +34,13 @@ public function test_simple_query_uses_normalized_cache(): void public function test_simple_query_with_depends_on_keeps_normalized_cache_and_honors_versions(): void { - if ($this->cacheManager()->isSlotting()) { - $this->markTestSkipped('In slotting mode, multi-dependency queries route to result cache — see ClusterModeTest'); - } - $author = Author::create(['name' => 'Alice']); // First call - miss Author::query()->dependsOn([Post::class])->get(); - $this->assertNotEmpty($this->redisKeys('test:query:*'), 'Should use normalized query cache'); - $this->assertEmpty($this->redisKeys('test:result:*'), 'Should not use result cache'); + $this->assertNotEmpty($this->redisKeys('query:*'), 'Should use normalized query cache'); + $this->assertEmpty($this->redisKeys('result:*'), 'Should not use result cache'); // Second call - hit DB::enableQueryLog(); @@ -70,7 +66,7 @@ public function test_complex_query_with_depends_on_uses_result_cache(): void // First call - miss Author::whereHas('posts')->dependsOn([Post::class])->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*'), 'Should use result cache'); + $this->assertNotEmpty($this->redisKeys('result:*'), 'Should use result cache'); } public function test_simple_aggregate_uses_result_with_inferred_dependencies(): void @@ -81,7 +77,7 @@ public function test_simple_aggregate_uses_result_with_inferred_dependencies(): // withCount('posts') should infer Post dependency Author::withCount('posts')->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*'), 'Simple aggregate should use result cache'); + $this->assertNotEmpty($this->redisKeys('result:*'), 'Simple aggregate should use result cache'); // Verify it hits DB::enableQueryLog(); diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php new file mode 100644 index 0000000..f1f77fd --- /dev/null +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -0,0 +1,272 @@ +space('content'); + + $this->assertSame('content', $builder->getSpace()); + } + + public function test_space_is_null_by_default(): void + { + $this->assertNull(Post::query()->getSpace()); + } + + public function test_cross_space_dependency_bypasses(): void + { + $plan = SpacedPost::query() + ->dependsOn([Author::class]) + ->cachePlan(SpacedPost::query()->toBase(), CachePlanContext::models()); + + $this->assertFalse($plan->isCacheable(), 'SpacedPost(content) depending on Author(default) must bypass'); + $this->assertSame(CacheOperation::Models, $plan->operation); + $this->assertTrue($plan->hasBypassReason('space')); + $this->assertFalse($plan->hasBypassReason('dependency')); + } + + public function test_same_space_dependency_still_caches(): void + { + // No declared spaces on Post/Author → both in default → no cross-space bypass. + $plan = Post::query() + ->dependsOn([Author::class]) + ->cachePlan(Post::query()->toBase(), CachePlanContext::models()); + + $this->assertTrue($plan->isCacheable(), 'default-space deps must not be downgraded'); + } + + public function test_named_space_query_can_cache_with_raw_table_dependency(): void + { + $author = SpacedAuthor::create(['name' => 'Alice']); + SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); + + $query = fn() => SpacedPost::query() + ->join('authors', 'authors.id', '=', 'posts.author_id') + ->where('authors.name', 'Alice') + ->select('posts.*') + ->dependsOnTables(['authors']) + ->get(); + + $this->assertSame(['Hello'], $query()->pluck('title')->all()); + $this->assertNotEmpty( + $this->cacheManager()->store()->scanPattern('{nc:content}:test:result:*'), + 'named-space raw table dependencies should cache under the active space', + ); + + DB::table('authors')->where('id', $author->id)->update(['name' => 'Bob']); + $this->cacheManager()->invalidateTableVersion('testing', 'authors'); + + $this->assertSame([], $query()->pluck('title')->all()); + } + + public function test_cacheable_plan_carries_the_resolved_space(): void + { + $plan = SpacedPost::query()->cachePlan( + SpacedPost::query()->toBase(), + CachePlanContext::models(), + ); + + $this->assertTrue($plan->isCacheable()); + $this->assertSame('content', $plan->space?->name); + } + + public function test_default_model_plan_carries_default_space(): void + { + $plan = Post::query()->cachePlan( + Post::query()->toBase(), + CachePlanContext::models(), + ); + + $this->assertSame('default', $plan->space?->name); + } + + public function test_spaced_model_query_writes_keys_under_its_space_tag(): void + { + Post::create(['title' => 'Hello', 'author_id' => 1]); + + SpacedPost::query()->get(); + + $store = $this->cacheManager()->store(); + + $this->assertNotEmpty( + $store->scanPattern('{nc:content}:*'), + 'SpacedPost (content) must write keys under the {nc:content} hash tag', + ); + } + + public function test_spaced_model_write_invalidates_its_space_cache(): void + { + SpacedPost::create(['title' => 'First', 'author_id' => 1]); + + $this->assertSame('First', SpacedPost::query()->get()->first()->title); + + $post = SpacedPost::query()->get()->first(); + $post->update(['title' => 'Second']); + + $this->assertSame( + 'Second', + SpacedPost::query()->get()->first()->title, + 'content-space cache must invalidate when a content model is written', + ); + } + + public function test_current_version_reads_spaced_model_home_space(): void + { + $before = $this->cacheManager()->currentVersion(SpacedPost::class); + + $this->cacheManager()->forceFlushModel(SpacedPost::class); + + $this->assertGreaterThan($before, $this->cacheManager()->currentVersion(SpacedPost::class)); + } + + public function test_simple_through_relation_caches_under_related_space(): void + { + $country = ReportingCountry::create(['name' => 'Australia']); + $author = SpacedAuthor::create(['name' => 'Alice', 'country_id' => $country->id]); + SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); + + $this->assertSame(['Hello'], $country->spacedPosts()->get()->pluck('title')->all()); + + $store = $this->cacheManager()->store(); + $this->assertNotEmpty($store->scanPattern('{nc:content}:test:through:*')); + $this->assertEmpty($store->scanPattern('{nc}:test:through:*')); + } + + public function test_non_simple_through_relation_bypasses_when_through_model_is_in_another_space(): void + { + $country = ReportingCountry::create(['name' => 'Australia']); + $author = ReportingAuthor::create(['name' => 'Alice', 'country_id' => $country->id]); + SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); + + $posts = $country->crossSpacePosts() + ->dependsOn([SpacedPost::class]) + ->get(); + + $this->assertSame(['Hello'], $posts->pluck('title')->all()); + $this->assertEmpty($this->cacheManager()->store()->scanPattern('{nc:content}:test:through:*')); + } + + public function test_spaced_pivot_relation_invalidates_when_pivot_table_changes(): void + { + $post = SpacedPost::create(['title' => 'First', 'author_id' => 1]); + $firstTag = CatalogTag::create(['name' => 'First']); + $secondTag = CatalogTag::create(['name' => 'Second']); + $post->catalogTags()->attach($firstTag->id); + + $first = SpacedPost::query()->with('catalogTags')->get()->first()->catalogTags->pluck('name')->all(); + $this->assertSame(['First'], $first); + $this->assertNotEmpty($this->cacheManager()->store()->scanPattern('{nc:catalog}:test:pivot:*')); + + $post->catalogTags()->attach($secondTag->id); + + $second = SpacedPost::query()->with('catalogTags')->get()->first()->catalogTags->pluck('name')->sort()->values()->all(); + $this->assertSame(['First', 'Second'], $second); + } + + public function test_flush_all_removes_spaced_keys(): void + { + SpacedPost::create(['title' => 'First', 'author_id' => 1]); + + SpacedPost::query()->get(); + + $store = $this->cacheManager()->store(); + $this->assertNotEmpty($store->scanPattern('{nc:content}:test:*')); + + $this->cacheManager()->flushAll(); + + $this->assertEmpty($store->scanPattern('{nc:content}:test:*')); + } + + public function test_flush_tag_removes_spaced_tagged_keys(): void + { + SpacedPost::create(['title' => 'First', 'author_id' => 1]); + + SpacedPost::query()->tag('homepage')->get(); + + $store = $this->cacheManager()->store(); + $this->assertNotEmpty($store->scanPattern('{nc:content}:test:query:*:homepage:*')); + + $this->cacheManager()->flushTag(SpacedPost::class, 'homepage'); + + $this->assertEmpty($store->scanPattern('{nc:content}:test:query:*:homepage:*')); + } + + public function test_flush_tag_across_models_removes_spaced_tagged_keys(): void + { + SpacedPost::create(['title' => 'First', 'author_id' => 1]); + + SpacedPost::query()->tag('deploy')->get(); + + $store = $this->cacheManager()->store(); + $this->assertNotEmpty($store->scanPattern('{nc:content}:test:query:*:deploy:*')); + + $this->cacheManager()->flushTagAcrossModels('deploy'); + + $this->assertEmpty($store->scanPattern('{nc:content}:test:query:*:deploy:*')); + } + + public function test_explain_shows_the_resolved_space(): void + { + $this->assertStringContainsString('[space: content]', SpacedPost::query()->explain()); + $this->assertStringNotContainsString('[space:', Post::query()->explain()); + } + + public function test_explain_reports_cross_space_bypass(): void + { + $explain = SpacedPost::query()->dependsOn([Author::class])->explain(); + + $this->assertStringContainsString('cross-space', $explain); + } + + public function test_co_located_relation_eager_load_caches_under_the_space_tag(): void + { + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'Hi', 'author_id' => $author->id]); + + $post = SpacedPost::query()->with('spacedAuthor')->get()->first(); + + $this->assertSame('Ann', $post->spacedAuthor->name); + + $store = $this->cacheManager()->store(); + $authorKeys = $store->scanPattern('{nc:content}:test:model:*authors*'); + + $this->assertNotEmpty( + $authorKeys, + 'co-located belongsTo (content) must cache the related model under {nc:content}', + ); + } + + public function test_pivot_invalidation_only_bumps_spaces_of_involved_models(): void + { + $registry = app(CacheSpaceRegistry::class); + $registry->space('content'); + $registry->space('catalog'); + $registry->space('reporting'); + + $store = $this->cacheManager()->store(); + + $post = SpacedPost::create(['title' => 'Draft', 'author_id' => 1]); + $tag = CatalogTag::create(['name' => 'PHP']); + $post->catalogTags()->attach($tag->id); + + $this->assertNotEmpty($store->scanPattern('{nc:content}:test:ver:*taggables*')); + $this->assertNotEmpty($store->scanPattern('{nc:catalog}:test:ver:*taggables*')); + $this->assertEmpty($store->scanPattern('{nc:reporting}:test:ver:*taggables*')); + } +} diff --git a/tests/Integration/Cache/StreamingOperationsTest.php b/tests/Integration/Cache/StreamingOperationsTest.php index 56bac07..e076390 100644 --- a/tests/Integration/Cache/StreamingOperationsTest.php +++ b/tests/Integration/Cache/StreamingOperationsTest.php @@ -24,7 +24,7 @@ public function test_chunk_does_not_write_query_cache_keys(): void Author::orderBy('id')->chunk(1, fn() => null); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_chunk_sees_fresh_data_after_version_bump(): void @@ -96,6 +96,6 @@ public function test_sole_does_not_populate_the_query_cache(): void Author::create(['name' => 'Alice']); Author::where('name', 'Alice')->sole(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } } diff --git a/tests/Integration/Cache/ThroughRelationTest.php b/tests/Integration/Cache/ThroughRelationTest.php index f158275..1f17777 100644 --- a/tests/Integration/Cache/ThroughRelationTest.php +++ b/tests/Integration/Cache/ThroughRelationTest.php @@ -53,12 +53,12 @@ public function test_has_many_through_caches_results(): void $first = $country->posts()->get()->pluck('title'); - $keyCountAfterFirst = count($this->redisKeys('test:*')); + $keyCountAfterFirst = count($this->redisKeys('*')); $second = $country->posts()->get()->pluck('title'); $this->assertEquals($first, $second); - $this->assertSame($keyCountAfterFirst, count($this->redisKeys('test:*'))); + $this->assertSame($keyCountAfterFirst, count($this->redisKeys('*'))); } public function test_simple_has_many_through_warm_hit_refetches_only_evicted_child_model(): void @@ -70,7 +70,7 @@ public function test_simple_has_many_through_warm_hit_refetches_only_evicted_chi $first = $country->posts()->get(); $this->assertSame(['Hello'], $first->pluck('title')->all()); - $this->assertNotEmpty($this->redisKeys('test:through:*')); + $this->assertNotEmpty($this->redisKeys('through:*')); Redis::connection('normcache-test') ->del($this->prefixedModelKey(Post::class, $post->id)); @@ -98,7 +98,7 @@ public function test_through_relation_corrupt_query_payload_degrades_to_miss_and $country->posts()->get(); - $queryKey = collect($this->redisKeys('test:through:*'))->first(); + $queryKey = collect($this->redisKeys('through:*'))->first(); $this->assertNotNull($queryKey); Redis::connection('normcache-test')->set($queryKey, 'NOT_JSON'); @@ -127,11 +127,11 @@ public function test_flush_tag_removes_tagged_through_relation_cache(): void $country->posts()->tag('homepage')->get(); - $this->assertNotEmpty($this->redisKeys('test:through:*:homepage:*')); + $this->assertNotEmpty($this->redisKeys('through:*:homepage:*')); NormCache::flushTag(Post::class, 'homepage'); - $this->assertEmpty($this->redisKeys('test:through:*:homepage:*')); + $this->assertEmpty($this->redisKeys('through:*:homepage:*')); } public function test_has_many_through_cache_invalidated_when_post_version_changes(): void @@ -221,7 +221,7 @@ public function test_through_relation_with_extra_join_delegates_to_eloquent(): v ->get(); $this->assertSame([$post->id], $posts->modelKeys()); - $this->assertEmpty($this->redisKeys('test:through:*')); + $this->assertEmpty($this->redisKeys('through:*')); } public function test_outdated_through_cache_entry_can_remain_after_through_model_version_bump(): void @@ -239,7 +239,7 @@ public function test_outdated_through_cache_entry_can_remain_after_through_model $this->assertGreaterThan($oldAuthorVersion, NormCache::currentVersion(Author::class)); - $orphanedKeys = $this->redisKeys("test:through:*:v{$oldPostVersion}:v{$oldAuthorVersion}:*"); + $orphanedKeys = $this->redisKeys("through:*:v{$oldPostVersion}:v{$oldAuthorVersion}:*"); $this->assertNotEmpty($orphanedKeys); $this->assertSame(['Hello'], $country->posts()->get()->pluck('title')->all()); @@ -303,6 +303,7 @@ public function test_through_relation_subquery_where_does_not_serve_outdated_dat // Remove the only comment: the whereExists no longer matches the post. DB::table('comments')->where('commentable_id', $post->id)->delete(); + NormCache::invalidateTableVersion(DB::getDefaultConnection(), 'comments'); $after = $country->posts() ->whereExists(function ($q) { @@ -321,7 +322,7 @@ public function test_through_wildcard_plus_extra_column_does_not_pollute_model_c $country->posts()->select('posts.*')->selectRaw('2 as polluted')->get(); - $cached = NormCache::getModels([$post->id], Post::class); + $cached = NormCache::hydrator()->getModels([$post->id], Post::class); $this->assertArrayNotHasKey('polluted', $cached[0]->getRawOriginal()); } diff --git a/tests/Integration/Cache/WhereHasCachingTest.php b/tests/Integration/Cache/WhereHasCachingTest.php index 932b836..16ef0e9 100644 --- a/tests/Integration/Cache/WhereHasCachingTest.php +++ b/tests/Integration/Cache/WhereHasCachingTest.php @@ -8,6 +8,7 @@ use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\Fixtures\Models\Tag; use NormCache\Tests\TestCase; +use NormCache\Values\CachePlanContext; /** * "Simple" whereHas/has() caching — see @@ -17,31 +18,25 @@ class WhereHasCachingTest extends TestCase { // Classification - public function test_simple_wherehas_infers_related_model_dependency(): void + public function test_simple_wherehas_infers_related_table_dependency(): void { - $builder = Author::whereHas('posts'); + $prepared = Author::whereHas('posts')->prepareCacheExecution(); + $plan = $prepared->builder->cachePlan($prepared->base, CachePlanContext::models()); - $dependencies = $builder->inferExistenceDependencies(); - - $this->assertTrue($dependencies->safe); - $this->assertSame([Post::class], $dependencies->models); - $this->assertSame([], $dependencies->tables); + $this->assertTrue($plan->dependencies->safe); + $this->assertContains('testing:posts', $plan->dependencies->tables); } - // HasMany — basic caching + // HasMany — cache routing - public function test_simple_wherehas_hasmany_caches_correct_results(): void + public function test_simple_wherehas_hasmany_uses_result_cache(): void { $author = Author::create(['name' => 'Alice']); Post::create(['title' => 'Hello', 'author_id' => $author->id]); - Author::create(['name' => 'Bob']); - $this->contract( - fn() => Author::whereHas('posts')->get(), - fn() => Author::withoutCache()->whereHas('posts')->get(), - ); + Author::whereHas('posts')->get(); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_simple_wherehas_hasmany_invalidates_on_new_related_row(): void @@ -60,21 +55,6 @@ public function test_simple_wherehas_hasmany_invalidates_on_new_related_row(): v // Constraint closures - public function test_wherehas_with_safe_constraint_caches_correct_results(): void - { - $author = Author::create(['name' => 'Alice']); - Post::create(['title' => 'Published', 'author_id' => $author->id, 'published' => true]); - Post::create(['title' => 'Draft', 'author_id' => $author->id, 'published' => false]); - Author::create(['name' => 'Bob']); - - $this->contract( - fn() => Author::whereHas('posts', fn($q) => $q->where('published', true))->get(), - fn() => Author::withoutCache()->whereHas('posts', fn($q) => $q->where('published', true))->get(), - ); - - $this->assertNotEmpty($this->redisKeys('test:result:*')); - } - public function test_wherehas_with_safe_constraint_invalidates_on_dependency_change(): void { $author = Author::create(['name' => 'Alice']); @@ -104,10 +84,17 @@ public function test_wherehas_relation_definition_with_lock_bypasses(): void $this->assertStringStartsWith('not cached', $result); } - public function test_wherehas_relation_definition_with_without_cache_bypasses(): void + public function test_wherehas_relation_definition_with_without_cache_remains_cacheable(): void { $result = Author::whereHas('cacheSkippedPosts')->explain(); + $this->assertStringStartsWith('cached', $result); + } + + public function test_wherehas_relation_on_non_cacheable_model_bypasses(): void + { + $result = Author::whereHas('uncachedPosts')->explain(); + $this->assertStringStartsWith('not cached', $result); } @@ -128,18 +115,6 @@ public function test_wheredoesnthave_caches_and_invalidates_on_membership_change $this->assertSame([], $second->pluck('id')->all()); } - public function test_orwherehas_caches_correct_results(): void - { - Author::create(['name' => 'Alice']); - $bob = Author::create(['name' => 'Bob']); - Post::create(['title' => 'Hello', 'author_id' => $bob->id]); - - $this->contract( - fn() => Author::where('name', 'Carol')->orWhereHas('posts')->get(), - fn() => Author::withoutCache()->where('name', 'Carol')->orWhereHas('posts')->get(), - ); - } - public function test_count_threshold_has_bypasses(): void { $result = Author::has('posts', '>', 1)->explain(); @@ -184,20 +159,6 @@ public function test_wherehas_hasmanythrough_caches_and_invalidates_on_through_p // MorphMany allowed, MorphTo bails - public function test_wherehas_morphmany_caches_correct_results(): void - { - $author = Author::create(['name' => 'Alice']); - Comment::create(['body' => 'Hi', 'commentable_type' => Author::class, 'commentable_id' => $author->id]); - Author::create(['name' => 'Bob']); - - $this->contract( - fn() => Author::whereHas('comments')->get(), - fn() => Author::withoutCache()->whereHas('comments')->get(), - ); - - $this->assertNotEmpty($this->redisKeys('test:result:*')); - } - public function test_wherehas_morphto_bails(): void { $author = Author::create(['name' => 'Alice']); diff --git a/tests/Integration/Contract/CustomBehaviorContractTest.php b/tests/Integration/Contract/CustomBehaviorContractTest.php index 7c9528e..b23084d 100644 --- a/tests/Integration/Contract/CustomBehaviorContractTest.php +++ b/tests/Integration/Contract/CustomBehaviorContractTest.php @@ -8,16 +8,6 @@ class CustomBehaviorContractTest extends TestCase { - public function test_custom_casts_are_preserved_in_cache(): void - { - $post = Post::create(['title' => 'Test', 'author_id' => 1, 'published' => true, 'metadata' => ['foo' => 'bar']]); - - $this->contract( - fn() => Post::where('id', $post->id)->get(), - fn() => Post::withoutCache()->where('id', $post->id)->get(), - ); - } - public function test_hidden_and_appends_visibility_is_respected(): void { $post = Post::create(['title' => 'Secret', 'author_id' => 1]); diff --git a/tests/Integration/Contract/ModelHydrationContractTest.php b/tests/Integration/Contract/ModelHydrationContractTest.php new file mode 100644 index 0000000..aff32d4 --- /dev/null +++ b/tests/Integration/Contract/ModelHydrationContractTest.php @@ -0,0 +1,46 @@ + 'Ivy']); + $post = Post::create(['title' => 'WithComments', 'author_id' => $author->id]); + Comment::create(['body' => 'Nice post', 'commentable_id' => $post->id, 'commentable_type' => Post::class]); + + $this->evictModelCache(Post::class, $post->id); + + $this->contract( + cached: fn() => Post::with('comments')->whereKey($post->id)->get(), + native: fn() => Post::withoutCache()->with('comments')->whereKey($post->id)->get(), + ); + } + + public function test_joined_models_with_an_explicit_root_select_match_native_eloquent(): void + { + $author = Author::create(['name' => 'Lyle']); + $post = Post::create(['title' => 'Joined', 'author_id' => $author->id]); + $this->evictModelCache(Post::class, $post->id); + + $this->contract( + cached: fn() => Post::query()->join('authors', 'authors.id', '=', 'posts.author_id') + ->select('posts.*') + ->whereKey($post->id) + ->get(), + native: fn() => Post::withoutCache()->join('authors', 'authors.id', '=', 'posts.author_id') + ->select('posts.*') + ->whereKey($post->id) + ->get(), + ); + } +} diff --git a/tests/Integration/Contract/QueryCallbackContractTest.php b/tests/Integration/Contract/QueryCallbackContractTest.php index 0112ca9..b20ff19 100644 --- a/tests/Integration/Contract/QueryCallbackContractTest.php +++ b/tests/Integration/Contract/QueryCallbackContractTest.php @@ -350,7 +350,7 @@ public function test_after_query_callback_does_not_contaminate_pivot_cache(): vo $this->assertSame(['laravel'], $filtered()->pluck('name')->all()); $this->assertSame(['laravel'], $filtered()->pluck('name')->all()); - $this->assertNotEmpty($this->redisKeys('test:pivot:*')); + $this->assertNotEmpty($this->redisKeys('pivot:*')); $this->assertSame( ['laravel', 'php'], $alice->tags()->orderBy('tags.name')->get()->pluck('name')->all() @@ -368,7 +368,7 @@ public function test_after_query_callback_does_not_contaminate_through_cache(): $this->assertSame(['A1', 'A2'], $filtered()->pluck('title')->all()); $this->assertSame(['A1', 'A2'], $filtered()->pluck('title')->all()); - $this->assertNotEmpty($this->redisKeys('test:through:*')); + $this->assertNotEmpty($this->redisKeys('through:*')); $this->assertSame( ['A1', 'A2', 'B1'], $country->posts()->orderBy('posts.title')->get()->pluck('title')->all() diff --git a/tests/Integration/Contract/ResultCacheContractTest.php b/tests/Integration/Contract/ResultCacheContractTest.php index a523ee2..da38321 100644 --- a/tests/Integration/Contract/ResultCacheContractTest.php +++ b/tests/Integration/Contract/ResultCacheContractTest.php @@ -18,10 +18,16 @@ private function author(): Author return Author::create(['name' => 'Alice']); } - public function test_boolean_cast_applied_on_result_cache_hit(): void + public function test_class_defined_casts_are_preserved_on_result_cache_hits(): void { $author = $this->author(); - Post::create(['title' => 'T', 'published' => true, 'author_id' => $author->id]); + Post::create([ + 'title' => 'T', + 'views' => 42, + 'published' => true, + 'metadata' => ['x' => 1], + 'author_id' => $author->id, + ]); $this->contract( fn() => Post::dependsOn([Author::class])->get()->first(), @@ -33,58 +39,11 @@ public function test_boolean_cast_applied_on_result_cache_hit(): void $this->assertIsBool($warm->published); $this->assertTrue($warm->published); - } - - public function test_array_cast_applied_on_result_cache_hit(): void - { - $author = $this->author(); - Post::create(['title' => 'T', 'metadata' => ['x' => 1], 'author_id' => $author->id]); - - $this->contract( - fn() => Post::dependsOn([Author::class])->get()->first(), - fn() => Post::withoutCache()->where('author_id', $author->id)->get()->first(), - ); - - Post::dependsOn([Author::class])->get(); - $warm = Post::dependsOn([Author::class])->get()->first(); - - $this->assertIsArray($warm->metadata); + $this->assertIsInt($warm->views); $this->assertSame(['x' => 1], $warm->metadata); - } - - public function test_date_cast_returns_carbon_on_result_cache_hit(): void - { - $author = $this->author(); - Post::create(['title' => 'T', 'author_id' => $author->id]); - - $this->contract( - fn() => Post::dependsOn([Author::class])->get()->first(), - fn() => Post::withoutCache()->where('author_id', $author->id)->get()->first(), - ); - - Post::dependsOn([Author::class])->get(); - $warm = Post::dependsOn([Author::class])->get()->first(); - $this->assertInstanceOf(Carbon::class, $warm->created_at); } - public function test_class_defined_casts_always_apply_on_warm_hit(): void - { - $author = $this->author(); - Post::create(['title' => 'T', 'views' => 42, 'published' => true, 'author_id' => $author->id]); - - $this->contract( - fn() => Post::dependsOn([Author::class])->get()->first(), - fn() => Post::withoutCache()->where('author_id', $author->id)->get()->first(), - ); - - Post::dependsOn([Author::class])->get(); - $warm = Post::dependsOn([Author::class])->get()->first(); - - $this->assertIsBool($warm->published); - $this->assertIsInt($warm->views); - } - public function test_with_casts_applied_on_result_cache_warm_hit(): void { // Passes the builder's model instance as a prototype to ensure stateful hydration of runtime casts. diff --git a/tests/Integration/Contract/WhereHasContractTest.php b/tests/Integration/Contract/WhereHasContractTest.php new file mode 100644 index 0000000..ccec734 --- /dev/null +++ b/tests/Integration/Contract/WhereHasContractTest.php @@ -0,0 +1,63 @@ + 'Alice']); + Post::create(['title' => 'Hello', 'author_id' => $author->id]); + Author::create(['name' => 'Bob']); + + $this->contract( + fn() => Author::whereHas('posts')->get(), + fn() => Author::withoutCache()->whereHas('posts')->get(), + ); + } + + public function test_where_has_with_a_safe_constraint_matches_native_eloquent(): void + { + $author = Author::create(['name' => 'Alice']); + Post::create(['title' => 'Published', 'author_id' => $author->id, 'published' => true]); + Post::create(['title' => 'Draft', 'author_id' => $author->id, 'published' => false]); + Author::create(['name' => 'Bob']); + + $this->contract( + fn() => Author::whereHas('posts', fn($query) => $query->where('published', true))->get(), + fn() => Author::withoutCache()->whereHas('posts', fn($query) => $query->where('published', true))->get(), + ); + } + + public function test_or_where_has_matches_native_eloquent(): void + { + Author::create(['name' => 'Alice']); + $bob = Author::create(['name' => 'Bob']); + Post::create(['title' => 'Hello', 'author_id' => $bob->id]); + + $this->contract( + fn() => Author::where('name', 'Carol')->orWhereHas('posts')->get(), + fn() => Author::withoutCache()->where('name', 'Carol')->orWhereHas('posts')->get(), + ); + } + + public function test_morph_many_where_has_matches_native_eloquent(): void + { + $author = Author::create(['name' => 'Alice']); + Comment::create(['body' => 'Hi', 'commentable_type' => Author::class, 'commentable_id' => $author->id]); + Author::create(['name' => 'Bob']); + + $this->contract( + fn() => Author::whereHas('comments')->get(), + fn() => Author::withoutCache()->whereHas('comments')->get(), + ); + } +} diff --git a/tests/Integration/Infrastructure/CacheEventsTest.php b/tests/Integration/Infrastructure/CacheEventsTest.php index 0af72a6..f545db8 100644 --- a/tests/Integration/Infrastructure/CacheEventsTest.php +++ b/tests/Integration/Infrastructure/CacheEventsTest.php @@ -72,19 +72,19 @@ public function test_model_cache_hit_fired_when_models_in_cache(): void }); } - public function test_partial_model_cache_miss_fires_both_events(): void + public function test_partial_model_cache_miss_fires_miss_for_both_when_version_bumped(): void { $alice = Author::create(['name' => 'Alice']); - Author::all(); // warm alice's model key + Author::all(); // warm alice's model key at version V - $bob = Author::create(['name' => 'Bob']); // bob's model key not cached yet + Author::create(['name' => 'Bob']); // version bumps to V+1; alice's V key is no longer current Event::fake([ModelCacheHit::class, ModelCacheMiss::class]); - // query cache is outdated (version bumped by Bob's insert), so both IDs are fetched via MGET + // query cache is stale; version bump makes alice's model key unreachable too, so both miss Author::all(); - Event::assertDispatched(ModelCacheHit::class); + Event::assertNotDispatched(ModelCacheHit::class); Event::assertDispatched(ModelCacheMiss::class); } @@ -98,7 +98,7 @@ public function test_query_cache_hit_event_carries_correct_key(): void Author::all(); Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { - return str_starts_with($e->key, 'query:{' . app('normcache')->classKey(Author::class) . '}:v'); + return str_starts_with($e->key, app('normcache')->keys()->prefixed('query:' . app('normcache')->keys()->classKey(Author::class) . ':v')); }); } @@ -124,7 +124,7 @@ public function test_result_depends_on_miss_fires_query_cache_miss(): void Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { return $e->modelClass === Author::class - && str_starts_with($e->key, 'result:{' . app('normcache')->classKey(Author::class) . '}:'); + && str_starts_with($e->key, app('normcache')->keys()->prefixed('result:' . app('normcache')->keys()->classKey(Author::class) . ':')); }); } @@ -152,7 +152,7 @@ public function test_through_relation_cache_fires_query_events(): void Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { return $e->modelClass === Post::class - && str_starts_with($e->key, 'through:{' . app('normcache')->classKey(Post::class) . '}:'); + && str_starts_with($e->key, app('normcache')->keys()->prefixed('through:' . app('normcache')->keys()->classKey(Post::class) . ':')); }); Event::fake([QueryCacheHit::class]); @@ -161,7 +161,7 @@ public function test_through_relation_cache_fires_query_events(): void Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { return $e->modelClass === Post::class - && str_starts_with($e->key, 'through:{' . app('normcache')->classKey(Post::class) . '}:'); + && str_starts_with($e->key, app('normcache')->keys()->prefixed('through:' . app('normcache')->keys()->classKey(Post::class) . ':')); }); } @@ -176,7 +176,7 @@ public function test_relation_aggregate_cache_fires_query_events(): void Event::assertDispatched(QueryCacheMiss::class, function (QueryCacheMiss $e) { return $e->modelClass === Author::class - && str_starts_with($e->key, 'result:{' . app('normcache')->classKey(Author::class) . '}:'); + && str_starts_with($e->key, app('normcache')->keys()->prefixed('result:' . app('normcache')->keys()->classKey(Author::class) . ':')); }); Event::fake([QueryCacheHit::class]); @@ -185,7 +185,7 @@ public function test_relation_aggregate_cache_fires_query_events(): void Event::assertDispatched(QueryCacheHit::class, function (QueryCacheHit $e) { return $e->modelClass === Author::class - && str_starts_with($e->key, 'result:{' . app('normcache')->classKey(Author::class) . '}:'); + && str_starts_with($e->key, app('normcache')->keys()->prefixed('result:' . app('normcache')->keys()->classKey(Author::class) . ':')); }); } } diff --git a/tests/Integration/Infrastructure/ClusterModeTest.php b/tests/Integration/Infrastructure/ClusterModeTest.php index 9051a58..bdb23c9 100644 --- a/tests/Integration/Infrastructure/ClusterModeTest.php +++ b/tests/Integration/Infrastructure/ClusterModeTest.php @@ -11,9 +11,8 @@ use NormCache\Tests\TestCase; /** - * Verifies that cross-model cache operations produce correct results when cluster mode is - * enabled. A single Redis instance is used — the per-slot version resolution logic is - * exercised even though all keys happen to land on the same node. + * Verifies that cross-model cache operations produce correct results when a Redis Cluster + * connection is used. All keys share the {nc} hash tag, preserving Lua atomicity. */ class ClusterModeTest extends TestCase { @@ -23,30 +22,64 @@ protected function setUp(): void $this->setClusterMode(true); } - // dependsOn result cache + public function test_predis_cluster_bulk_reads_preserve_order_and_missing_values_across_spaces(): void + { + $this->requirePredisCluster(); + + $store = $this->cacheManager()->store(); + $defaultKeys = [ + '{nc}:test:bulk:1', + '{nc}:test:bulk:missing', + '{nc}:test:bulk:2', + ]; + $contentKeys = [ + '{nc:content}:test:bulk:1', + '{nc:content}:test:bulk:missing', + '{nc:content}:test:bulk:2', + ]; + + $store->set($defaultKeys[0], 'default-one', 60); + $store->set($defaultKeys[2], 'default-two', 60); + $store->set($contentKeys[0], 'content-one', 60); + $store->set($contentKeys[2], 'content-two', 60); + + $this->assertSame(['default-one', null, 'default-two'], $store->getMany($defaultKeys)); + $this->assertSame(['content-one', null, 'content-two'], $store->getMany($contentKeys)); + } - public function test_multi_dependency_normalized_query_routes_to_result_when_slotting_is_enabled(): void + public function test_predis_cluster_delete_groups_mixed_space_keys_without_crossslot_errors(): void { - Author::create(['name' => 'Alice']); + $this->requirePredisCluster(); + + $store = $this->cacheManager()->store(); + $keys = [ + '{nc}:test:delete:1', + '{nc}:test:delete:2', + '{nc:content}:test:delete:1', + '{nc:content}:test:delete:2', + ]; + + foreach ($keys as $key) { + $store->set($key, 'value', 60); + } - Author::query()->dependsOn([Post::class, Tag::class])->get(); + $store->delete($keys); - $this->assertEmpty($this->redisKeys('test:query:*'), 'Slotting mode should avoid multi-dependency normalized query keys'); - $this->assertNotEmpty($this->redisKeys('test:result:*'), 'Slotting mode should use result cache for multi-dependency queries'); + foreach ($keys as $key) { + $this->assertNull($store->get($key)); + } } - public function test_multi_dependency_normalized_query_stays_normalized_when_cluster_uses_fixed_hash_tag(): void - { - $this->app->forgetInstance(CacheManager::class); - $this->app->forgetInstance('normcache'); - config(['normcache.cluster' => true, 'normcache.slotting' => false]); + // dependsOn — multi-dependency normalized query stays normalized (no forced result cache) + public function test_multi_dependency_normalized_query_stays_normalized_in_cluster_mode(): void + { Author::create(['name' => 'Alice']); Author::query()->dependsOn([Post::class, Tag::class])->get(); - $this->assertNotEmpty($this->redisKeys('{nc}:test:query:*'), 'Fixed hash tag cluster mode should allow normalized query keys'); - $this->assertEmpty($this->redisKeys('{nc}:test:result:*'), 'Fixed hash tag cluster mode should not force result cache'); + $this->assertNotEmpty($this->redisKeys('query:*'), 'Multi-dependency normalized queries should use query keys in cluster mode'); + $this->assertEmpty($this->redisKeys('result:*'), 'Multi-dependency normalized queries should not fall back to result cache'); } public function test_depends_on_returns_correct_results_in_cluster_mode(): void @@ -210,11 +243,11 @@ public function test_flush_all_clears_all_cache_keys_in_cluster_mode(): void Author::get(); Author::orderBy('name')->get(); - $this->assertNotEmpty($this->redisKeys('test:*')); + $this->assertNotEmpty($this->redisKeys('*')); $this->cacheManager()->flushAll(); - $this->assertEmpty($this->redisKeys('test:*')); + $this->assertEmpty($this->redisKeys('*')); } public function test_flush_tag_clears_only_tagged_keys_in_cluster_mode(): void @@ -224,13 +257,13 @@ public function test_flush_tag_clears_only_tagged_keys_in_cluster_mode(): void Author::tag('homepage')->get(); Author::get(); - $taggedKeys = $this->redisKeys('test:query:*:homepage:*'); + $taggedKeys = $this->redisKeys('query:*:homepage:*'); $this->assertNotEmpty($taggedKeys, 'tagged query keys must exist before flush'); $this->cacheManager()->flushTag(Author::class, 'homepage'); - $this->assertEmpty($this->redisKeys('test:query:*:homepage:*'), 'tagged keys must be gone after flushTag'); - $this->assertNotEmpty($this->redisKeys('test:query:*'), 'untagged query keys must survive flushTag'); + $this->assertEmpty($this->redisKeys('query:*:homepage:*'), 'tagged keys must be gone after flushTag'); + $this->assertNotEmpty($this->redisKeys('query:*'), 'untagged query keys must survive flushTag'); } public function test_flush_tag_across_models_clears_tagged_keys_for_all_models_in_cluster_mode(): void @@ -242,12 +275,12 @@ public function test_flush_tag_across_models_clears_tagged_keys_for_all_models_i Post::tag('homepage')->get(); Author::get(); - $this->assertNotEmpty($this->redisKeys('test:query:*:homepage:*'), 'tagged keys must exist before flush'); + $this->assertNotEmpty($this->redisKeys('query:*:homepage:*'), 'tagged keys must exist before flush'); $this->cacheManager()->flushTagAcrossModels('homepage'); - $this->assertEmpty($this->redisKeys('test:query:*:homepage:*'), 'all tagged keys must be gone after flushTagAcrossModels'); - $this->assertNotEmpty($this->redisKeys('test:query:*'), 'untagged query keys must survive'); + $this->assertEmpty($this->redisKeys('query:*:homepage:*'), 'all tagged keys must be gone after flushTagAcrossModels'); + $this->assertNotEmpty($this->redisKeys('query:*'), 'untagged query keys must survive'); } public function test_prefix_sensitive_scan_and_flush_operations_work_in_cluster_mode(): void @@ -265,28 +298,28 @@ public function test_prefix_sensitive_scan_and_flush_operations_work_in_cluster_ Post::tag('homepage')->get(); Author::get(); - $this->assertNotEmpty($this->redisKeys('test:query:*')); - foreach ($this->redisKeys('test:*') as $key) { + $this->assertNotEmpty($this->redisKeys('query:*')); + foreach ($this->redisKeys('*') as $key) { $this->assertStringNotContainsString('laravel:', $key); } $this->cacheManager()->flushTag(Author::class, 'homepage'); - $this->assertEmpty($this->redisKeys('test:query:{testing:authors}:homepage:*')); - $this->assertNotEmpty($this->redisKeys('test:query:{testing:posts}:homepage:*')); + $this->assertEmpty($this->redisKeys('query:testing:authors:homepage:*'), 'author tagged keys must be gone'); + $this->assertNotEmpty($this->redisKeys('query:testing:posts:homepage:*'), 'post tagged keys must survive'); $this->cacheManager()->flushTagAcrossModels('homepage'); - $this->assertEmpty($this->redisKeys('test:query:*:homepage:*')); - $this->assertNotEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*:homepage:*')); + $this->assertNotEmpty($this->redisKeys('query:*')); - $removed = $this->cacheManager()->getStore()->flushByPatterns(['query:*']); + $removed = $this->cacheManager()->store()->flushByPatterns([$this->cacheManager()->keys()->prefixed('query:*')]); $this->assertGreaterThan(0, $removed); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); Author::get(); - $this->assertNotEmpty($this->redisKeys('test:*')); + $this->assertNotEmpty($this->redisKeys('*')); $this->cacheManager()->flushAll(); - $this->assertEmpty($this->redisKeys('test:*')); + $this->assertEmpty($this->redisKeys('*')); } finally { Redis::purge('normcache-test'); config()->set('database.redis.options.prefix', ''); @@ -325,34 +358,17 @@ public function test_single_model_invalidation_still_works_in_cluster_mode(): vo // Version key TTL — incrementAndExpire cluster-safety (ensures hash tags route EVAL to owning node) - public function test_version_key_has_ttl_in_slotting_cluster_mode(): void + public function test_version_key_has_ttl_in_cluster_mode(): void { Author::create(['name' => 'Alice']); $redis = Redis::connection('normcache-test'); - $classKey = $this->cacheManager()->classKey(Author::class); - $verKey = 'test:ver:{' . $classKey . '}:'; + $classKey = $this->cacheManager()->keys()->classKey(Author::class); + $verKey = '{nc}:test:ver:' . $classKey . ':'; $ttl = $redis->ttl($verKey); - $this->assertGreaterThan(0, $ttl, 'version key must carry a TTL in slotting cluster mode'); - } - - public function test_version_key_has_ttl_in_fixed_hashtag_cluster_mode(): void - { - $this->app->forgetInstance(CacheManager::class); - $this->app->forgetInstance('normcache'); - config(['normcache.cluster' => true, 'normcache.slotting' => false]); - - Author::create(['name' => 'Alice']); - - $redis = Redis::connection('normcache-test'); - $classKey = $this->cacheManager()->classKey(Author::class); - $verKey = '{nc}:test:ver:{' . $classKey . '}:'; - - $ttl = $redis->ttl($verKey); - - $this->assertGreaterThan(0, $ttl, 'version key must carry a TTL in fixed-hashtag cluster mode'); + $this->assertGreaterThan(0, $ttl, 'version key must carry a TTL in cluster mode'); } // count() pagination total (getNamespacedCache) @@ -369,4 +385,13 @@ public function test_paginate_count_cache_works_in_cluster_mode(): void $this->assertSame(3, $cold->total()); $this->assertSame(3, $warm->total()); } + + private function requirePredisCluster(): void + { + $cluster = env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true; + + if (!$cluster || env('REDIS_CLIENT') !== 'predis') { + $this->markTestSkipped('Requires a real Predis Redis Cluster connection.'); + } + } } diff --git a/tests/Integration/Infrastructure/ClusterSpacesTest.php b/tests/Integration/Infrastructure/ClusterSpacesTest.php new file mode 100644 index 0000000..1bcf875 --- /dev/null +++ b/tests/Integration/Infrastructure/ClusterSpacesTest.php @@ -0,0 +1,324 @@ +requiresRedisCluster(); + $this->setClusterMode(true); + } + + public function test_cluster_connection_is_real_cluster_backed(): void + { + $this->assertNotEmpty($this->clusterMasterNodes()); + } + + public function test_distinct_spaces_use_distinct_cluster_slots(): void + { + $default = $this->redisClusterSlot('nc'); + $content = $this->redisClusterSlot('nc:content'); + $catalog = $this->redisClusterSlot('nc:catalog'); + $reporting = $this->redisClusterSlot('nc:reporting'); + + $this->assertNotSame($default, $content); + $this->assertNotSame($default, $catalog); + $this->assertNotSame($default, $reporting); + $this->assertCount(4, array_unique([$default, $content, $catalog, $reporting])); + } + + public function test_each_space_full_cache_lifecycle_runs_without_crossslot(): void + { + $this->assertNoCrossSlot(function () { + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'Article', 'author_id' => $author->id]); + CatalogTag::create(['name' => 'Widgets']); + ReportingCountry::create(['name' => 'Atlantis']); + + $this->assertSame('Article', SpacedPost::query()->get()->first()->title); + $this->assertSame('Article', SpacedPost::query()->get()->first()->title); + + $this->assertSame('Widgets', CatalogTag::query()->get()->first()->name); + $this->assertSame('Widgets', CatalogTag::query()->get()->first()->name); + + $this->assertSame('Atlantis', ReportingCountry::query()->get()->first()->name); + $this->assertSame('Atlantis', ReportingCountry::query()->get()->first()->name); + }); + + $this->assertAnyKeysForHashTag('nc:content', 'test:*'); + $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); + $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); + } + + public function test_explicit_space_query_with_allowed_dependencies_caches_in_that_space(): void + { + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); + + $this->assertNoCrossSlot(function () { + return SpacedPost::query() + ->space('content') + ->dependsOn([SpacedAuthor::class]) + ->get(); + }); + + $this->assertSame( + 'First', + SpacedPost::query()->space('content')->dependsOn([SpacedAuthor::class])->get()->first()->title, + ); + $this->assertAnyKeysForHashTag('nc:content', 'test:query:*'); + $this->assertNoKeysForHashTag('nc', 'test:query:*posts*'); + + $author->update(['name' => 'Annie']); + + $post = SpacedPost::query()->space('content')->with('spacedAuthor')->get()->first(); + $this->assertSame('Annie', $post->spacedAuthor->name); + } + + public function test_cross_space_dependency_bypasses_before_redis(): void + { + config(['normcache.spaces.cross_space_behavior' => 'bypass']); + + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); + CatalogTag::create(['name' => 'Widgets']); + + $result = $this->assertNoCrossSlot(fn() => SpacedPost::query() + ->space('content') + ->dependsOn([CatalogTag::class]) + ->get()); + + $this->assertSame('First', $result->first()->title); + $this->assertNoKeysForHashTag('nc:content', 'test:query:*'); + } + + public function test_cross_space_dependency_throw_mode_fails_before_redis(): void + { + config(['normcache.spaces.cross_space_behavior' => 'throw']); + + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); + CatalogTag::create(['name' => 'Widgets']); + + try { + SpacedPost::query() + ->space('content') + ->dependsOn([CatalogTag::class]) + ->get(); + + $this->fail('Expected a cross-space planning exception.'); + } catch (RuntimeException $e) { + $this->assertStringContainsString('cross-space', $e->getMessage()); + $this->assertStringNotContainsString('CROSSSLOT', $e->getMessage()); + } + } + + public function test_model_update_invalidates_all_declared_model_spaces(): void + { + $author = SpacedAuthor::create(['name' => 'Ann']); + $post = MultiSpacePost::create(['title' => 'First', 'author_id' => $author->id]); + + $this->assertSame('First', MultiSpacePost::query()->space('content')->get()->first()->title); + $this->assertSame('First', MultiSpacePost::query()->space('reporting')->get()->first()->title); + + $this->assertAnyKeysForHashTag('nc:content', 'test:query:*'); + $this->assertAnyKeysForHashTag('nc:reporting', 'test:query:*'); + + $post->update(['title' => 'Second']); + + $this->assertSame('Second', MultiSpacePost::query()->space('content')->get()->first()->title); + $this->assertSame('Second', MultiSpacePost::query()->space('reporting')->get()->first()->title); + } + + public function test_through_relation_cache_uses_related_model_space_on_cluster(): void + { + $country = ReportingCountry::create(['name' => 'Australia']); + $author = SpacedAuthor::create(['name' => 'Alice', 'country_id' => $country->id]); + $post = SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); + + $this->assertSame(['Hello'], $this->assertNoCrossSlot(fn() => $country->spacedPosts()->get()->pluck('title')->all())); + $this->assertSame(['Hello'], $country->spacedPosts()->get()->pluck('title')->all()); + + $this->assertAnyKeysForHashTag('nc:content', 'test:through:*'); + $this->assertNoKeysForHashTag('nc', 'test:through:*'); + + $post->update(['title' => 'Updated']); + + $this->assertSame(['Updated'], $country->spacedPosts()->get()->pluck('title')->all()); + } + + public function test_pivot_cache_uses_related_model_space_on_cluster(): void + { + $author = SpacedAuthor::create(['name' => 'Ann']); + $post = SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); + $firstTag = CatalogTag::create(['name' => 'First']); + $secondTag = CatalogTag::create(['name' => 'Second']); + + $post->catalogTags()->attach($firstTag->id); + + $first = $this->assertNoCrossSlot(fn() => SpacedPost::query() + ->with('catalogTags') + ->get() + ->first() + ->catalogTags + ->pluck('name') + ->all()); + + $this->assertSame(['First'], $first); + $this->assertAnyKeysForHashTag('nc:catalog', 'test:pivot:*'); + $this->assertNoKeysForHashTag('nc:content', 'test:pivot:*'); + + $post->catalogTags()->attach($secondTag->id); + + $second = SpacedPost::query() + ->with('catalogTags') + ->get() + ->first() + ->catalogTags + ->pluck('name') + ->sort() + ->values() + ->all(); + + $this->assertSame(['First', 'Second'], $second); + } + + public function test_flush_space_clears_only_that_space_on_cluster(): void + { + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'Article', 'author_id' => $author->id]); + CatalogTag::create(['name' => 'Widgets']); + ReportingCountry::create(['name' => 'Atlantis']); + + SpacedPost::query()->get(); + CatalogTag::query()->get(); + ReportingCountry::query()->get(); + + $this->assertAnyKeysForHashTag('nc:content', 'test:*'); + $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); + $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); + + $this->assertNoCrossSlot(fn() => $this->cacheManager()->flushAll('content')); + + $this->assertNoKeysForHashTag('nc:content', 'test:*'); + $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); + $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); + } + + public function test_flush_all_clears_default_and_known_spaces_on_cluster(): void + { + Author::create(['name' => 'Default']); + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'Article', 'author_id' => $author->id]); + CatalogTag::create(['name' => 'Widgets']); + ReportingCountry::create(['name' => 'Atlantis']); + + Author::query()->get(); + SpacedPost::query()->get(); + CatalogTag::query()->get(); + ReportingCountry::query()->get(); + + $this->assertAnyKeysForHashTag('nc', 'test:*'); + $this->assertAnyKeysForHashTag('nc:content', 'test:*'); + $this->assertAnyKeysForHashTag('nc:catalog', 'test:*'); + $this->assertAnyKeysForHashTag('nc:reporting', 'test:*'); + + $this->assertNoCrossSlot(fn() => $this->cacheManager()->flushAll()); + + $this->assertNoKeysForHashTag('nc', 'test:*'); + $this->assertNoKeysForHashTag('nc:content', 'test:*'); + $this->assertNoKeysForHashTag('nc:catalog', 'test:*'); + $this->assertNoKeysForHashTag('nc:reporting', 'test:*'); + } + + public function test_scheduled_invalidation_keys_are_scoped_to_each_affected_space(): void + { + $author = SpacedAuthor::create(['name' => 'Ann']); + $post = SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); + + SpacedPost::query()->get(); + + $this->cacheManager()->config()->cooldown = 5; + + $this->assertNoCrossSlot(fn() => $post->update(['title' => 'Second'])); + + $classKey = $this->cacheManager()->keys()->classKey(SpacedPost::class); + $contentScheduledKey = $this->cacheManager()->keys()->scheduledKey( + $classKey, + $this->cacheManager()->spaceFor(SpacedPost::class), + ); + $defaultScheduledKey = $this->cacheManager()->keys()->scheduledKey($classKey); + + $scheduledKeys = $this->keysForHashTag('nc:content', 'test:scheduled:*'); + $this->assertNotEmpty($scheduledKeys, 'Cooldown must write a scheduled key under {nc:content}.'); + $this->assertAllKeysShareHashTag($scheduledKeys, 'nc:content'); + $this->assertContains($contentScheduledKey, $scheduledKeys); + + $defaultScheduledKeys = $this->keysForHashTag('nc', 'test:scheduled:*'); + $this->assertNotEmpty($defaultScheduledKeys, 'A same-table default-space cache must also be invalidated.'); + $this->assertAllKeysShareHashTag($defaultScheduledKeys, 'nc'); + $this->assertContains($defaultScheduledKey, $defaultScheduledKeys); + + $contentSlot = $this->redisClusterSlot('nc:content'); + foreach ($scheduledKeys as $key) { + preg_match('/^\{([^}]+)\}:/', $key, $m); + $this->assertSame($contentSlot, $this->redisClusterSlot($m[1] ?? '')); + } + + // The read Lua passes each version key with its scheduled key; mismatched slots throw CROSSSLOT. + $this->assertNoCrossSlot(fn() => SpacedPost::query()->get()); + } + + public function test_building_and_wake_keys_share_payload_slot_per_space(): void + { + $author = SpacedAuthor::create(['name' => 'Ann']); + $post = SpacedPost::create(['title' => 'Hello', 'author_id' => $author->id]); + $country = ReportingCountry::create(['name' => 'Oz']); + SpacedAuthor::where('id', $author->id)->update(['country_id' => $country->id]); + $tag = CatalogTag::create(['name' => 'Widget']); + $post->catalogTags()->attach($tag->id); + + // Building/wake keys are ephemeral (Lua sets them on miss, deletes on store); co-location is + // proven indirectly — Lua passes them with version keys as KEYS[], and Redis Cluster + // requires all KEYS[] to share a slot, so CROSSSLOT fires immediately on any mismatch. + $this->assertNoCrossSlot(fn() => SpacedPost::query()->get()); + $this->assertAnyKeysForHashTag('nc:content', 'test:query:*'); + $this->assertNoKeysForHashTag('nc', 'test:query:*spaced*'); + + $this->assertNoCrossSlot(fn() => SpacedPost::query()->count()); + $this->assertAnyKeysForHashTag('nc:content', 'test:count:*'); + $this->assertNoKeysForHashTag('nc', 'test:count:*spaced*'); + + $this->assertNoCrossSlot(fn() => ReportingCountry::first()->spacedPosts()->get()); + $this->assertAnyKeysForHashTag('nc:content', 'test:through:*'); + $this->assertNoKeysForHashTag('nc', 'test:through:*'); + + // storeManyVersionedResults issues batched multi-key Lua writes; pivot is the highest CROSSSLOT risk. + $this->assertNoCrossSlot(fn() => SpacedPost::query()->with('catalogTags')->get()); + $this->assertAnyKeysForHashTag('nc:catalog', 'test:pivot:*'); + $this->assertNoKeysForHashTag('nc:content', 'test:pivot:*'); + $this->assertNoKeysForHashTag('nc', 'test:pivot:*'); + + $this->assertNoCrossSlot(fn() => SpacedPost::query()->get()); + $this->assertNoCrossSlot(fn() => SpacedPost::query()->count()); + $this->assertNoCrossSlot(fn() => ReportingCountry::first()->spacedPosts()->get()); + $this->assertNoCrossSlot(fn() => SpacedPost::query()->with('catalogTags')->get()); + } +} diff --git a/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php b/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php new file mode 100644 index 0000000..3bdf6d9 --- /dev/null +++ b/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php @@ -0,0 +1,141 @@ +markTestSkipped('Requires a Redis Cluster (composer test:cluster).'); + } + + $client = RedisFacade::connection('normcache-test')->client(); + + if ($client instanceof RedisCluster || $client instanceof PredisClient) { + return; + } + + if (class_exists(Redis::class)) { + [$host, $port] = $this->clusterProbeNode(); + $probe = new Redis; + + try { + $probe->connect($host, $port); + $slots = $probe->rawCommand('CLUSTER', 'SLOTS'); + } finally { + $probe->close(); + } + + if (is_array($slots) && $slots !== []) { + return; + } + } + + $this->markTestSkipped('Redis connection is not cluster-backed.'); + } + + /** @return list */ + protected function clusterMasterNodes(): array + { + if (!class_exists(Redis::class)) { + $this->markTestSkipped('Physical cluster inspection requires the phpredis extension.'); + } + + [$host, $port] = $this->clusterProbeNode(); + $probe = new Redis; + + try { + $probe->connect($host, $port); + $slots = $probe->rawCommand('CLUSTER', 'SLOTS'); + } finally { + $probe->close(); + } + + $nodes = []; + foreach ((array) $slots as $range) { + if (!is_array($range) || !isset($range[2]) || !is_array($range[2])) { + continue; + } + + $masterHost = is_string($range[2][0] ?? null) ? $range[2][0] : $host; + $masterPort = (int) ($range[2][1] ?? 0); + + if ($masterPort > 0) { + $nodes["{$masterHost}:{$masterPort}"] = [$masterHost, $masterPort]; + } + } + + return array_values($nodes); + } + + protected function redisClusterSlot(string $hashTag): int + { + $crc = 0; + + foreach (str_split($hashTag) as $char) { + $crc ^= ord($char) << 8; + + for ($i = 0; $i < 8; $i++) { + $crc = ($crc & 0x8000) ? (($crc << 1) ^ 0x1021) & 0xFFFF : ($crc << 1) & 0xFFFF; + } + } + + return $crc % 16384; + } + + /** @return list */ + protected function keysForHashTag(string $hashTag, string $suffix = '*'): array + { + return array_values($this->cacheManager()->store()->scanPattern('{' . $hashTag . '}:' . $suffix)); + } + + protected function assertAnyKeysForHashTag(string $hashTag, string $suffix = '*'): void + { + $this->assertNotEmpty( + $this->keysForHashTag($hashTag, $suffix), + "Expected keys for {{$hashTag}}:{$suffix}.", + ); + } + + protected function assertNoKeysForHashTag(string $hashTag, string $suffix = '*'): void + { + $this->assertEmpty( + $this->keysForHashTag($hashTag, $suffix), + "Expected no keys for {{$hashTag}}:{$suffix}.", + ); + } + + /** @param list $keys */ + protected function assertAllKeysShareHashTag(array $keys, string $hashTag): void + { + $this->assertNotEmpty($keys, 'Expected at least one key to inspect.'); + + foreach ($keys as $key) { + $this->assertStringStartsWith('{' . $hashTag . '}:', $key); + } + } + + protected function assertNoCrossSlot(callable $callback): mixed + { + try { + return $callback(); + } catch (\Throwable $e) { + $this->assertStringNotContainsString('CROSSSLOT', $e->getMessage()); + throw $e; + } + } + + /** @return array{0: string, 1: int} */ + protected function clusterProbeNode(): array + { + $node = config('database.redis.clusters.normcache-test.0'); + + return [$node['host'], (int) $node['port']]; + } +} diff --git a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php index 57f2d6a..4193438 100644 --- a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php +++ b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php @@ -24,7 +24,7 @@ private function redis() private function setKey(string $key, string $value, ?int $ttl = null): void { - $prefixed = 'test:' . $key; + $prefixed = '{nc}:test:' . $key; $ttl !== null ? $this->redis()->setex($prefixed, $ttl, $value) : $this->redis()->set($prefixed, $value); @@ -32,7 +32,7 @@ private function setKey(string $key, string $value, ?int $ttl = null): void private function getKey(string $key): mixed { - return $this->redis()->get('test:' . $key); + return $this->redis()->get('{nc}:test:' . $key); } private function authorQueryHash(): string @@ -46,13 +46,13 @@ public function test_corrupt_query_entry_is_deleted_and_treated_as_miss(): void { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); $hash = $this->authorQueryHash(); Author::get(); $version = NormCache::currentVersion(Author::class); - $this->setKey("query:{{$ck}}:v{$version}:{$hash}", 'not-valid-json'); + $this->setKey("query:{$ck}:v{$version}:{$hash}", 'not-valid-json'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -63,7 +63,7 @@ public function test_corrupt_query_entry_is_deleted_and_treated_as_miss(): void $this->assertGreaterThan(0, $queryCount); $this->assertCount(1, $results); - $this->assertIsArray(json_decode($this->getKey("query:{{$ck}}:v{$version}:{$hash}"), true)); + $this->assertIsArray(json_decode($this->getKey("query:{$ck}:v{$version}:{$hash}"), true)); } // luaFetchVersionedQuery — cooldown firing @@ -72,36 +72,40 @@ public function test_pending_cooldown_fires_version_bump_on_read(): void { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); Author::get(); $version = NormCache::currentVersion(Author::class); + // Reads only check scheduled keys while the cooldown toggle is active. + $this->cacheManager()->config()->cooldown = 1; + // Place a past-due scheduled invalidation directly in Redis $pastMs = (int) (microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{{$ck}}:", (string) $pastMs); + $this->setKey("scheduled:{$ck}:", (string) $pastMs); Author::get(); // read triggers the Lua cooldown check — past-due scheduled key fires the bump - $this->assertSame($version + 1, (int) $this->getKey("ver:{{$ck}}:")); - $this->assertNull($this->getKey("scheduled:{{$ck}}:")); + $this->assertSame($version + 1, (int) $this->getKey("ver:{$ck}:")); + $this->assertNull($this->getKey("scheduled:{$ck}:")); } public function test_non_numeric_scheduled_key_is_cleaned_up_without_version_bump(): void { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); Author::get(); $version = NormCache::currentVersion(Author::class); - $this->setKey("scheduled:{{$ck}}:", 'garbage'); + $this->cacheManager()->config()->cooldown = 1; + $this->setKey("scheduled:{$ck}:", 'garbage'); Author::get(); // Non-numeric value cannot be a valid timestamp — Lua cleans it without bumping the version. - $this->assertSame($version, (int) $this->getKey("ver:{{$ck}}:")); - $this->assertNull($this->getKey("scheduled:{{$ck}}:")); + $this->assertSame($version, (int) $this->getKey("ver:{$ck}:")); + $this->assertNull($this->getKey("scheduled:{$ck}:")); } } diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php index a369c42..9274310 100644 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php @@ -27,7 +27,7 @@ private function redis() private function setKey(string $key, string $value, ?int $ttl = null): void { - $prefixed = 'test:' . $key; + $prefixed = '{nc}:test:' . $key; $ttl !== null ? $this->redis()->setex($prefixed, $ttl, $value) : $this->redis()->set($prefixed, $value); @@ -35,13 +35,13 @@ private function setKey(string $key, string $value, ?int $ttl = null): void private function getKey(string $key): mixed { - return $this->redis()->get('test:' . $key); + return $this->redis()->get('{nc}:test:' . $key); } private function bumpVersionInRedis(string $classKey, int $times = 1): void { for ($i = 0; $i < $times; $i++) { - $this->redis()->incr("test:ver:{{$classKey}}:"); + $this->redis()->incr("{nc}:test:ver:{$classKey}:"); } } @@ -59,33 +59,33 @@ private function authorQueryHash(): string public function test_cooldown_fires_version_bump_on_standalone_version_resolution(): void { - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); - $this->setKey("ver:{{$ck}}:", '3'); + $this->setKey("ver:{$ck}:", '3'); $pastMs = (int) (microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{{$ck}}:", (string) $pastMs); + $this->setKey("scheduled:{$ck}:", (string) $pastMs); $this->setCooldown(1); $version = NormCache::currentVersion(Author::class); $this->assertSame(4, $version); - $this->assertNull($this->getKey("scheduled:{{$ck}}:")); + $this->assertNull($this->getKey("scheduled:{$ck}:")); } public function test_non_numeric_scheduled_key_cleaned_on_standalone_version_resolution(): void { - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); - $this->setKey("ver:{{$ck}}:", '3'); - $this->setKey("scheduled:{{$ck}}:", 'garbage'); + $this->setKey("ver:{$ck}:", '3'); + $this->setKey("scheduled:{$ck}:", 'garbage'); $this->setCooldown(1); $version = NormCache::currentVersion(Author::class); $this->assertSame(3, $version); - $this->assertNull($this->getKey("scheduled:{{$ck}}:")); + $this->assertNull($this->getKey("scheduled:{$ck}:")); } // dependsOn blob — building key causes DB fallthrough @@ -94,12 +94,12 @@ public function test_building_key_in_deps_query_causes_db_fallthrough(): void { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); $hash = $this->authorQueryHash(); $authorVer = NormCache::currentVersion(Author::class); $postVer = NormCache::currentVersion(Post::class); - $this->setKey("building:{{$ck}}:v{$authorVer}:v{$postVer}:{$hash}", '1', 30); + $this->setKey("building:{$ck}:v{$authorVer}:v{$postVer}:{$hash}", '1', 30); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -127,20 +127,20 @@ public function test_cooldown_due_invalidation_applies_to_result_depends_on_cach ->get(); $this->assertCount(1, $query()); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); $post->update(['published' => false]); - $postClassKey = NormCache::classKey(Post::class); + $postClassKey = NormCache::keys()->classKey(Post::class); $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{{$postClassKey}}:", (string) $pastMs); + $this->setKey("scheduled:{$postClassKey}:", (string) $pastMs); // Post version is still 0 (never bumped) — the scheduled key is what triggers the bump - $this->assertSame('0', (string) ($this->getKey("ver:{{$postClassKey}}:") ?? '0')); + $this->assertSame('0', (string) ($this->getKey("ver:{$postClassKey}:") ?? '0')); $this->assertCount(0, $query()); - $this->assertSame('1', (string) $this->getKey("ver:{{$postClassKey}}:")); - $this->assertNull($this->getKey("scheduled:{{$postClassKey}}:")); + $this->assertSame('1', (string) $this->getKey("ver:{$postClassKey}:")); + $this->assertNull($this->getKey("scheduled:{$postClassKey}:")); } public function test_result_cache_write_is_skipped_when_dependency_version_changes_during_build(): void @@ -148,77 +148,71 @@ public function test_result_cache_write_is_skipped_when_dependency_version_chang $manager = $this->cacheManager(); $hash = 'manual-result-build'; - $miss = $manager->getResultCache(Author::class, [Post::class], $hash); + $miss = $manager->results()->fetch(Author::class, [Post::class], $hash, null, []); $this->assertSame('miss', $miss->status->value); - $this->bumpVersionInRedis(NormCache::classKey(Post::class)); + $this->bumpVersionInRedis(NormCache::keys()->classKey(Post::class)); - $manager->storeResultCache( + $manager->results()->store( $miss->key, [['id' => 1, 'name' => 'Old']], - $miss->buildingKey, 60, - $miss->wakeKey, - $miss->versionKeys, - $miss->expectedVersions, + $miss->build, ); - $this->assertNull($manager->getStore()->get($miss->key)); - $this->assertNull($manager->getStore()->getRaw($miss->buildingKey)); + $this->assertNull($manager->store()->get($miss->key)); + $this->assertNull($manager->store()->getRaw($miss->build->buildingKey)); } public function test_namespaced_result_write_is_skipped_when_dependency_version_changes_during_build(): void { $manager = $this->cacheManager(); - $cache = $manager->getResultCache(Author::class, [Post::class], 'manual-count-build', null, [], 'count'); + $cache = $manager->results()->fetch(Author::class, [Post::class], 'manual-count-build', null, [], 'count'); - $this->bumpVersionInRedis(NormCache::classKey(Post::class)); + $this->bumpVersionInRedis(NormCache::keys()->classKey(Post::class)); - $manager->storeVersionedResult( + $manager->results()->storeEntry( $cache->key, [10], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build, ); - $this->assertNull($manager->getStore()->get($cache->key)); + $this->assertNull($manager->store()->get($cache->key)); } public function test_through_result_write_is_skipped_when_dependency_version_changes_during_build(): void { $manager = $this->cacheManager(); - $cache = $manager->getResultCache(Post::class, [Country::class], 'manual-through-build', null, [], 'through'); + $cache = $manager->results()->fetch(Post::class, [Country::class], 'manual-through-build', null, [], 'through'); - $this->bumpVersionInRedis(NormCache::classKey(Country::class)); + $this->bumpVersionInRedis(NormCache::keys()->classKey(Country::class)); - $manager->storeVersionedResult( + $manager->results()->storeEntry( $cache->key, ['ids' => [1], 'throughKeys' => [1 => 1]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build, ); - $this->assertNull($manager->getStore()->get($cache->key)); + $this->assertNull($manager->store()->get($cache->key)); } public function test_related_model_payload_is_not_cached_when_through_result_write_is_skipped(): void { $manager = $this->cacheManager(); - $cache = $manager->getResultCache(Post::class, [Country::class], 'manual-through-build', null, [], 'through'); + $cache = $manager->results()->fetch(Post::class, [Country::class], 'manual-through-build', null, [], 'through'); - $this->bumpVersionInRedis(NormCache::classKey(Country::class)); + $this->bumpVersionInRedis(NormCache::keys()->classKey(Country::class)); - if ($manager->storeVersionedResult( + if ($manager->results()->storeEntry( $cache->key, ['ids' => [1], 'throughKeys' => [1 => 1]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build, )) { - $manager->cacheModelAttrs(Post::class, [1 => ['id' => 1, 'title' => 'Old']]); + $manager->models()->store(Post::class, [1 => ['id' => 1, 'title' => 'Old']]); } $this->assertNull($this->modelCacheEntry(Post::class, 1)); @@ -227,44 +221,42 @@ public function test_related_model_payload_is_not_cached_when_through_result_wri public function test_pivot_result_write_is_skipped_when_dependency_version_changes_during_build(): void { $manager = $this->cacheManager(); - $pivotTableKey = $manager->tableKey(DB::getDefaultConnection(), 'author_tag'); - $cache = $manager->getPivotCache(Author::class, Tag::class, 'tags', [1], 'manual-pivot-build', $pivotTableKey); - $authorKey = NormCache::classKey(Author::class); - $tagKey = NormCache::classKey(Tag::class); - $pivotKey = "pivot:{{$authorKey}}:{$tagKey}:tags:manual-pivot-build:{$cache->seg}:1"; + $pivotTableKey = $manager->keys()->tableKey(DB::getDefaultConnection(), 'author_tag'); + $cache = $manager->results()->fetchPivot(Author::class, Tag::class, 'tags', [1], 'manual-pivot-build', $pivotTableKey); + $authorKey = NormCache::keys()->classKey(Author::class); + $tagKey = NormCache::keys()->classKey(Tag::class); + $pivotKey = $manager->keys()->pivotKey($authorKey, $tagKey, 'tags', 'manual-pivot-build', $cache->seg, 1); $this->bumpVersionInRedis($pivotTableKey); - $manager->storeVersionedResult( + $manager->results()->storeEntry( $pivotKey, [['id' => 1, 'pivot' => []]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build, ); - $this->assertNull($manager->getStore()->get($pivotKey)); + $this->assertNull($manager->store()->get($pivotKey)); } public function test_related_model_payload_is_not_cached_when_pivot_result_write_is_skipped(): void { $manager = $this->cacheManager(); - $pivotTableKey = $manager->tableKey(DB::getDefaultConnection(), 'author_tag'); - $cache = $manager->getPivotCache(Author::class, Tag::class, 'tags', [1], 'manual-pivot-build', $pivotTableKey); - $authorKey = NormCache::classKey(Author::class); - $tagKey = NormCache::classKey(Tag::class); - $pivotKey = "pivot:{{$authorKey}}:{$tagKey}:tags:manual-pivot-build:{$cache->seg}:1"; + $pivotTableKey = $manager->keys()->tableKey(DB::getDefaultConnection(), 'author_tag'); + $cache = $manager->results()->fetchPivot(Author::class, Tag::class, 'tags', [1], 'manual-pivot-build', $pivotTableKey); + $authorKey = NormCache::keys()->classKey(Author::class); + $tagKey = NormCache::keys()->classKey(Tag::class); + $pivotKey = $manager->keys()->pivotKey($authorKey, $tagKey, 'tags', 'manual-pivot-build', $cache->seg, 1); $this->bumpVersionInRedis($tagKey); - if ($manager->storeVersionedResult( + if ($manager->results()->storeEntry( $pivotKey, [['id' => 1, 'pivot' => []]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build, )) { - $manager->cacheModelAttrs(Tag::class, [1 => ['id' => 1, 'name' => 'Old']]); + $manager->models()->store(Tag::class, [1 => ['id' => 1, 'name' => 'Old']]); } $this->assertNull($this->modelCacheEntry(Tag::class, 1)); @@ -283,18 +275,18 @@ public function test_cooldown_due_invalidation_applies_to_scalar_cache(): void ->count(); $this->assertSame(1, $query()); - $this->assertNotEmpty($this->redisKeys('test:count:*')); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('count:*')); + $this->assertEmpty($this->redisKeys('result:*')); $post->update(['published' => false]); - $postClassKey = NormCache::classKey(Post::class); + $postClassKey = NormCache::keys()->classKey(Post::class); $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{{$postClassKey}}:", (string) $pastMs); + $this->setKey("scheduled:{$postClassKey}:", (string) $pastMs); $this->assertSame(0, $query()); - $this->assertSame('1', (string) $this->getKey("ver:{{$postClassKey}}:")); - $this->assertNull($this->getKey("scheduled:{{$postClassKey}}:")); + $this->assertSame('1', (string) $this->getKey("ver:{$postClassKey}:")); + $this->assertNull($this->getKey("scheduled:{$postClassKey}:")); } public function test_cooldown_due_invalidation_applies_to_pivot_cache(): void @@ -308,14 +300,14 @@ public function test_cooldown_due_invalidation_applies_to_pivot_cache(): void $author->tags()->attach($old->id); $this->assertSame(['old'], $author->tags()->get()->pluck('name')->all()); - $this->assertNotEmpty($this->redisKeys('test:pivot:*')); + $this->assertNotEmpty($this->redisKeys('pivot:*')); $author->tags()->detach($old->id); $author->tags()->attach($new->id); - $pivotTableKey = NormCache::tableKey($author->getConnection()->getName(), 'author_tag'); + $pivotTableKey = NormCache::keys()->tableKey($author->getConnection()->getName(), 'author_tag'); $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{{$pivotTableKey}}:", (string) $pastMs); + $this->setKey("scheduled:{$pivotTableKey}:", (string) $pastMs); $this->assertSame(['new'], $author->tags()->get()->pluck('name')->all()); } @@ -334,13 +326,13 @@ public function test_cooldown_due_invalidation_applies_to_aggregate_cache(): voi Post::create(['title' => 'Post 3', 'author_id' => $author->id]); - $postClassKey = NormCache::classKey(Post::class); + $postClassKey = NormCache::keys()->classKey(Post::class); $pastMs = (int) floor(microtime(true) * 1000) - 5000; - $this->setKey("scheduled:{{$postClassKey}}:", (string) $pastMs); + $this->setKey("scheduled:{$postClassKey}:", (string) $pastMs); $this->assertSame(3, $query()->posts_count); - $this->assertSame('1', (string) $this->getKey("ver:{{$postClassKey}}:")); - $this->assertNull($this->getKey("scheduled:{{$postClassKey}}:")); + $this->assertSame('1', (string) $this->getKey("ver:{$postClassKey}:")); + $this->assertNull($this->getKey("scheduled:{$postClassKey}:")); } public function test_late_writer_does_not_commit_outdated_version_as_current(): void @@ -349,30 +341,28 @@ public function test_late_writer_does_not_commit_outdated_version_as_current(): $initialVersion = NormCache::currentVersion(Author::class); - $buildLock = NormCache::getResultCache(Author::class, [], 'test-hash'); + $buildLock = NormCache::results()->fetch(Author::class, [], 'test-hash', null, []); $this->assertSame('miss', $buildLock->status->value); $author->update(['name' => 'Bob']); $bumpedVersion = NormCache::currentVersion(Author::class); $this->assertGreaterThan($initialVersion, $bumpedVersion); - $committed = NormCache::storeResultCache( + $committed = NormCache::results()->store( $buildLock->key, ['data' => 'outdated'], - $buildLock->buildingKey, null, - $buildLock->wakeKey, - $buildLock->versionKeys, - $buildLock->expectedVersions, // Worker A thinks this is still current - $buildLock->buildingToken + $buildLock->build, ); $this->assertFalse($committed, 'Late writer should have its commit rejected'); - $freshRead = NormCache::getResultCache( + $freshRead = NormCache::results()->fetch( Author::class, [], - 'test-hash' + 'test-hash', + null, + [], ); $this->assertNotSame(['data' => 'outdated'], $freshRead->payload ?? null, 'Late writer must not poison the current fresh cache'); diff --git a/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php b/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php new file mode 100644 index 0000000..383c70a --- /dev/null +++ b/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php @@ -0,0 +1,104 @@ +markTestSkipped('Requires a Redis Cluster (composer test:cluster).'); + } + + if (!class_exists(Redis::class)) { + $this->markTestSkipped('Physical cluster placement check requires the phpredis extension.'); + } + + $this->setClusterMode(true); + } + + public function test_three_spaces_run_and_land_on_distinct_master_nodes(): void + { + // 1. Each space's write + cached read succeeds only if its keys co-locate. + SpacedPost::create(['title' => 'Article', 'author_id' => 1]); + $this->assertSame('Article', SpacedPost::query()->get()->first()->title); + + CatalogTag::create(['name' => 'Widgets']); + $this->assertSame('Widgets', CatalogTag::query()->get()->first()->name); + + ReportingCountry::create(['name' => 'Atlantis']); + $this->assertSame('Atlantis', ReportingCountry::query()->get()->first()->name); + + // 2. Each space's keys physically reside on its own master node. + $contentNode = $this->masterHolding('nc:content'); + $catalogNode = $this->masterHolding('nc:catalog'); + $reportingNode = $this->masterHolding('nc:reporting'); + + $this->assertNotNull($contentNode, 'content keys must exist on a master node'); + $this->assertNotNull($catalogNode, 'catalog keys must exist on a master node'); + $this->assertNotNull($reportingNode, 'reporting keys must exist on a master node'); + + // 3. Distribution: the three spaces spread across three distinct shards. + $this->assertCount( + 3, + array_unique([$contentNode, $catalogNode, $reportingNode]), + 'content/catalog/reporting must each land on a different master node', + ); + } + + // Master node whose keyspace holds this space tag's keys, or null. + private function masterHolding(string $hashTag): ?string + { + foreach ($this->masterNodes() as [$host, $port]) { + $node = new Redis; + $node->connect($host, $port); + + // KEYS treats { } as literal; only the owning node returns this space's keys. + if (!empty($node->keys('{' . $hashTag . '}:*'))) { + $node->close(); + + return "{$host}:{$port}"; + } + + $node->close(); + } + + return null; + } + + /** @return list master nodes, from CLUSTER SLOTS */ + private function masterNodes(): array + { + [$host, $port] = $this->clusterProbeNode(); + $probe = new Redis; + $probe->connect($host, $port); + $slots = $probe->rawCommand('CLUSTER', 'SLOTS'); + $probe->close(); + + $nodes = []; + foreach ($slots as $range) { + $masterPort = (int) $range[2][1]; // [start, end, [masterIp, masterPort, id], ...] + $nodes["{$host}:{$masterPort}"] = [$host, $masterPort]; + } + + return array_values($nodes); + } + + /** @return array{0: string, 1: int} */ + private function clusterProbeNode(): array + { + $node = config('database.redis.clusters.normcache-test.0'); + + return [$node['host'], (int) $node['port']]; + } +} diff --git a/tests/Integration/Infrastructure/SpaceClusterDistributionTest.php b/tests/Integration/Infrastructure/SpaceClusterDistributionTest.php new file mode 100644 index 0000000..2641b49 --- /dev/null +++ b/tests/Integration/Infrastructure/SpaceClusterDistributionTest.php @@ -0,0 +1,64 @@ +markTestSkipped('Requires a Redis Cluster (composer test:cluster).'); + } + + $this->setClusterMode(true); + } + + public function test_distinct_spaces_map_to_distinct_cluster_slots(): void + { + $default = $this->slotFor('nc'); + $content = $this->slotFor('nc:content'); + $catalog = $this->slotFor('nc:catalog'); + + $this->assertNotSame($default, $content, 'content must not share the default slot'); + $this->assertNotSame($content, $catalog, 'content and catalog must be on different slots'); + $this->assertNotSame($default, $catalog, 'catalog must not share the default slot'); + } + + public function test_spaced_model_full_cycle_works_on_cluster(): void + { + // Passes only if the operation's keys co-locate; a cross-slot key throws CROSSSLOT. + $author = SpacedAuthor::create(['name' => 'Ann']); + SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); + + $post = SpacedPost::query()->with('spacedAuthor')->get()->first(); + $this->assertSame('First', $post->title); + $this->assertSame('Ann', $post->spacedAuthor->name); + + $post->update(['title' => 'Second']); + $this->assertSame('Second', SpacedPost::query()->get()->first()->title); + } + + // Redis Cluster hash slot: CRC16 (XMODEM) of the hash tag, mod 16384. + private function slotFor(string $hashTag): int + { + $crc = 0; + + foreach (str_split($hashTag) as $char) { + $crc ^= ord($char) << 8; + + for ($i = 0; $i < 8; $i++) { + $crc = ($crc & 0x8000) ? (($crc << 1) ^ 0x1021) & 0xFFFF : ($crc << 1) & 0xFFFF; + } + } + + return $crc % 16384; + } +} diff --git a/tests/Integration/Infrastructure/StampedeProtectionTest.php b/tests/Integration/Infrastructure/StampedeProtectionTest.php index 1e3ac8a..23231bd 100644 --- a/tests/Integration/Infrastructure/StampedeProtectionTest.php +++ b/tests/Integration/Infrastructure/StampedeProtectionTest.php @@ -29,7 +29,7 @@ private function redis() private function setKey(string $key, string $value, ?int $ttl = null): void { - $prefixed = 'test:' . $key; + $prefixed = '{nc}:test:' . $key; $ttl !== null ? $this->redis()->setex($prefixed, $ttl, $value) : $this->redis()->set($prefixed, $value); @@ -46,17 +46,17 @@ public function test_waiter_serves_from_cache_after_build_completes(): void { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); $hash = $this->authorQueryHash(); Author::get(); - $this->redis()->incr("test:ver:{{$ck}}:"); + $this->redis()->incr("{nc}:test:ver:{$ck}:"); $newVersion = NormCache::currentVersion(Author::class); - $this->redis()->set("test:building:{{$ck}}:{$hash}", '1'); - $this->redis()->lpush("test:wake:{{$ck}}:{$hash}", '1'); - $this->setKey("query:{{$ck}}:v{$newVersion}:{$hash}", json_encode([(string) Author::first()->id], JSON_THROW_ON_ERROR), 60); + $this->redis()->set("{nc}:test:building:{$ck}:v{$newVersion}:{$hash}", '1'); + $this->redis()->lpush("{nc}:test:wake:{$ck}:{$hash}", '1'); + $this->setKey("query:{$ck}:v{$newVersion}:{$hash}", json_encode([(string) Author::first()->id], JSON_THROW_ON_ERROR), 60); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -73,11 +73,12 @@ public function test_budget_exhausted_falls_through_to_db(): void { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); $hash = $this->authorQueryHash(); - $this->redis()->incr("test:ver:{{$ck}}:"); - $this->redis()->set("test:building:{{$ck}}:{$hash}", '1'); + $this->redis()->incr("{nc}:test:ver:{$ck}:"); + $newVersion = NormCache::currentVersion(Author::class); + $this->redis()->set("{nc}:test:building:{$ck}:v{$newVersion}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -94,11 +95,12 @@ public function test_lock_holder_crash_falls_through_after_budget(): void { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); $hash = $this->authorQueryHash(); - $this->redis()->incr("test:ver:{{$ck}}:"); - $this->redis()->set("test:building:{{$ck}}:{$hash}", '1'); + $this->redis()->incr("{nc}:test:ver:{$ck}:"); + $newVersion = NormCache::currentVersion(Author::class); + $this->redis()->set("{nc}:test:building:{$ck}:v{$newVersion}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -116,7 +118,7 @@ public function test_first_miss_claims_building_lock_and_populates_cache(): void Author::create(['name' => 'Alice']); Author::create(['name' => 'Bob']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); $hash = $this->authorQueryHash(); $queryCount = 0; @@ -128,7 +130,7 @@ public function test_first_miss_claims_building_lock_and_populates_cache(): void $this->assertGreaterThan(0, $queryCount); $this->assertCount(2, $results); - $this->assertGreaterThan(0, $this->redis()->llen("test:wake:{{$ck}}:{$hash}")); + $this->assertGreaterThan(0, $this->redis()->llen("{nc}:test:wake:{$ck}:{$hash}")); $queryCount = 0; Author::get(); diff --git a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php b/tests/Integration/Infrastructure/StringPrimaryKeyTest.php index dd38779..2a79a81 100644 --- a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php +++ b/tests/Integration/Infrastructure/StringPrimaryKeyTest.php @@ -118,8 +118,8 @@ public function test_version_key_has_ttl_after_invalidation(): void UuidItem::create(['id' => 'aaaaaaaa-0000-0000-0000-000000000001', 'name' => 'Alpha']); $redis = Redis::connection('normcache-test'); - $classKey = $this->cacheManager()->classKey(UuidItem::class); - $verKey = 'test:ver:{' . $classKey . '}:'; + $classKey = $this->cacheManager()->keys()->classKey(UuidItem::class); + $verKey = '{nc}:test:ver:' . $classKey . ':'; $ttl = $redis->ttl($verKey); @@ -132,8 +132,8 @@ public function test_version_key_ttl_exceeds_longest_payload_ttl(): void UuidItem::create(['id' => 'aaaaaaaa-0000-0000-0000-000000000001', 'name' => 'Alpha']); $redis = Redis::connection('normcache-test'); - $classKey = $this->cacheManager()->classKey(UuidItem::class); - $verKey = 'test:ver:{' . $classKey . '}:'; + $classKey = $this->cacheManager()->keys()->classKey(UuidItem::class); + $verKey = '{nc}:test:ver:' . $classKey . ':'; $verTtl = $redis->ttl($verKey); $modelTtl = (int) config('normcache.ttl'); diff --git a/tests/Integration/Invalidation/ModelInvalidationTest.php b/tests/Integration/Invalidation/ModelInvalidationTest.php index 40077f1..2925cd1 100644 --- a/tests/Integration/Invalidation/ModelInvalidationTest.php +++ b/tests/Integration/Invalidation/ModelInvalidationTest.php @@ -2,8 +2,10 @@ namespace NormCache\Tests\Integration\Invalidation; -use Illuminate\Support\Facades\Redis; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Queue\Events\JobProcessed; use NormCache\Facades\NormCache; +use NormCache\Support\CacheFallback; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; @@ -39,6 +41,20 @@ public function test_updating_model_flushes_model_key_and_increments_version(): $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); } + public function test_save_invalidates_again_after_job_processed_reenables_cache(): void + { + $author = Author::create(['name' => 'Alice']); + $manager = $this->cacheManager(); + $versionBefore = $manager->currentVersion(Author::class); + + CacheFallback::fallback($manager->config(), new \RuntimeException('simulated Redis error')); + $this->app['events']->dispatch(new JobProcessed('testing', $this->createStub(Job::class))); + $author->update(['name' => 'Alicia']); + + $this->assertTrue($manager->isEnabled()); + $this->assertGreaterThan($versionBefore, $manager->currentVersion(Author::class)); + } + public function test_deleting_model_flushes_model_key_and_increments_version(): void { $author = Author::create(['name' => 'Alice']); @@ -79,36 +95,6 @@ public function test_incrementing_model_increments_version_by_exactly_one(): voi $this->assertSame($versionBefore + 1, NormCache::currentVersion(Post::class)); } - public function test_members_set_has_ttl_matching_model_ttl(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $classKey = NormCache::classKey(Author::class); - $memberKey = 'test:members:model:{' . $classKey . '}'; - $redis = Redis::connection('normcache-test'); - - $this->assertGreaterThan(0, $redis->ttl($memberKey), 'members:model: set must have a TTL'); - } - - public function test_members_set_dead_keys_are_bounded_by_ttl(): void - { - $author = Author::create(['name' => 'Alice']); - Author::all(); - - $classKey = NormCache::classKey(Author::class); - $memberKey = 'test:members:model:{' . $classKey . '}'; - $modelKey = $this->prefixedModelKey(Author::class, $author->id); - $redis = Redis::connection('normcache-test'); - - // Simulate model key expiry by deleting it directly. - $redis->del($modelKey); - $this->assertFalse((bool) $redis->exists($modelKey)); - - // Dead keys can accumulate in the members set, but the set's own TTL bounds that growth. - $this->assertGreaterThan(0, $redis->ttl($memberKey), 'members set must expire, bounding dead-key accumulation'); - } - public function test_quiet_instance_writes_still_invalidate_cache(): void { $author = Author::create(['name' => 'Alice']); @@ -193,40 +179,49 @@ public function test_touching_belongs_to_relation_invalidates_related_model_cach $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); } - public function test_instance_update_only_evicts_that_model_key(): void + public function test_instance_update_bumps_version_and_invalidates_all_model_keys(): void { $a1 = Author::create(['name' => 'Alice']); $a2 = Author::create(['name' => 'Bob']); Author::all(); + $versionBefore = NormCache::currentVersion(Author::class); + $a1->update(['name' => 'Alicia']); + $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); $this->assertNull($this->modelCacheEntry(Author::class, $a1->id)); - $this->assertNotNull($this->modelCacheEntry(Author::class, $a2->id)); + $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); } - public function test_instance_increment_only_evicts_that_model_key(): void + public function test_instance_increment_bumps_version_and_invalidates_all_model_keys(): void { $a1 = Author::create(['name' => 'Alice']); $a2 = Author::create(['name' => 'Bob']); Author::all(); + $versionBefore = NormCache::currentVersion(Author::class); + $a1->increment('id', 0); + $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); $this->assertNull($this->modelCacheEntry(Author::class, $a1->id)); - $this->assertNotNull($this->modelCacheEntry(Author::class, $a2->id)); + $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); } - public function test_instance_decrement_only_evicts_that_model_key(): void + public function test_instance_decrement_bumps_version_and_invalidates_all_model_keys(): void { $a1 = Author::create(['name' => 'Alice']); $a2 = Author::create(['name' => 'Bob']); Author::all(); + $versionBefore = NormCache::currentVersion(Author::class); + $a1->decrement('id', 0); + $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); $this->assertNull($this->modelCacheEntry(Author::class, $a1->id)); - $this->assertNotNull($this->modelCacheEntry(Author::class, $a2->id)); + $this->assertNull($this->modelCacheEntry(Author::class, $a2->id)); } public function test_new_query_from_existing_instance_update_invalidates_cache(): void @@ -308,10 +303,11 @@ public function test_dirty_existing_model_save_only_invalidates_once_outside_tra $after = NormCache::currentVersion(Post::class); + // Pre-save flush (observer safety) + post-save flush = two version bumps outside a transaction. $this->assertSame( - $before + 1, + $before + 2, $after, - 'Dirty existing model save should invalidate the model version exactly once.' + 'Dirty existing model save outside a transaction bumps the version twice.' ); } diff --git a/tests/Integration/Invalidation/SoftDeleteInvalidationTest.php b/tests/Integration/Invalidation/SoftDeleteInvalidationTest.php index 410161a..8f98423 100644 --- a/tests/Integration/Invalidation/SoftDeleteInvalidationTest.php +++ b/tests/Integration/Invalidation/SoftDeleteInvalidationTest.php @@ -101,7 +101,7 @@ public function test_soft_deleted_model_is_not_written_to_model_cache_on_miss(): $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); // Trashed models must not be written to the model cache on a miss; normal queries would then surface them. - NormCache::getModels([$post->id], Post::class); + NormCache::hydrator()->getModels([$post->id], Post::class); $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); } @@ -114,7 +114,7 @@ public function test_soft_deleted_model_is_not_returned_from_model_cache_miss(): Post::all(); $post->delete(); - $this->assertSame([], NormCache::getModels([$post->id], Post::class)); + $this->assertSame([], NormCache::hydrator()->getModels([$post->id], Post::class)); } public function test_with_trashed_query_can_return_soft_deleted_model_after_model_cache_miss(): void @@ -140,7 +140,7 @@ public function test_soft_deleted_model_excluded_from_subsequent_cache_reads(): $post->delete(); // Populate model cache for the deleted ID - NormCache::getModels([$post->id], Post::class); + NormCache::hydrator()->getModels([$post->id], Post::class); $ids = Post::all()->pluck('id'); $this->assertNotContains($post->id, $ids); diff --git a/tests/Integration/Invalidation/TransactionInvalidationTest.php b/tests/Integration/Invalidation/TransactionInvalidationTest.php index 494b1c0..360fe51 100644 --- a/tests/Integration/Invalidation/TransactionInvalidationTest.php +++ b/tests/Integration/Invalidation/TransactionInvalidationTest.php @@ -67,7 +67,7 @@ public function test_model_key_deleted_after_transaction_commits(): void $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); } - public function test_transaction_commit_evicts_only_the_updated_model_key(): void + public function test_transaction_commit_bumps_version_and_evicts_all_model_keys(): void { $alice = Author::create(['name' => 'Alice']); $bob = Author::create(['name' => 'Bob']); @@ -76,12 +76,15 @@ public function test_transaction_commit_evicts_only_the_updated_model_key(): voi $this->assertNotNull($this->modelCacheEntry(Author::class, $alice->id)); $this->assertNotNull($this->modelCacheEntry(Author::class, $bob->id)); + $versionBefore = NormCache::currentVersion(Author::class); + DB::transaction(function () use ($alice) { $alice->update(['name' => 'Alicia']); }); + $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); $this->assertNull($this->modelCacheEntry(Author::class, $alice->id)); - $this->assertNotNull($this->modelCacheEntry(Author::class, $bob->id)); + $this->assertNull($this->modelCacheEntry(Author::class, $bob->id)); } public function test_model_key_preserved_after_transaction_rollback(): void @@ -188,7 +191,7 @@ public function test_committed_transaction_invalidates_outdated_query_cache(): v $this->assertSame('Alicia', $result->name); } - public function test_insert_in_transaction_preserves_existing_model_payloads_on_commit(): void + public function test_insert_in_transaction_bumps_version_on_commit(): void { $alice = Author::create(['name' => 'Alice']); $bob = Author::create(['name' => 'Bob']); @@ -197,13 +200,15 @@ public function test_insert_in_transaction_preserves_existing_model_payloads_on_ $this->assertNotNull($this->modelCacheEntry(Author::class, $alice->id)); $this->assertNotNull($this->modelCacheEntry(Author::class, $bob->id)); + $versionBefore = NormCache::currentVersion(Author::class); + DB::transaction(function () { Author::create(['name' => 'Carol']); }); - // Insert only needs a version bump — existing payloads should not be flushed. - $this->assertNotNull($this->modelCacheEntry(Author::class, $alice->id)); - $this->assertNotNull($this->modelCacheEntry(Author::class, $bob->id)); + $this->assertGreaterThan($versionBefore, NormCache::currentVersion(Author::class)); + $this->assertNull($this->modelCacheEntry(Author::class, $alice->id)); + $this->assertNull($this->modelCacheEntry(Author::class, $bob->id)); } public function test_insert_in_transaction_still_invalidates_query_cache_on_commit(): void @@ -286,4 +291,17 @@ public function test_flush_and_update_in_transaction_bumps_version_once(): void $this->assertSame($before + 1, $after); } + + public function test_model_and_table_invalidation_for_the_same_table_bump_once_on_commit(): void + { + $author = Author::create(['name' => 'Alice']); + $before = NormCache::currentVersion(Author::class); + + DB::transaction(function () use ($author) { + $author->update(['name' => 'Alicia']); + NormCache::invalidateTableVersion(DB::getDefaultConnection(), 'authors'); + }); + + $this->assertSame($before + 1, NormCache::currentVersion(Author::class)); + } } diff --git a/tests/Integration/Planning/QueryDependencyPlanningTest.php b/tests/Integration/Planning/QueryDependencyPlanningTest.php new file mode 100644 index 0000000..d424ef4 --- /dev/null +++ b/tests/Integration/Planning/QueryDependencyPlanningTest.php @@ -0,0 +1,171 @@ +modelsPlan( + Author::whereIn('id', fn($query) => $query->from('posts')->select('author_id')), + ); + + $this->assertTracksTableOrBypasses($plan, 'testing:posts'); + } + + public function test_or_where_in_query_builder_tracks_its_subquery_table_or_bypasses(): void + { + $plan = $this->modelsPlan( + Author::where('name', 'Alice')->orWhereIn('id', Post::select('author_id')), + ); + + $this->assertTracksTableOrBypasses($plan, 'testing:posts'); + } + + public function test_or_where_not_in_query_builder_tracks_its_subquery_table_or_bypasses(): void + { + $plan = $this->modelsPlan( + Author::where('name', 'Alice')->orWhereNotIn('id', Post::select('author_id')), + ); + + $this->assertTracksTableOrBypasses($plan, 'testing:posts'); + } + + public function test_raw_expression_subquery_predicate_bypasses(): void + { + $plan = $this->modelsPlan( + Author::where( + DB::raw('(select count(*) from posts where posts.author_id = authors.id)'), + '>', + 3, + ), + ); + + $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); + $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); + } + + public function test_raw_expression_in_predicate_bypasses(): void + { + $plan = $this->modelsPlan( + Author::whereIn('id', [DB::raw('select author_id from banned_authors')]), + ); + + $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); + $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); + } + + public function test_nested_where_preserves_captured_subquery_dependencies(): void + { + $plan = $this->modelsPlan( + Author::where(fn($query) => $query->whereIn('id', Post::select('author_id'))), + ); + + $this->assertSame(CacheStrategy::VersionedResult, $plan->strategy); + $this->assertContains('testing:posts', $plan->dependencies->tables); + } + + public function test_nested_where_preserves_captured_safety_reasons(): void + { + $plan = $this->modelsPlan( + Author::where(fn($query) => $query->whereHas('lockedPosts')), + ); + + $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); + $this->assertNotEmpty($plan->bypassReasons['safety'] ?? []); + } + + public function test_relation_callback_lock_bypasses_and_writes_no_result_cache(): void + { + $builder = Author::whereHas('posts', fn($query) => $query->lockForUpdate()); + + $this->assertSame(CacheStrategy::LiveQuery, $this->modelsPlan($builder)->strategy); + + $builder->get(); + + $this->assertEmpty($this->redisKeys('result:*')); + } + + public function test_subquery_dependency_uses_the_subquery_connection_namespace(): void + { + config()->set('database.connections.secondary_testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + DB::purge('secondary_testing'); + + $plan = $this->modelsPlan( + Author::query()->whereIn('id', Author::on('secondary_testing')->select('id')), + ); + + $this->assertContains('secondary_testing:authors', $plan->dependencies->tables); + $this->assertNotContains('testing:authors', $plan->dependencies->tables); + } + + public function test_scalar_plan_honours_captured_dependency_reasons(): void + { + $plan = $this->scalarPlan($this->opaqueSelectSubqueryWithHaving()); + + $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); + $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); + } + + public function test_pagination_count_plan_honours_captured_dependency_reasons(): void + { + $plan = $this->paginationPlan($this->opaqueSelectSubqueryWithHaving()); + + $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); + $this->assertNotEmpty($plan->bypassReasons['dependency'] ?? []); + } + + private function opaqueSelectSubqueryWithHaving(): CacheableBuilder + { + return Author::selectSub( + fn($query) => $query->from('posts') + ->selectRaw('count(*)') + ->whereColumn('posts.author_id', 'authors.id'), + 'x', + )->having('x', '>', 0); + } + + private function modelsPlan(CacheableBuilder $builder): CachePlan + { + return $this->plan($builder, CachePlanContext::models()); + } + + private function scalarPlan(CacheableBuilder $builder): CachePlan + { + return $this->plan($builder, CachePlanContext::scalar(['*'])); + } + + private function paginationPlan(CacheableBuilder $builder): CachePlan + { + return $this->plan($builder, CachePlanContext::paginationCount()); + } + + private function plan(CacheableBuilder $builder, CachePlanContext $context): CachePlan + { + $prepared = $builder->prepareCacheExecution(); + + return $prepared->builder->cachePlan($prepared->base, $context); + } + + private function assertTracksTableOrBypasses(CachePlan $plan, string $table): void + { + $this->assertTrue( + $plan->strategy === CacheStrategy::LiveQuery || in_array($table, $plan->dependencies->tables, true), + "Expected the plan to bypass or track [{$table}], got [{$plan->strategy->name}] with dependencies [" + . implode(', ', $plan->dependencies->tables) . '].', + ); + } +} diff --git a/tests/Integration/Planning/SilentBypassLogTest.php b/tests/Integration/Planning/SilentBypassLogTest.php index e6eda88..fa23e70 100644 --- a/tests/Integration/Planning/SilentBypassLogTest.php +++ b/tests/Integration/Planning/SilentBypassLogTest.php @@ -4,6 +4,7 @@ use Illuminate\Support\Facades\Log; use NormCache\Tests\Fixtures\Models\Author; +use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\TestCase; class SilentBypassLogTest extends TestCase @@ -21,6 +22,20 @@ public function test_logs_warning_on_unsafe_dependency_bypass() Author::whereHas('posts', fn($q) => $q->whereRaw('1 = 1'))->get(); } + public function test_cross_space_bypass_logs_only_the_space_warning() + { + config(['app.debug' => true]); + + Log::shouldReceive('warning') + ->once() + ->withArgs(function ($msg) { + return str_contains($msg, 'not in that space') + && !str_contains($msg, 'unsafe dependency inference'); + }); + + SpacedPost::query()->dependsOn([Author::class])->get(); + } + public function test_logs_dependency_bypass_warning_when_events_and_debugbar_are_disabled() { config([ diff --git a/tests/Integration/RelationOverrideTest.php b/tests/Integration/RelationOverrideTest.php index 2d3cf50..0f5da08 100644 --- a/tests/Integration/RelationOverrideTest.php +++ b/tests/Integration/RelationOverrideTest.php @@ -88,11 +88,11 @@ public function test_eager_has_many_relation_uses_normalized_query_and_model_cac $this->assertCount(1, $first->posts); $this->assertNotEmpty( - $this->redisKeys('test:query:{*posts}:*'), + $this->redisKeys('query:*:posts:*'), 'Expected simple hasMany eager load to populate the normalized query-id cache' ); $this->assertNotEmpty( - $this->redisKeys('test:model:{*posts}:*'), + $this->redisKeys('model:*:posts:*'), 'Expected simple hasMany eager load to populate the per-id model cache' ); } @@ -161,7 +161,7 @@ public function test_has_many_result_payload_with_count_invalidates_when_counted $first = $query(); $this->assertSame(1, $first->posts->first()->comments_count); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); $warm = $query(); $this->assertSame(1, $warm->posts->first()->comments_count); @@ -172,7 +172,7 @@ public function test_has_many_result_payload_with_count_invalidates_when_counted $this->assertSame(2, $after->posts->first()->comments_count); } - public function test_has_many_subquery_constraint_bypasses_result_payload(): void + public function test_has_many_subquery_constraint_uses_result_payload(): void { $author = Author::create(['name' => 'Alice']); $post = Post::create(['title' => 'Post 1', 'author_id' => $author->id]); @@ -185,7 +185,7 @@ public function test_has_many_subquery_constraint_bypasses_result_payload(): voi ->get(); $this->assertSame(['Post 1'], $posts->pluck('title')->all()); - $this->assertEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_eager_morph_many_relation_is_served_from_cache_and_invalidated(): void diff --git a/tests/TestCase.php b/tests/TestCase.php index 9432ebc..1018935 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,21 +10,12 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; -use NormCache\Cache\ExecutionEngine; -use NormCache\Cache\ModelHydrator; -use NormCache\Cache\NormalizedCacheReader; -use NormCache\Cache\NormalizedThroughReader; -use NormCache\Cache\ResultCacheReader; -use NormCache\Cache\ResultExecutor; -use NormCache\Cache\VersionTracker; use NormCache\CacheManager; +use NormCache\CacheManagerFactory; use NormCache\CacheServiceProvider; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisStore; -use NormCache\Values\CacheConfig; use Orchestra\Testbench\TestCase as OrchestraTestCase; use Predis\Client; -use ReflectionProperty; abstract class TestCase extends OrchestraTestCase { @@ -90,7 +81,6 @@ protected function defineEnvironment($app): void 'password' => env('REDIS_PASSWORD', null), ]; }, $nodes)); - $app['config']->set('normcache.cluster', true); } else { $app['config']->set('database.redis.normcache-test', [ 'host' => env('REDIS_HOST', '127.0.0.1'), @@ -104,7 +94,6 @@ protected function defineEnvironment($app): void $app['config']->set('normcache.enabled', true); $app['config']->set('normcache.events', true); $app['config']->set('normcache.key_prefix', 'test:'); - $app['config']->set('normcache.slotting', true); $app['config']->set('normcache.ttl', 3600); $app['config']->set('normcache.query_ttl', 60); $app['config']->set('normcache.cooldown', 0); @@ -117,34 +106,42 @@ protected function defineDatabaseMigrations(): void protected function resetClassKeyCache(): void { - (new ReflectionProperty(CacheKeyBuilder::class, 'classKeys'))->setValue(null, []); - (new ReflectionProperty(CacheKeyBuilder::class, 'prototypes'))->setValue(null, []); - (new ReflectionProperty(CacheKeyBuilder::class, 'deletedAtColumns'))->setValue(null, []); + CacheKeyBuilder::reset(); } protected function modelCacheEntry(string $class, mixed $id): mixed { - $key = 'model:{' . $this->cacheManager()->classKey($class) . '}:' . $id; + $manager = $this->cacheManager(); - return $this->cacheManager()->getStore()->get($key); + return $manager->store()->get($this->currentModelKey($manager, $class, $id)); } protected function evictModelCache(string $class, mixed $id): void { - $key = 'model:{' . $this->cacheManager()->classKey($class) . '}:' . $id; - $this->cacheManager()->getStore()->delete($key); + $manager = $this->cacheManager(); + $manager->store()->delete($this->currentModelKey($manager, $class, $id)); } protected function prefixedModelKey(string $class, mixed $id): string { - $key = 'model:{' . $this->cacheManager()->classKey($class) . '}:' . $id; + $manager = $this->cacheManager(); - return $this->cacheManager()->getStore()->prefix($key); + return $this->currentModelKey($manager, $class, $id); + } + + private function currentModelKey(CacheManager $manager, string $class, mixed $id): string + { + $classKey = $manager->keys()->classKey($class); + $version = $manager->currentVersion($class); + + return $manager->keys()->modelPrefix($classKey, $version) . $id; } protected function redisKeys(string $pattern = '*'): array { - return $this->cacheManager()->getStore()->scanPattern($pattern); + $manager = $this->cacheManager(); + + return $manager->store()->scanPattern($manager->keys()->prefixed($pattern)); } protected function cacheManager(): CacheManager @@ -156,12 +153,11 @@ protected function setClusterMode(bool $enabled): void { $this->app->forgetInstance(CacheManager::class); $this->app->forgetInstance('normcache'); - config(['normcache.cluster' => $enabled]); } /** * Build a standalone CacheManager (not bound in the container) for tests - * that need specific construction parameters like cooldown or slotting. + * that need specific construction parameters like cooldown. */ protected function buildManager( string $connection = 'normcache-test', @@ -169,7 +165,6 @@ protected function buildManager( ?int $queryTtl = null, string $keyPrefix = 'test:', int $cooldown = 0, - bool $cluster = false, bool $enabled = true, bool $dispatchEvents = true, bool $fallback = false, @@ -177,41 +172,21 @@ protected function buildManager( int $buildingLockTtl = 5, int $stampedeWaitMs = 200, int $stampedeWakeTokens = 64, - bool $slotting = false, ): CacheManager { - $ttl ??= (int) config('normcache.ttl'); - $queryTtl ??= (int) config('normcache.query_ttl'); - - $slottingActive = $cluster && $slotting; - $store = new RedisStore($connection, $keyPrefix, $slottingActive, $slotting ? '' : '{nc}:', $stampedeWakeTokens); - $keys = new CacheKeyBuilder; - $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens); - $engine = new ExecutionEngine; - $config = new CacheConfig( - ttl: $ttl, - queryTtl: $queryTtl, - cooldown: $cooldown, - enabled: $enabled, - fallbackEnabled: $fallback, - dispatchEvents: $dispatchEvents, - cluster: $cluster, - slotting: $slottingActive, - stampedeWakeTokens: $stampedeWakeTokens, - ); - - return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens), - resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens), - result: new ResultExecutor($engine, $resultReader, $config), - hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), - versions: $versions, - engine: $engine, - store: $store, - keys: $keys, - config: $config, - ); + return $this->app->make(CacheManagerFactory::class)->make([ + 'connection' => $connection, + 'ttl' => $ttl ?? (int) config('normcache.ttl'), + 'query_ttl' => $queryTtl ?? (int) config('normcache.query_ttl'), + 'key_prefix' => $keyPrefix, + 'cooldown' => $cooldown, + 'enabled' => $enabled, + 'events' => $dispatchEvents, + 'fallback' => $fallback, + 'fire_retrieved' => $fireRetrieved, + 'building_lock_ttl' => $buildingLockTtl, + 'stampede_wait_ms' => $stampedeWaitMs, + 'stampede_wake_tokens' => $stampedeWakeTokens, + ]); } /** Assert native == cold == warm for a given query. */ diff --git a/tests/Unit/BypassReasonsTest.php b/tests/Unit/BypassReasonsTest.php index 135487b..d71c20d 100644 --- a/tests/Unit/BypassReasonsTest.php +++ b/tests/Unit/BypassReasonsTest.php @@ -30,7 +30,7 @@ public function test_raw_order_is_reported_as_dependency_bypass_reason(): void public function test_raw_where_is_reported_as_dependency_bypass_reason(): void { $query = $this->makeBaseQuery(null); - $query->wheres = [['type' => 'Raw', 'sql' => 'LOWER(name) = ?', 'boolean' => 'and']]; + $query->wheres = [['type' => 'raw', 'sql' => 'LOWER(name) = ?', 'boolean' => 'and']]; $this->assertSame( ['dependency' => ['raw WHERE expression']], diff --git a/tests/Unit/Cache/ExecutionEngineTest.php b/tests/Unit/Cache/ExecutionEngineTest.php index 33ebffd..7c53673 100644 --- a/tests/Unit/Cache/ExecutionEngineTest.php +++ b/tests/Unit/Cache/ExecutionEngineTest.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Collection; use NormCache\Cache\ExecutionEngine; use NormCache\Enums\CacheStatus; +use NormCache\Values\BuildHandle; use NormCache\Values\PivotCacheResult; use NormCache\Values\QueryCacheResult; use NormCache\Values\ResultCacheResult; @@ -20,103 +21,13 @@ protected function setUp(): void $this->executor = new ExecutionEngine; } - // ------------------------------------------------------------------------- - // runResult - // ------------------------------------------------------------------------- - - public function test_run_result_calls_on_hit_and_returns_collection(): void - { - $hit = new ResultCacheResult(CacheStatus::Hit, 'k', ['data'], null, null, null, [], []); - $hitCalled = false; - - $result = $this->executor->runResult( - fetch: fn() => $hit, - waitForBuild: fn() => null, - onMiss: fn($r) => [new Collection, []], - onStore: fn($p, $r) => null, - onHit: function ($r) use (&$hitCalled) { - $hitCalled = true; - - return new Collection; - }, - onBuild: fn() => new Collection, - ); - - $this->assertTrue($hitCalled); - $this->assertInstanceOf(Collection::class, $result); - } - - public function test_run_result_calls_on_miss_store_and_returns_models(): void - { - $miss = new ResultCacheResult(CacheStatus::Miss, 'k', null, null, null, null, [], []); - $storeCalled = false; - $models = new Collection; - - $result = $this->executor->runResult( - fetch: fn() => $miss, - waitForBuild: fn() => null, - onMiss: fn($r) => [$models, ['payload']], - onStore: function ($p, $r) use (&$storeCalled) { - $storeCalled = true; - }, - onHit: fn($r) => new Collection, - onBuild: fn() => new Collection, - ); - - $this->assertTrue($storeCalled); - $this->assertSame($models, $result); - } - - public function test_run_result_calls_on_build_when_wait_returns_null(): void - { - $building = new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - $buildCalled = false; - - $this->executor->runResult( - fetch: fn() => $building, - waitForBuild: fn() => null, - onMiss: fn($r) => [new Collection, []], - onStore: fn($p, $r) => null, - onHit: fn($r) => new Collection, - onBuild: function () use (&$buildCalled) { - $buildCalled = true; - - return new Collection; - }, - ); - - $this->assertTrue($buildCalled); - } - - public function test_run_result_retries_hit_after_successful_wait(): void - { - $building = new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - $hit = new ResultCacheResult(CacheStatus::Hit, 'k', ['data'], null, null, null, [], []); - $hitCalled = false; - - $this->executor->runResult( - fetch: fn() => $building, - waitForBuild: fn() => $hit, - onMiss: fn($r) => [new Collection, []], - onStore: fn($p, $r) => null, - onHit: function ($r) use (&$hitCalled) { - $hitCalled = true; - - return new Collection; - }, - onBuild: fn() => new Collection, - ); - - $this->assertTrue($hitCalled); - } - // ------------------------------------------------------------------------- // runPivot // ------------------------------------------------------------------------- public function test_run_pivot_calls_on_hit_when_no_missed_ids(): void { - $pivotResult = new PivotCacheResult('v1', [1 => [['id' => 5]]], [], []); + $pivotResult = new PivotCacheResult('v1', [1 => [['id' => 5]]]); $hitCalled = false; $this->executor->runPivot( @@ -137,7 +48,7 @@ public function test_run_pivot_calls_on_hit_when_no_missed_ids(): void public function test_run_pivot_calls_on_miss_and_store_when_missed_ids_present(): void { - $pivotResult = new PivotCacheResult('v1', [1 => null, 2 => [['id' => 5]]], [], []); + $pivotResult = new PivotCacheResult('v1', [1 => null, 2 => [['id' => 5]]]); $missCalled = false; $storeCalled = false; @@ -162,7 +73,7 @@ public function test_run_pivot_calls_on_miss_and_store_when_missed_ids_present() public function test_run_pivot_can_store_raw_models_and_return_transformed_models(): void { - $pivotResult = new PivotCacheResult('v1', [1 => null], [], []); + $pivotResult = new PivotCacheResult('v1', [1 => null]); $raw = new Collection(['raw']); $visible = new Collection(['visible']); $stored = null; @@ -188,7 +99,7 @@ public function test_run_pivot_can_store_raw_models_and_return_transformed_model public function test_run_normalized_calls_on_hit_with_result(): void { - $hit = new QueryCacheResult(CacheStatus::Hit, 'k', [1, 2], null, null, null, [], []); + $hit = new QueryCacheResult(CacheStatus::Hit, 'k', [1, 2], null); $received = null; $this->executor->runNormalized( @@ -208,7 +119,7 @@ public function test_run_normalized_calls_on_hit_with_result(): void public function test_run_normalized_calls_on_miss_with_result(): void { - $miss = new QueryCacheResult(CacheStatus::Miss, 'k', null, null, 'bk', 'tok', ['ver:k:'], ['5']); + $miss = new QueryCacheResult(CacheStatus::Miss, 'k', null, null, new BuildHandle('bk', 'tok', null, ['ver:k:'], ['5'])); $received = null; $this->executor->runNormalized( @@ -228,7 +139,7 @@ public function test_run_normalized_calls_on_miss_with_result(): void public function test_run_normalized_calls_on_build_when_wait_returns_null(): void { - $building = new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); + $building = new QueryCacheResult(CacheStatus::Building, null, null, null); $buildCalled = false; $this->executor->runNormalized( @@ -248,8 +159,8 @@ public function test_run_normalized_calls_on_build_when_wait_returns_null(): voi public function test_run_normalized_retries_after_successful_wait(): void { - $building = new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - $hit = new QueryCacheResult(CacheStatus::Hit, 'k', [1], null, null, null, [], []); + $building = new QueryCacheResult(CacheStatus::Building, null, null, null); + $hit = new QueryCacheResult(CacheStatus::Hit, 'k', [1], null); $hitCalled = false; $this->executor->runNormalized( @@ -273,7 +184,7 @@ public function test_run_normalized_retries_after_successful_wait(): void public function test_run_scalar_calls_on_hit_on_cache_hit(): void { - $hit = new ResultCacheResult(CacheStatus::Hit, 'k', 42, null, null, null, [], []); + $hit = new ResultCacheResult(CacheStatus::Hit, 'k', 42); $received = null; $this->executor->runScalar( @@ -293,7 +204,7 @@ public function test_run_scalar_calls_on_hit_on_cache_hit(): void public function test_run_scalar_calls_compute_and_store_on_miss(): void { - $miss = new ResultCacheResult(CacheStatus::Miss, 'k', null, null, null, null, [], []); + $miss = new ResultCacheResult(CacheStatus::Miss, 'k', null); $storeCalled = false; $result = $this->executor->runScalar( @@ -312,7 +223,7 @@ public function test_run_scalar_calls_compute_and_store_on_miss(): void public function test_run_scalar_calls_compute_on_build_budget_exhausted(): void { - $building = new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); + $building = new ResultCacheResult(CacheStatus::Building, null, null); $result = $this->executor->runScalar( fetch: fn() => $building, @@ -327,8 +238,8 @@ public function test_run_scalar_calls_compute_on_build_budget_exhausted(): void public function test_run_scalar_retries_after_successful_wait(): void { - $building = new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); - $hit = new ResultCacheResult(CacheStatus::Hit, 'k', 55, null, null, null, [], []); + $building = new ResultCacheResult(CacheStatus::Building, null, null); + $hit = new ResultCacheResult(CacheStatus::Hit, 'k', 55); $hitCalled = false; $this->executor->runScalar( diff --git a/tests/Unit/Cache/VersionTrackerSpaceTest.php b/tests/Unit/Cache/VersionTrackerSpaceTest.php new file mode 100644 index 0000000..7f81a60 --- /dev/null +++ b/tests/Unit/Cache/VersionTrackerSpaceTest.php @@ -0,0 +1,44 @@ +classKey(Post::class); + + $store->setRaw($keys->verKey($classKey, $content), '7', 60); + + $this->assertSame(7, $tracker->currentVersion(Post::class, $content)); + // The default space's version key was never seeded. + $this->assertSame(0, $tracker->currentVersion(Post::class)); + } + + public function test_build_lock_tokens_are_random_128_bit_values(): void + { + $tracker = new VersionTracker( + new RedisStore('normcache-test'), + new CacheKeyBuilder, + ); + + $first = $tracker->buildLockToken(); + $second = $tracker->buildLockToken(); + + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $first); + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $second); + $this->assertNotSame($first, $second); + } +} diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php new file mode 100644 index 0000000..8541918 --- /dev/null +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -0,0 +1,117 @@ +assertSame('{nc:content}:test:ver:mysql:posts:', $keys->verKey('mysql:posts', $content)); + $this->assertSame('{nc:content}:test:model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3, $content)); + $this->assertSame('{nc:content}:test:query:mysql:posts:', $keys->queryPrefix('mysql:posts', null, $content)); + $this->assertSame('{nc:content}:test:query:*', $keys->prefixed('query:*', $content)); + } + + public function test_null_space_keeps_the_default_tag(): void + { + $keys = new CacheKeyBuilder('{nc}:', 'test:'); + + $this->assertSame('{nc}:test:ver:mysql:posts:', $keys->verKey('mysql:posts')); + } + + public function test_class_key_can_be_scoped_to_an_effective_connection(): void + { + $keys = new CacheKeyBuilder; + + $this->assertSame('testing:authors', $keys->classKey(Author::class)); + $this->assertSame('secondary_testing:authors', $keys->classKey(Author::class, 'secondary_testing')); + } + + public function test_table_key_strips_an_explicit_sql_alias(): void + { + $keys = new CacheKeyBuilder; + + $this->assertSame('testing:authors', $keys->tableKey('testing', 'authors as a')); + $this->assertSame('authors', CacheKeyBuilder::stripTableAlias('authors as a')); + } + + public function test_class_key_rejects_connection_name_containing_colon(): void + { + $model = new class extends Model + { + protected $connection = 'tenant:7'; + }; + + $keys = new CacheKeyBuilder; + + $this->expectException(\InvalidArgumentException::class); + + $keys->classKey($model::class); + } + + public function test_version_keys_are_brace_free(): void + { + $keys = new CacheKeyBuilder('', ''); + + $this->assertSame('ver:mysql:posts:', $keys->verKey('mysql:posts')); + $this->assertSame('scheduled:mysql:posts:', $keys->scheduledKey('mysql:posts')); + $this->assertSame('building:mysql:posts:', $keys->buildingPrefix('mysql:posts')); + $this->assertSame('wake:mysql:posts:', $keys->wakePrefix('mysql:posts')); + $this->assertSame('model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3)); + } + + public function test_dep_key_pairs_respects_active_space_in_static_cache(): void + { + $keys = new CacheKeyBuilder('{nc}:', 'test:'); + $content = new CacheSpace('content', 'nc:content'); + + [$defaultVer] = $keys->depKeyPairs('mysql:posts', []); + [$contentVer] = $keys->withSpace($content, fn() => $keys->depKeyPairs('mysql:posts', [])); + + $this->assertSame('{nc}:test:ver:mysql:posts:', $defaultVer[0]); + $this->assertSame('{nc:content}:test:ver:mysql:posts:', $contentVer[0]); + $this->assertNotSame($defaultVer[0], $contentVer[0], 'same classKey under two spaces must produce distinct version keys'); + } + + public function test_active_space_is_exposed_and_restored(): void + { + $keys = new CacheKeyBuilder('{nc}:', 'test:'); + $content = new CacheSpace('content', 'nc:content'); + + $seen = $keys->withSpace($content, fn() => $keys->activeSpace()); + + $this->assertSame($content, $seen); + $this->assertNull($keys->activeSpace()); + } + + public function test_key_methods_emit_full_keys_with_hash_tag_and_prefix(): void + { + $keys = new CacheKeyBuilder('{nc}:', 'test:'); + + $this->assertSame('{nc}:test:ver:mysql:posts:', $keys->verKey('mysql:posts')); + $this->assertSame('{nc}:test:scheduled:mysql:posts:', $keys->scheduledKey('mysql:posts')); + $this->assertSame('{nc}:test:building:mysql:posts:', $keys->buildingPrefix('mysql:posts')); + $this->assertSame('{nc}:test:wake:mysql:posts:', $keys->wakePrefix('mysql:posts')); + $this->assertSame('{nc}:test:model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3)); + $this->assertSame('{nc}:test:query:mysql:posts:', $keys->queryPrefix('mysql:posts')); + $this->assertSame('{nc}:test:query:*', $keys->prefixed('query:*')); + } + + public function test_result_build_identity_uses_xxh128(): void + { + $keys = new CacheKeyBuilder; + $hash = $keys->resultBuildIdentityHash('scalar', 'report', 'query-hash'); + + $this->assertSame(32, strlen($hash)); + $this->assertSame(hash('xxh128', 'scalar:report:query-hash'), $hash); + } +} diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index ab192ee..52f4642 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -2,12 +2,25 @@ namespace NormCache\Tests\Unit; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Queue\Events\JobProcessed; +use Illuminate\Queue\Events\Looping; +use Illuminate\Redis\Connections\PredisConnection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use NormCache\CacheManager; +use NormCache\CacheManagerFactory; +use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Spaces\CacheSpaceResolver; +use NormCache\Support\CacheFallback; +use NormCache\Support\CacheKeyBuilder; +use NormCache\Support\RedisStore; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; +use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\TestCase; +use NormCache\Values\BuildHandle; +use ReflectionProperty; class CacheManagerTest extends TestCase { @@ -19,16 +32,103 @@ protected function setUp(): void $this->manager = $this->cacheManager(); } + public function test_factory_builds_manager_with_explicit_overrides(): void + { + $manager = $this->app->make(CacheManagerFactory::class)->make([ + 'connection' => 'normcache-test', + 'ttl' => 123, + 'query_ttl' => 45, + 'key_prefix' => 'factory:', + 'cooldown' => 7, + 'enabled' => false, + 'events' => false, + 'fallback' => true, + 'fire_retrieved' => true, + 'building_lock_ttl' => 9, + 'stampede_wait_ms' => 11, + 'stampede_wake_tokens' => 3, + ]); + + $this->assertSame(123, $manager->config()->ttl); + $this->assertSame(45, $manager->config()->queryTtl); + $this->assertSame(7, $manager->config()->cooldown); + $this->assertFalse($manager->isEnabled()); + $this->assertFalse($manager->isEventsEnabled()); + $this->assertTrue($manager->isFallbackEnabled()); + + $classKey = $manager->keys()->classKey(Author::class); + $this->assertSame("{nc}:factory:ver:{$classKey}:", $manager->keys()->verKey($classKey)); + } + + public function test_container_scopes_cache_manager_to_the_current_lifecycle(): void + { + $first = $this->app->make(CacheManager::class); + $first->disable(); + + $this->app->forgetScopedInstances(); + + $second = $this->app->make(CacheManager::class); + + $this->assertNotSame($first, $second); + $this->assertTrue($second->isEnabled()); + } + + public function test_enable_reenables_cache_after_fallback(): void + { + CacheFallback::fallback($this->manager->config(), new \RuntimeException('Redis unavailable')); + + $this->assertFalse($this->manager->isEnabled()); + + $this->manager->enable(); + + $this->assertTrue($this->manager->isEnabled()); + } + + public function test_job_processed_listener_reenables_cache_and_discards_pending_invalidations(): void + { + $before = $this->manager->currentVersion(Author::class); + + DB::beginTransaction(); + + try { + $this->manager->invalidateVersion(new Author); + CacheFallback::fallback($this->manager->config(), new \RuntimeException('Redis unavailable')); + + $this->app['events']->dispatch(new JobProcessed('testing', $this->createStub(Job::class))); + + $this->assertTrue($this->manager->isEnabled()); + + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + + throw $e; + } + + $this->assertSame($before, $this->manager->currentVersion(Author::class)); + } + + public function test_lifecycle_listener_resets_static_cache_metadata(): void + { + $first = CacheKeyBuilder::prototype(Author::class); + + $this->app['events']->dispatch(new Looping('testing', 'default')); + + $second = CacheKeyBuilder::prototype(Author::class); + + $this->assertNotSame($first, $second); + } + // ------------------------------------------------------------------------- // RedisStore pass-through (basic I/O tests live in RedisStoreTest) // ------------------------------------------------------------------------- public function test_store_get_many_returns_values_in_key_order(): void { - $this->manager->getStore()->set('a', 'alpha', 60); - $this->manager->getStore()->set('c', 'gamma', 60); + $this->manager->store()->set('{nc}:a', 'alpha', 60); + $this->manager->store()->set('{nc}:c', 'gamma', 60); - $result = $this->manager->getStore()->getMany(['a', 'b', 'c']); + $result = $this->manager->store()->getMany(['{nc}:a', '{nc}:b', '{nc}:c']); $this->assertSame(['alpha', null, 'gamma'], $result); } @@ -59,10 +159,10 @@ public function test_invalidate_version_called_twice_increments_twice(): void public function test_invalidate_version_schedules_once_with_cooldown(): void { - $manager = $this->buildManager(cooldown: 60, cluster: true, slotting: true); + $manager = $this->buildManager(cooldown: 60); $redis = Redis::connection('normcache-test'); - $classKey = $manager->classKey(Author::class); - $scheduledKey = "test:scheduled:{{$classKey}}:"; + $classKey = $manager->keys()->classKey(Author::class); + $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; $manager->invalidateVersion(new Author); $firstDueAt = $redis->get($scheduledKey); @@ -77,11 +177,11 @@ public function test_invalidate_version_schedules_once_with_cooldown(): void public function test_current_version_applies_due_scheduled_invalidation(): void { - $manager = $this->buildManager(cooldown: 60, cluster: true, slotting: true); + $manager = $this->buildManager(cooldown: 60); - $classKey = $manager->classKey(Author::class); + $classKey = $manager->keys()->classKey(Author::class); Redis::connection('normcache-test')->set( - "test:scheduled:{{$classKey}}:", + "{nc}:test:scheduled:{$classKey}:", (string) ((int) floor(microtime(true) * 1000) - 1000) ); @@ -90,71 +190,81 @@ public function test_current_version_applies_due_scheduled_invalidation(): void public function test_current_version_always_reads_from_redis(): void { - Redis::connection('normcache-test')->set('test:ver:{' . DB::getDefaultConnection() . ':posts}:', 99); + Redis::connection('normcache-test')->set('{nc}:test:ver:' . DB::getDefaultConnection() . ':posts:', 99); $this->assertSame(99, $this->manager->currentVersion(Post::class)); } - public function test_default_non_slotting_prefixes_all_keys_and_preserves_existing_prefix(): void + public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void { - $manager = $this->buildManager(cluster: true, slotting: false); + $manager = $this->buildManager(); + + $classKey = $manager->keys()->classKey(Author::class); + $keys = $manager->keys(); - $classKey = $manager->classKey(Author::class); - $store = $manager->getStore(); + $fullVerKey = $keys->verKey($classKey); + $this->assertSame("{nc}:test:ver:{$classKey}:", $fullVerKey); - $store->set("ver:{{$classKey}}:", 7, 60); + $manager->store()->set($fullVerKey, 7, 60); - $this->assertSame("{nc}:test:ver:{{$classKey}}:", $store->prefix("ver:{{$classKey}}:")); - $this->assertSame('7', Redis::connection('normcache-test')->get("{nc}:test:ver:{{$classKey}}:")); + $this->assertSame('7', Redis::connection('normcache-test')->get("{nc}:test:ver:{$classKey}:")); $this->assertSame(7, $manager->currentVersion(Author::class)); } + public function test_with_space_reuses_matching_active_space(): void + { + $content = $this->manager->spaceFor(SpacedPost::class); + + $seen = $this->manager->keys()->withSpace( + $content, + fn() => $this->manager->withSpace($content, fn() => $this->manager->keys()->activeSpace()?->name), + ); + + $this->assertSame('content', $seen); + } + // ------------------------------------------------------------------------- // Flush operations // ------------------------------------------------------------------------- public function test_flush_model_bumps_version_and_clears_related_keys(): void { - $redis = Redis::connection('normcache-test'); - $store = $this->manager->getStore(); + $store = $this->manager->store(); $postsKey = DB::getDefaultConnection() . ':posts'; $authorsKey = DB::getDefaultConnection() . ':authors'; - $redis->sadd("test:members:model:{{$postsKey}}", "test:model:{{$postsKey}}:1", "test:model:{{$postsKey}}:2"); - $store->set("model:{{$postsKey}}:1", ['id' => 1], 3600); - $store->set("model:{{$postsKey}}:2", ['id' => 2], 3600); + $store->set("model:{{$postsKey}}:v0:1", ['id' => 1], 3600); + $store->set("model:{{$postsKey}}:v0:2", ['id' => 2], 3600); $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("model:{{$authorsKey}}:1", ['id' => 1], 3600); + $store->set("model:{{$authorsKey}}:v0:1", ['id' => 1], 3600); $versionBefore = $this->manager->currentVersion(Post::class); $this->manager->forceFlushModel(Post::class); - $this->assertNull($store->get("model:{{$postsKey}}:1")); - $this->assertNull($store->get("model:{{$postsKey}}:2")); - $this->assertSame(0, $redis->exists("test:members:model:{{$postsKey}}")); - $this->assertNotNull($store->get("query:{{$postsKey}}:v1:abc")); - $this->assertNotNull($store->get("model:{{$authorsKey}}:1")); $this->assertGreaterThan($versionBefore, $this->manager->currentVersion(Post::class)); + $this->assertNull($this->modelCacheEntry(Post::class, 1)); + $this->assertNull($this->modelCacheEntry(Post::class, 2)); + $this->assertNotNull($store->get("query:{{$postsKey}}:v1:abc")); + $this->assertNotNull($store->get("model:{{$authorsKey}}:v0:1")); } public function test_flush_all_removes_all_package_keys_and_returns_count(): void { - $store = $this->manager->getStore(); + $store = $this->manager->store(); $postsKey = DB::getDefaultConnection() . ':posts'; - $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("model:{{$postsKey}}:1", ['id' => 1], 3600); - $store->set("ver:{{$postsKey}}:", 3, 3600); - $store->set("through:{{$postsKey}}:author:v1:v1:abc", [1], 3600); - $store->set("scheduled:{{$postsKey}}:", (string) ((int) floor(microtime(true) * 1000) + 1000), 3600); - $store->set("building:query:{{$postsKey}}:v1:abc", 1, 3600); - Redis::connection('normcache-test')->sadd("test:members:model:{{$postsKey}}", "test:model:{{$postsKey}}:1"); + $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); + $store->set("{nc}:test:model:{{$postsKey}}:v0:1", ['id' => 1], 3600); + $store->set("{nc}:test:ver:{{$postsKey}}:", 3, 3600); + $store->set("{nc}:test:through:{{$postsKey}}:author:v1:v1:abc", [1], 3600); + $store->set("{nc}:test:scheduled:{{$postsKey}}:", (string) ((int) floor(microtime(true) * 1000) + 1000), 3600); + $store->set("{nc}:test:building:query:{{$postsKey}}:v1:abc", 1, 3600); $deleted = $this->manager->flushAll(); - $this->assertSame(7, $deleted); - $this->assertEmpty($this->redisKeys('test:*')); + $this->assertSame(6, $deleted); + $this->assertEmpty($this->redisKeys('*')); } public function test_flush_all_returns_zero_when_cache_is_empty(): void @@ -162,53 +272,267 @@ public function test_flush_all_returns_zero_when_cache_is_empty(): void $this->assertSame(0, $this->manager->flushAll()); } + public function test_flush_all_uses_wildcard_hash_tag_patterns_on_standalone_after_fresh_registry_boot(): void + { + if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { + $this->markTestSkipped('Standalone wildcard hash-tag flush path is not used in Redis Cluster mode.'); + } + + $this->app->forgetInstance(CacheSpaceRegistry::class); + + $store = $this->manager->store(); + $postsKey = DB::getDefaultConnection() . ':posts'; + + $store->set("{nc:content}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); + + $deleted = $this->manager->flushAll(); + + $this->assertSame(1, $deleted); + $this->assertEmpty($store->scanPattern('{nc:content}:test:*')); + } + + public function test_table_space_registration_survives_a_fresh_registry_instance(): void + { + $tableKey = 'testing:authors'; + $registry = $this->app->make(CacheSpaceRegistry::class); + + $this->assertTrue($registry->registerTableDependencies($registry->space('content'), [$tableKey])); + + $this->app->forgetInstance(CacheSpaceRegistry::class); + $freshRegistry = $this->app->make(CacheSpaceRegistry::class); + + $this->assertContains( + 'content', + array_map(fn($space) => $space->name, $freshRegistry->spacesForTable($tableKey)), + ); + } + + public function test_model_invalidation_bumps_registered_table_spaces(): void + { + $tableKey = 'testing:authors'; + $registry = $this->app->make(CacheSpaceRegistry::class); + $content = $registry->space('content'); + + $this->assertTrue($registry->registerTableDependencies($content, [$tableKey])); + + $this->manager->invalidateVersion(new Author); + + $this->assertSame( + '1', + $this->manager->store()->getRaw($this->manager->keys()->verKey($tableKey, $content)), + ); + } + + public function test_immediate_model_invalidation_reuses_memoized_table_space_metadata(): void + { + $connection = new class extends PredisConnection + { + public int $lookups = 0; + + public function __construct() {} + + public function command($method, array $parameters = []) + { + if (strtolower($method) === 'smembers') { + $this->lookups++; + + return []; + } + + return null; + } + }; + $metadataStore = new RedisStore('normcache-test'); + (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($metadataStore, $connection); + $registry = new CacheSpaceRegistry(metadataStore: $metadataStore, metadataKeyPrefix: 'test:'); + $manager = (new CacheManagerFactory($registry, new CacheSpaceResolver($registry)))->make(); + + $manager->invalidateVersion(new Author); + $manager->invalidateVersion(new Author); + + $this->assertSame(1, $connection->lookups); + } + + public function test_force_model_flush_refreshes_table_space_metadata(): void + { + $connection = new class extends PredisConnection + { + public int $lookups = 0; + + public function __construct() {} + + public function command($method, array $parameters = []) + { + if (strtolower($method) === 'smembers') { + return $this->lookups++ === 0 ? [] : ['content']; + } + + return null; + } + }; + $metadataStore = new RedisStore('normcache-test'); + (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($metadataStore, $connection); + $registry = new CacheSpaceRegistry(metadataStore: $metadataStore, metadataKeyPrefix: 'test:'); + $manager = (new CacheManagerFactory($registry, new CacheSpaceResolver($registry)))->make(); + $tableKey = 'testing:authors'; + + $registry->spacesForTable($tableKey); + $manager->forceFlushModel(Author::class); + + $this->assertSame(2, $connection->lookups); + $this->assertSame( + '1', + $manager->store()->getRaw($manager->keys()->verKey($tableKey, $registry->space('content'))), + ); + } + + public function test_lifecycle_listener_refreshes_table_space_mappings_registered_by_another_registry(): void + { + $tableKey = 'testing:authors'; + $registry = $this->app->make(CacheSpaceRegistry::class); + + $this->assertSame( + ['default'], + array_map(fn($space) => $space->name, $registry->spacesForTable($tableKey)), + ); + + $otherRegistry = new CacheSpaceRegistry( + metadataStore: $this->manager->store(), + metadataKeyPrefix: 'test:', + ); + $this->assertTrue($otherRegistry->registerTableDependencies($otherRegistry->space('content'), [$tableKey])); + + $this->app['events']->dispatch(new Looping('testing', 'default')); + + $this->assertContains( + 'content', + array_map(fn($space) => $space->name, $registry->spacesForTable($tableKey)), + ); + } + + public function test_transaction_commit_resolves_table_spaces_registered_after_queueing(): void + { + $tableKey = 'testing:authors'; + $registry = $this->app->make(CacheSpaceRegistry::class); + $content = $registry->space('content'); + + $this->assertSame( + ['default'], + array_map(fn($space) => $space->name, $registry->spacesForTable($tableKey)), + ); + + DB::beginTransaction(); + + try { + $this->manager->invalidateTableVersion('testing', 'authors'); + + $otherRegistry = new CacheSpaceRegistry( + metadataStore: $this->manager->store(), + metadataKeyPrefix: 'test:', + ); + $this->assertTrue($otherRegistry->registerTableDependencies($content, [$tableKey])); + + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + + throw $e; + } + + $versionKey = $this->manager->keys()->verKey($tableKey, $content); + + $this->assertSame('1', $this->manager->store()->getRaw($versionKey)); + } + + public function test_transaction_commit_resolves_model_table_spaces_registered_after_queueing(): void + { + $tableKey = 'testing:authors'; + $registry = $this->app->make(CacheSpaceRegistry::class); + $content = $registry->space('content'); + + DB::beginTransaction(); + + try { + $this->manager->invalidateVersion(new Author); + + $otherRegistry = new CacheSpaceRegistry( + metadataStore: $this->manager->store(), + metadataKeyPrefix: 'test:', + ); + $this->assertTrue($otherRegistry->registerTableDependencies($content, [$tableKey])); + + DB::commit(); + } catch (\Throwable $e) { + DB::rollBack(); + throw $e; + } + + $this->assertSame( + '1', + $this->manager->store()->getRaw($this->manager->keys()->verKey($tableKey, $content)), + ); + } + + public function test_flush_all_can_target_one_space(): void + { + $store = $this->manager->store(); + $postsKey = DB::getDefaultConnection() . ':posts'; + + $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1], 3600); + $store->set("{nc:content}:test:query:{{$postsKey}}:v1:def", [2], 3600); + + $deleted = $this->manager->flushAll('content'); + + $this->assertSame(1, $deleted); + $this->assertNotEmpty($store->scanPattern('{nc}:test:*')); + $this->assertEmpty($store->scanPattern('{nc:content}:test:*')); + } + public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enabled(): void { config()->set('database.redis.options.prefix', 'laravel:'); Redis::purge('normcache-test'); - $manager = $this->buildManager(cluster: true, slotting: true); + $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $postsKey = DB::getDefaultConnection() . ':posts'; - $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("model:{{$postsKey}}:1", ['id' => 1], 3600); - $store->set("ver:{{$postsKey}}:", 3, 3600); + $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); + $store->set("{nc}:test:model:{{$postsKey}}:1", ['id' => 1], 3600); + $store->set("{nc}:test:ver:{{$postsKey}}:", 3, 3600); $deleted = $manager->flushAll(); $this->assertSame(3, $deleted); - $this->assertSame([], Redis::connection('normcache-test')->keys('*')); + $this->assertSame([], $store->scanPattern('{nc}:test:*')); Redis::purge('normcache-test'); config()->set('database.redis.options.prefix', ''); } - public function test_targeted_delete_outside_transaction_removes_membership_reference(): void + public function test_targeted_update_bumps_version_and_makes_model_cache_unreachable(): void { $author = Author::create(['name' => 'Alice']); Author::all(); - $classKey = $this->manager->classKey(Author::class); - $memberKey = "test:members:model:{{$classKey}}"; - $modelKey = "test:model:{{$classKey}}:{$author->id}"; - $redis = Redis::connection('normcache-test'); + $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertTrue((bool) $redis->sismember($memberKey, $modelKey)); + $versionBefore = $this->manager->currentVersion(Author::class); Author::whereKey($author->id)->update(['name' => 'Alicia']); - $this->assertFalse((bool) $redis->sismember($memberKey, $modelKey)); + $this->assertGreaterThan($versionBefore, $this->manager->currentVersion(Author::class)); + $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); } public function test_scheduled_invalidation_key_persists_until_processed(): void { - $manager = $this->buildManager(cooldown: 1, cluster: true, slotting: true); + $manager = $this->buildManager(cooldown: 1); $model = new Author; - $classKey = $manager->classKey(Author::class); - $scheduledKey = "test:scheduled:{{$classKey}}:"; + $classKey = $manager->keys()->classKey(Author::class); + $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; $redis = Redis::connection('normcache-test'); $manager->invalidateVersion($model); @@ -219,20 +543,135 @@ public function test_scheduled_invalidation_key_persists_until_processed(): void $this->assertSame($firstDueAt, $redis->get($scheduledKey)); } + public function test_get_models_applies_due_cooldown_before_reading_model_cache(): void + { + $author = Author::create(['name' => 'Fresh']); + $manager = $this->buildManager(cooldown: 60); + $classKey = $manager->keys()->classKey(Author::class); + $store = $manager->store(); + + $store->setRaw($manager->keys()->verKey($classKey), '0', 3600); + $store->set($manager->keys()->modelPrefix($classKey, 0) . $author->id, [ + 'id' => $author->id, + 'name' => 'Stale', + ], 3600); + + $store->setRaw( + $manager->keys()->scheduledKey($classKey), + (string) ((int) floor(microtime(true) * 1000) - 1000), + 3600, + ); + + $models = $manager->hydrator()->getModels([$author->id], Author::class, null, null, Author::query()); + + $this->assertSame('Fresh', $models[0]->name); + $this->assertSame(1, $manager->currentVersion(Author::class)); + $this->assertNull($store->getRaw($manager->keys()->scheduledKey($classKey))); + } + + public function test_store_through_ids_returns_false_on_version_mismatch(): void + { + $store = $this->cacheManager()->store(); + $classKey = $this->cacheManager()->keys()->classKey(Author::class); + + $versionKey = "ver:{{$classKey}}:"; + $throughKey = "through:{{$classKey}}:v5:through-hash"; + $buildingKey = "building:{{$classKey}}:v5:through-hash"; + $wakeKey = "wake:{{$classKey}}:through-hash"; + + $store->setRaw($versionKey, '5', 3600); + $store->setRaw($buildingKey, '1', 3600); + $store->increment($versionKey); + + $stored = $this->cacheManager()->through()->store( + $throughKey, + [1], + ['through-1'], + 3600, + new BuildHandle($buildingKey, null, $wakeKey, [$versionKey], ['5']), + ); + + $this->assertFalse($stored); + $this->assertNull($store->getRaw($throughKey)); + $this->assertNull($store->getRaw($buildingKey)); + } + + public function test_store_model_attrs_for_versioned_result_uses_matching_version_key(): void + { + $manager = $this->cacheManager(); + $store = $manager->store(); + $authorKey = $manager->keys()->classKey(Author::class); + $postKey = $manager->keys()->classKey(Post::class); + + $store->setRaw($manager->keys()->verKey($authorKey), '9', 3600); + $store->setRaw($manager->keys()->verKey($postKey), '4', 3600); + + $manager->models()->storeForBuild( + Author::class, + [1 => ['id' => 1, 'name' => 'Fresh']], + new BuildHandle( + versionKeys: [$manager->keys()->verKey($postKey), $manager->keys()->verKey($authorKey)], + expectedVersions: ['4', '9'], + ), + ); + + $this->assertSame( + ['id' => 1, 'name' => 'Fresh'], + $store->get($manager->keys()->modelPrefix($authorKey, 9) . '1'), + ); + } + + public function test_store_model_attrs_for_version_skips_stale_version_write(): void + { + $manager = $this->cacheManager(); + $store = $manager->store(); + $classKey = $manager->keys()->classKey(Author::class); + + $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); + $store->increment($manager->keys()->verKey($classKey)); + + $manager->models()->storeForVersion(Author::class, [1 => ['id' => 1, 'name' => 'Stale']], 5); + + $this->assertNull($store->get($manager->keys()->modelPrefix($classKey, 5) . '1')); + $this->assertNull($store->get($manager->keys()->modelPrefix($classKey, 6) . '1')); + } + + public function test_store_model_attrs_for_version_writes_when_version_matches(): void + { + $manager = $this->cacheManager(); + $store = $manager->store(); + $classKey = $manager->keys()->classKey(Author::class); + + $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); + + $manager->models()->storeForVersion(Author::class, [1 => ['id' => 1, 'name' => 'Fresh']], 5); + + $this->assertSame( + ['id' => 1, 'name' => 'Fresh'], + $store->get($manager->keys()->modelPrefix($classKey, 5) . '1') + ); + } + public function test_store_query_ids_skips_write_on_version_mismatch(): void { - $store = $this->cacheManager()->getStore(); - $classKey = $this->cacheManager()->classKey(Author::class); + $store = $this->cacheManager()->store(); + $classKey = $this->cacheManager()->keys()->classKey(Author::class); $versionKey = "ver:{{$classKey}}:"; $queryKey = "query:{{$classKey}}:v5:abc123"; $buildingKey = "building:{{$classKey}}:abc123"; + $wakeKey = "wake:{{$classKey}}:abc123"; $store->setRaw($versionKey, '5', 3600); $store->setRaw($buildingKey, '1', 3600); $store->increment($versionKey); // now 6 - $this->cacheManager()->storeQueryIds($queryKey, [1, 2, 3], 3600, $buildingKey, [$versionKey], ['5']); + $this->cacheManager()->queries()->store( + $queryKey, + [1, 2, 3], + 3600, + new BuildHandle($buildingKey, null, $wakeKey, [$versionKey], ['5']), + ); $this->assertNull($store->getRaw($queryKey), 'CAS skips write when version has been bumped'); $this->assertNull($store->getRaw($buildingKey), 'Building lock released even when write is skipped'); @@ -240,17 +679,23 @@ public function test_store_query_ids_skips_write_on_version_mismatch(): void public function test_store_query_ids_writes_when_version_matches(): void { - $store = $this->cacheManager()->getStore(); - $classKey = $this->cacheManager()->classKey(Author::class); + $store = $this->cacheManager()->store(); + $classKey = $this->cacheManager()->keys()->classKey(Author::class); $versionKey = "ver:{{$classKey}}:"; $queryKey = "query:{{$classKey}}:v5:def456"; $buildingKey = "building:{{$classKey}}:def456"; + $wakeKey = "wake:{{$classKey}}:def456"; $store->setRaw($versionKey, '5', 3600); $store->setRaw($buildingKey, '1', 3600); - $this->cacheManager()->storeQueryIds($queryKey, [4, 5, 6], 3600, $buildingKey, [$versionKey], ['5']); + $this->cacheManager()->queries()->store( + $queryKey, + [4, 5, 6], + 3600, + new BuildHandle($buildingKey, null, $wakeKey, [$versionKey], ['5']), + ); $this->assertNotNull($store->getRaw($queryKey), 'CAS writes when version still matches'); $this->assertNull($store->getRaw($buildingKey), 'Building lock released after successful write'); @@ -258,17 +703,23 @@ public function test_store_query_ids_writes_when_version_matches(): void public function test_store_query_ids_does_not_write_or_release_when_building_token_mismatches(): void { - $store = $this->cacheManager()->getStore(); - $classKey = $this->cacheManager()->classKey(Author::class); + $store = $this->cacheManager()->store(); + $classKey = $this->cacheManager()->keys()->classKey(Author::class); $versionKey = "ver:{{$classKey}}:"; $queryKey = "query:{{$classKey}}:v5:token-mismatch"; $buildingKey = "building:{{$classKey}}:token-mismatch"; + $wakeKey = "wake:{{$classKey}}:token-mismatch"; $store->setRaw($versionKey, '5', 3600); $store->setRaw($buildingKey, 'new-owner', 3600); - $this->cacheManager()->storeQueryIds($queryKey, [7, 8, 9], 3600, $buildingKey, [$versionKey], ['5'], 'old-owner'); + $this->cacheManager()->queries()->store( + $queryKey, + [7, 8, 9], + 3600, + new BuildHandle($buildingKey, 'old-owner', $wakeKey, [$versionKey], ['5']), + ); $this->assertNull($store->getRaw($queryKey), 'Outdated builder must not write when lock token changed'); $this->assertSame('new-owner', $store->getRaw($buildingKey), 'Outdated builder must not release a newer lock'); @@ -278,26 +729,22 @@ public function test_store_query_ids_does_not_write_or_release_when_building_tok // storeQueryIds — corrupt/default path // ------------------------------------------------------------------------- - public function test_store_query_ids_skips_write_without_building_key_and_version_keys(): void + public function test_store_query_ids_writes_normally_with_building_key_and_wake_key(): void { - $store = $this->manager->getStore(); - $key = 'query:corrupt_default_path:abc'; - - $this->manager->storeQueryIds($key, ['1', '2', '3'], 60, null, [], [], null); - - $this->assertNull($store->getRaw($key), 'Corrupt/default path must not write unprotected query IDs'); - } - - public function test_store_query_ids_writes_normally_with_building_key_only(): void - { - $store = $this->manager->getStore(); - $classKey = $this->manager->classKey(Author::class); + $store = $this->manager->store(); + $classKey = $this->manager->keys()->classKey(Author::class); $buildingKey = "building:{{$classKey}}:write_with_building_key"; + $wakeKey = "wake:{{$classKey}}:write_with_building_key"; $key = "query:{{$classKey}}:write_with_building_key"; $store->setRaw($buildingKey, 'token', 3600); - $this->manager->storeQueryIds($key, ['1', '2'], 60, $buildingKey, [], [], 'token'); + $this->manager->queries()->store( + $key, + ['1', '2'], + 60, + new BuildHandle($buildingKey, 'token', $wakeKey), + ); $this->assertNotNull($store->getRaw($key), 'Non-CAS write should proceed when buildingKey is set'); } @@ -338,17 +785,15 @@ public function test_invalidate_multiple_versions_inside_transaction_queues_vers $this->manager->invalidateMultipleVersions([Author::class, Post::class], 'testing'); }); - // Version bumped after commit $this->assertGreaterThan($versionBefore, $this->manager->currentVersion(Author::class)); - // Model payloads preserved (version bump only, not full flush) - $this->assertNotNull($this->modelCacheEntry(Author::class, $author->id)); - $this->assertNotNull($this->modelCacheEntry(Post::class, $post->id)); + $this->assertNull($this->modelCacheEntry(Author::class, $author->id)); + $this->assertNull($this->modelCacheEntry(Post::class, $post->id)); } public function test_store_versioned_result_does_not_write_or_release_when_building_token_mismatches(): void { - $store = $this->cacheManager()->getStore(); - $classKey = $this->cacheManager()->classKey(Author::class); + $store = $this->cacheManager()->store(); + $classKey = $this->cacheManager()->keys()->classKey(Author::class); $versionKey = "ver:{{$classKey}}:"; $resultKey = "result:{{$classKey}}:v5:token-mismatch"; @@ -358,15 +803,11 @@ public function test_store_versioned_result_does_not_write_or_release_when_build $store->setRaw($versionKey, '5', 3600); $store->setRaw($buildingKey, 'new-owner', 3600); - $written = $this->cacheManager()->storeVersionedResult( + $written = $this->cacheManager()->results()->storeEntry( $resultKey, [['id' => 1, 'name' => 'Old']], 3600, - [$versionKey], - ['5'], - $buildingKey, - $wakeKey, - 'old-owner' + new BuildHandle($buildingKey, 'old-owner', $wakeKey, [$versionKey], ['5']), ); $this->assertFalse($written); @@ -375,66 +816,8 @@ public function test_store_versioned_result_does_not_write_or_release_when_build $this->assertNull($store->getRaw($wakeKey), 'Outdated builder must not wake waiters for a lock it no longer owns'); } - // ------------------------------------------------------------------------- - // Flow control (rescue/attempt/fallback) - // ------------------------------------------------------------------------- - public function test_is_enabled_true_by_default(): void { $this->assertTrue($this->manager->isEnabled()); } - - public function test_rescue_returns_operation_result_on_success(): void - { - $this->assertSame(42, $this->manager->rescue(fn() => 42, fn() => 0)); - } - - public function test_rescue_calls_fallback_when_operation_throws_and_fallback_enabled(): void - { - $manager = $this->buildManager(fallback: true); - - $result = $manager->rescue( - fn() => throw new \RuntimeException('redis down'), - fn() => 'fallback' - ); - - $this->assertSame('fallback', $result); - } - - public function test_rescue_rethrows_when_fallback_disabled(): void - { - $manager = $this->buildManager(fallback: false); - - $this->expectException(\RuntimeException::class); - $manager->rescue(fn() => throw new \RuntimeException('boom'), fn() => null); - } - - public function test_attempt_returns_true_on_success(): void - { - $this->assertTrue($this->manager->attempt(fn() => null)); - } - - public function test_attempt_returns_false_when_fallback_enabled_and_throws(): void - { - $manager = $this->buildManager(fallback: true); - - $this->assertFalse($manager->attempt(fn() => throw new \RuntimeException)); - } - - public function test_attempt_rethrows_when_fallback_disabled(): void - { - $manager = $this->buildManager(fallback: false); - - $this->expectException(\RuntimeException::class); - $manager->attempt(fn() => throw new \RuntimeException); - } - - public function test_fallback_disables_manager_when_fallback_enabled(): void - { - $manager = $this->buildManager(fallback: true); - - $manager->fallback(new \RuntimeException); - - $this->assertFalse($manager->isEnabled()); - } } diff --git a/tests/Unit/CachePlanSpaceValidatorTest.php b/tests/Unit/CachePlanSpaceValidatorTest.php new file mode 100644 index 0000000..37ef497 --- /dev/null +++ b/tests/Unit/CachePlanSpaceValidatorTest.php @@ -0,0 +1,92 @@ +validate($plan, $builder, $builder->getModel()); + + $this->assertSame(CacheStrategy::LiveQuery, $validated->strategy); + $this->assertSame('content', $validated->space?->name); + $this->assertStringContainsString(CatalogTag::class, $validated->bypassReasons['space'][0]); + $this->assertArrayNotHasKey('dependency', $validated->bypassReasons); + } + + public function test_failed_table_space_registration_uses_space_bypass_category(): void + { + $connection = new class extends PredisConnection + { + public function __construct() {} + + public function command($method, array $parameters = []) + { + return match (strtolower($method)) { + 'smembers' => [], + 'sadd' => throw new RuntimeException('SADD denied'), + default => null, + }; + } + }; + $store = new RedisStore('normcache-test'); + (new \ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); + + $registry = new CacheSpaceRegistry(metadataStore: $store); + $validator = new CachePlanSpaceValidator($registry, new CacheSpaceResolver($registry)); + $builder = SpacedPost::query(); + $plan = CachePlan::result( + CacheOperation::Models, + new DependencySet(models: [SpacedPost::class], tables: ['testing:authors']), + ); + + $validated = $validator->validate($plan, $builder, $builder->getModel()); + + $this->assertSame( + ['failed to register table-space dependencies'], + $validated->bypassReasons['space'], + ); + $this->assertArrayNotHasKey('dependency', $validated->bypassReasons); + } + + public function test_cross_space_dependencies_can_throw(): void + { + $registry = new CacheSpaceRegistry; + $validator = new CachePlanSpaceValidator( + $registry, + new CacheSpaceResolver($registry), + crossSpaceBehavior: 'throw', + ); + $builder = SpacedPost::query(); + $plan = CachePlan::result( + CacheOperation::Models, + new DependencySet(models: [SpacedPost::class, CatalogTag::class]), + ); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('cross-space dependencies for space [content]'); + + $validator->validate($plan, $builder, $builder->getModel()); + } +} diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index 884b3d0..16712cb 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -2,251 +2,128 @@ namespace NormCache\Tests\Unit; -use Illuminate\Support\Facades\Log; use NormCache\Enums\CacheStrategy; use NormCache\Planning\CachePlanner; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; use NormCache\Values\CachePlanContext; -use NormCache\Values\DependencySet; -class CachePlannerTest extends TestCase +class CachePlannerTest extends UnitTestCase { - public function test_planner_logs_warning_for_under_declared_dependencies_in_debug_mode(): void + public function test_successful_hot_plan_does_not_build_reason_strings(): void { - config(['app.debug' => true]); - - Log::shouldReceive('warning') - ->once() - ->withArgs(function ($message) { - return str_contains($message, 'under-declared dependency') && str_contains($message, 'authors'); - }); + $prepared = Author::where('name', 'Alice')->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - $planner = new CachePlanner; + $this->assertTrue($plan->isNormalized()); + $this->assertSame([], $plan->bypassReasons); + } - $dependencies = new DependencySet([], ['posts']); + public function test_active_soft_delete_scope_allows_a_direct_primary_key_plan(): void + { + $prepared = Post::whereKey([3, 1, 2])->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - $reflection = new \ReflectionMethod($planner, 'checkDependencyCompleteness'); - $reflection->setAccessible(true); - $reflection->invoke($planner, ['posts', 'authors'], $dependencies, 'posts'); + $this->assertSame(CacheStrategy::DirectModels, $plan->strategy); + $this->assertSame([1, 2, 3], $plan->primaryKeys); } - public function test_successful_hot_plan_does_not_build_reason_strings(): void + public function test_removed_soft_delete_scope_does_not_ignore_a_manual_null_constraint(): void { - $prepared = Author::where('name', 'Alice')->prepareCacheExecution(); + $prepared = Post::withTrashed()->whereNull('posts.deleted_at')->whereKey([3, 1, 2])->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::models(), - ); - - $this->assertTrue($plan->isNormalized()); - $this->assertSame([], $plan->reasons); - $this->assertSame([], $plan->bypassReasons); + $this->assertSame(CacheStrategy::NormalizedQuery, $plan->strategy); } - public function test_bypass_plan_still_contains_human_readable_reasons(): void + public function test_raw_order_bypasses_with_human_readable_reason(): void { - $prepared = Author::orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [1]) - ->prepareCacheExecution(); - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::models(), - ); + $prepared = Author::orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [1])->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); $this->assertContains('raw ORDER expression', $plan->bypassReasons['dependency']); } - public function test_global_opted_out_bypass_precedes_query_inspection(): void + public function test_global_opt_out_precedes_query_analysis(): void { - $prepared = Author::withoutCache() - ->orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [1]) - ->prepareCacheExecution(); - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::models(), - ); + $prepared = Author::withoutCache()->orderByRaw('id')->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertSame( - ['opted_out' => ['withoutCache() was called explicitly']], - $plan->bypassReasons, - ); + $this->assertSame(['opted_out' => ['withoutCache() was called explicitly']], $plan->bypassReasons); } - public function test_simple_scalar_query_uses_versioned_result_strategy(): void + public function test_simple_scalar_uses_versioned_result_strategy(): void { $prepared = Author::where('name', 'Alice')->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar('count', ['*']), - ); - - $this->assertTrue($plan->usesResultCache()); $this->assertSame(CacheStrategy::VersionedResult, $plan->strategy); $this->assertSame([Author::class], $plan->dependencies->models); } - public function test_scalar_query_with_raw_dependency_clause_bypasses(): void + public function test_raw_scalar_dependency_clause_bypasses(): void { $prepared = Author::whereRaw('name = ?', ['Alice'])->prepareCacheExecution(); - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar('count', ['*']), - ); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertContains('raw WHERE expression', $plan->bypassReasons['dependency']); } - public function test_scalar_query_with_raw_order_bypasses(): void + public function test_scalar_context_dependency_reason_is_merged_into_the_inspection(): void { - $prepared = Author::orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [1]) - ->prepareCacheExecution(); - + $prepared = Author::where('name', 'Alice')->prepareCacheExecution(); $plan = (new CachePlanner)->plan( $prepared->builder, $prepared->base, - CachePlanContext::scalar('value', ['name']), + CachePlanContext::scalar(['*'], ['dependency' => ['custom subquery could not be inferred']]), ); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertContains('raw ORDER expression', $plan->bypassReasons['dependency']); + $this->assertSame(['custom subquery could not be inferred'], $plan->bypassReasons['dependency']); } - public function test_grouped_scalar_query_preserves_result_cache_behavior(): void + public function test_grouped_scalar_preserves_result_cache_behavior(): void { - $prepared = Author::groupBy('name') - ->having('name', '!=', '') - ->prepareCacheExecution(); + $prepared = Author::groupBy('name')->having('name', '!=', '')->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['name'])); - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar('count', ['name']), - ); - - $this->assertTrue($plan->usesResultCache()); $this->assertSame(CacheStrategy::VersionedResult, $plan->strategy); - $this->assertSame(['name'], $plan->columns); - } - - public function test_scalar_context_reason_skips_fast_path(): void - { - $prepared = Author::where('name', 'Alice')->prepareCacheExecution(); - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar( - 'count', - ['*'], - contextReasons: ['opted_out' => ['test bypass']], - ), - ); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertSame(['test bypass'], $plan->bypassReasons['opted_out']); } - public function test_scalar_join_without_dependencies_bypasses(): void + public function test_scalar_join_uses_inferred_table_dependency(): void { - $prepared = Author::join('posts', 'posts.author_id', '=', 'authors.id') - ->prepareCacheExecution(); + $prepared = Author::join('posts', 'posts.author_id', '=', 'authors.id')->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar('count', ['*']), - ); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertContains('complex_query_requires_depends_on', $plan->bypassReasons['dependency']); + $this->assertSame(CacheStrategy::VersionedResult, $plan->strategy); + $this->assertContains('testing:posts', $plan->dependencies->tables); } public function test_locked_scalar_query_bypasses(): void { $prepared = Author::lockForUpdate()->prepareCacheExecution(); - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar('count', ['*']), - ); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); - $this->assertContains('query lock (SELECT FOR UPDATE)', $plan->bypassReasons['safety']); } - public function test_exists_where_with_safe_inferred_dependency_is_cached_as_result(): void + public function test_exists_query_uses_query_derived_dependency(): void { - $prepared = Author::query()->prepareCacheExecution(); - $prepared->base->wheres[] = [ - 'type' => 'Exists', - 'query' => Author::query()->getQuery(), - 'boolean' => 'and', - ]; - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::models(inferred: new DependencySet(models: [Post::class])), - ); + $prepared = Author::whereHas('posts')->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); $this->assertSame(CacheStrategy::VersionedResult, $plan->strategy); - $this->assertTrue($plan->dependencies->safe); - $this->assertSame([Author::class, Post::class], $plan->dependencies->models); - } - - public function test_exists_where_without_inferred_dependency_still_bypasses(): void - { - $prepared = Author::query()->prepareCacheExecution(); - $prepared->base->wheres[] = [ - 'type' => 'Exists', - 'query' => Author::query()->getQuery(), - 'boolean' => 'and', - ]; - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::models(), - ); - - $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); + $this->assertContains('testing:posts', $plan->dependencies->tables); } - public function test_exists_where_combined_with_raw_where_bypasses_even_with_inferred_dependency(): void + public function test_exists_with_nested_raw_where_bypasses(): void { - $prepared = Author::query()->prepareCacheExecution(); - $prepared->base->wheres[] = [ - 'type' => 'Exists', - 'query' => Author::query()->getQuery(), - 'boolean' => 'and', - ]; - $prepared->base->wheres[] = [ - 'type' => 'Raw', - 'sql' => 'LOWER(name) = LOWER(name)', - 'boolean' => 'and', - ]; - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::models(inferred: new DependencySet(models: [Post::class])), - ); + $prepared = Author::whereHas('posts', fn($query) => $query->whereRaw('views > 0'))->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); + $this->assertContains('raw WHERE expression', $plan->bypassReasons['dependency']); } } diff --git a/tests/Unit/CacheableBuilderPlanTest.php b/tests/Unit/CacheableBuilderPlanTest.php new file mode 100644 index 0000000..5377284 --- /dev/null +++ b/tests/Unit/CacheableBuilderPlanTest.php @@ -0,0 +1,45 @@ +where('name', 'A'); + $prepared = $builder->prepareCacheExecution(); + $context = fn() => CachePlanContext::models( + ProjectionClassifier::resolve($prepared->base, ['*']), + selectAll: true, + ); + + $this->assertEquals( + $builder->cachePlan($prepared->base, $context()), + $builder->planPrepared($prepared, $context), + ); + } + + public function test_plan_prepared_infers_join_table_dependency(): void + { + $builder = Post::query() + ->join('authors', 'authors.id', '=', 'posts.author_id') + ->select('posts.*'); + $prepared = $builder->prepareCacheExecution(); + + $plan = $builder->planPrepared( + $prepared, + fn() => CachePlanContext::models( + ProjectionClassifier::resolve($prepared->base, ['*']), + selectAll: false, + ), + ); + + $this->assertContains('testing:authors', $plan->dependencies->tables); + } +} diff --git a/tests/Unit/QueryAnalyzerTest.php b/tests/Unit/QueryAnalyzerTest.php index 6e0b40b..075839e 100644 --- a/tests/Unit/QueryAnalyzerTest.php +++ b/tests/Unit/QueryAnalyzerTest.php @@ -22,14 +22,14 @@ public function test_simple_query_has_no_flags(): void $this->assertSame(0, $inspection->flags); $this->assertSame(0, $analyzer->flags($query, 'authors', null)); - $this->assertNull($inspection->tables); + $this->assertTrue($inspection->dependencies->hasNoDependencies()); } public function test_nested_wheres_are_scanned_once_for_raw_and_exists_flags(): void { $nested = $this->makeBaseQuery(); $nested->wheres = [ - ['type' => 'Raw', 'sql' => 'LOWER(name) = ?'], + ['type' => 'raw', 'sql' => 'LOWER(name) = ?'], ['type' => 'Exists', 'query' => $this->makeBaseQuery()], ]; @@ -53,7 +53,7 @@ public function test_notexists_where_type_sets_exists_where_flag(): void $this->assertTrue($inspection->has(QueryInspection::EXISTS_WHERE)); $this->assertFalse($inspection->has(QueryInspection::SUBQUERY_WHERE)); - $this->assertTrue($inspection->hasOnlyExistsDependencyBypass()); + $this->assertFalse($inspection->hasDependencyBypass()); } public function test_sub_where_type_sets_subquery_where_flag_not_exists_where(): void @@ -65,7 +65,7 @@ public function test_sub_where_type_sets_subquery_where_flag_not_exists_where(): $this->assertTrue($inspection->has(QueryInspection::SUBQUERY_WHERE)); $this->assertFalse($inspection->has(QueryInspection::EXISTS_WHERE)); - $this->assertFalse($inspection->hasOnlyExistsDependencyBypass()); + $this->assertFalse($inspection->hasDependencyBypass()); } public function test_structural_flags_map_to_existing_reason_strings(): void @@ -80,7 +80,7 @@ public function test_structural_flags_map_to_existing_reason_strings(): void $query->lock = true; $query->orders = [['type' => 'Raw']]; - $inspection = (new QueryAnalyzer)->inspect($query, 'authors', $query->columns, includeTables: true); + $inspection = (new QueryAnalyzer)->inspect($query, 'authors', $query->columns); $this->assertSame( [ @@ -99,7 +99,7 @@ public function test_structural_flags_map_to_existing_reason_strings(): void ], BypassReasons::fromInspection($inspection), ); - $this->assertSame(['authors', 'countries'], $inspection->tables); + $this->assertSame(['authors', 'countries'], (new QueryAnalyzer)->extractTables($query, 'authors')); } public function test_primary_keys_are_extracted_without_reason_generation(): void @@ -121,6 +121,44 @@ public function test_primary_keys_are_extracted_without_reason_generation(): voi $this->assertSame([1, 2, 3], $inspection->primaryKeys); } + public function test_primary_keys_allow_the_model_soft_delete_constraint(): void + { + $query = $this->makeBaseQuery(from: 'posts'); + $query->wheres = [ + ['type' => 'Null', 'column' => 'posts.deleted_at', 'boolean' => 'and'], + ['type' => 'In', 'column' => 'posts.id', 'values' => [3, 1, 2]], + ]; + + $inspection = (new QueryAnalyzer)->inspect( + $query, + 'posts', + null, + ['id', 'posts.id'], + softDeleteScopeColumn: 'posts.deleted_at', + ); + + $this->assertSame([1, 2, 3], $inspection->primaryKeys); + } + + public function test_primary_keys_do_not_ignore_an_arbitrary_null_constraint(): void + { + $query = $this->makeBaseQuery(from: 'posts'); + $query->wheres = [ + ['type' => 'Null', 'column' => 'posts.published_at', 'boolean' => 'and'], + ['type' => 'In', 'column' => 'posts.id', 'values' => [3, 1, 2]], + ]; + + $inspection = (new QueryAnalyzer)->inspect( + $query, + 'posts', + null, + ['id', 'posts.id'], + softDeleteScopeColumn: 'posts.deleted_at', + ); + + $this->assertNull($inspection->primaryKeys); + } + public function test_expression_values_are_subquery_bypasses(): void { $expression = $this->createStub(Expression::class); @@ -137,46 +175,22 @@ public function test_expression_values_are_subquery_bypasses(): void $this->assertNull($inspection->primaryKeys); } - public function test_exists_where_flag_alone_satisfies_only_exists_dependency_bypass(): void + public function test_exists_and_subquery_flags_do_not_bypass_dependency_inference(): void { - $inspection = new QueryInspection(flags: QueryInspection::EXISTS_WHERE); + $exists = new QueryInspection(flags: QueryInspection::EXISTS_WHERE); + $subquery = new QueryInspection(flags: QueryInspection::SUBQUERY_WHERE); - $this->assertTrue($inspection->hasDependencyBypass()); - $this->assertTrue($inspection->hasOnlyExistsDependencyBypass()); + $this->assertFalse($exists->hasDependencyBypass()); + $this->assertFalse($subquery->hasDependencyBypass()); + $this->assertSame([], BypassReasons::fromInspection($exists)); } - public function test_exists_where_combined_with_raw_where_is_not_only_exists_bypass(): void + public function test_raw_where_still_bypasses_when_combined_with_exists(): void { $inspection = new QueryInspection(flags: QueryInspection::EXISTS_WHERE | QueryInspection::RAW_WHERE); $this->assertTrue($inspection->hasDependencyBypass()); - $this->assertFalse($inspection->hasOnlyExistsDependencyBypass()); - } - - public function test_subquery_where_alone_is_not_only_exists_bypass(): void - { - $inspection = new QueryInspection(flags: QueryInspection::SUBQUERY_WHERE); - - $this->assertTrue($inspection->hasDependencyBypass()); - $this->assertFalse($inspection->hasOnlyExistsDependencyBypass()); - } - - public function test_no_dependency_bypass_is_not_only_exists_bypass(): void - { - $inspection = new QueryInspection(flags: 0); - - $this->assertFalse($inspection->hasDependencyBypass()); - $this->assertFalse($inspection->hasOnlyExistsDependencyBypass()); - } - - public function test_exists_where_flag_maps_to_subquery_dependency_reason(): void - { - $inspection = new QueryInspection(flags: QueryInspection::EXISTS_WHERE); - - $this->assertSame( - ['dependency' => ['subquery WHERE (whereHas/whereExists)']], - BypassReasons::fromInspection($inspection), - ); + $this->assertContains('raw WHERE expression', BypassReasons::fromInspection($inspection)['dependency']); } public function test_direct_primary_key_inspection_allows_harmless_single_row_ordering(): void @@ -213,6 +227,31 @@ public function test_direct_primary_key_inspection_rejects_structural_query_shap $this->assertNotSame(0, $inspection->normalizationFlags()); } + public function test_query_dependencies_include_nested_where_and_union_tables(): void + { + $exists = $this->makeBaseQuery(from: 'posts as p'); + $union = $this->makeBaseQuery(from: 'archived_authors'); + $query = $this->makeBaseQuery(); + $query->wheres = [['type' => 'Exists', 'query' => $exists]]; + $query->unions = [['query' => $union]]; + + $dependencies = (new QueryAnalyzer)->inferQueryDependencies($query, 'testing', 'authors'); + + $this->assertTrue($dependencies->safe); + $this->assertSame(['testing:posts', 'testing:archived_authors'], $dependencies->tables); + } + + public function test_query_dependencies_reject_opaque_join_sources(): void + { + $query = $this->makeBaseQuery(); + $query->joins = [(object) ['table' => $this->createStub(Expression::class), 'wheres' => []]]; + + $dependencies = (new QueryAnalyzer)->inferQueryDependencies($query, 'testing', 'authors'); + + $this->assertFalse($dependencies->safe); + $this->assertSame(['joined subquery dependency could not be inferred'], $dependencies->reasons); + } + private function makeBaseQuery(?array $columns = null, string $from = 'authors'): Builder { $query = new Builder( diff --git a/tests/Unit/QueryHasherTest.php b/tests/Unit/QueryHasherTest.php index 112c744..b5b0f33 100644 --- a/tests/Unit/QueryHasherTest.php +++ b/tests/Unit/QueryHasherTest.php @@ -6,9 +6,9 @@ use NormCache\CacheableBuilder; use NormCache\Support\QueryHasher; use NormCache\Tests\Fixtures\Models\Author; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; -class QueryHasherTest extends TestCase +class QueryHasherTest extends UnitTestCase { private function makeBuilder(): Builder { @@ -56,8 +56,8 @@ public function test_it_hashes_raw_string(): void { $hash = QueryHasher::hash('some data'); $this->assertIsString($hash); - $this->assertEquals(16, strlen($hash)); - $this->assertSame(hash('xxh3', 'some data'), $hash); + $this->assertEquals(32, strlen($hash)); + $this->assertSame(hash('xxh128', 'some data'), $hash); } public function test_pagination_count_hash_differs_from_normalized_query_hash(): void diff --git a/tests/Unit/RedisScriptsTest.php b/tests/Unit/RedisScriptsTest.php index 74c009e..9b1dfc1 100644 --- a/tests/Unit/RedisScriptsTest.php +++ b/tests/Unit/RedisScriptsTest.php @@ -3,22 +3,19 @@ namespace NormCache\Tests\Unit; use NormCache\Support\RedisScripts; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; use ReflectionProperty; -class RedisScriptsTest extends TestCase +class RedisScriptsTest extends UnitTestCase { private static array $knownScripts = [ + 'fetch_batch_build_status', 'fetch_version_with_cooldown', - 'fetch_model_build_status', - 'fetch_multi_versioned_query', - 'fetch_pivot_build_status', + 'fetch_versioned_payload', 'fetch_versioned_pivot', - 'fetch_versioned_query', - 'fetch_versioned_result', 'release_building', - 'store_many_tracked_if_version', - 'store_many_versioned', + 'store_model_attrs', + 'store_versioned_payload', ]; protected function setUp(): void @@ -42,8 +39,8 @@ public function test_it_loads_scripts_from_filesystem(): void public function test_it_caches_loaded_scripts_statically(): void { - $first = RedisScripts::get('fetch_versioned_query'); - $second = RedisScripts::get('fetch_versioned_query'); + $first = RedisScripts::get('fetch_versioned_payload'); + $second = RedisScripts::get('fetch_versioned_payload'); $this->assertSame($first, $second); @@ -51,12 +48,12 @@ public function test_it_caches_loaded_scripts_statically(): void $cache->setAccessible(true); $stored = $cache->getValue(); - $this->assertArrayHasKey('fetch_versioned_query', $stored); - $this->assertSame($first, $stored['fetch_versioned_query']); + $this->assertArrayHasKey('fetch_versioned_payload', $stored); + $this->assertSame($first, $stored['fetch_versioned_payload']); - RedisScripts::get('store_many_versioned'); + RedisScripts::get('store_versioned_payload'); $stored = $cache->getValue(); - $this->assertArrayHasKey('store_many_versioned', $stored); + $this->assertArrayHasKey('store_versioned_payload', $stored); } public function test_it_throws_exception_for_missing_scripts(): void diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index 303b34f..991e605 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -18,18 +18,7 @@ class RedisStoreTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->store = new RedisStore('normcache-test', 'test:', false, '{nc}:'); - } - - public function test_it_prefixes_keys(): void - { - $this->assertSame('{nc}:test:foo', $this->store->prefix('foo')); - } - - public function test_it_prefixes_keys_in_slotting_mode(): void - { - $store = new RedisStore('normcache-test', 'test:', true, ''); - $this->assertSame('test:foo', $store->prefix('foo')); + $this->store = new RedisStore('normcache-test'); } public function test_it_can_set_and_get_values(): void @@ -64,56 +53,62 @@ public function test_it_can_increment_values(): void public function test_it_can_release_building_locks(): void { - $this->store->set('build:foo', '1', 60); - $this->store->releaseBuilding('build:foo', 'wake:foo'); + $this->store->set('{t}:build:foo', '1', 60); + $this->store->releaseBuilding('{t}:build:foo', '{t}:wake:foo'); - $this->assertNull($this->store->getRaw('build:foo')); - $this->assertTrue($this->store->brpop('wake:foo', 1)); + $this->assertNull($this->store->getRaw('{t}:build:foo')); + $this->assertTrue($this->store->brpop('{t}:wake:foo', 1)); } public function test_release_building_pushes_configured_wake_tokens(): void { - $store = new RedisStore('normcache-test', 'test:', false, '{nc}:', wakeTokenCount: 3); + $store = new RedisStore('normcache-test', wakeTokenCount: 3); - $store->set('build:tokens', '1', 60); - $store->releaseBuilding('build:tokens', 'wake:tokens'); + $store->set('{t}:build:tokens', '1', 60); + $store->releaseBuilding('{t}:build:tokens', '{t}:wake:tokens'); - $this->assertSame(3, (int) $store->script("return redis.call('LLEN', KEYS[1])", ['wake:tokens'])); + $this->assertSame(3, (int) $store->script("return redis.call('LLEN', KEYS[1])", ['{t}:wake:tokens'])); } public function test_it_can_get_many_values(): void { - $this->store->set('foo', 'bar', 60); - $this->store->set('baz', 'qux', 60); + $this->store->set('{nc}:foo', 'bar', 60); + $this->store->set('{nc}:baz', 'qux', 60); - $results = $this->store->getMany(['foo', 'baz', 'missing']); + $results = $this->store->getMany(['{nc}:foo', '{nc}:baz', '{nc}:missing']); $this->assertSame(['bar', 'qux', null], $results); } - public function test_it_can_set_many_values(): void + public function test_predis_cluster_get_many_uses_one_same_slot_mget_command(): void { - $this->store->setMany(['foo' => 'bar', 'baz' => 'qux'], 60); + $connection = new class extends PredisClusterConnection + { + public array $commands = []; - $this->assertSame('bar', $this->store->get('foo')); - $this->assertSame('qux', $this->store->get('baz')); - } + public array $values = []; - public function test_it_can_group_keys_by_tag_in_slotting_mode(): void - { - $store = new RedisStore('normcache-test', 'test:', true, ''); + public function __construct() {} - $method = new \ReflectionMethod(RedisStore::class, 'groupByTag'); - $method->setAccessible(true); + public function command($method, array $parameters = []) + { + $this->commands[] = [$method, $parameters]; - $keys = ['{user:1}:a', '{user:1}:b', '{user:2}:c', 'no-tag']; - $groups = $method->invoke($store, $keys); + return array_map(fn($key) => $this->values[$key] ?? null, $parameters); + } + }; - $this->assertSame([ - 'user:1' => ['{user:1}:a', '{user:1}:b'], - 'user:2' => ['{user:2}:c'], - 'no-tag' => ['no-tag'], - ], $groups); + $store = new RedisStore('normcache-test'); + $connection->values = [ + '{nc}:foo' => $store->serialize('bar'), + '{nc}:baz' => $store->serialize('qux'), + ]; + (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); + + $keys = ['{nc}:foo', '{nc}:baz', '{nc}:missing']; + + $this->assertSame(['bar', 'qux', null], $store->getMany($keys)); + $this->assertSame([['mget', $keys]], $connection->commands); } public function test_it_can_run_lua_scripts(): void @@ -126,106 +121,104 @@ public function test_it_can_run_lua_scripts(): void $this->assertSame('bar', $this->store->unserialize($result)); } - public function test_it_can_set_many_tracked_if_version(): void + public function test_it_can_set_many_if_version(): void { - $this->store->delete(['member:1', 'ver:1', 'key:1', 'key:2']); - $this->store->setRaw('ver:1', '1', 60); + $this->store->delete(['{t}:ver:1', '{t}:key:1', '{t}:key:2']); + $this->store->setRaw('{t}:ver:1', '1', 60); $attrs = [ - 'key:1' => ['id' => 1, 'name' => 'Alice'], - 'key:2' => ['id' => 2, 'name' => 'Bob'], + '{t}:key:1' => ['id' => 1, 'name' => 'Alice'], + '{t}:key:2' => ['id' => 2, 'name' => 'Bob'], ]; - $this->store->setManyTrackedIfVersion($attrs, 60, 'member:1', 'ver:1', 1); + $this->store->setManyIfVersion($attrs, 60, '{t}:ver:1', 1); - $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('key:1')); - $this->assertSame(['id' => 2, 'name' => 'Bob'], $this->store->get('key:2')); + $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('{t}:key:1')); + $this->assertSame(['id' => 2, 'name' => 'Bob'], $this->store->get('{t}:key:2')); // Should NOT update if version mismatch - $attrs2 = ['key:1' => ['id' => 1, 'name' => 'Charlie']]; - $this->store->setManyTrackedIfVersion($attrs2, 60, 'member:1', 'ver:1', 2); + $attrs2 = ['{t}:key:1' => ['id' => 1, 'name' => 'Charlie']]; + $this->store->setManyIfVersion($attrs2, 60, '{t}:ver:1', 2); - $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('key:1')); + $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('{t}:key:1')); } - public function test_it_can_set_many_tracked_if_version_with_lock_release(): void + public function test_it_can_set_many_if_version_with_lock_release(): void { - $this->store->delete(['member:2', 'ver:2', 'key:3', 'key:4', 'lock:2', 'wake:2']); - $this->store->setRaw('ver:2', '1', 60); - $this->store->setNxEx('lock:2', 'tok', 60); + $this->store->delete(['{t}:ver:2', '{t}:key:3', '{t}:key:4', '{t}:lock:2', '{t}:wake:2']); + $this->store->setRaw('{t}:ver:2', '1', 60); + $this->store->setNxEx('{t}:lock:2', 'tok', 60); $attrs = [ - 'key:3' => ['id' => 3, 'name' => 'Dee'], - 'key:4' => ['id' => 4, 'name' => 'Eve'], + '{t}:key:3' => ['id' => 3, 'name' => 'Dee'], + '{t}:key:4' => ['id' => 4, 'name' => 'Eve'], ]; - $this->store->setManyTrackedIfVersion($attrs, 60, 'member:2', 'ver:2', 1, 'lock:2', 'wake:2', 'tok'); + $this->store->setManyIfVersion($attrs, 60, '{t}:ver:2', 1, '{t}:lock:2', '{t}:wake:2', 'tok'); - $this->assertSame(['id' => 3, 'name' => 'Dee'], $this->store->get('key:3')); - $this->assertSame(['id' => 4, 'name' => 'Eve'], $this->store->get('key:4')); - $this->assertNull($this->store->getRaw('lock:2'), 'build lock should be released after the write'); + $this->assertSame(['id' => 3, 'name' => 'Dee'], $this->store->get('{t}:key:3')); + $this->assertSame(['id' => 4, 'name' => 'Eve'], $this->store->get('{t}:key:4')); + $this->assertNull($this->store->getRaw('{t}:lock:2'), 'build lock should be released after the write'); } - public function test_set_many_tracked_if_version_handles_large_script_batches(): void + public function test_set_many_if_version_handles_large_script_batches(): void { - $this->store->delete(['member:large', 'ver:large']); - $this->store->setRaw('ver:large', '1', 60); + $this->store->delete(['{t}:ver:large']); + $this->store->setRaw('{t}:ver:large', '1', 60); $attrs = []; for ($i = 0; $i < 10000; $i++) { - $attrs["key:large:{$i}"] = ['id' => $i, 'name' => "Name {$i}"]; + $attrs["{t}:key:large:{$i}"] = ['id' => $i, 'name' => "Name {$i}"]; } - $this->store->setManyTrackedIfVersion($attrs, 60, 'member:large', 'ver:large', 1); + $this->store->setManyIfVersion($attrs, 60, '{t}:ver:large', 1); - $this->assertSame(['id' => 9999, 'name' => 'Name 9999'], $this->store->get('key:large:9999')); - $this->assertSame(10000, (int) $this->store->script("return redis.call('SCARD', KEYS[1])", ['member:large'])); + $this->assertSame(['id' => 9999, 'name' => 'Name 9999'], $this->store->get('{t}:key:large:9999')); } - public function test_set_many_tracked_script_chunks_sadd_internally(): void + public function test_set_many_if_version_script_chunks_internally(): void { - $this->store->setRaw('ver:script-large', '1', 60); + $this->store->setRaw('{t}:ver:script-large', '1', 60); $count = 8200; $keys = []; $values = []; for ($i = 0; $i < $count; $i++) { - $keys[] = "key:script-large:{$i}"; + $keys[] = "{t}:key:script-large:{$i}"; $values[] = $this->store->serialize(['id' => $i]); } $result = $this->store->script( - RedisScripts::get('store_many_tracked_if_version'), - array_merge(['ver:script-large', 'member:script-large', '', ''], $keys), + RedisScripts::get('store_model_attrs'), + array_merge(['{t}:ver:script-large'], $keys), array_merge(['1', '60', (string) $count, ''], $values) ); $this->assertSame($count, (int) $result); - $this->assertSame($count, (int) $this->store->script("return redis.call('SCARD', KEYS[1])", ['member:script-large'])); } public function test_it_skips_write_but_still_releases_on_version_mismatch(): void { - $this->store->delete(['member:3', 'ver:3', 'key:5', 'lock:3', 'wake:3']); - $this->store->setRaw('ver:3', '2', 60); - $this->store->setNxEx('lock:3', 'tok', 60); + $this->store->delete(['{t}:ver:3', '{t}:key:5', '{t}:lock:3', '{t}:wake:3']); + $this->store->setRaw('{t}:ver:3', '2', 60); + $this->store->setNxEx('{t}:lock:3', 'tok', 60); - $this->store->setManyTrackedIfVersion( - ['key:5' => ['id' => 5]], 60, 'member:3', 'ver:3', 1, 'lock:3', 'wake:3', 'tok' + $this->store->setManyIfVersion( + ['{t}:key:5' => ['id' => 5]], 60, '{t}:ver:3', 1, '{t}:lock:3', '{t}:wake:3', 'tok' ); - $this->assertNull($this->store->get('key:5')); - $this->assertNull($this->store->getRaw('lock:3'), 'build lock should still be released even when the write is skipped'); + $this->assertNull($this->store->get('{t}:key:5')); + $this->assertNull($this->store->getRaw('{t}:lock:3'), 'build lock should still be released even when the write is skipped'); } public function test_it_releases_lock_unconditionally_when_there_is_nothing_to_write(): void { - $this->store->delete(['lock:4', 'wake:4']); - $this->store->setNxEx('lock:4', 'tok', 60); + $this->store->delete(['{t}:lock:4', '{t}:wake:4']); + $this->store->setNxEx('{t}:lock:4', 'tok', 60); - $this->store->setManyTrackedIfVersion([], 60, 'member:4', 'ver:4', 1, 'lock:4', 'wake:4', 'tok'); + $this->store->setManyIfVersion([], 60, '{t}:ver:4', 1, '{t}:lock:4', '{t}:wake:4', 'tok'); - $this->assertNull($this->store->getRaw('lock:4')); + $this->assertNull($this->store->getRaw('{t}:lock:4')); } public function test_it_can_flush_by_patterns(): void @@ -242,6 +235,41 @@ public function test_it_can_flush_by_patterns(): void $this->assertSame('c', $this->store->get('bar:1')); } + public function test_predis_cluster_delete_batches_keys_by_hash_tag(): void + { + $connection = new class extends PredisClusterConnection + { + public array $commands = []; + + public function __construct() {} + + public function command($method, array $parameters = []) + { + $this->commands[] = [$method, $parameters]; + + return count($parameters); + } + }; + + $store = new RedisStore('normcache-test'); + (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); + + $store->delete([ + '{nc}:foo:1', + '{nc:content}:foo:1', + '{nc}:foo:2', + 'untagged:1', + 'untagged:2', + ]); + + $this->assertSame([ + ['del', ['{nc}:foo:1', '{nc}:foo:2']], + ['del', ['{nc:content}:foo:1']], + ['del', ['untagged:1']], + ['del', ['untagged:2']], + ], $connection->commands); + } + public function test_scan_pattern_strips_connection_prefix_from_returned_keys(): void { if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { @@ -252,10 +280,10 @@ public function test_scan_pattern_strips_connection_prefix_from_returned_keys(): Redis::purge('normcache-test'); try { - $store = new RedisStore('normcache-test', 'test:', false); - $store->set('query:abc', [1], 60); - $store->set('query:def', [2], 60); - $store->set('model:1', ['id' => 1], 60); + $store = new RedisStore('normcache-test'); + $store->set('test:query:abc', [1], 60); + $store->set('test:query:def', [2], 60); + $store->set('test:model:1', ['id' => 1], 60); $keys = $store->scanPattern('test:query:*'); @@ -280,30 +308,60 @@ public function test_flush_by_patterns_works_with_connection_prefix(): void Redis::purge('normcache-test'); try { - $store = new RedisStore('normcache-test', 'test:', false); - $store->set('query:1', 'a', 60); - $store->set('query:2', 'b', 60); - $store->set('model:1', 'c', 60); + $store = new RedisStore('normcache-test'); + $store->set('test:query:1', 'a', 60); + $store->set('test:query:2', 'b', 60); + $store->set('test:model:1', 'c', 60); - $count = $store->flushByPatterns(['query:*']); + $count = $store->flushByPatterns(['test:query:*']); $this->assertSame(2, $count); - $this->assertNull($store->get('query:1')); - $this->assertNull($store->get('query:2')); - $this->assertSame('c', $store->get('model:1')); + $this->assertNull($store->get('test:query:1')); + $this->assertNull($store->get('test:query:2')); + $this->assertSame('c', $store->get('test:model:1')); } finally { Redis::purge('normcache-test'); config()->set('database.redis.options.prefix', ''); } } - public function test_flush_by_patterns_scans_all_phpredis_cluster_masters(): void + public function test_flush_by_patterns_scans_each_phpredis_cluster_master_once_and_filters_keys(): void { - $connection = new class extends PhpRedisClusterConnection + $client = new class + { + public array $scans = []; + + public function getOption($option) + { + return 'laravel:'; + } + + public function _masters(): array + { + return ['node-a', 'node-b']; + } + + public function scan(&$cursor, $node, $pattern = null, $count = 0) + { + $this->scans[] = [$node, $pattern]; + $cursor = 0; + + return match ($node) { + 'node-a' => [ + 'laravel:{nc}:test:model:{testing:posts}:1', + 'laravel:{nc}:test:other:keep', + ], + 'node-b' => ['laravel:{nc}:test:query:{testing:posts}:v1:abc'], + default => [], + }; + } + }; + + $connection = new class($client) extends PhpRedisClusterConnection { public array $unlinked = []; - public function __construct() {} + public function __construct(private object $redisClient) {} public function _prefix($key) { @@ -312,30 +370,7 @@ public function _prefix($key) public function client() { - return new class - { - public function getOption($option) - { - return 'laravel:'; - } - - public function _masters(): array - { - return ['node-a', 'node-b']; - } - - public function scan(&$cursor, $node, $pattern = null, $count = 0) - { - $prev = $cursor; - $cursor = 0; - - return match ([$pattern, $node, $prev]) { - ['laravel:test:model:*', 'node-a', null] => ['laravel:test:model:{testing:posts}:1'], - ['laravel:test:query:*', 'node-b', null] => ['laravel:test:query:{testing:posts}:v1:abc'], - default => [], - }; - } - }; + return $this->redisClient; } public function unlink($keys) @@ -346,16 +381,20 @@ public function unlink($keys) } }; - $store = new RedisStore('normcache-test', 'test:', true); + $store = new RedisStore('normcache-test'); (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - $deleted = $store->flushByPatterns(['model:*', 'query:*']); + $deleted = $store->flushByPatterns(['{nc}:test:model:*', '{nc}:test:query:*']); $this->assertSame(2, $deleted); $this->assertSame([ - ['test:model:{testing:posts}:1'], - ['test:query:{testing:posts}:v1:abc'], - ], $connection->unlinked); + ['node-a', 'laravel:{nc}:test:*'], + ['node-b', 'laravel:{nc}:test:*'], + ], $client->scans); + $this->assertSame([[ + '{nc}:test:model:{testing:posts}:1', + '{nc}:test:query:{testing:posts}:v1:abc', + ]], $connection->unlinked); } public function test_flush_by_patterns_scans_all_nodes_on_predis_cluster(): void @@ -378,23 +417,99 @@ public function test_flush_by_patterns_scans_all_nodes_on_predis_cluster(): void $directClient = new PredisClient($standaloneConn); for ($i = 0; $i < 2500; $i++) { - $directClient->setex("testscan:model:{posts}:{$i}", 60, 'x'); + $directClient->setex("model:{posts}:{$i}", 60, 'x'); } - $this->assertCount(2500, $directClient->keys('testscan:*')); + $this->assertCount(2500, $directClient->keys('model:*')); // Use 'predis' cluster type so the client iterates over configured nodes // without requiring CLUSTER SLOTS (which standalone Redis doesn't support). $predisClient = new PredisClient([$standaloneConn], ['cluster' => 'predis']); $clusterConnection = new PredisClusterConnection($predisClient); - $store = new RedisStore('normcache-test', 'testscan:', false); + $store = new RedisStore('normcache-test'); (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $clusterConnection); $deleted = $store->flushByPatterns(['model:{posts}:*']); $this->assertSame(2500, $deleted); - $this->assertEmpty($directClient->keys('testscan:*')); + $this->assertEmpty($directClient->keys('model:*')); + } + + public function test_predis_cluster_scan_targets_owner_for_concrete_hash_tag_pattern(): void + { + $owner = new class + { + public array $patterns = []; + + public function scan($cursor, array $options) + { + $this->patterns[] = $options['match'] ?? null; + + return ['0', ['{nc:content}:test:query:testing:posts:v1:abc']]; + } + }; + + $other = new class + { + public int $calls = 0; + + public function scan($cursor, array $options) + { + $this->calls++; + + return ['0', []]; + } + }; + + $connection = new class($owner, $other) extends PredisClusterConnection + { + public function __construct(private object $owner, private object $other) {} + + public function client() + { + return new class($this->owner, $this->other) implements \IteratorAggregate + { + public function __construct(private object $owner, private object $other) {} + + public function getOptions() + { + return new class + { + public $prefix = null; + }; + } + + public function getConnection() + { + return new class($this->owner) + { + public function __construct(private object $owner) {} + + public function getConnectionBySlot($slot) + { + return $this->owner; + } + }; + } + + public function getIterator(): \Traversable + { + return new \ArrayIterator([$this->owner, $this->other]); + } + }; + } + }; + + $store = new RedisStore('normcache-test'); + (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); + + $this->assertSame( + ['{nc:content}:test:query:testing:posts:v1:abc'], + $store->scanPattern('{nc:content}:test:query:*') + ); + $this->assertSame(['{nc:content}:test:query:*'], $owner->patterns); + $this->assertSame(0, $other->calls); } public function test_predis_cluster_scan_deduplicates_keys_returned_by_multiple_nodes(): void @@ -438,7 +553,7 @@ public function scan($cursor, array $options) } }; - $store = new RedisStore('normcache-test', '', true); + $store = new RedisStore('normcache-test'); (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); $this->assertSame( @@ -453,7 +568,7 @@ public function test_unserialize_detects_format_by_magic_header(): void $this->markTestSkipped('igbinary extension not available in this environment'); } - $store = new RedisStore('normcache-test', '', false); + $store = new RedisStore('normcache-test'); $data = ['x' => 1]; $this->assertSame($data, $store->unserialize(igbinary_serialize($data))); @@ -477,7 +592,7 @@ public function test_eval_returns_correct_result_on_first_call(): void { (new ReflectionProperty(RedisStore::class, 'shas'))->setValue(null, []); - $store = new RedisStore('normcache-test', '', false); + $store = new RedisStore('normcache-test'); $script = RedisScripts::get('fetch_version_with_cooldown'); Redis::connection('normcache-test')->setex('ver:{authors}:', 60, '7'); @@ -491,7 +606,7 @@ public function test_php_sha_cache_is_populated_after_first_eval(): void { (new ReflectionProperty(RedisStore::class, 'shas'))->setValue(null, []); - $store = new RedisStore('normcache-test', '', false); + $store = new RedisStore('normcache-test'); $script = RedisScripts::get('fetch_version_with_cooldown'); $this->assertArrayNotHasKey($script, (new ReflectionProperty(RedisStore::class, 'shas'))->getValue()); @@ -510,7 +625,7 @@ public function test_igbinary_blob_returns_null_when_extension_absent(): void $this->markTestSkipped('igbinary extension not available in this environment'); } - $store = new RedisStore('normcache-test', '', false); + $store = new RedisStore('normcache-test'); $serializer = (new ReflectionProperty($store, 'serializer'))->getValue($store); (new ReflectionProperty($serializer, 'igbinary'))->setValue($serializer, false); diff --git a/tests/Unit/Relations/CachesRelationAggregatesAliasTest.php b/tests/Unit/Relations/CachesRelationAggregatesAliasTest.php index 0981489..8116fb3 100644 --- a/tests/Unit/Relations/CachesRelationAggregatesAliasTest.php +++ b/tests/Unit/Relations/CachesRelationAggregatesAliasTest.php @@ -4,9 +4,9 @@ use Illuminate\Database\Query\Expression; use NormCache\Tests\Fixtures\Models\Author; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; -class CachesRelationAggregatesAliasTest extends TestCase +class CachesRelationAggregatesAliasTest extends UnitTestCase { private function resolveAlias(mixed $column, ?string $name, ?string $function, mixed $columnArg): string { diff --git a/tests/Unit/Relations/RelationDependencyClassifierTest.php b/tests/Unit/Relations/RelationDependencyClassifierTest.php deleted file mode 100644 index 664442c..0000000 --- a/tests/Unit/Relations/RelationDependencyClassifierTest.php +++ /dev/null @@ -1,50 +0,0 @@ -classify((new Author)->posts(), null); - - $this->assertSame(Post::class, $entry['relatedClass']); - $this->assertNull($entry['throughClass']); - $this->assertNull($entry['tableKey']); - $this->assertSame([], $entry['constraintModels']); - $this->assertSame([], $entry['constraintTables']); - } - - public function test_belongs_to_many_relation_includes_pivot_table_key(): void - { - $entry = (new RelationDependencyClassifier)->classify((new Author)->tags(), null); - - $this->assertNotNull($entry['tableKey']); - } - - public function test_lock_for_update_relation_definition_is_not_classifiable(): void - { - $entry = (new RelationDependencyClassifier)->classify((new Author)->lockedPosts(), null); - - $this->assertNull($entry); - } - - public function test_without_cache_relation_definition_is_not_classifiable(): void - { - $entry = (new RelationDependencyClassifier)->classify((new Author)->cacheSkippedPosts(), null); - - $this->assertNull($entry); - } - - public function test_non_cacheable_related_model_is_not_classifiable(): void - { - $entry = (new RelationDependencyClassifier)->classify((new Author)->uncachedPosts(), null); - - $this->assertNull($entry); - } -} diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php new file mode 100644 index 0000000..bca05c3 --- /dev/null +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -0,0 +1,302 @@ +setValue($store, $connection); + + return new CacheSpaceRegistry(metadataStore: $store, metadataKeyPrefix: 'test:'); + } + + public function test_default_space_maps_to_nc_hash_tag(): void + { + $default = $this->registry()->defaultSpace(); + + $this->assertSame('default', $default->name); + $this->assertSame('nc', $default->hashTag); + } + + public function test_named_space_derives_hash_tag_by_convention(): void + { + $space = $this->registry()->space('content'); + + $this->assertSame('content', $space->name); + $this->assertSame('nc:content', $space->hashTag); + } + + public function test_placement_config_overrides_the_hash_tag(): void + { + $registry = new CacheSpaceRegistry(16, ['catalog' => ['hash_tag' => 'shard7']]); + + $this->assertSame('shard7', $registry->space('catalog')->hashTag); + $this->assertSame('nc:content', $registry->space('content')->hashTag); + } + + public function test_known_spaces_include_default_and_materialized_spaces(): void + { + $registry = new CacheSpaceRegistry(16); + $registry->space('catalog'); + + $this->assertSame( + ['default', 'catalog'], + array_map(fn($s) => $s->name, $registry->knownSpaces()), + ); + } + + public function test_known_spaces_include_configured_placement_spaces(): void + { + $registry = new CacheSpaceRegistry(16, ['catalog' => ['hash_tag' => 'shard7']]); + + $this->assertSame( + ['default', 'catalog'], + array_map(fn($s) => $s->name, $registry->knownSpaces()), + ); + } + + public function test_invalid_space_name_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->registry()->space('has space'); + } + + public function test_logical_spaces_cannot_share_the_same_hash_tag(): void + { + $registry = new CacheSpaceRegistry(16, [ + 'content' => ['hash_tag' => 'shared'], + 'reporting' => ['hash_tag' => 'shared'], + ]); + + $this->expectException(\InvalidArgumentException::class); + + $registry->knownSpaces(); + } + + public function test_model_without_declaration_falls_back_to_default(): void + { + $registry = $this->registry(); + + $spaces = $registry->spacesForModel(Author::class); + + $this->assertSame(['default'], array_map(fn($s) => $s->name, $spaces)); + $this->assertTrue($registry->modelAllowedInSpace(Author::class, 'default')); + $this->assertFalse($registry->modelAllowedInSpace(Author::class, 'content')); + } + + public function test_model_declaration_drives_membership(): void + { + $registry = $this->registry(); + + $spaces = $registry->spacesForModel(SpacedPost::class); + + $this->assertSame(['content'], array_map(fn($s) => $s->name, $spaces)); + $this->assertTrue($registry->modelAllowedInSpace(SpacedPost::class, 'content')); + $this->assertFalse($registry->modelAllowedInSpace(SpacedPost::class, 'default')); + } + + public function test_model_in_too_many_spaces_throws_on_resolution(): void + { + $model = new class extends Model + { + use Cacheable; + + protected static array $normCacheSpaces = ['a', 'b', 'c']; + }; + + $this->expectException(\InvalidArgumentException::class); + + $this->registry(maxPerModel: 2)->spacesForModel($model::class); + } + + public function test_tables_start_in_the_default_space(): void + { + $registry = $this->registry(); + + $this->assertSame(['default'], array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags'))); + } + + public function test_validating_table_dependency_does_not_mutate_registry(): void + { + $registry = $this->registry(); + $content = $registry->space('content'); + + $result = $registry->validateDependencies($content, [], ['mysql:legacy_flags'], includeDependenciesBySpace: true); + + $this->assertTrue($result->isValid); + $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); + $this->assertSame( + ['default'], + array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), + ); + } + + public function test_table_dependencies_can_be_registered_after_plan_acceptance(): void + { + $registry = $this->registry(); + $content = $registry->space('content'); + + $this->assertTrue($registry->registerTableDependencies($content, ['mysql:legacy_flags'])); + + $this->assertContains( + 'content', + array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), + ); + } + + public function test_failed_table_space_registration_is_reported_and_not_memoized(): void + { + $connection = new class extends PredisConnection + { + public function __construct() {} + + public function command($method, array $parameters = []) + { + return match (strtolower($method)) { + 'smembers' => [], + 'sadd' => throw new RuntimeException('SADD denied'), + default => null, + }; + } + }; + $registry = $this->registryWithConnection($connection); + $content = $registry->space('content'); + + $this->assertFalse($registry->registerTableDependencies($content, ['mysql:legacy_flags'])); + $this->assertSame( + ['default'], + array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), + ); + } + + public function test_failed_table_space_lookup_falls_back_without_memoizing_the_failure(): void + { + $connection = new class extends PredisConnection + { + private int $lookups = 0; + + public function __construct() {} + + public function command($method, array $parameters = []) + { + if (strtolower($method) !== 'smembers') { + return null; + } + + if ($this->lookups++ === 0) { + throw new RuntimeException('SMEMBERS denied'); + } + + return ['content']; + } + }; + $registry = $this->registryWithConnection($connection); + + $this->assertSame( + ['default'], + array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), + ); + $this->assertSame( + ['default', 'content'], + array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), + ); + } + + public function test_table_space_lookup_is_memoized_until_runtime_reset(): void + { + $connection = new class extends PredisConnection + { + public int $lookups = 0; + + public function __construct() {} + + public function command($method, array $parameters = []) + { + if (strtolower($method) === 'smembers') { + $this->lookups++; + + return ['content']; + } + + return null; + } + }; + $registry = $this->registryWithConnection($connection); + + $registry->spacesForTable('mysql:legacy_flags'); + $registry->spacesForTable('mysql:legacy_flags'); + + $this->assertSame(1, $connection->lookups); + + $registry->resetMetadataMemo(); + $registry->spacesForTable('mysql:legacy_flags'); + + $this->assertSame(2, $connection->lookups); + } + + public function test_single_base_model_dependencies_do_not_need_validation(): void + { + $registry = $this->registry(); + + $this->assertTrue($registry->dependenciesAreOnlyModel(Author::class, [Author::class], [])); + $this->assertFalse($registry->dependenciesAreOnlyModel(Author::class, [Author::class, SpacedPost::class], [])); + $this->assertFalse($registry->dependenciesAreOnlyModel(Author::class, [Author::class], ['mysql:legacy_flags'])); + } + + public function test_validate_dependencies_passes_when_all_allowed(): void + { + $registry = $this->registry(); + $content = $registry->space('content'); + + $result = $registry->validateDependencies($content, [SpacedPost::class], []); + + $this->assertTrue($result->isValid); + $this->assertSame([], $result->invalidModels); + $this->assertSame([], $result->dependenciesBySpace); + } + + public function test_validate_dependencies_can_build_map_for_explain(): void + { + $registry = $this->registry(); + $content = $registry->space('content'); + + $result = $registry->validateDependencies($content, [SpacedPost::class], [], includeDependenciesBySpace: true); + + $this->assertTrue($result->isValid); + $this->assertSame(['content'], $result->dependenciesBySpace[SpacedPost::class]); + } + + public function test_validate_dependencies_reports_cross_space_members(): void + { + $registry = $this->registry(); + $content = $registry->space('content'); + + // Author is default-only; raw table dependencies are valid in the active space. + $result = $registry->validateDependencies($content, [SpacedPost::class, Author::class], ['mysql:legacy_flags']); + + $this->assertFalse($result->isValid); + $this->assertSame([Author::class], $result->invalidModels); + $this->assertSame(['default'], $result->dependenciesBySpace[Author::class]); + $this->assertSame(['content'], $result->dependenciesBySpace[SpacedPost::class]); + $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); + } +} diff --git a/tests/Unit/Spaces/CacheSpaceResolverTest.php b/tests/Unit/Spaces/CacheSpaceResolverTest.php new file mode 100644 index 0000000..ece148e --- /dev/null +++ b/tests/Unit/Spaces/CacheSpaceResolverTest.php @@ -0,0 +1,40 @@ +assertSame('default', $this->resolver()->resolve(Author::class, null)->name); + } + + public function test_declared_model_resolves_to_its_first_space(): void + { + // SpacedPost declares ['content']. + $this->assertSame('content', $this->resolver()->resolve(SpacedPost::class, null)->name); + } + + public function test_explicit_member_space_is_used(): void + { + $this->assertSame('content', $this->resolver()->resolve(SpacedPost::class, 'content')->name); + } + + public function test_explicit_non_member_space_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->resolver()->resolve(SpacedPost::class, 'reporting'); + } +} diff --git a/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php b/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php new file mode 100644 index 0000000..fce5b9e --- /dev/null +++ b/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php @@ -0,0 +1,20 @@ +assertSame([], Author::normCacheSpaces()); + } + + public function test_model_with_declaration_returns_its_spaces(): void + { + $this->assertSame(['content'], SpacedPost::normCacheSpaces()); + } +} diff --git a/tests/Unit/Support/CacheSerializerTest.php b/tests/Unit/Support/CacheSerializerTest.php index 2cd4b98..cfbffa6 100644 --- a/tests/Unit/Support/CacheSerializerTest.php +++ b/tests/Unit/Support/CacheSerializerTest.php @@ -3,9 +3,9 @@ namespace NormCache\Tests\Unit\Support; use NormCache\Support\CacheSerializer; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; -class CacheSerializerTest extends TestCase +class CacheSerializerTest extends UnitTestCase { private CacheSerializer $serializer; diff --git a/tests/Unit/Support/ProjectionClassifierTest.php b/tests/Unit/Support/ProjectionClassifierTest.php index 8aedc12..423c29e 100644 --- a/tests/Unit/Support/ProjectionClassifierTest.php +++ b/tests/Unit/Support/ProjectionClassifierTest.php @@ -38,14 +38,6 @@ public function test_is_exact_full_model_projection(): void $this->assertFalse(ProjectionClassifier::isExactFullModelProjection(['authors.id'], 'authors')); } - public function test_contains_wildcard(): void - { - $this->assertFalse(ProjectionClassifier::containsWildcard(null)); - $this->assertTrue(ProjectionClassifier::containsWildcard(['*'])); - $this->assertTrue(ProjectionClassifier::containsWildcard(['authors.*'])); - $this->assertFalse(ProjectionClassifier::containsWildcard(['id'])); - } - public function test_has_required_key(): void { $this->assertTrue(ProjectionClassifier::hasRequiredKey(['*'], 'authors', 'id')); diff --git a/tests/Unit/Values/ResultValueObjectsTest.php b/tests/Unit/Values/ResultValueObjectsTest.php index f3fe19b..60d96b5 100644 --- a/tests/Unit/Values/ResultValueObjectsTest.php +++ b/tests/Unit/Values/ResultValueObjectsTest.php @@ -3,6 +3,7 @@ namespace NormCache\Tests\Unit\Values; use NormCache\Enums\CacheStatus; +use NormCache\Values\BuildHandle; use NormCache\Values\PivotCacheResult; use NormCache\Values\QueryCacheResult; use NormCache\Values\ResultCacheResult; @@ -17,14 +18,11 @@ public function test_query_cache_result_exposes_typed_status(): void key: 'query:v1:abc', ids: [1, 2, 3], models: null, - buildingKey: null, - buildingToken: null, - versionKeys: [], - expectedVersions: [], ); $this->assertSame(CacheStatus::Hit, $result->status); $this->assertSame([1, 2, 3], $result->ids); + $this->assertNull($result->build->buildingKey); } public function test_result_cache_result_exposes_typed_status(): void @@ -33,16 +31,18 @@ public function test_result_cache_result_exposes_typed_status(): void status: CacheStatus::Miss, key: 'result:v1:abc', payload: null, - buildingKey: 'building:abc', - buildingToken: 'tok123', - wakeKey: 'wake:abc', - versionKeys: ['ver:posts:'], - expectedVersions: ['5'], + build: new BuildHandle( + buildingKey: 'building:abc', + buildingToken: 'tok123', + wakeKey: 'wake:abc', + versionKeys: ['ver:posts:'], + expectedVersions: ['5'], + ), ); $this->assertSame(CacheStatus::Miss, $result->status); - $this->assertSame('building:abc', $result->buildingKey); - $this->assertSame(['ver:posts:'], $result->versionKeys); + $this->assertSame('building:abc', $result->build->buildingKey); + $this->assertSame(['ver:posts:'], $result->build->versionKeys); } public function test_pivot_cache_result_missed_ids_returns_non_array_entries(): void @@ -50,8 +50,6 @@ public function test_pivot_cache_result_missed_ids_returns_non_array_entries(): $result = new PivotCacheResult( seg: 'v1', data: [1 => [['id' => 5]], 2 => null, 3 => false], - versionKeys: [], - expectedVersions: [], ); $this->assertSame([2, 3], $result->missedIds()); @@ -62,8 +60,6 @@ public function test_pivot_cache_result_missed_ids_empty_when_all_present(): voi $result = new PivotCacheResult( seg: 'v1', data: [1 => [['id' => 5]], 2 => [['id' => 6]]], - versionKeys: [], - expectedVersions: [], ); $this->assertSame([], $result->missedIds()); diff --git a/tests/UnitTestCase.php b/tests/UnitTestCase.php new file mode 100644 index 0000000..e5e10ff --- /dev/null +++ b/tests/UnitTestCase.php @@ -0,0 +1,49 @@ +set('database.default', 'testing'); + $app['config']->set('database.connections.testing', [ + 'driver' => 'sqlite', + 'database' => ':memory:', + 'prefix' => '', + ]); + + $app['config']->set('database.redis.client', 'predis'); + $app['config']->set('database.redis.options.prefix', ''); + $app['config']->set('database.redis.normcache-test', [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'port' => env('REDIS_PORT', 6379), + 'database' => 15, + 'password' => env('REDIS_PASSWORD', null), + ]); + + $app['config']->set('normcache.connection', 'normcache-test'); + $app['config']->set('normcache.enabled', true); + $app['config']->set('normcache.events', true); + $app['config']->set('normcache.key_prefix', 'test:'); + $app['config']->set('normcache.ttl', 3600); + $app['config']->set('normcache.query_ttl', 60); + $app['config']->set('normcache.cooldown', 0); + } +}