From e93d5306dba1626172db12dac74e4f766ad53491 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 26 Jun 2026 11:22:06 +1000 Subject: [PATCH 01/73] remove current sharding logic --- config/normcache.php | 7 - src/Cache/NormalizedCacheReader.php | 55 ++------ src/Cache/NormalizedThroughReader.php | 58 ++------- src/Cache/ResultCacheReader.php | 82 ------------ src/Cache/SlottedQueryAccess.php | 55 -------- src/CacheManager.php | 10 -- src/CacheServiceProvider.php | 13 +- src/Planning/CachePlanner.php | 16 +-- src/Support/RedisStore.php | 123 ++++-------------- src/Values/CacheConfig.php | 2 - tests/Integration/Cache/DependsOnTest.php | 4 - .../Cache/ModelHydratorStampedeTest.php | 4 +- tests/Integration/Cache/OptimizationsTest.php | 18 ++- tests/Integration/Cache/PivotCacheTest.php | 4 - .../Integration/Cache/SimplificationTest.php | 4 - .../Infrastructure/ClusterModeTest.php | 52 ++------ .../Infrastructure/LuaScriptBehaviorTest.php | 4 +- .../LuaScriptConsistencyTest.php | 6 +- .../Infrastructure/StampedeProtectionTest.php | 18 +-- .../Infrastructure/StringPrimaryKeyTest.php | 4 +- .../Invalidation/ModelInvalidationTest.php | 4 +- tests/TestCase.php | 22 ++-- tests/Unit/CacheManagerTest.php | 30 ++--- tests/Unit/RedisStoreTest.php | 37 +----- 24 files changed, 123 insertions(+), 509 deletions(-) delete mode 100644 src/Cache/SlottedQueryAccess.php diff --git a/config/normcache.php b/config/normcache.php index b818903..5c4b045 100644 --- a/config/normcache.php +++ b/config/normcache.php @@ -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), diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index d88d422..4db7f56 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -11,8 +11,6 @@ final class NormalizedCacheReader { - use SlottedQueryAccess; - public function __construct( private readonly RedisStore $store, private readonly CacheKeyBuilder $keys, @@ -20,7 +18,6 @@ public function __construct( private readonly int $queryTtl, private readonly int $buildingLockTtl, private readonly int $stampedeWaitMs = 200, - private readonly bool $slotting = false, private readonly int $wakeTokenCount = 64, ) {} @@ -50,10 +47,6 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $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, @@ -74,37 +67,6 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep ); } - 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); @@ -182,12 +144,6 @@ public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, $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'), @@ -230,6 +186,17 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr return $result->status === CacheStatus::Building ? null : $result; } + 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 luaFetchVersionedQuery(string $classKey, string $hash, ?string $tag, string $lockToken): mixed { return $this->store->script(RedisScripts::get('fetch_versioned_query'), [ diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index ef4fbf2..87b3161 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -11,8 +11,6 @@ final class NormalizedThroughReader { - use SlottedQueryAccess; - public function __construct( private readonly RedisStore $store, private readonly CacheKeyBuilder $keys, @@ -20,7 +18,6 @@ public function __construct( private readonly int $queryTtl, private readonly int $buildingLockTtl, private readonly int $stampedeWaitMs = 200, - private readonly bool $slotting = false, private readonly int $wakeTokenCount = 64, ) {} @@ -33,10 +30,6 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $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), @@ -58,40 +51,6 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep ); } - 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); @@ -162,12 +121,6 @@ public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?s $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'), @@ -210,6 +163,17 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr return $result->status === CacheStatus::Building ? null : $result; } + 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 luaFetchMultiVersionedThrough( array $versionKeys, array $scheduledKeys, string $queryPrefix, string $buildingPrefix, string $wakePrefix, diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index e4b74f0..b2e081b 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -19,15 +19,9 @@ public function __construct( private readonly int $queryTtl, private readonly int $buildingLockTtl, private readonly int $stampedeWaitMs, - private readonly bool $slotting = false, private readonly int $wakeTokenCount = 64, ) {} - private function usesSlotting(): bool - { - return $this->store->requiresSlotting($this->slotting); - } - public function fetch( string $modelClass, array $depClasses, string $hash, ?string $tag, array $depTableKeys, @@ -39,22 +33,6 @@ public function fetch( $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), @@ -121,23 +99,6 @@ public function fetchPivot( $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 = []; @@ -241,20 +202,9 @@ public function storeMany( ?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), [ @@ -275,13 +225,6 @@ public function storeEntry( 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, [ @@ -297,31 +240,6 @@ public function storeEntry( ); } - 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, 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/CacheManager.php b/src/CacheManager.php index a044264..55e57cd 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -72,16 +72,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; diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 471de83..8b5ef8c 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -34,7 +34,6 @@ public function register(): void $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); @@ -42,13 +41,11 @@ public function register(): void $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); + $store = new RedisStore($connection, $keyPrefix, '{nc}:', $stampedeWakeTokens); $keys = new CacheKeyBuilder; $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); $engine = new ExecutionEngine; $config = new CacheConfig( ttl: $ttl, @@ -57,15 +54,13 @@ public function register(): void enabled: $enabled, fallbackEnabled: $fallback, dispatchEvents: $events, - cluster: $cluster, - slotting: $slottingActive, stampedeWakeTokens: $stampedeWakeTokens, ); return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 0c7ec94..78cd8cd 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -233,16 +233,12 @@ private function planModels( } 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)) { diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 55a9966..92522f6 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -22,8 +22,7 @@ final class RedisStore public function __construct( string $redisConnection, private string $keyPrefix, - private bool $slotting, - private string $slotPrefix = '', + private string $hashTagPrefix = '{nc}:', private int $wakeTokenCount = 64, ) { $this->serializer = new CacheSerializer; @@ -36,8 +35,12 @@ public function __construct( public function prefix(string $key): string { - // Follow the expected order: slotPrefix then keyPrefix. - return $this->slotPrefix . $this->keyPrefix . $key; + return $this->hashTagPrefix . $this->keyPrefix . $key; + } + + public function hashTagPrefix(): string + { + return $this->hashTagPrefix; } public function get(string $key): mixed @@ -157,51 +160,27 @@ 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. + // MGET in input order, with null for missing keys. All keys share the same hash tag so + // a plain MGET is safe on Redis Cluster (same slot guaranteed). 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; + $prefixed = []; + foreach ($keys as $key) { + $prefixed[] = $this->prefix($key); } - $results = []; - - foreach ($this->groupByTag($keys) as $groupKeys) { - $prefixed = []; - foreach ($groupKeys as $key) { - $prefixed[] = $this->prefix($key); - } - - $raw = $this->connection->mget($prefixed); + $raw = $this->connection->mget($prefixed); + $values = []; - $idx = 0; - foreach ($groupKeys as $key) { - $results[$key] = $this->mgetValue($raw[$idx++], $unserialize); - } - } - - $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 @@ -315,18 +294,8 @@ public function asyncDel(array $prefixedKeys): void 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); } } @@ -367,20 +336,10 @@ public function script(string $script, array $keys, array $args = []): mixed } if ($this->isCluster()) { - $tag = null; - foreach ($prefixedKeys as $pk) { - $hashTag = $this->hashTag($pk, includeBraces: true); - if ($hashTag !== null) { - $tag = $hashTag; - break; - } - } - - if ($tag !== null) { - foreach ($prefixedKeys as $i => $prefixedKey) { - if ($prefixedKey === '') { - $prefixedKeys[$i] = "{$tag}:null"; - } + $nullKey = rtrim($this->hashTagPrefix, ':') . ':null'; + foreach ($prefixedKeys as $i => $prefixedKey) { + if ($prefixedKey === '') { + $prefixedKeys[$i] = $nullKey; } } } @@ -450,48 +409,12 @@ 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. diff --git a/src/Values/CacheConfig.php b/src/Values/CacheConfig.php index 4d38b9d..26c4feb 100644 --- a/src/Values/CacheConfig.php +++ b/src/Values/CacheConfig.php @@ -15,8 +15,6 @@ public function __construct( public bool $enabled = true, public bool $fallbackEnabled = true, public bool $dispatchEvents = true, - public bool $cluster = false, - public bool $slotting = false, public int $stampedeWakeTokens = 64, ) {} } diff --git a/tests/Integration/Cache/DependsOnTest.php b/tests/Integration/Cache/DependsOnTest.php index b688812..b63f827 100644 --- a/tests/Integration/Cache/DependsOnTest.php +++ b/tests/Integration/Cache/DependsOnTest.php @@ -19,10 +19,6 @@ 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(); diff --git a/tests/Integration/Cache/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index 596eae4..b6d12e0 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -14,7 +14,7 @@ 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); @@ -38,7 +38,7 @@ public function test_acquires_lock_fetches_once_caches_and_releases_lock_on_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); diff --git a/tests/Integration/Cache/OptimizationsTest.php b/tests/Integration/Cache/OptimizationsTest.php index 62d12d3..51c18c5 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'); + $store = app('normcache')->getStore(); - $keys = $redis->keys($prefix . 'query:*'); + $keys = $redis->keys($store->prefix('query:*')); $this->assertNotEmpty($keys); $value = $redis->get($keys[0]); @@ -101,8 +101,9 @@ public function test_corrupt_query_cache_payload_degrades_to_miss_and_repairs(): $classKey = app('normcache')->classKey(Author::class); $version = app('normcache')->currentVersion(Author::class); + $store = app('normcache')->getStore(); Redis::connection(config('normcache.connection'))->set( - config('normcache.key_prefix') . "query:{{$classKey}}:v{$version}:{$hash}", + $store->prefix("query:{{$classKey}}:v{$version}:{$hash}"), '{not-json' ); @@ -147,8 +148,9 @@ 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')->getStore(); + $raw = $store->getRaw( + str_replace($store->prefix(''), '', $queryKey) ); $repaired = $raw !== null ? json_decode($raw, true) : null; $this->assertSame([(string) $found->first()->id], $repaired); @@ -244,10 +246,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('test:query:*')); } } diff --git a/tests/Integration/Cache/PivotCacheTest.php b/tests/Integration/Cache/PivotCacheTest.php index 5b5a7d0..5ad5a4b 100644 --- a/tests/Integration/Cache/PivotCacheTest.php +++ b/tests/Integration/Cache/PivotCacheTest.php @@ -114,10 +114,6 @@ 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); diff --git a/tests/Integration/Cache/SimplificationTest.php b/tests/Integration/Cache/SimplificationTest.php index 908406e..5cce391 100644 --- a/tests/Integration/Cache/SimplificationTest.php +++ b/tests/Integration/Cache/SimplificationTest.php @@ -34,10 +34,6 @@ 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 diff --git a/tests/Integration/Infrastructure/ClusterModeTest.php b/tests/Integration/Infrastructure/ClusterModeTest.php index 9051a58..09daa55 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,16 @@ protected function setUp(): void $this->setClusterMode(true); } - // dependsOn result cache + // dependsOn — multi-dependency normalized query stays normalized (no forced result cache) - public function test_multi_dependency_normalized_query_routes_to_result_when_slotting_is_enabled(): void + 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->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'); - } - - 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]); - - 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('test:query:*'), 'Multi-dependency normalized queries should use query keys in cluster mode'); + $this->assertEmpty($this->redisKeys('test:result:*'), 'Multi-dependency normalized queries should not fall back to result cache'); } public function test_depends_on_returns_correct_results_in_cluster_mode(): void @@ -271,8 +256,8 @@ public function test_prefix_sensitive_scan_and_flush_operations_work_in_cluster_ } $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('test:query:{testing:authors}:homepage:*'), 'author tagged keys must be gone'); + $this->assertNotEmpty($this->redisKeys('test:query:{testing:posts}:homepage:*'), 'post tagged keys must survive'); $this->cacheManager()->flushTagAcrossModels('homepage'); $this->assertEmpty($this->redisKeys('test:query:*:homepage:*')); @@ -325,34 +310,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 . '}:'; - - $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) diff --git a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php index 57f2d6a..4c7297b 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 diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php index a369c42..7d6e160 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}}:"); } } diff --git a/tests/Integration/Infrastructure/StampedeProtectionTest.php b/tests/Integration/Infrastructure/StampedeProtectionTest.php index 1e3ac8a..5620f86 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); @@ -51,11 +51,11 @@ public function test_waiter_serves_from_cache_after_build_completes(): void 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->redis()->set("{nc}:test:building:{{$ck}}:{$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; @@ -76,8 +76,8 @@ public function test_budget_exhausted_falls_through_to_db(): void $ck = NormCache::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}}:"); + $this->redis()->set("{nc}:test:building:{{$ck}}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -97,8 +97,8 @@ public function test_lock_holder_crash_falls_through_after_budget(): void $ck = NormCache::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}}:"); + $this->redis()->set("{nc}:test:building:{{$ck}}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -128,7 +128,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..bf60900 100644 --- a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php +++ b/tests/Integration/Infrastructure/StringPrimaryKeyTest.php @@ -119,7 +119,7 @@ public function test_version_key_has_ttl_after_invalidation(): void $redis = Redis::connection('normcache-test'); $classKey = $this->cacheManager()->classKey(UuidItem::class); - $verKey = 'test:ver:{' . $classKey . '}:'; + $verKey = '{nc}:test:ver:{' . $classKey . '}:'; $ttl = $redis->ttl($verKey); @@ -133,7 +133,7 @@ public function test_version_key_ttl_exceeds_longest_payload_ttl(): void $redis = Redis::connection('normcache-test'); $classKey = $this->cacheManager()->classKey(UuidItem::class); - $verKey = 'test:ver:{' . $classKey . '}:'; + $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..4a9d58a 100644 --- a/tests/Integration/Invalidation/ModelInvalidationTest.php +++ b/tests/Integration/Invalidation/ModelInvalidationTest.php @@ -85,7 +85,7 @@ public function test_members_set_has_ttl_matching_model_ttl(): void Author::all(); $classKey = NormCache::classKey(Author::class); - $memberKey = 'test:members:model:{' . $classKey . '}'; + $memberKey = '{nc}:test:members:model:{' . $classKey . '}'; $redis = Redis::connection('normcache-test'); $this->assertGreaterThan(0, $redis->ttl($memberKey), 'members:model: set must have a TTL'); @@ -97,7 +97,7 @@ public function test_members_set_dead_keys_are_bounded_by_ttl(): void Author::all(); $classKey = NormCache::classKey(Author::class); - $memberKey = 'test:members:model:{' . $classKey . '}'; + $memberKey = '{nc}:test:members:model:{' . $classKey . '}'; $modelKey = $this->prefixedModelKey(Author::class, $author->id); $redis = Redis::connection('normcache-test'); diff --git a/tests/TestCase.php b/tests/TestCase.php index 9432ebc..dcccd99 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -90,7 +90,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 +103,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); @@ -144,7 +142,9 @@ protected function prefixedModelKey(string $class, mixed $id): string protected function redisKeys(string $pattern = '*'): array { - return $this->cacheManager()->getStore()->scanPattern($pattern); + $store = $this->cacheManager()->getStore(); + + return $store->scanPattern($store->hashTagPrefix() . $pattern); } protected function cacheManager(): CacheManager @@ -156,12 +156,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 +168,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,16 +175,14 @@ 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); + $store = new RedisStore($connection, $keyPrefix, '{nc}:', $stampedeWakeTokens); $keys = new CacheKeyBuilder; $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); $engine = new ExecutionEngine; $config = new CacheConfig( ttl: $ttl, @@ -195,15 +191,13 @@ protected function buildManager( 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), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $slottingActive, $stampedeWakeTokens), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index ab192ee..a5f623b 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -59,10 +59,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}}:"; + $scheduledKey = "{nc}:test:scheduled:{{$classKey}}:"; $manager->invalidateVersion(new Author); $firstDueAt = $redis->get($scheduledKey); @@ -77,11 +77,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); Redis::connection('normcache-test')->set( - "test:scheduled:{{$classKey}}:", + "{nc}:test:scheduled:{{$classKey}}:", (string) ((int) floor(microtime(true) * 1000) - 1000) ); @@ -90,14 +90,14 @@ 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->classKey(Author::class); $store = $manager->getStore(); @@ -120,7 +120,7 @@ public function test_flush_model_bumps_version_and_clears_related_keys(): void $postsKey = DB::getDefaultConnection() . ':posts'; $authorsKey = DB::getDefaultConnection() . ':authors'; - $redis->sadd("test:members:model:{{$postsKey}}", "test:model:{{$postsKey}}:1", "test:model:{{$postsKey}}:2"); + $redis->sadd("{nc}:test:members:model:{{$postsKey}}", "{nc}:test:model:{{$postsKey}}:1", "{nc}:test:model:{{$postsKey}}:2"); $store->set("model:{{$postsKey}}:1", ['id' => 1], 3600); $store->set("model:{{$postsKey}}:2", ['id' => 2], 3600); $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); @@ -132,7 +132,7 @@ public function test_flush_model_bumps_version_and_clears_related_keys(): void $this->assertNull($store->get("model:{{$postsKey}}:1")); $this->assertNull($store->get("model:{{$postsKey}}:2")); - $this->assertSame(0, $redis->exists("test:members:model:{{$postsKey}}")); + $this->assertSame(0, $redis->exists("{nc}: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)); @@ -149,7 +149,7 @@ public function test_flush_all_removes_all_package_keys_and_returns_count(): voi $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"); + Redis::connection('normcache-test')->sadd("{nc}:test:members:model:{{$postsKey}}", "{nc}:test:model:{{$postsKey}}:1"); $deleted = $this->manager->flushAll(); @@ -167,7 +167,7 @@ public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enab config()->set('database.redis.options.prefix', 'laravel:'); Redis::purge('normcache-test'); - $manager = $this->buildManager(cluster: true, slotting: true); + $manager = $this->buildManager(); $store = $manager->getStore(); $postsKey = DB::getDefaultConnection() . ':posts'; @@ -191,8 +191,8 @@ public function test_targeted_delete_outside_transaction_removes_membership_refe Author::all(); $classKey = $this->manager->classKey(Author::class); - $memberKey = "test:members:model:{{$classKey}}"; - $modelKey = "test:model:{{$classKey}}:{$author->id}"; + $memberKey = "{nc}:test:members:model:{{$classKey}}"; + $modelKey = "{nc}:test:model:{{$classKey}}:{$author->id}"; $redis = Redis::connection('normcache-test'); $this->assertTrue((bool) $redis->sismember($memberKey, $modelKey)); @@ -204,11 +204,11 @@ public function test_targeted_delete_outside_transaction_removes_membership_refe 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}}:"; + $scheduledKey = "{nc}:test:scheduled:{{$classKey}}:"; $redis = Redis::connection('normcache-test'); $manager->invalidateVersion($model); diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index 303b34f..46d7121 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -18,7 +18,7 @@ class RedisStoreTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->store = new RedisStore('normcache-test', 'test:', false, '{nc}:'); + $this->store = new RedisStore('normcache-test', 'test:', '{nc}:'); } public function test_it_prefixes_keys(): void @@ -26,12 +26,6 @@ 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')); - } - public function test_it_can_set_and_get_values(): void { $this->store->set('foo', 'bar', 60); @@ -73,7 +67,7 @@ public function test_it_can_release_building_locks(): void 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', 'test:', '{nc}:', wakeTokenCount: 3); $store->set('build:tokens', '1', 60); $store->releaseBuilding('build:tokens', 'wake:tokens'); @@ -99,23 +93,6 @@ public function test_it_can_set_many_values(): void $this->assertSame('qux', $this->store->get('baz')); } - public function test_it_can_group_keys_by_tag_in_slotting_mode(): void - { - $store = new RedisStore('normcache-test', 'test:', true, ''); - - $method = new \ReflectionMethod(RedisStore::class, 'groupByTag'); - $method->setAccessible(true); - - $keys = ['{user:1}:a', '{user:1}:b', '{user:2}:c', 'no-tag']; - $groups = $method->invoke($store, $keys); - - $this->assertSame([ - 'user:1' => ['{user:1}:a', '{user:1}:b'], - 'user:2' => ['{user:2}:c'], - 'no-tag' => ['no-tag'], - ], $groups); - } - public function test_it_can_run_lua_scripts(): void { $script = "return redis.call('GET', KEYS[1])"; @@ -330,8 +307,8 @@ public function scan(&$cursor, $node, $pattern = null, $count = 0) $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'], + ['laravel:{nc}:test:model:*', 'node-a', null] => ['laravel:{nc}:test:model:{testing:posts}:1'], + ['laravel:{nc}:test:query:*', 'node-b', null] => ['laravel:{nc}:test:query:{testing:posts}:v1:abc'], default => [], }; } @@ -346,15 +323,15 @@ public function unlink($keys) } }; - $store = new RedisStore('normcache-test', 'test:', true); + $store = new RedisStore('normcache-test', 'test:', '{nc}:'); (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); $deleted = $store->flushByPatterns(['model:*', 'query:*']); $this->assertSame(2, $deleted); $this->assertSame([ - ['test:model:{testing:posts}:1'], - ['test:query:{testing:posts}:v1:abc'], + ['{nc}:test:model:{testing:posts}:1'], + ['{nc}:test:query:{testing:posts}:v1:abc'], ], $connection->unlinked); } From 0689f4417a643126b858f592b65059c9743ceb64 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:15:21 +1000 Subject: [PATCH 02/73] consolidate lua scripts --- src/Cache/ModelHydrator.php | 2 +- src/Cache/NormalizedCacheReader.php | 54 +++--------------- src/Cache/NormalizedThroughReader.php | 4 +- src/Cache/ResultCacheReader.php | 10 ++-- ...tatus.lua => fetch_batch_build_status.lua} | 18 +++--- src/Lua/fetch_multi_versioned_query.lua | 52 ------------------ src/Lua/fetch_pivot_build_status.lua | 45 --------------- src/Lua/fetch_versioned_payload.lua | 50 +++++++++++++++++ src/Lua/fetch_versioned_query.lua | 41 -------------- src/Lua/fetch_versioned_result.lua | 55 ------------------- src/Support/CacheKeyBuilder.php | 4 +- .../Cache/ModelHydratorStampedeTest.php | 10 ++-- tests/Integration/Cache/PivotStampedeTest.php | 12 ++-- .../Infrastructure/StampedeProtectionTest.php | 8 ++- tests/Unit/RedisScriptsTest.php | 15 ++--- 15 files changed, 99 insertions(+), 281 deletions(-) rename src/Lua/{fetch_model_build_status.lua => fetch_batch_build_status.lua} (60%) delete mode 100644 src/Lua/fetch_multi_versioned_query.lua delete mode 100644 src/Lua/fetch_pivot_build_status.lua create mode 100644 src/Lua/fetch_versioned_payload.lua delete mode 100644 src/Lua/fetch_versioned_query.lua delete mode 100644 src/Lua/fetch_versioned_result.lua diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index d2e34cb..7f9a9c1 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -177,7 +177,7 @@ private function fetchMissedStatus( $fetchKeys = $this->modelKeysFor($classKey, $idsToFetch); $result = $this->store->script( - RedisScripts::get('fetch_model_build_status'), + RedisScripts::get('fetch_batch_build_status'), [...$fetchKeys, $lockKey, $this->keys->verKey($classKey), $wakeKey], [$token, (string) $this->buildingLockTtl] ); diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index 4db7f56..20e02a7 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -24,33 +24,18 @@ public function __construct( public function fetch(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult { $classKey = $this->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(); - $result = $this->luaFetchMultiVersionedQuery( - $versionKeys, $scheduledKeys, $queryPrefix, - $this->keys->buildingPrefix($classKey), $classKey, - $hash, $lockToken + $result = $this->store->script( + RedisScripts::get('fetch_versioned_payload'), + array_merge($versionKeys, $scheduledKeys, [ + $queryPrefix, + $this->keys->buildingPrefix($classKey), + $this->keys->wakePrefix($classKey), + ]), + [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] ); $seg = (string) ($result[1] ?? ''); @@ -196,27 +181,4 @@ private function fetchModels(string $classKey, array $ids): array return $this->store->getMany(array_map(static fn($id) => $modelPrefix . $id, $ids)); } - - 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/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 87b3161..1298dce 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -180,9 +180,9 @@ private function luaFetchMultiVersionedThrough( string $hash, string $lockToken, ): mixed { return $this->store->script( - RedisScripts::get('fetch_multi_versioned_query'), + RedisScripts::get('fetch_versioned_payload'), array_merge($versionKeys, $scheduledKeys, [$queryPrefix, $buildingPrefix, $wakePrefix]), - [$hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] + [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] ); } } diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index b2e081b..2d27665 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -123,12 +123,12 @@ public function fetchPivot( } $result = $this->store->script( - RedisScripts::get('fetch_pivot_build_status'), - [...$missedKeys, $lockKey, $wakeKey], + RedisScripts::get('fetch_batch_build_status'), + [...$missedKeys, $lockKey, '', $wakeKey], [$token, (string) $this->buildingLockTtl] ); - $raw = $this->store->unserializeMany($result[2] ?? []); + $raw = $this->store->unserializeMany($result[3] ?? []); foreach ($missed as $i => $id) { if (isset($raw[$i]) && is_array($raw[$i])) { $data[$id] = $raw[$i]; @@ -260,9 +260,9 @@ private function luaFetchVersionedResult( string $hash, string $lockSuffix, string $lockToken ): array { return $this->store->script( - RedisScripts::get('fetch_versioned_result'), + RedisScripts::get('fetch_versioned_payload'), array_merge($versionKeys, $scheduledKeys, [$resultPrefix, $buildingPrefix, $wakePrefix]), - [$hash, $lockSuffix, (string) $this->buildingLockTtl, (string) (int) floor(microtime(true) * 1000), $lockToken] + [$hash, $lockSuffix, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] ); } diff --git a/src/Lua/fetch_model_build_status.lua b/src/Lua/fetch_batch_build_status.lua similarity index 60% rename from src/Lua/fetch_model_build_status.lua rename to src/Lua/fetch_batch_build_status.lua index 5e17ae9..977c9cd 100644 --- a/src/Lua/fetch_model_build_status.lua +++ b/src/Lua/fetch_batch_build_status.lua @@ -1,15 +1,15 @@ --- Re-checks still-missing model keys and atomically claims the build lock if anything's still --- missing. See ModelHydrator::fetchMissedStatus(). +-- Re-checks still-missing model/pivot keys and atomically claims the build lock if +-- anything is still missing. Used for both model attributes (with version) and pivot +-- payloads (without version). -- --- KEYS[1..n] = model keys to recheck --- KEYS[n+1] = lock key --- KEYS[n+2] = model-class version key +-- KEYS[1..n] = model/pivot keys to re-check +-- KEYS[n+1] = building lock key +-- KEYS[n+2] = version key ('' to skip — pivot case) -- KEYS[n+3] = wake key -- ARGV[1] = lock token --- ARGV[2] = lock ttl +-- ARGV[2] = lock TTL in seconds -- --- Returns: {status, lockTokenOrFalse, version, rawValues} — rawValues are the raw MGET results --- in KEYS[1..n] order; the caller unserializes/hydrates them. +-- Returns: {status, lockTokenOrFalse, version|false, rawValues} local n = #KEYS - 3 local chunkSize = 500 local values = {} @@ -34,7 +34,7 @@ for start = 1, n, chunkSize do end end -local version = redis.call('GET', KEYS[n + 2]) or '0' +local version = KEYS[n + 2] ~= '' and (redis.call('GET', KEYS[n + 2]) or '0') or false if allHit then return {'hit', 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_pivot_build_status.lua b/src/Lua/fetch_pivot_build_status.lua deleted file mode 100644 index 2c4e35d..0000000 --- a/src/Lua/fetch_pivot_build_status.lua +++ /dev/null @@ -1,45 +0,0 @@ --- 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. --- --- KEYS[1..n] = pivot keys to recheck --- KEYS[n+1] = lock key --- KEYS[n+2] = wake key --- ARGV[1] = lock token --- ARGV[2] = lock ttl --- --- Returns: {status, lockTokenOrFalse, rawValues} — rawValues are the raw MGET results in --- KEYS[1..n] order; the caller unserializes/hydrates them. -local n = #KEYS - 2 -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 - -if allHit then - return {'hit', 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} -end - -return {'building', false, values} diff --git a/src/Lua/fetch_versioned_payload.lua b/src/Lua/fetch_versioned_payload.lua new file mode 100644 index 0000000..cd7d948 --- /dev/null +++ b/src/Lua/fetch_versioned_payload.lua @@ -0,0 +1,50 @@ +-- 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 (one per version key, same order) +-- KEYS[2n+1] = payload key prefix +-- KEYS[2n+2] = building key prefix +-- KEYS[2n+3] = 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 +-- +-- Returns: {'hit', seg, payload} | {'miss', seg, token} | {'building', seg} + +local n = (#KEYS - 3) / 2 +local now = tonumber(ARGV[3]) + +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 data = redis.call('GET', KEYS[2 * n + 1] .. seg .. ':' .. ARGV[1]) +if data then + return {'hit', seg, data} +end + +local building_key = KEYS[2 * n + 2] .. seg .. ':' .. ARGV[2] +if redis.call('SET', building_key, ARGV[5], 'NX', 'EX', tonumber(ARGV[4])) then + redis.call('DEL', KEYS[2 * n + 3] .. 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/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 788a877..096dccf 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -50,7 +50,7 @@ public function queryPrefix(string $classKey, ?string $tag = null): string { $base = self::K_QUERY . ':{' . $classKey . '}:'; - return $tag !== null ? $base . $tag . ':v' : $base . 'v'; + return $tag !== null ? $base . $tag . ':' : $base; } public function namespacedPrefix(string $namespace, string $classKey, ?string $tag = null): string @@ -138,7 +138,7 @@ public function wakeKey(string $classKey, string $lockSuffix): string public function queryKey(string $classKey, ?string $tag, int|string $version, string $hash): string { - return $this->queryPrefix($classKey, $tag) . $version . ':' . $hash; + return $this->queryPrefix($classKey, $tag) . 'v' . $version . ':' . $hash; } public function namespacedKey(string $namespace, string $classKey, ?string $tag, string $seg, string $hash): string diff --git a/tests/Integration/Cache/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index b6d12e0..a8a45f9 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -73,7 +73,7 @@ public function test_build_status_script_claims_lock_when_unheld(): void $modelKey = $keys->modelPrefix($classKey) . 'missing-id'; $result = $store->script( - RedisScripts::get('fetch_model_build_status'), + RedisScripts::get('fetch_batch_build_status'), [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -95,7 +95,7 @@ public function test_build_status_script_reports_building_when_lock_already_held $store->setNxEx($lockKey, 'other-token', 5); $result = $store->script( - RedisScripts::get('fetch_model_build_status'), + RedisScripts::get('fetch_batch_build_status'), [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -118,7 +118,7 @@ public function test_build_status_script_reports_hit_without_claiming_lock_when_ $store->set($modelKey, ['id' => 1, 'name' => 'Present'], 60); $result = $store->script( - RedisScripts::get('fetch_model_build_status'), + RedisScripts::get('fetch_batch_build_status'), [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -174,7 +174,7 @@ public function test_build_status_script_all_hit_across_multiple_mget_chunks(): } $result = $store->script( - RedisScripts::get('fetch_model_build_status'), + RedisScripts::get('fetch_batch_build_status'), [...$modelKeys, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -213,7 +213,7 @@ public function test_build_status_script_partial_miss_across_chunk_boundary(): v } $result = $store->script( - RedisScripts::get('fetch_model_build_status'), + RedisScripts::get('fetch_batch_build_status'), [...$modelKeys, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); diff --git a/tests/Integration/Cache/PivotStampedeTest.php b/tests/Integration/Cache/PivotStampedeTest.php index 6ece698..ba207e2 100644 --- a/tests/Integration/Cache/PivotStampedeTest.php +++ b/tests/Integration/Cache/PivotStampedeTest.php @@ -84,8 +84,8 @@ public function test_pivot_build_status_script_claims_lock_when_unheld(): void $pivotKey = 'pivot:missing:1'; $result = $store->script( - RedisScripts::get('fetch_pivot_build_status'), - [$pivotKey, $lockKey, $wakeKey], + RedisScripts::get('fetch_batch_build_status'), + [$pivotKey, $lockKey, '', $wakeKey], ['token', '5'] ); @@ -106,8 +106,8 @@ public function test_pivot_build_status_script_reports_building_when_lock_alread $store->setNxEx($lockKey, 'other-token', 5); $result = $store->script( - RedisScripts::get('fetch_pivot_build_status'), - [$pivotKey, $lockKey, $wakeKey], + RedisScripts::get('fetch_batch_build_status'), + [$pivotKey, $lockKey, '', $wakeKey], ['token', '5'] ); @@ -128,8 +128,8 @@ public function test_pivot_build_status_script_reports_hit_without_claiming_lock $store->setRaw($pivotKey, $store->serialize([['id' => 1]]), 60); $result = $store->script( - RedisScripts::get('fetch_pivot_build_status'), - [$pivotKey, $lockKey, $wakeKey], + RedisScripts::get('fetch_batch_build_status'), + [$pivotKey, $lockKey, '', $wakeKey], ['token', '5'] ); diff --git a/tests/Integration/Infrastructure/StampedeProtectionTest.php b/tests/Integration/Infrastructure/StampedeProtectionTest.php index 5620f86..dc9775b 100644 --- a/tests/Integration/Infrastructure/StampedeProtectionTest.php +++ b/tests/Integration/Infrastructure/StampedeProtectionTest.php @@ -54,7 +54,7 @@ public function test_waiter_serves_from_cache_after_build_completes(): void $this->redis()->incr("{nc}:test:ver:{{$ck}}:"); $newVersion = NormCache::currentVersion(Author::class); - $this->redis()->set("{nc}:test:building:{{$ck}}:{$hash}", '1'); + $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); @@ -77,7 +77,8 @@ public function test_budget_exhausted_falls_through_to_db(): void $hash = $this->authorQueryHash(); $this->redis()->incr("{nc}:test:ver:{{$ck}}:"); - $this->redis()->set("{nc}:test:building:{{$ck}}:{$hash}", '1'); + $newVersion = NormCache::currentVersion(Author::class); + $this->redis()->set("{nc}:test:building:{{$ck}}:v{$newVersion}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -98,7 +99,8 @@ public function test_lock_holder_crash_falls_through_after_budget(): void $hash = $this->authorQueryHash(); $this->redis()->incr("{nc}:test:ver:{{$ck}}:"); - $this->redis()->set("{nc}:test:building:{{$ck}}:{$hash}", '1'); + $newVersion = NormCache::currentVersion(Author::class); + $this->redis()->set("{nc}:test:building:{{$ck}}:v{$newVersion}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { diff --git a/tests/Unit/RedisScriptsTest.php b/tests/Unit/RedisScriptsTest.php index 74c009e..1bb61c8 100644 --- a/tests/Unit/RedisScriptsTest.php +++ b/tests/Unit/RedisScriptsTest.php @@ -9,13 +9,10 @@ class RedisScriptsTest extends TestCase { 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', @@ -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,8 +48,8 @@ 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'); $stored = $cache->getValue(); From b0ba4ca8accb788dc3d0b431cfac921ef762698b Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 26 Jun 2026 12:32:28 +1000 Subject: [PATCH 03/73] remove dead code / consolidate lua --- src/Cache/NormalizedThroughReader.php | 24 +++++++-------------- src/Cache/ResultCacheReader.php | 14 +----------- src/Cache/VersionTracker.php | 31 --------------------------- 3 files changed, 9 insertions(+), 60 deletions(-) diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 1298dce..28dce7f 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -30,11 +30,14 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $lockToken = $this->versions->buildLockToken(); - $result = $this->luaFetchMultiVersionedThrough( - $versionKeys, $scheduledKeys, $queryPrefix, - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - $hash, $lockToken + $result = $this->store->script( + RedisScripts::get('fetch_versioned_payload'), + array_merge($versionKeys, $scheduledKeys, [ + $queryPrefix, + $this->keys->buildingPrefix($classKey), + $this->keys->wakePrefix($classKey), + ]), + [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] ); $seg = (string) ($result[1] ?? ''); @@ -174,15 +177,4 @@ private function fetchModels(string $classKey, array $ids): array return $this->store->getMany(array_map(static fn($id) => $modelPrefix . $id, $ids)); } - 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_versioned_payload'), - array_merge($versionKeys, $scheduledKeys, [$queryPrefix, $buildingPrefix, $wakePrefix]), - [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] - ); - } } diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index 2d27665..dcafd9f 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -225,19 +225,7 @@ public function storeEntry( array $versionKeys, array $expectedVersions, ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null ): bool { - 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] - ) - ); + return $this->storeMany([$key => $payload], $ttl, $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken); } public function waitForBuild( diff --git a/src/Cache/VersionTracker.php b/src/Cache/VersionTracker.php index acfb424..73d35c8 100644 --- a/src/Cache/VersionTracker.php +++ b/src/Cache/VersionTracker.php @@ -27,37 +27,6 @@ public function currentTableVersion(string $connectionName, string $table): int ); } - 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()); From 56c1b5c60408d36dff70aad77e45e7ea6e5b9953 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 26 Jun 2026 17:40:04 +1000 Subject: [PATCH 04/73] WIP --- src/Cache/ModelHydrator.php | 88 +++++++++++-------- src/Cache/NormalizedCacheReader.php | 10 ++- src/Cache/NormalizedThroughReader.php | 9 +- src/CacheManager.php | 7 +- ..._version.lua => store_many_if_version.lua} | 38 +++----- src/Support/CacheKeyBuilder.php | 24 +++-- src/Support/RedisStore.php | 41 +-------- src/Traits/HandlesInvalidation.php | 61 ++----------- tests/Integration/Cache/ModelCachingTest.php | 9 +- .../Cache/ModelHydratorStampedeTest.php | 19 ++-- .../Infrastructure/CacheEventsTest.php | 10 +-- .../Invalidation/ModelInvalidationTest.php | 52 ++++------- .../TransactionInvalidationTest.php | 18 ++-- tests/TestCase.php | 21 +++-- tests/Unit/CacheManagerTest.php | 44 +++++----- tests/Unit/RedisScriptsTest.php | 2 +- tests/Unit/RedisStoreTest.php | 36 ++++---- 17 files changed, 198 insertions(+), 291 deletions(-) rename src/Lua/{store_many_tracked_if_version.lua => store_many_if_version.lua} (53%) diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index 7f9a9c1..2528584 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -19,6 +19,8 @@ final class ModelHydrator private static ?\Closure $setAttributeDirectClosure = null; + private static array $overridesNewFromBuilder = []; + private static ?\Closure $getAttributeDirectClosure = null; private static ?\Closure $transformScalarClosure = null; @@ -81,12 +83,12 @@ public function getModels( $ids = array_values($ids); $classKey = $this->keys->classKey($modelClass); $projection = $columns !== null ? AttributeProjector::normalizeProjection($columns) : null; + $modelVersion = $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); - $raw ??= $this->store->getMany($this->modelKeysFor($classKey, $ids)); + $raw ??= $this->store->getMany($this->modelKeysFor($classKey, $modelVersion, $ids)); ['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, [ @@ -108,24 +110,27 @@ public function getModels( [$lockKey, $wakeKey, $token] = $this->buildLockTriple($classKey, $missed); - [$status, $missed, $version] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $modelVersion, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); if ($status === 'building' && $missed !== []) { $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); - [$status, $missed, $version] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $modelVersion, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); } if ($status === 'miss') { try { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $version, $lockKey, $wakeKey, $token); + $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, true, $lockKey, $wakeKey, $token); } catch (\Throwable $e) { $this->store->releaseBuilding($lockKey, $wakeKey, $token); throw $e; } + } elseif ($status === 'hit' && $missed !== []) { + // Corrupt Redis payload: Lua reported hit but PHP deserialization failed — overwrite. + $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, true); } elseif ($missed !== []) { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $version); + $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, false); } $ordered = []; @@ -151,9 +156,9 @@ private function buildLockTriple(string $classKey, array $ids): array 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; @@ -167,6 +172,7 @@ private function fetchMissedStatus( array $idsToFetch, string $modelClass, string $classKey, + int $modelVersion, ?array $projection, ?Model $prototype, string $lockKey, @@ -174,7 +180,7 @@ private function fetchMissedStatus( string $token, array &$hits, ): array { - $fetchKeys = $this->modelKeysFor($classKey, $idsToFetch); + $fetchKeys = $this->modelKeysFor($classKey, $modelVersion, $idsToFetch); $result = $this->store->script( RedisScripts::get('fetch_batch_build_status'), @@ -182,8 +188,6 @@ private function fetchMissedStatus( [$token, (string) $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); @@ -193,13 +197,12 @@ private function fetchMissedStatus( } if ($stillMissed === []) { - return ['hit', [], 0]; + return ['hit', []]; } - return [$status, $stillMissed, $version]; + return [$result[0], $stillMissed]; } - // Fetches still-missing ids from the database, caches them, and merges into $hits by reference. private function fetchAndMerge( array $missed, string $modelClass, @@ -209,6 +212,7 @@ private function fetchAndMerge( bool $preserveQueryShape, array &$hits, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -219,7 +223,7 @@ private function fetchAndMerge( $fetched = $this->fetchFromDatabaseAndCache( $missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, - $modelVersion, $buildingKey, $wakeKey, $token + $modelVersion, $writeCache, $buildingKey, $wakeKey, $token ); foreach ($fetched as $id => $model) { @@ -326,6 +330,7 @@ private function fetchFromDatabaseAndCache( ?EloquentBuilder $missedQuery, bool $preserveQueryShape, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -338,12 +343,12 @@ private function fetchFromDatabaseAndCache( if ($this->overridesNewFromBuilder($query->getModel())) { return $this->fetchAndCacheUsingEloquent( - $missed, $modelClass, $classKey, $projection, $query, $modelVersion, $buildingKey, $wakeKey, $token + $missed, $modelClass, $classKey, $projection, $query, $modelVersion, $writeCache, $buildingKey, $wakeKey, $token ); } return $this->fetchAndCacheUsingClosure( - $missed, $modelClass, $classKey, $projection, $query, $query->getModel(), $modelVersion, $buildingKey, $wakeKey, $token + $missed, $modelClass, $classKey, $projection, $query, $query->getModel(), $modelVersion, $writeCache, $buildingKey, $wakeKey, $token ); } @@ -354,6 +359,7 @@ private function fetchAndCacheUsingEloquent( ?array $projection, EloquentBuilder $query, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -373,8 +379,8 @@ private function fetchAndCacheUsingEloquent( $attrs = $model->getRawOriginal(); $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); - if (!$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; + if ($writeCache && !$isTrashed) { + $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; } if ($hydrate !== null) { @@ -382,18 +388,7 @@ private function fetchAndCacheUsingEloquent( } } - if ($attrsByKey !== [] || $buildingKey !== null) { - $this->store->setManyTrackedIfVersion( - $attrsByKey, - $this->ttl, - $this->keys->membersKey($classKey), - $this->keys->verKey($classKey), - $modelVersion, - $buildingKey, - $wakeKey, - $token - ); - } + $this->writeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); return $loaded->all(); } @@ -406,6 +401,7 @@ private function fetchAndCacheUsingClosure( EloquentBuilder $query, Model $prototype, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -433,8 +429,8 @@ private function fetchAndCacheUsingClosure( $id = $attrs[$pk]; $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); - if (!$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; + if ($writeCache && !$isTrashed) { + $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; } $returnAttrs = $projection !== null @@ -448,11 +444,23 @@ private function fetchAndCacheUsingClosure( $models[$id] = $instance; } + $this->writeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); + + return $models; + } + + private function writeModelAttrs( + array $attrsByKey, + string $classKey, + int $modelVersion, + ?string $buildingKey, + ?string $wakeKey, + ?string $token, + ): void { if ($attrsByKey !== [] || $buildingKey !== null) { - $this->store->setManyTrackedIfVersion( + $this->store->setManyIfVersion( $attrsByKey, $this->ttl, - $this->keys->membersKey($classKey), $this->keys->verKey($classKey), $modelVersion, $buildingKey, @@ -460,15 +468,19 @@ private function fetchAndCacheUsingClosure( $token ); } + } - return $models; + public static function reset(): void + { + self::$overridesNewFromBuilder = []; } private function overridesNewFromBuilder(Model $model): bool { - $method = new \ReflectionMethod($model, 'newFromBuilder'); + $class = $model::class; - return $method->getDeclaringClass()->getName() !== Model::class; + return self::$overridesNewFromBuilder[$class] ??= + (new \ReflectionMethod($model, 'newFromBuilder'))->getDeclaringClass()->getName() !== Model::class; } public static function hydrateClosure(): \Closure diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index 20e02a7..a483617 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -44,11 +44,15 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $expectedVersions = $this->keys->versionsFromSegment($seg); [$status, $ids] = $this->resolveIds($result, $queryKey); + $modelVersion = is_array($ids) + ? $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))) + : 0; + 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 + is_array($ids) ? $this->fetchModels($classKey, $modelVersion, $ids) : null ); } @@ -171,13 +175,13 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr return $result->status === CacheStatus::Building ? null : $result; } - private function fetchModels(string $classKey, array $ids): array + private function fetchModels(string $classKey, int $modelVersion, array $ids): array { if ($ids === []) { return []; } - $modelPrefix = $this->keys->modelPrefix($classKey); + $modelPrefix = $this->keys->modelPrefix($classKey, $modelVersion); return $this->store->getMany(array_map(static fn($id) => $modelPrefix . $id, $ids)); } diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 28dce7f..c840800 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -45,7 +45,10 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $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; + $modelVersion = $status->hasPayload() + ? $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))) + : 0; + $models = $status->hasPayload() ? $this->fetchModels($classKey, $modelVersion, $ids) : null; return $this->toThroughResult( $status, $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), @@ -166,13 +169,13 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr return $result->status === CacheStatus::Building ? null : $result; } - private function fetchModels(string $classKey, array $ids): array + private function fetchModels(string $classKey, int $modelVersion, array $ids): array { if ($ids === []) { return []; } - $modelPrefix = $this->keys->modelPrefix($classKey); + $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 55e57cd..fc0c5bf 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -212,17 +212,16 @@ public function cacheModelAttrs(string $modelClass, array $modelAttrs): void } $classKey = $this->keys->classKey($modelClass); - $modelVersion = $this->currentVersion($modelClass); + $modelVersion = $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); $attrsByKey = []; foreach ($modelAttrs as $id => $attrs) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; + $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; } - $this->store->setManyTrackedIfVersion( + $this->store->setManyIfVersion( $attrsByKey, $this->config->ttl, - $this->keys->membersKey($classKey), $this->keys->verKey($classKey), $modelVersion ); diff --git a/src/Lua/store_many_tracked_if_version.lua b/src/Lua/store_many_if_version.lua similarity index 53% rename from src/Lua/store_many_tracked_if_version.lua rename to src/Lua/store_many_if_version.lua index 6b76843..09f7b28 100644 --- a/src/Lua/store_many_tracked_if_version.lua +++ b/src/Lua/store_many_if_version.lua @@ -2,10 +2,9 @@ -- 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 +-- KEYS[2] = building lock key (or '' to skip release) +-- KEYS[3] = wake key (or '' to skip) +-- KEYS[4..] = model attribute keys to write -- ARGV[1] = expected version -- ARGV[2] = TTL in seconds -- ARGV[3] = n (number of model keys) @@ -16,18 +15,18 @@ 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 + if KEYS[2] == '' then return end + if token ~= '' and redis.call('GET', KEYS[2]) ~= token then return end + redis.call('DEL', KEYS[2]) + if KEYS[3] ~= '' then for i = 1, wake_count do - redis.call('LPUSH', KEYS[4], '1') + redis.call('LPUSH', KEYS[3], '1') end - redis.call('EXPIRE', KEYS[4], 10) + redis.call('EXPIRE', KEYS[3], 10) end end -if KEYS[3] ~= '' and token ~= '' and redis.call('GET', KEYS[3]) ~= token then +if KEYS[2] ~= '' and token ~= '' and redis.call('GET', KEYS[2]) ~= token then return 0 end @@ -39,25 +38,10 @@ 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 + redis.call('SETEX', KEYS[3 + i], ttl, ARGV[4 + i]) 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/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 096dccf..1607ea9 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use NormCache\Cache\ModelHydrator; class CacheKeyBuilder { @@ -17,8 +18,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,13 +36,15 @@ class CacheKeyBuilder private static array $deletedAtColumns = []; + private static array $singleDepPairs = []; + // ------------------------------------------------------------------------- // Prefixes // ------------------------------------------------------------------------- - public function modelPrefix(string $classKey): string + public function modelPrefix(string $classKey, int|string $version): string { - return self::K_MODEL . ':{' . $classKey . '}:'; + return self::K_MODEL . ':{' . $classKey . '}:v' . $version . ':'; } public function queryPrefix(string $classKey, ?string $tag = null): string @@ -93,6 +94,9 @@ public static function reset(): void self::$classKeys = []; self::$prototypes = []; self::$deletedAtColumns = []; + self::$singleDepPairs = []; + + ModelHydrator::reset(); } public static function prototype(string $class): Model @@ -122,11 +126,6 @@ public function scheduledKey(string $classKey): string return self::K_SCHEDULED . ':{' . $classKey . '}:'; } - public function membersKey(string $classKey): string - { - return self::K_MEMBERS . ':{' . $classKey . '}'; - } - public function wakeKey(string $classKey, string $lockSuffix): string { return self::K_WAKE . ':{' . $classKey . '}:' . $lockSuffix; @@ -156,10 +155,7 @@ public function pivotKey(string $parentKey, string $relatedKey, string $relation return $this->pivotPrefix($parentKey, $relatedKey, $relation, $constraintHash, $seg) . $parentId; } - public function modelKey(string $modelClass, string $id): string - { - return $this->modelPrefix($this->classKey($modelClass)) . $id; - } + // ------------------------------------------------------------------------- // Versioning Helpers @@ -206,7 +202,7 @@ public function buildingToWakeKey(string $buildingKey): string public function depKeyPairs(string $classKey, array $depClasses, array $depTableKeys = []): array { if ($depClasses === [] && $depTableKeys === []) { - return [ + return self::$singleDepPairs[$classKey] ??= [ [$this->verKey($classKey)], [$this->scheduledKey($classKey)], ]; diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 92522f6..9e1d064 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -214,10 +214,9 @@ public function setMany(array $pairs, int $ttl): void } // 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, @@ -232,7 +231,7 @@ public function setManyTrackedIfVersion( return; } - $script = RedisScripts::get('store_many_tracked_if_version'); + $script = RedisScripts::get('store_many_if_version'); $chunks = array_chunk($attrsByKey, 500, true); $lastChunk = array_key_last($chunks); @@ -242,7 +241,7 @@ public function setManyTrackedIfVersion( $this->script( $script, array_merge( - [$versionKey, $memberKey, $isLast ? ($buildingKey ?? '') : '', $isLast ? ($wakeKey ?? '') : ''], + [$versionKey, $isLast ? ($buildingKey ?? '') : '', $isLast ? ($wakeKey ?? '') : ''], array_keys($chunk) ), array_merge( @@ -254,40 +253,6 @@ 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)) { diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 59e68a6..4632536 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -19,9 +19,6 @@ trait HandlesInvalidation /** @var array> */ private array $versionQueue = []; - /** @var array> model class, model key, members key */ - private array $instanceEvictQueue = []; - // Invalidation public function invalidateVersion(Model $model): void @@ -65,19 +62,11 @@ public function flushInstance(Model $model): void $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)); - }, + fn() => $this->queueVersionFlush($conn, $this->keys->classKey($class)), + fn() => $this->doInvalidateVersion($class), ); } @@ -98,26 +87,14 @@ public function invalidateTableVersion(string $connectionName, string $table): v public function evictModelKey(string $modelClass, mixed $id): 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) - ); - }); + // Versioned model keys cannot be individually deleted; the version bump in + // flushInstance / invalidateVersion is the invalidation mechanism. } public function forceFlushModel(string $modelClass): void { $classKey = $this->keys->classKey($modelClass); $this->store->incrementAndExpire($this->keys->verKey($classKey), $this->versionTtl()); // bypass cooldown - - $this->store->sscanAndFlushSet($this->store->prefix($this->keys->membersKey($classKey))); } public function flushAll(): int @@ -125,7 +102,6 @@ public function flushAll(): int return $this->store->flushByPatterns([ CacheKeyBuilder::K_QUERY . ':*', CacheKeyBuilder::K_MODEL . ':*', - CacheKeyBuilder::K_MEMBERS . ':*', CacheKeyBuilder::K_VER . ':*', CacheKeyBuilder::K_COUNT . ':*', CacheKeyBuilder::K_SCALAR . ':*', @@ -191,51 +167,37 @@ 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]); + unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName]); - if ((empty($flushes) && empty($versions) && empty($evicts)) || !$this->isEnabled()) { + if ((empty($flushes) && empty($versions)) || !$this->isEnabled()) { return; } - $this->attempt(function () use ($flushes, $versions, $evicts) { + $this->attempt(function () use ($flushes, $versions) { foreach ($flushes as $modelClass) { $this->forceFlushModel($modelClass); } $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 ($versions as $classKey) { - if (!in_array($classKey, $evictClassKeys, true) && !in_array($classKey, $flushClassKeys, true)) { + if (!in_array($classKey, $flushClassKeys, true)) { $this->doInvalidateKey($classKey); } } - - foreach ($evictClasses as $class) { - if (!in_array($this->keys->classKey($class), $flushClassKeys, true)) { - $this->doInvalidateVersion($class); - } - } - - foreach ($evicts as [, $key, $membersKey]) { - $this->store->deleteFromSet($key, $membersKey); - } }); } public function discardPending(string $connectionName): void { - unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName], $this->instanceEvictQueue[$connectionName]); + unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName]); } public function discardAllPending(): void { $this->flushQueue = []; $this->versionQueue = []; - $this->instanceEvictQueue = []; } // Private — invalidation internals @@ -301,9 +263,4 @@ private function queueVersionFlush(string $connectionName, string $classKey): vo { $this->versionQueue[$connectionName][$classKey] = true; } - - private function queueInstanceEvict(string $connectionName, string $modelClass, string $key, string $membersKey): void - { - $this->instanceEvictQueue[$connectionName][] = [$modelClass, $key, $membersKey]; - } } diff --git a/tests/Integration/Cache/ModelCachingTest.php b/tests/Integration/Cache/ModelCachingTest.php index 8243b17..40d8cd5 100644 --- a/tests/Integration/Cache/ModelCachingTest.php +++ b/tests/Integration/Cache/ModelCachingTest.php @@ -39,15 +39,14 @@ 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/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index a8a45f9..bc167ae 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -58,8 +58,8 @@ 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 @@ -70,7 +70,7 @@ public function test_build_status_script_claims_lock_when_unheld(): void $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_batch_build_status'), @@ -91,7 +91,7 @@ public function test_build_status_script_reports_building_when_lock_already_held $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( @@ -114,7 +114,7 @@ public function test_build_status_script_reports_hit_without_claiming_lock_when_ $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( @@ -138,14 +138,15 @@ public function test_fetch_missed_status_resolves_via_retry_mget_without_claimin $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 = []; $method = new \ReflectionMethod($hydrator, 'fetchMissedStatus'); - $args = [[$author->id], Author::class, $classKey, null, null, $lockKey, $keys->wakeKey($classKey, 'test-lock'), 'token', &$hits]; + $args = [[$author->id], Author::class, $classKey, $version, null, null, $lockKey, $keys->wakeKey($classKey, 'test-lock'), 'token', &$hits]; [$status, $missed] = $method->invokeArgs($hydrator, $args); $this->assertSame('hit', $status); @@ -168,7 +169,7 @@ 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; } @@ -205,7 +206,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); } diff --git a/tests/Integration/Infrastructure/CacheEventsTest.php b/tests/Integration/Infrastructure/CacheEventsTest.php index 0af72a6..695f97d 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 outdated; version bumped means alice's model key is also stale → both miss Author::all(); - Event::assertDispatched(ModelCacheHit::class); + Event::assertNotDispatched(ModelCacheHit::class); Event::assertDispatched(ModelCacheMiss::class); } diff --git a/tests/Integration/Invalidation/ModelInvalidationTest.php b/tests/Integration/Invalidation/ModelInvalidationTest.php index 4a9d58a..9f439f5 100644 --- a/tests/Integration/Invalidation/ModelInvalidationTest.php +++ b/tests/Integration/Invalidation/ModelInvalidationTest.php @@ -2,7 +2,6 @@ namespace NormCache\Tests\Integration\Invalidation; -use Illuminate\Support\Facades\Redis; use NormCache\Facades\NormCache; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; @@ -79,36 +78,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 = '{nc}: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 = '{nc}: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 +162,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 diff --git a/tests/Integration/Invalidation/TransactionInvalidationTest.php b/tests/Integration/Invalidation/TransactionInvalidationTest.php index 494b1c0..552018d 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,16 @@ 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)); + // Insert bumps the version; model attrs cached under the old version are unreachable. + $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 diff --git a/tests/TestCase.php b/tests/TestCase.php index dcccd99..05dd6cc 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -122,22 +122,31 @@ protected function resetClassKeyCache(): void protected function modelCacheEntry(string $class, mixed $id): mixed { - $key = 'model:{' . $this->cacheManager()->classKey($class) . '}:' . $id; + $manager = $this->cacheManager(); + $classKey = $manager->classKey($class); + $version = $manager->currentVersion($class); + $key = 'model:{' . $classKey . '}:v' . $version . ':' . $id; - return $this->cacheManager()->getStore()->get($key); + return $manager->getStore()->get($key); } protected function evictModelCache(string $class, mixed $id): void { - $key = 'model:{' . $this->cacheManager()->classKey($class) . '}:' . $id; - $this->cacheManager()->getStore()->delete($key); + $manager = $this->cacheManager(); + $classKey = $manager->classKey($class); + $version = $manager->currentVersion($class); + $key = 'model:{' . $classKey . '}:v' . $version . ':' . $id; + $manager->getStore()->delete($key); } protected function prefixedModelKey(string $class, mixed $id): string { - $key = 'model:{' . $this->cacheManager()->classKey($class) . '}:' . $id; + $manager = $this->cacheManager(); + $classKey = $manager->classKey($class); + $version = $manager->currentVersion($class); + $key = 'model:{' . $classKey . '}:v' . $version . ':' . $id; - return $this->cacheManager()->getStore()->prefix($key); + return $manager->getStore()->prefix($key); } protected function redisKeys(string $pattern = '*'): array diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index a5f623b..15e1a74 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -115,27 +115,27 @@ public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void public function test_flush_model_bumps_version_and_clears_related_keys(): void { - $redis = Redis::connection('normcache-test'); $store = $this->manager->getStore(); $postsKey = DB::getDefaultConnection() . ':posts'; $authorsKey = DB::getDefaultConnection() . ':authors'; - $redis->sadd("{nc}:test:members:model:{{$postsKey}}", "{nc}:test:model:{{$postsKey}}:1", "{nc}:test:model:{{$postsKey}}:2"); - $store->set("model:{{$postsKey}}:1", ['id' => 1], 3600); - $store->set("model:{{$postsKey}}:2", ['id' => 2], 3600); + // Seed model attrs under version 0 (the version before any invalidation). + $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("{nc}:test:members:model:{{$postsKey}}")); - $this->assertNotNull($store->get("query:{{$postsKey}}:v1:abc")); - $this->assertNotNull($store->get("model:{{$authorsKey}}:1")); + // Version must have bumped; stale v0 keys are unreachable via the new version. $this->assertGreaterThan($versionBefore, $this->manager->currentVersion(Post::class)); + $this->assertNull($this->modelCacheEntry(Post::class, 1)); + $this->assertNull($this->modelCacheEntry(Post::class, 2)); + // Query cache and unrelated model cache are unaffected. + $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 @@ -144,16 +144,15 @@ public function test_flush_all_removes_all_package_keys_and_returns_count(): voi $postsKey = DB::getDefaultConnection() . ':posts'; $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("model:{{$postsKey}}:1", ['id' => 1], 3600); + $store->set("model:{{$postsKey}}:v0: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("{nc}:test:members:model:{{$postsKey}}", "{nc}:test:model:{{$postsKey}}:1"); $deleted = $this->manager->flushAll(); - $this->assertSame(7, $deleted); + $this->assertSame(6, $deleted); $this->assertEmpty($this->redisKeys('test:*')); } @@ -185,21 +184,19 @@ public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enab 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 = "{nc}:test:members:model:{{$classKey}}"; - $modelKey = "{nc}: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 @@ -338,11 +335,10 @@ public function test_invalidate_multiple_versions_inside_transaction_queues_vers $this->manager->invalidateMultipleVersions([Author::class, Post::class], 'testing'); }); - // Version bumped after commit + // Version bumped after commit; model entries seeded under old version are unreachable. $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 diff --git a/tests/Unit/RedisScriptsTest.php b/tests/Unit/RedisScriptsTest.php index 1bb61c8..92b5126 100644 --- a/tests/Unit/RedisScriptsTest.php +++ b/tests/Unit/RedisScriptsTest.php @@ -14,7 +14,7 @@ class RedisScriptsTest extends TestCase 'fetch_versioned_payload', 'fetch_versioned_pivot', 'release_building', - 'store_many_tracked_if_version', + 'store_many_if_version', 'store_many_versioned', ]; diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index 46d7121..d6ba16f 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -103,9 +103,9 @@ 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->delete(['ver:1', 'key:1', 'key:2']); $this->store->setRaw('ver:1', '1', 60); $attrs = [ @@ -113,21 +113,21 @@ public function test_it_can_set_many_tracked_if_version(): void 'key:2' => ['id' => 2, 'name' => 'Bob'], ]; - $this->store->setManyTrackedIfVersion($attrs, 60, 'member:1', 'ver:1', 1); + $this->store->setManyIfVersion($attrs, 60, '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')); // Should NOT update if version mismatch $attrs2 = ['key:1' => ['id' => 1, 'name' => 'Charlie']]; - $this->store->setManyTrackedIfVersion($attrs2, 60, 'member:1', 'ver:1', 2); + $this->store->setManyIfVersion($attrs2, 60, 'ver:1', 2); $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('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->delete(['ver:2', 'key:3', 'key:4', 'lock:2', 'wake:2']); $this->store->setRaw('ver:2', '1', 60); $this->store->setNxEx('lock:2', 'tok', 60); @@ -136,16 +136,16 @@ public function test_it_can_set_many_tracked_if_version_with_lock_release(): voi '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, 'ver:2', 1, 'lock:2', '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'); } - 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->delete(['ver:large']); $this->store->setRaw('ver:large', '1', 60); $attrs = []; @@ -153,13 +153,12 @@ public function test_set_many_tracked_if_version_handles_large_script_batches(): $attrs["key:large:{$i}"] = ['id' => $i, 'name' => "Name {$i}"]; } - $this->store->setManyTrackedIfVersion($attrs, 60, 'member:large', 'ver:large', 1); + $this->store->setManyIfVersion($attrs, 60, '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'])); } - 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); @@ -172,23 +171,22 @@ public function test_set_many_tracked_script_chunks_sadd_internally(): void } $result = $this->store->script( - RedisScripts::get('store_many_tracked_if_version'), - array_merge(['ver:script-large', 'member:script-large', '', ''], $keys), + RedisScripts::get('store_many_if_version'), + array_merge(['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->delete(['ver:3', 'key:5', 'lock:3', 'wake:3']); $this->store->setRaw('ver:3', '2', 60); $this->store->setNxEx('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( + ['key:5' => ['id' => 5]], 60, 'ver:3', 1, 'lock:3', 'wake:3', 'tok' ); $this->assertNull($this->store->get('key:5')); @@ -200,7 +198,7 @@ public function test_it_releases_lock_unconditionally_when_there_is_nothing_to_w $this->store->delete(['lock:4', 'wake:4']); $this->store->setNxEx('lock:4', 'tok', 60); - $this->store->setManyTrackedIfVersion([], 60, 'member:4', 'ver:4', 1, 'lock:4', 'wake:4', 'tok'); + $this->store->setManyIfVersion([], 60, 'ver:4', 1, 'lock:4', 'wake:4', 'tok'); $this->assertNull($this->store->getRaw('lock:4')); } From 4e624818fca21f0e610806efe75e9a6be5d0a2e6 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:49:26 +1000 Subject: [PATCH 05/73] replace members-set model tracking with versioned model keys --- src/Cache/ModelHydrator.php | 112 ++++++++++++------ src/Cache/NormalizedCacheReader.php | 9 +- src/Cache/NormalizedThroughReader.php | 7 +- src/CacheManager.php | 7 +- ..._version.lua => store_many_if_version.lua} | 38 ++---- src/Support/CacheKeyBuilder.php | 24 ++-- src/Support/RedisStore.php | 41 +------ src/Traits/Cacheable.php | 43 ------- src/Traits/HandlesInvalidation.php | 63 ++-------- tests/Integration/Cache/ModelCachingTest.php | 9 +- .../Cache/ModelHydratorStampedeTest.php | 19 +-- .../Infrastructure/CacheEventsTest.php | 10 +- .../Invalidation/ModelInvalidationTest.php | 52 +++----- .../TransactionInvalidationTest.php | 17 ++- tests/TestCase.php | 25 ++-- tests/Unit/CacheManagerTest.php | 40 +++---- tests/Unit/RedisScriptsTest.php | 2 +- tests/Unit/RedisStoreTest.php | 52 ++++---- 18 files changed, 218 insertions(+), 352 deletions(-) rename src/Lua/{store_many_tracked_if_version.lua => store_many_if_version.lua} (53%) diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index 7f9a9c1..9fab714 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -19,6 +19,8 @@ final class ModelHydrator private static ?\Closure $setAttributeDirectClosure = null; + private static array $overridesNewFromBuilder = []; + private static ?\Closure $getAttributeDirectClosure = null; private static ?\Closure $transformScalarClosure = null; @@ -82,11 +84,16 @@ public function getModels( $classKey = $this->keys->classKey($modelClass); $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->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); + $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, [ @@ -99,6 +106,10 @@ public function getModels( return array_values($hits); } + if ($containedInQueryHit) { + $modelVersion = $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); + } + if ($reporting) { CacheReporter::modelMissActive($modelClass, $missed, $debugbarStart, [ 'hits' => array_keys($hits), @@ -108,24 +119,36 @@ public function getModels( [$lockKey, $wakeKey, $token] = $this->buildLockTriple($classKey, $missed); - [$status, $missed, $version] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $modelVersion, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); if ($status === 'building' && $missed !== []) { $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); - [$status, $missed, $version] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $modelVersion, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); } if ($status === 'miss') { try { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $version, $lockKey, $wakeKey, $token); + $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, true, $lockKey, $wakeKey, $token); } catch (\Throwable $e) { $this->store->releaseBuilding($lockKey, $wakeKey, $token); throw $e; } - } elseif ($missed !== []) { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $version); + } elseif ($status === 'hit' && $missed !== []) { + // Corrupt payload: Lua reported hit but deserialization failed. Only the lock owner overwrites. + if ($this->store->setNxEx($lockKey, $token, $this->buildingLockTtl)) { + try { + $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, true, $lockKey, $wakeKey, $token); + } catch (\Throwable $e) { + $this->store->releaseBuilding($lockKey, $wakeKey, $token); + throw $e; + } + } else { + $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, false); + } + } elseif ($status === 'building' && $missed !== []) { + $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, false); } $ordered = []; @@ -151,9 +174,9 @@ private function buildLockTriple(string $classKey, array $ids): array 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; @@ -167,6 +190,7 @@ private function fetchMissedStatus( array $idsToFetch, string $modelClass, string $classKey, + int $modelVersion, ?array $projection, ?Model $prototype, string $lockKey, @@ -174,16 +198,14 @@ private function fetchMissedStatus( string $token, array &$hits, ): array { - $fetchKeys = $this->modelKeysFor($classKey, $idsToFetch); + $fetchKeys = $this->modelKeysFor($classKey, $modelVersion, $idsToFetch); $result = $this->store->script( RedisScripts::get('fetch_batch_build_status'), - [...$fetchKeys, $lockKey, $this->keys->verKey($classKey), $wakeKey], + [...$fetchKeys, $lockKey, '', $wakeKey], [$token, (string) $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); @@ -193,13 +215,12 @@ private function fetchMissedStatus( } if ($stillMissed === []) { - return ['hit', [], 0]; + return ['hit', []]; } - return [$status, $stillMissed, $version]; + return [$result[0], $stillMissed]; } - // Fetches still-missing ids from the database, caches them, and merges into $hits by reference. private function fetchAndMerge( array $missed, string $modelClass, @@ -209,6 +230,7 @@ private function fetchAndMerge( bool $preserveQueryShape, array &$hits, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -219,7 +241,7 @@ private function fetchAndMerge( $fetched = $this->fetchFromDatabaseAndCache( $missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, - $modelVersion, $buildingKey, $wakeKey, $token + $modelVersion, $writeCache, $buildingKey, $wakeKey, $token ); foreach ($fetched as $id => $model) { @@ -326,6 +348,7 @@ private function fetchFromDatabaseAndCache( ?EloquentBuilder $missedQuery, bool $preserveQueryShape, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -338,12 +361,12 @@ private function fetchFromDatabaseAndCache( if ($this->overridesNewFromBuilder($query->getModel())) { return $this->fetchAndCacheUsingEloquent( - $missed, $modelClass, $classKey, $projection, $query, $modelVersion, $buildingKey, $wakeKey, $token + $missed, $modelClass, $classKey, $projection, $query, $modelVersion, $writeCache, $buildingKey, $wakeKey, $token ); } return $this->fetchAndCacheUsingClosure( - $missed, $modelClass, $classKey, $projection, $query, $query->getModel(), $modelVersion, $buildingKey, $wakeKey, $token + $missed, $modelClass, $classKey, $projection, $query, $query->getModel(), $modelVersion, $writeCache, $buildingKey, $wakeKey, $token ); } @@ -354,6 +377,7 @@ private function fetchAndCacheUsingEloquent( ?array $projection, EloquentBuilder $query, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -373,8 +397,8 @@ private function fetchAndCacheUsingEloquent( $attrs = $model->getRawOriginal(); $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); - if (!$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; + if ($writeCache && !$isTrashed) { + $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; } if ($hydrate !== null) { @@ -382,17 +406,8 @@ private function fetchAndCacheUsingEloquent( } } - if ($attrsByKey !== [] || $buildingKey !== null) { - $this->store->setManyTrackedIfVersion( - $attrsByKey, - $this->ttl, - $this->keys->membersKey($classKey), - $this->keys->verKey($classKey), - $modelVersion, - $buildingKey, - $wakeKey, - $token - ); + if ($writeCache || $buildingKey !== null) { + $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); } return $loaded->all(); @@ -406,6 +421,7 @@ private function fetchAndCacheUsingClosure( EloquentBuilder $query, Model $prototype, int $modelVersion, + bool $writeCache, ?string $buildingKey = null, ?string $wakeKey = null, ?string $token = null, @@ -433,8 +449,8 @@ private function fetchAndCacheUsingClosure( $id = $attrs[$pk]; $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); - if (!$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; + if ($writeCache && !$isTrashed) { + $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; } $returnAttrs = $projection !== null @@ -448,11 +464,25 @@ private function fetchAndCacheUsingClosure( $models[$id] = $instance; } + if ($writeCache || $buildingKey !== null) { + $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); + } + + return $models; + } + + private function storeModelAttrs( + array $attrsByKey, + string $classKey, + int $modelVersion, + ?string $buildingKey, + ?string $wakeKey, + ?string $token, + ): void { if ($attrsByKey !== [] || $buildingKey !== null) { - $this->store->setManyTrackedIfVersion( + $this->store->setManyIfVersion( $attrsByKey, $this->ttl, - $this->keys->membersKey($classKey), $this->keys->verKey($classKey), $modelVersion, $buildingKey, @@ -460,15 +490,19 @@ private function fetchAndCacheUsingClosure( $token ); } + } - return $models; + public static function reset(): void + { + self::$overridesNewFromBuilder = []; } private function overridesNewFromBuilder(Model $model): bool { - $method = new \ReflectionMethod($model, 'newFromBuilder'); + $class = $model::class; - return $method->getDeclaringClass()->getName() !== Model::class; + return self::$overridesNewFromBuilder[$class] ??= + (new \ReflectionMethod($model, 'newFromBuilder'))->getDeclaringClass()->getName() !== Model::class; } public static function hydrateClosure(): \Closure diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index 20e02a7..5d61604 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -44,11 +44,14 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $expectedVersions = $this->keys->versionsFromSegment($seg); [$status, $ids] = $this->resolveIds($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; + 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 + is_array($ids) ? $this->fetchModels($classKey, $modelVersion, $ids) : null ); } @@ -171,13 +174,13 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr return $result->status === CacheStatus::Building ? null : $result; } - private function fetchModels(string $classKey, array $ids): array + private function fetchModels(string $classKey, int $modelVersion, array $ids): array { if ($ids === []) { return []; } - $modelPrefix = $this->keys->modelPrefix($classKey); + $modelPrefix = $this->keys->modelPrefix($classKey, $modelVersion); return $this->store->getMany(array_map(static fn($id) => $modelPrefix . $id, $ids)); } diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 28dce7f..5fc2fc3 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -45,7 +45,8 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $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; + $modelVersion = $status->hasPayload() ? (int) ($expectedVersions[0] ?? 0) : 0; + $models = $status->hasPayload() ? $this->fetchModels($classKey, $modelVersion, $ids) : null; return $this->toThroughResult( $status, $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), @@ -166,13 +167,13 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr return $result->status === CacheStatus::Building ? null : $result; } - private function fetchModels(string $classKey, array $ids): array + private function fetchModels(string $classKey, int $modelVersion, array $ids): array { if ($ids === []) { return []; } - $modelPrefix = $this->keys->modelPrefix($classKey); + $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 55e57cd..fc0c5bf 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -212,17 +212,16 @@ public function cacheModelAttrs(string $modelClass, array $modelAttrs): void } $classKey = $this->keys->classKey($modelClass); - $modelVersion = $this->currentVersion($modelClass); + $modelVersion = $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); $attrsByKey = []; foreach ($modelAttrs as $id => $attrs) { - $attrsByKey[$this->keys->modelPrefix($classKey) . $id] = $attrs; + $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; } - $this->store->setManyTrackedIfVersion( + $this->store->setManyIfVersion( $attrsByKey, $this->config->ttl, - $this->keys->membersKey($classKey), $this->keys->verKey($classKey), $modelVersion ); diff --git a/src/Lua/store_many_tracked_if_version.lua b/src/Lua/store_many_if_version.lua similarity index 53% rename from src/Lua/store_many_tracked_if_version.lua rename to src/Lua/store_many_if_version.lua index 6b76843..09f7b28 100644 --- a/src/Lua/store_many_tracked_if_version.lua +++ b/src/Lua/store_many_if_version.lua @@ -2,10 +2,9 @@ -- 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 +-- KEYS[2] = building lock key (or '' to skip release) +-- KEYS[3] = wake key (or '' to skip) +-- KEYS[4..] = model attribute keys to write -- ARGV[1] = expected version -- ARGV[2] = TTL in seconds -- ARGV[3] = n (number of model keys) @@ -16,18 +15,18 @@ 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 + if KEYS[2] == '' then return end + if token ~= '' and redis.call('GET', KEYS[2]) ~= token then return end + redis.call('DEL', KEYS[2]) + if KEYS[3] ~= '' then for i = 1, wake_count do - redis.call('LPUSH', KEYS[4], '1') + redis.call('LPUSH', KEYS[3], '1') end - redis.call('EXPIRE', KEYS[4], 10) + redis.call('EXPIRE', KEYS[3], 10) end end -if KEYS[3] ~= '' and token ~= '' and redis.call('GET', KEYS[3]) ~= token then +if KEYS[2] ~= '' and token ~= '' and redis.call('GET', KEYS[2]) ~= token then return 0 end @@ -39,25 +38,10 @@ 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 + redis.call('SETEX', KEYS[3 + i], ttl, ARGV[4 + i]) 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/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 096dccf..d874217 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; +use NormCache\Cache\ModelHydrator; class CacheKeyBuilder { @@ -17,8 +18,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,13 +36,15 @@ class CacheKeyBuilder private static array $deletedAtColumns = []; + private static array $singleDepPairs = []; + // ------------------------------------------------------------------------- // Prefixes // ------------------------------------------------------------------------- - public function modelPrefix(string $classKey): string + public function modelPrefix(string $classKey, int|string $version): string { - return self::K_MODEL . ':{' . $classKey . '}:'; + return self::K_MODEL . ':{' . $classKey . '}:v' . $version . ':'; } public function queryPrefix(string $classKey, ?string $tag = null): string @@ -93,6 +94,9 @@ public static function reset(): void self::$classKeys = []; self::$prototypes = []; self::$deletedAtColumns = []; + self::$singleDepPairs = []; + + ModelHydrator::reset(); } public static function prototype(string $class): Model @@ -122,11 +126,6 @@ public function scheduledKey(string $classKey): string return self::K_SCHEDULED . ':{' . $classKey . '}:'; } - public function membersKey(string $classKey): string - { - return self::K_MEMBERS . ':{' . $classKey . '}'; - } - public function wakeKey(string $classKey, string $lockSuffix): string { return self::K_WAKE . ':{' . $classKey . '}:' . $lockSuffix; @@ -156,11 +155,6 @@ public function pivotKey(string $parentKey, string $relatedKey, string $relation return $this->pivotPrefix($parentKey, $relatedKey, $relation, $constraintHash, $seg) . $parentId; } - public function modelKey(string $modelClass, string $id): string - { - return $this->modelPrefix($this->classKey($modelClass)) . $id; - } - // ------------------------------------------------------------------------- // Versioning Helpers // ------------------------------------------------------------------------- @@ -206,7 +200,7 @@ public function buildingToWakeKey(string $buildingKey): string public function depKeyPairs(string $classKey, array $depClasses, array $depTableKeys = []): array { if ($depClasses === [] && $depTableKeys === []) { - return [ + return self::$singleDepPairs[$classKey] ??= [ [$this->verKey($classKey)], [$this->scheduledKey($classKey)], ]; diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 92522f6..9e1d064 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -214,10 +214,9 @@ public function setMany(array $pairs, int $ttl): void } // 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, @@ -232,7 +231,7 @@ public function setManyTrackedIfVersion( return; } - $script = RedisScripts::get('store_many_tracked_if_version'); + $script = RedisScripts::get('store_many_if_version'); $chunks = array_chunk($attrsByKey, 500, true); $lastChunk = array_key_last($chunks); @@ -242,7 +241,7 @@ public function setManyTrackedIfVersion( $this->script( $script, array_merge( - [$versionKey, $memberKey, $isLast ? ($buildingKey ?? '') : '', $isLast ? ($wakeKey ?? '') : ''], + [$versionKey, $isLast ? ($buildingKey ?? '') : '', $isLast ? ($wakeKey ?? '') : ''], array_keys($chunk) ), array_merge( @@ -254,40 +253,6 @@ 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)) { diff --git a/src/Traits/Cacheable.php b/src/Traits/Cacheable.php index 099225c..570b52f 100644 --- a/src/Traits/Cacheable.php +++ b/src/Traits/Cacheable.php @@ -90,8 +90,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,39 +117,9 @@ 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 - { - if (!$existsBefore) { - return; - } - - if (!$this->isDirty()) { - return; - } - - if ($this->isPendingRestoreSave()) { - return; - } - - if (!NormCache::isEnabled()) { - return; - } - - if ($this->getConnection()->transactionLevel() !== 0) { - return; - } - - NormCache::evictModelKey(static::class, $originalKey ?? $this->getKey()); - } - private function isRestoreSave(bool $existsBefore): bool { if (!$existsBefore || !method_exists($this, 'getDeletedAtColumn')) { @@ -163,17 +131,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/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 59e68a6..f0ab0a6 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -19,9 +19,6 @@ trait HandlesInvalidation /** @var array> */ private array $versionQueue = []; - /** @var array> model class, model key, members key */ - private array $instanceEvictQueue = []; - // Invalidation public function invalidateVersion(Model $model): void @@ -65,19 +62,11 @@ public function flushInstance(Model $model): void $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)); - }, + fn() => $this->queueVersionFlush($conn, $this->keys->classKey($class)), + fn() => $this->doInvalidateVersion($class), ); } @@ -96,28 +85,10 @@ public function invalidateTableVersion(string $connectionName, string $table): v ); } - public function evictModelKey(string $modelClass, mixed $id): 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) - ); - }); - } - public function forceFlushModel(string $modelClass): void { $classKey = $this->keys->classKey($modelClass); $this->store->incrementAndExpire($this->keys->verKey($classKey), $this->versionTtl()); // bypass cooldown - - $this->store->sscanAndFlushSet($this->store->prefix($this->keys->membersKey($classKey))); } public function flushAll(): int @@ -125,7 +96,6 @@ public function flushAll(): int return $this->store->flushByPatterns([ CacheKeyBuilder::K_QUERY . ':*', CacheKeyBuilder::K_MODEL . ':*', - CacheKeyBuilder::K_MEMBERS . ':*', CacheKeyBuilder::K_VER . ':*', CacheKeyBuilder::K_COUNT . ':*', CacheKeyBuilder::K_SCALAR . ':*', @@ -191,51 +161,37 @@ 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]); + unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName]); - if ((empty($flushes) && empty($versions) && empty($evicts)) || !$this->isEnabled()) { + if ((empty($flushes) && empty($versions)) || !$this->isEnabled()) { return; } - $this->attempt(function () use ($flushes, $versions, $evicts) { + $this->attempt(function () use ($flushes, $versions) { foreach ($flushes as $modelClass) { $this->forceFlushModel($modelClass); } $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 ($versions as $classKey) { - if (!in_array($classKey, $evictClassKeys, true) && !in_array($classKey, $flushClassKeys, true)) { + if (!in_array($classKey, $flushClassKeys, true)) { $this->doInvalidateKey($classKey); } } - - foreach ($evictClasses as $class) { - if (!in_array($this->keys->classKey($class), $flushClassKeys, true)) { - $this->doInvalidateVersion($class); - } - } - - foreach ($evicts as [, $key, $membersKey]) { - $this->store->deleteFromSet($key, $membersKey); - } }); } public function discardPending(string $connectionName): void { - unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName], $this->instanceEvictQueue[$connectionName]); + unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName]); } public function discardAllPending(): void { $this->flushQueue = []; $this->versionQueue = []; - $this->instanceEvictQueue = []; } // Private — invalidation internals @@ -301,9 +257,4 @@ private function queueVersionFlush(string $connectionName, string $classKey): vo { $this->versionQueue[$connectionName][$classKey] = true; } - - private function queueInstanceEvict(string $connectionName, string $modelClass, string $key, string $membersKey): void - { - $this->instanceEvictQueue[$connectionName][] = [$modelClass, $key, $membersKey]; - } } diff --git a/tests/Integration/Cache/ModelCachingTest.php b/tests/Integration/Cache/ModelCachingTest.php index 8243b17..40d8cd5 100644 --- a/tests/Integration/Cache/ModelCachingTest.php +++ b/tests/Integration/Cache/ModelCachingTest.php @@ -39,15 +39,14 @@ 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/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index a8a45f9..bc167ae 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -58,8 +58,8 @@ 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 @@ -70,7 +70,7 @@ public function test_build_status_script_claims_lock_when_unheld(): void $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_batch_build_status'), @@ -91,7 +91,7 @@ public function test_build_status_script_reports_building_when_lock_already_held $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( @@ -114,7 +114,7 @@ public function test_build_status_script_reports_hit_without_claiming_lock_when_ $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( @@ -138,14 +138,15 @@ public function test_fetch_missed_status_resolves_via_retry_mget_without_claimin $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 = []; $method = new \ReflectionMethod($hydrator, 'fetchMissedStatus'); - $args = [[$author->id], Author::class, $classKey, null, null, $lockKey, $keys->wakeKey($classKey, 'test-lock'), 'token', &$hits]; + $args = [[$author->id], Author::class, $classKey, $version, null, null, $lockKey, $keys->wakeKey($classKey, 'test-lock'), 'token', &$hits]; [$status, $missed] = $method->invokeArgs($hydrator, $args); $this->assertSame('hit', $status); @@ -168,7 +169,7 @@ 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; } @@ -205,7 +206,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); } diff --git a/tests/Integration/Infrastructure/CacheEventsTest.php b/tests/Integration/Infrastructure/CacheEventsTest.php index 0af72a6..f89fdd6 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); } diff --git a/tests/Integration/Invalidation/ModelInvalidationTest.php b/tests/Integration/Invalidation/ModelInvalidationTest.php index 4a9d58a..9f439f5 100644 --- a/tests/Integration/Invalidation/ModelInvalidationTest.php +++ b/tests/Integration/Invalidation/ModelInvalidationTest.php @@ -2,7 +2,6 @@ namespace NormCache\Tests\Integration\Invalidation; -use Illuminate\Support\Facades\Redis; use NormCache\Facades\NormCache; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; @@ -79,36 +78,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 = '{nc}: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 = '{nc}: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 +162,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 diff --git a/tests/Integration/Invalidation/TransactionInvalidationTest.php b/tests/Integration/Invalidation/TransactionInvalidationTest.php index 494b1c0..0fdb32d 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 diff --git a/tests/TestCase.php b/tests/TestCase.php index dcccd99..5aedafa 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -24,7 +24,6 @@ use NormCache\Values\CacheConfig; use Orchestra\Testbench\TestCase as OrchestraTestCase; use Predis\Client; -use ReflectionProperty; abstract class TestCase extends OrchestraTestCase { @@ -115,29 +114,35 @@ 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->getStore()->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->getStore()->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 $manager->getStore()->prefix($this->currentModelKey($manager, $class, $id)); + } + + private function currentModelKey(CacheManager $manager, string $class, mixed $id): string + { + $classKey = $manager->classKey($class); + $version = $manager->currentVersion($class); + + return 'model:{' . $classKey . '}:v' . $version . ':' . $id; } protected function redisKeys(string $pattern = '*'): array diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index a5f623b..e840662 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -115,27 +115,24 @@ public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void public function test_flush_model_bumps_version_and_clears_related_keys(): void { - $redis = Redis::connection('normcache-test'); $store = $this->manager->getStore(); $postsKey = DB::getDefaultConnection() . ':posts'; $authorsKey = DB::getDefaultConnection() . ':authors'; - $redis->sadd("{nc}:test:members:model:{{$postsKey}}", "{nc}:test:model:{{$postsKey}}:1", "{nc}: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("{nc}: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 @@ -144,16 +141,15 @@ public function test_flush_all_removes_all_package_keys_and_returns_count(): voi $postsKey = DB::getDefaultConnection() . ':posts'; $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("model:{{$postsKey}}:1", ['id' => 1], 3600); + $store->set("model:{{$postsKey}}:v0: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("{nc}:test:members:model:{{$postsKey}}", "{nc}:test:model:{{$postsKey}}:1"); $deleted = $this->manager->flushAll(); - $this->assertSame(7, $deleted); + $this->assertSame(6, $deleted); $this->assertEmpty($this->redisKeys('test:*')); } @@ -185,21 +181,19 @@ public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enab 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 = "{nc}:test:members:model:{{$classKey}}"; - $modelKey = "{nc}: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 @@ -338,11 +332,9 @@ 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 diff --git a/tests/Unit/RedisScriptsTest.php b/tests/Unit/RedisScriptsTest.php index 1bb61c8..92b5126 100644 --- a/tests/Unit/RedisScriptsTest.php +++ b/tests/Unit/RedisScriptsTest.php @@ -14,7 +14,7 @@ class RedisScriptsTest extends TestCase 'fetch_versioned_payload', 'fetch_versioned_pivot', 'release_building', - 'store_many_tracked_if_version', + 'store_many_if_version', 'store_many_versioned', ]; diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index 46d7121..a97b524 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -103,9 +103,9 @@ 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->delete(['ver:1', 'key:1', 'key:2']); $this->store->setRaw('ver:1', '1', 60); $attrs = [ @@ -113,21 +113,21 @@ public function test_it_can_set_many_tracked_if_version(): void 'key:2' => ['id' => 2, 'name' => 'Bob'], ]; - $this->store->setManyTrackedIfVersion($attrs, 60, 'member:1', 'ver:1', 1); + $this->store->setManyIfVersion($attrs, 60, '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')); // Should NOT update if version mismatch $attrs2 = ['key:1' => ['id' => 1, 'name' => 'Charlie']]; - $this->store->setManyTrackedIfVersion($attrs2, 60, 'member:1', 'ver:1', 2); + $this->store->setManyIfVersion($attrs2, 60, 'ver:1', 2); $this->assertSame(['id' => 1, 'name' => 'Alice'], $this->store->get('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->delete(['ver:2', 'key:3', 'key:4', 'lock:2', 'wake:2']); $this->store->setRaw('ver:2', '1', 60); $this->store->setNxEx('lock:2', 'tok', 60); @@ -136,16 +136,16 @@ public function test_it_can_set_many_tracked_if_version_with_lock_release(): voi '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, 'ver:2', 1, 'lock:2', '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'); } - 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->delete(['ver:large']); $this->store->setRaw('ver:large', '1', 60); $attrs = []; @@ -153,13 +153,12 @@ public function test_set_many_tracked_if_version_handles_large_script_batches(): $attrs["key:large:{$i}"] = ['id' => $i, 'name' => "Name {$i}"]; } - $this->store->setManyTrackedIfVersion($attrs, 60, 'member:large', 'ver:large', 1); + $this->store->setManyIfVersion($attrs, 60, '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'])); } - 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); @@ -172,23 +171,22 @@ public function test_set_many_tracked_script_chunks_sadd_internally(): void } $result = $this->store->script( - RedisScripts::get('store_many_tracked_if_version'), - array_merge(['ver:script-large', 'member:script-large', '', ''], $keys), + RedisScripts::get('store_many_if_version'), + array_merge(['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->delete(['ver:3', 'key:5', 'lock:3', 'wake:3']); $this->store->setRaw('ver:3', '2', 60); $this->store->setNxEx('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( + ['key:5' => ['id' => 5]], 60, 'ver:3', 1, 'lock:3', 'wake:3', 'tok' ); $this->assertNull($this->store->get('key:5')); @@ -200,7 +198,7 @@ public function test_it_releases_lock_unconditionally_when_there_is_nothing_to_w $this->store->delete(['lock:4', 'wake:4']); $this->store->setNxEx('lock:4', 'tok', 60); - $this->store->setManyTrackedIfVersion([], 60, 'member:4', 'ver:4', 1, 'lock:4', 'wake:4', 'tok'); + $this->store->setManyIfVersion([], 60, 'ver:4', 1, 'lock:4', 'wake:4', 'tok'); $this->assertNull($this->store->getRaw('lock:4')); } @@ -229,7 +227,7 @@ public function test_scan_pattern_strips_connection_prefix_from_returned_keys(): Redis::purge('normcache-test'); try { - $store = new RedisStore('normcache-test', 'test:', false); + $store = new RedisStore('normcache-test', 'test:', ''); $store->set('query:abc', [1], 60); $store->set('query:def', [2], 60); $store->set('model:1', ['id' => 1], 60); @@ -257,7 +255,7 @@ public function test_flush_by_patterns_works_with_connection_prefix(): void Redis::purge('normcache-test'); try { - $store = new RedisStore('normcache-test', 'test:', false); + $store = new RedisStore('normcache-test', 'test:', ''); $store->set('query:1', 'a', 60); $store->set('query:2', 'b', 60); $store->set('model:1', 'c', 60); @@ -365,7 +363,7 @@ public function test_flush_by_patterns_scans_all_nodes_on_predis_cluster(): void $predisClient = new PredisClient([$standaloneConn], ['cluster' => 'predis']); $clusterConnection = new PredisClusterConnection($predisClient); - $store = new RedisStore('normcache-test', 'testscan:', false); + $store = new RedisStore('normcache-test', 'testscan:', ''); (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $clusterConnection); $deleted = $store->flushByPatterns(['model:{posts}:*']); @@ -415,7 +413,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( @@ -430,7 +428,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))); @@ -454,7 +452,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'); @@ -468,7 +466,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()); @@ -487,7 +485,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); From c1d2d14ee6e9fff0ae8d2b82fdd765a0eb7a23db Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sat, 27 Jun 2026 10:59:24 +1000 Subject: [PATCH 06/73] lua script name cleanup --- src/Cache/NormalizedCacheReader.php | 2 +- src/Cache/NormalizedThroughReader.php | 2 +- src/Cache/ResultCacheReader.php | 2 +- .../{store_many_if_version.lua => store_model_attrs.lua} | 0 ...ore_many_versioned.lua => store_versioned_payload.lua} | 0 src/Support/RedisStore.php | 4 ++-- tests/Unit/RedisScriptsTest.php | 8 ++++---- tests/Unit/RedisStoreTest.php | 2 +- 8 files changed, 10 insertions(+), 10 deletions(-) rename src/Lua/{store_many_if_version.lua => store_model_attrs.lua} (100%) rename src/Lua/{store_many_versioned.lua => store_versioned_payload.lua} (100%) diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index 5d61604..72c7edb 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -134,7 +134,7 @@ public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, if (!empty($versionKeys)) { $this->store->script( - RedisScripts::get('store_many_versioned'), + RedisScripts::get('store_versioned_payload'), array_merge($versionKeys, [ $key, $buildingKey ?? '', diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 5fc2fc3..ef9d836 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -127,7 +127,7 @@ public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?s if (!empty($versionKeys)) { $this->store->script( - RedisScripts::get('store_many_versioned'), + RedisScripts::get('store_versioned_payload'), array_merge($versionKeys, [ $key, $buildingKey ?? '', diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index dcafd9f..adba4e6 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -206,7 +206,7 @@ public function storeMany( } return (bool) $this->store->script( - RedisScripts::get('store_many_versioned'), + RedisScripts::get('store_versioned_payload'), array_merge($versionKeys, array_keys($entries), [ $buildingKey ?? '', $wakeKey ?? ($buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : ''), diff --git a/src/Lua/store_many_if_version.lua b/src/Lua/store_model_attrs.lua similarity index 100% rename from src/Lua/store_many_if_version.lua rename to src/Lua/store_model_attrs.lua diff --git a/src/Lua/store_many_versioned.lua b/src/Lua/store_versioned_payload.lua similarity index 100% rename from src/Lua/store_many_versioned.lua rename to src/Lua/store_versioned_payload.lua diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 9e1d064..d41dfaf 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -122,7 +122,7 @@ public function storeRawAndRelease(string $key, string $value, int $ttl, ?string } return (bool) $this->script( - RedisScripts::get('store_many_versioned'), + RedisScripts::get('store_versioned_payload'), [$key, $buildingKey, $wakeKey ?? ''], ['0', '1', (string) $ttl, $value, $token ?? '', (string) $this->wakeTokenCount] ); @@ -231,7 +231,7 @@ public function setManyIfVersion( return; } - $script = RedisScripts::get('store_many_if_version'); + $script = RedisScripts::get('store_model_attrs'); $chunks = array_chunk($attrsByKey, 500, true); $lastChunk = array_key_last($chunks); diff --git a/tests/Unit/RedisScriptsTest.php b/tests/Unit/RedisScriptsTest.php index 92b5126..0f4dc82 100644 --- a/tests/Unit/RedisScriptsTest.php +++ b/tests/Unit/RedisScriptsTest.php @@ -14,8 +14,8 @@ class RedisScriptsTest extends TestCase 'fetch_versioned_payload', 'fetch_versioned_pivot', 'release_building', - 'store_many_if_version', - 'store_many_versioned', + 'store_model_attrs', + 'store_versioned_payload', ]; protected function setUp(): void @@ -51,9 +51,9 @@ public function test_it_caches_loaded_scripts_statically(): void $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 a97b524..d423c40 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -171,7 +171,7 @@ public function test_set_many_if_version_script_chunks_internally(): void } $result = $this->store->script( - RedisScripts::get('store_many_if_version'), + RedisScripts::get('store_model_attrs'), array_merge(['ver:script-large', '', ''], $keys), array_merge(['1', '60', (string) $count, ''], $values) ); From c856f52cb4f12e6dbaf0c25fe8c4960db5a35a62 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sat, 27 Jun 2026 12:13:42 +1000 Subject: [PATCH 07/73] unify normalized readers / extract DependencyResolver --- src/Cache/NormalizedCacheReader.php | 139 +++---------------- src/Cache/NormalizedReader.php | 191 ++++++++++++++++++++++++++ src/Cache/NormalizedThroughReader.php | 134 +++--------------- src/Planning/CachePlanner.php | 130 +----------------- src/Planning/DependencyResolver.php | 139 +++++++++++++++++++ tests/Unit/CachePlannerTest.php | 7 +- 6 files changed, 382 insertions(+), 358 deletions(-) create mode 100644 src/Cache/NormalizedReader.php create mode 100644 src/Planning/DependencyResolver.php diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index 72c7edb..7e3f78b 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -4,79 +4,42 @@ use NormCache\Enums\CacheStatus; use NormCache\Enums\LuaStatus; -use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; -use NormCache\Support\RedisStore; use NormCache\Values\QueryCacheResult; -final class NormalizedCacheReader +/** + * Query cache: payload is a bare id list, hydrated from per-model attribute keys. + * + * @extends NormalizedReader + */ +final class NormalizedCacheReader extends NormalizedReader { - public function __construct( - private readonly RedisStore $store, - private readonly CacheKeyBuilder $keys, - private readonly VersionTracker $versions, - private readonly int $queryTtl, - private readonly int $buildingLockTtl, - private readonly int $stampedeWaitMs = 200, - private readonly int $wakeTokenCount = 64, - ) {} - - public function fetch(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult + protected function queryPrefix(string $classKey, ?string $tag): string { - $classKey = $this->keys->classKey($modelClass); - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); - $queryPrefix = $this->keys->queryPrefix($classKey, $tag); - $lockToken = $this->versions->buildLockToken(); - - $result = $this->store->script( - RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $scheduledKeys, [ - $queryPrefix, - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - ]), - [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $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); - - // Use the version Lua already resolved atomically; a separate GET would race with version bumps. - $modelVersion = is_array($ids) ? (int) ($expectedVersions[0] ?? 0) : 0; - - return $this->toQueryResult( - $status, $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), - $versionKeys, $expectedVersions, - $ids, - is_array($ids) ? $this->fetchModels($classKey, $modelVersion, $ids) : null - ); + return $this->keys->queryPrefix($classKey, $tag); } - private function resolveIds(array $result, string $queryKey): array + protected function decodePayload(array $result, string $queryKey): array { $status = LuaStatus::fromLua($result[0] ?? null); if (!$status->hasPayload()) { - return [$status, null]; + return [$status, null, null]; } if (!isset($result[2])) { - return [LuaStatus::Corrupt, null]; + return [LuaStatus::Corrupt, null, null]; } $ids = $this->resolveIdsPayload($result[2], $queryKey); if ($ids === null) { - return [LuaStatus::Corrupt, null]; + return [LuaStatus::Corrupt, null, null]; } if (empty($ids)) { - return [LuaStatus::Empty, []]; + return [LuaStatus::Empty, [], null]; } - return [$status, $ids]; + return [$status, $ids, null]; } private function resolveIdsPayload(mixed $payload, string $queryKey): ?array @@ -92,15 +55,16 @@ private function resolveIdsPayload(mixed $payload, string $queryKey): ?array return $ids; } - private function toQueryResult( + protected function buildResult( LuaStatus $status, string $queryKey, string $buildingKey, string $lockToken, array $versionKeys, array $expectedVersions, - mixed $ids = null, - ?array $models = null + ?array $ids = null, + mixed $extra = null, + ?array $models = null, ): QueryCacheResult { return match ($status) { LuaStatus::Hit => new QueryCacheResult(CacheStatus::Hit, $queryKey, $ids, $models ?? [], null, null, [], []), @@ -111,77 +75,18 @@ private function toQueryResult( }; } - 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 (!empty($versionKeys)) { - $this->store->script( - RedisScripts::get('store_versioned_payload'), - 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( + $this->storePayload( $key, json_encode($ids, JSON_THROW_ON_ERROR), $ttl, $buildingKey, - $this->keys->buildingToWakeKey($buildingKey), - $buildingToken + $versionKeys, + $expectedVersions, + $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 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/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php new file mode 100644 index 0000000..a7e4065 --- /dev/null +++ b/src/Cache/NormalizedReader.php @@ -0,0 +1,191 @@ +, 2: mixed} + */ + abstract protected function decodePayload(array $result, string $queryKey): array; + + /** + * Build the concrete result DTO for this reader. + * + * @return TResult + */ + abstract protected function buildResult( + LuaStatus $status, + string $queryKey, + string $buildingKey, + string $lockToken, + array $versionKeys, + array $expectedVersions, + ?array $ids = null, + mixed $extra = null, + ?array $models = null, + ): QueryCacheResult|ThroughCacheResult; + + /** + * @return TResult + */ + public function fetch(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult|ThroughCacheResult + { + $classKey = $this->keys->classKey($modelClass); + [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); + $queryPrefix = $this->queryPrefix($classKey, $tag); + $lockToken = $this->versions->buildLockToken(); + + $result = $this->store->script( + RedisScripts::get('fetch_versioned_payload'), + array_merge($versionKeys, $scheduledKeys, [ + $queryPrefix, + $this->keys->buildingPrefix($classKey), + $this->keys->wakePrefix($classKey), + ]), + [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] + ); + + $seg = (string) ($result[1] ?? ''); + $queryKey = $queryPrefix . $seg . ':' . $hash; + $buildingKey = $this->keys->buildingPrefix($classKey) . $seg . ':' . $hash; + $expectedVersions = $this->keys->versionsFromSegment($seg); + + [$status, $ids, $extra] = $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, + $queryKey, + $buildingKey, + (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), + $versionKeys, + $expectedVersions, + $ids, + $extra, + $models, + ); + } + + /** + * @return TResult|null + */ + public function waitForBuild(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult|ThroughCacheResult|null + { + $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; + } + + /** + * Corrupt hit: claim the build lock to rebuild, else report Building. + * + * @return TResult + */ + protected function claimMissAfterCorruptHit( + string $queryKey, + string $buildingKey, + string $lockToken, + array $versionKeys, + array $expectedVersions, + ): QueryCacheResult|ThroughCacheResult { + if ($this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { + $this->store->delete($this->keys->buildingToWakeKey($buildingKey)); + + return $this->buildResult(LuaStatus::Miss, $queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions); + } + + return $this->buildResult(LuaStatus::Building, $queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions); + } + + // Store an encoded payload, version-guarded, releasing the build lock. + protected function storePayload( + string $key, + string $payload, + ?int $ttl, + ?string $buildingKey, + array $versionKeys, + array $expectedVersions, + ?string $buildingToken, + ): void { + $ttl ??= $this->queryTtl; + + if (!empty($versionKeys)) { + $this->store->script( + RedisScripts::get('store_versioned_payload'), + 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 + ); + } + + 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/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index ef9d836..94681a8 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -5,57 +5,22 @@ use NormCache\Enums\CacheStatus; use NormCache\Enums\LuaStatus; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; -use NormCache\Support\RedisStore; use NormCache\Values\ThroughCacheResult; -final class NormalizedThroughReader +/** + * Through cache (HasManyThrough/HasOneThrough): payload is `{i, t}` — id list + * plus per-row through keys Eloquent needs to re-stitch the relation. + * + * @extends NormalizedReader + */ +final class NormalizedThroughReader extends NormalizedReader { - public function __construct( - private readonly RedisStore $store, - private readonly CacheKeyBuilder $keys, - private readonly VersionTracker $versions, - private readonly int $queryTtl, - private readonly int $buildingLockTtl, - private readonly int $stampedeWaitMs = 200, - private readonly int $wakeTokenCount = 64, - ) {} - - public function fetch(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): ThroughCacheResult + protected function queryPrefix(string $classKey, ?string $tag): string { - $classKey = $this->keys->classKey($modelClass); - - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); - $queryPrefix = $this->keys->namespacedPrefix(CacheKeyBuilder::K_THROUGH, $classKey, $tag); - - $lockToken = $this->versions->buildLockToken(); - - $result = $this->store->script( - RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $scheduledKeys, [ - $queryPrefix, - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - ]), - [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $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); - $modelVersion = $status->hasPayload() ? (int) ($expectedVersions[0] ?? 0) : 0; - $models = $status->hasPayload() ? $this->fetchModels($classKey, $modelVersion, $ids) : null; - - return $this->toThroughResult( - $status, $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), - $versionKeys, $expectedVersions, - $ids, $throughKeys, $models - ); + return $this->keys->namespacedPrefix(CacheKeyBuilder::K_THROUGH, $classKey, $tag); } - private function resolveIdsAndThroughKeys(array $result, string $queryKey): array + protected function decodePayload(array $result, string $queryKey): array { $status = LuaStatus::fromLua($result[0] ?? null); @@ -82,19 +47,19 @@ private function resolveIdsAndThroughKeys(array $result, string $queryKey): arra return [LuaStatus::Hit, $parsed['i'], $parsed['t']]; } - private function toThroughResult( + protected function buildResult( LuaStatus $status, string $queryKey, string $buildingKey, string $lockToken, array $versionKeys, array $expectedVersions, - ?array $ids, - ?array $throughKeys, - ?array $models + ?array $ids = null, + mixed $extra = null, + ?array $models = null, ): ThroughCacheResult { return match ($status) { - LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $queryKey, $ids, $throughKeys, $models ?? [], null, null, [], []), + LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $queryKey, $ids, $extra, $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, [], []), @@ -102,80 +67,19 @@ private function toThroughResult( }; } - 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 (!empty($versionKeys)) { - $this->store->script( - RedisScripts::get('store_versioned_payload'), - 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( + $this->storePayload( $key, $payload, $ttl, $buildingKey, - $this->keys->buildingToWakeKey($buildingKey), - $buildingToken + $versionKeys, + $expectedVersions, + $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 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/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 78cd8cd..c39c6d5 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -4,7 +4,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Query\Builder as QueryBuilder; -use Illuminate\Support\Facades\Log; use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; use NormCache\Enums\PlanningMode; @@ -38,6 +37,7 @@ final class CachePlanner public function __construct( private readonly QueryAnalyzer $analyzer = new QueryAnalyzer, + private readonly DependencyResolver $dependencies = new DependencyResolver, ) {} public function plan( @@ -88,7 +88,7 @@ private function globalBypass( return CachePlan::bypass( operation: $context->operation, - dependencies: $this->baseDependencies($builder, $model, $context), + dependencies: $this->dependencies->resolveBase($builder, $model, $context), reasons: $reasons, bypassReasons: ['opted_out' => $reasons], ); @@ -103,7 +103,7 @@ private function transactionBypass( return CachePlan::bypass( operation: $context->operation, - dependencies: $this->baseDependencies($builder, $model, $context), + dependencies: $this->dependencies->resolveBase($builder, $model, $context), reasons: $reasons, bypassReasons: ['safety' => $reasons], ); @@ -205,7 +205,7 @@ private function planModels( $dependencies = $this->dependsOnPrimaryModelOnly($hasExplicit, $inferred, $hasDependencyBypass, $hasContextDependencyBypass) ? DependencySet::singleModel($modelClass) - : $this->resolveDependencies( + : $this->dependencies->resolve( $modelClass, $context, $inspection, @@ -243,7 +243,7 @@ private function planModels( if ($dependencies->safe && $this->hasResultDependencies($context, $hasExplicit)) { if ($this->requiresExplicitSelectForJoinResult($builder, $base, $context)) { - $this->warnUnderDeclaredDependencies($modelTable, $base, $inspection, $dependencies); + $this->dependencies->warnUnderDeclared($modelTable, $base, $inspection, $dependencies); $reasons = ['join_result_requires_explicit_select']; return CachePlan::bypass( @@ -282,7 +282,7 @@ private function planInspectedResult( $inferred = $context->inferredDependencies; $inspection = $this->inspect($model, $base, $context, collectTables: $explain); - $dependencies = $this->resolveDependencies( + $dependencies = $this->dependencies->resolve( $modelClass, $context, $inspection, @@ -402,71 +402,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, @@ -516,7 +451,7 @@ private function resultPlan( DependencySet $dependencies, bool $normalizable = false, ): CachePlan { - $this->warnUnderDeclaredDependencies($modelTable, $base, $inspection, $dependencies); + $this->dependencies->warnUnderDeclared($modelTable, $base, $inspection, $dependencies); return CachePlan::result( operation: $context->operation, @@ -563,57 +498,6 @@ private function bypassPlan( ); } - 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, diff --git a/src/Planning/DependencyResolver.php b/src/Planning/DependencyResolver.php new file mode 100644 index 0000000..ae1dd4a --- /dev/null +++ b/src/Planning/DependencyResolver.php @@ -0,0 +1,139 @@ +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). + public function resolveBase( + CacheableBuilder $builder, + Model $model, + CachePlanContext $context, + ): DependencySet { + return $this->resolve( + $model::class, + $context, + null, + $builder->explicitDependencies(), + $builder->explicitTableDependencies(), + $builder->hasExplicitDependencies(), + ); + } + + public function warnUnderDeclared( + 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." + ); + } + } +} diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index 884b3d0..f63ca1f 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\Log; use NormCache\Enums\CacheStrategy; use NormCache\Planning\CachePlanner; +use NormCache\Planning\DependencyResolver; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\TestCase; @@ -23,13 +24,13 @@ public function test_planner_logs_warning_for_under_declared_dependencies_in_deb return str_contains($message, 'under-declared dependency') && str_contains($message, 'authors'); }); - $planner = new CachePlanner; + $resolver = new DependencyResolver; $dependencies = new DependencySet([], ['posts']); - $reflection = new \ReflectionMethod($planner, 'checkDependencyCompleteness'); + $reflection = new \ReflectionMethod($resolver, 'checkDependencyCompleteness'); $reflection->setAccessible(true); - $reflection->invoke($planner, ['posts', 'authors'], $dependencies, 'posts'); + $reflection->invoke($resolver, ['posts', 'authors'], $dependencies, 'posts'); } public function test_successful_hot_plan_does_not_build_reason_strings(): void From 4534725285b5ca7cde61f68c65d9d1ba7527fb23 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:14:56 +1000 Subject: [PATCH 08/73] eject colon in connection name --- src/Support/CacheKeyBuilder.php | 6 ++++++ tests/Unit/CacheKeyBuilderTest.php | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+) create mode 100644 tests/Unit/CacheKeyBuilderTest.php diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index d874217..00f70c8 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -272,6 +272,12 @@ private function resolveClassKey(string $class): 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()}"; } diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php new file mode 100644 index 0000000..1dc7e1e --- /dev/null +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -0,0 +1,24 @@ +expectException(\InvalidArgumentException::class); + + $keys->classKey($model::class); + } +} From a14a0d45d4e507df4bc4a9ac90fdd1dd4f7fb137 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sat, 27 Jun 2026 15:47:07 +1000 Subject: [PATCH 09/73] remove braces for slotting --- src/Support/CacheKeyBuilder.php | 24 +++++------ src/Traits/HandlesInvalidation.php | 10 ++--- tests/Integration/Cache/OptimizationsTest.php | 4 +- .../Cache/ProjectionBypassTest.php | 4 +- .../Infrastructure/CacheEventsTest.php | 12 +++--- .../Infrastructure/ClusterModeTest.php | 6 +-- .../Infrastructure/LuaScriptBehaviorTest.php | 16 +++---- .../LuaScriptConsistencyTest.php | 42 +++++++++---------- .../Infrastructure/StampedeProtectionTest.php | 18 ++++---- .../Infrastructure/StringPrimaryKeyTest.php | 4 +- tests/Integration/RelationOverrideTest.php | 4 +- tests/TestCase.php | 2 +- tests/Unit/CacheKeyBuilderTest.php | 29 +++++++++++++ tests/Unit/CacheManagerTest.php | 14 +++---- 14 files changed, 108 insertions(+), 81 deletions(-) diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 00f70c8..d2d5feb 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -44,24 +44,24 @@ class CacheKeyBuilder public function modelPrefix(string $classKey, int|string $version): string { - return self::K_MODEL . ':{' . $classKey . '}:v' . $version . ':'; + return self::K_MODEL . ':' . $classKey . ':v' . $version . ':'; } public function queryPrefix(string $classKey, ?string $tag = null): string { - $base = self::K_QUERY . ':{' . $classKey . '}:'; + $base = self::K_QUERY . ':' . $classKey . ':'; return $tag !== null ? $base . $tag . ':' : $base; } public function namespacedPrefix(string $namespace, string $classKey, ?string $tag = null): string { - return "{$namespace}:{{$classKey}}:" . $this->tagSegment($tag); + return "{$namespace}:{$classKey}:" . $this->tagSegment($tag); } public function pivotBasePrefix(string $parentKey, string $relatedKey): string { - return self::K_PIVOT . ':{' . $parentKey . '}:' . $relatedKey . ':'; + return self::K_PIVOT . ':' . $parentKey . ':' . $relatedKey . ':'; } public function pivotPrefix(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg): string @@ -71,12 +71,12 @@ public function pivotPrefix(string $parentKey, string $relatedKey, string $relat public function buildingPrefix(string $classKey): string { - return self::K_BUILDING . ':{' . $classKey . '}:'; + return self::K_BUILDING . ':' . $classKey . ':'; } public function wakePrefix(string $classKey): string { - return self::K_WAKE . ':{' . $classKey . '}:'; + return self::K_WAKE . ':' . $classKey . ':'; } // ------------------------------------------------------------------------- @@ -118,17 +118,17 @@ public function tableKey(string $connectionName, string $table): string public function verKey(string $classKey): string { - return self::K_VER . ':{' . $classKey . '}:'; + return self::K_VER . ':' . $classKey . ':'; } public function scheduledKey(string $classKey): string { - return self::K_SCHEDULED . ':{' . $classKey . '}:'; + return self::K_SCHEDULED . ':' . $classKey . ':'; } public function wakeKey(string $classKey, string $lockSuffix): string { - return self::K_WAKE . ':{' . $classKey . '}:' . $lockSuffix; + return self::K_WAKE . ':' . $classKey . ':' . $lockSuffix; } // ------------------------------------------------------------------------- @@ -183,11 +183,9 @@ public function versionsFromSegment(string $seg): array public function buildingToWakeKey(string $buildingKey): string { - $classKeyEnd = strpos($buildingKey, '}:') + 2; + $parts = explode(':', $buildingKey); - return self::K_WAKE - . substr($buildingKey, strlen(self::K_BUILDING), $classKeyEnd - strlen(self::K_BUILDING)) - . substr(strrchr($buildingKey, ':'), 1); + return self::K_WAKE . ':' . $parts[1] . ':' . $parts[2] . ':' . end($parts); } // ------------------------------------------------------------------------- diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index f0ab0a6..d303c2c 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -115,11 +115,11 @@ 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 . ':*', + 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 . ':*', ]); } diff --git a/tests/Integration/Cache/OptimizationsTest.php b/tests/Integration/Cache/OptimizationsTest.php index 51c18c5..7343b6e 100644 --- a/tests/Integration/Cache/OptimizationsTest.php +++ b/tests/Integration/Cache/OptimizationsTest.php @@ -103,7 +103,7 @@ public function test_corrupt_query_cache_payload_degrades_to_miss_and_repairs(): $store = app('normcache')->getStore(); Redis::connection(config('normcache.connection'))->set( - $store->prefix("query:{{$classKey}}:v{$version}:{$hash}"), + $store->prefix("query:{$classKey}:v{$version}:{$hash}"), '{not-json' ); @@ -114,7 +114,7 @@ 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 = app('normcache')->getStore()->getRaw("query:{$classKey}:v{$version}:{$hash}"); $repaired = $raw !== null ? json_decode($raw, true) : null; $this->assertSame([(string) $found->first()->id], $repaired); } diff --git a/tests/Integration/Cache/ProjectionBypassTest.php b/tests/Integration/Cache/ProjectionBypassTest.php index 1d8ce8f..e6b3999 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('test: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('test:query:testing:authors:*')); } // ── BelongsToMany ───────────────────────────────────────────────────────── diff --git a/tests/Integration/Infrastructure/CacheEventsTest.php b/tests/Integration/Infrastructure/CacheEventsTest.php index f89fdd6..7f31b07 100644 --- a/tests/Integration/Infrastructure/CacheEventsTest.php +++ b/tests/Integration/Infrastructure/CacheEventsTest.php @@ -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, 'query:' . app('normcache')->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, 'result:' . app('normcache')->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, 'through:' . app('normcache')->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, 'through:' . app('normcache')->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, 'result:' . app('normcache')->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, 'result:' . app('normcache')->classKey(Author::class) . ':'); }); } } diff --git a/tests/Integration/Infrastructure/ClusterModeTest.php b/tests/Integration/Infrastructure/ClusterModeTest.php index 09daa55..0ccd00a 100644 --- a/tests/Integration/Infrastructure/ClusterModeTest.php +++ b/tests/Integration/Infrastructure/ClusterModeTest.php @@ -256,8 +256,8 @@ public function test_prefix_sensitive_scan_and_flush_operations_work_in_cluster_ } $this->cacheManager()->flushTag(Author::class, 'homepage'); - $this->assertEmpty($this->redisKeys('test:query:{testing:authors}:homepage:*'), 'author tagged keys must be gone'); - $this->assertNotEmpty($this->redisKeys('test:query:{testing:posts}:homepage:*'), 'post tagged keys must survive'); + $this->assertEmpty($this->redisKeys('test:query:testing:authors:homepage:*'), 'author tagged keys must be gone'); + $this->assertNotEmpty($this->redisKeys('test:query:testing:posts:homepage:*'), 'post tagged keys must survive'); $this->cacheManager()->flushTagAcrossModels('homepage'); $this->assertEmpty($this->redisKeys('test:query:*:homepage:*')); @@ -316,7 +316,7 @@ public function test_version_key_has_ttl_in_cluster_mode(): void $redis = Redis::connection('normcache-test'); $classKey = $this->cacheManager()->classKey(Author::class); - $verKey = '{nc}:test:ver:{' . $classKey . '}:'; + $verKey = '{nc}:test:ver:' . $classKey . ':'; $ttl = $redis->ttl($verKey); diff --git a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php index 4c7297b..586314c 100644 --- a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php +++ b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php @@ -52,7 +52,7 @@ public function test_corrupt_query_entry_is_deleted_and_treated_as_miss(): void 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 @@ -79,12 +79,12 @@ public function test_pending_cooldown_fires_version_bump_on_read(): void // 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 @@ -96,12 +96,12 @@ public function test_non_numeric_scheduled_key_is_cleaned_up_without_version_bum Author::get(); $version = NormCache::currentVersion(Author::class); - $this->setKey("scheduled:{{$ck}}:", 'garbage'); + $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 7d6e160..898762e 100644 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php @@ -41,7 +41,7 @@ private function getKey(string $key): mixed private function bumpVersionInRedis(string $classKey, int $times = 1): void { for ($i = 0; $i < $times; $i++) { - $this->redis()->incr("{nc}:test:ver:{{$classKey}}:"); + $this->redis()->incr("{nc}:test:ver:{$classKey}:"); } } @@ -61,31 +61,31 @@ public function test_cooldown_fires_version_bump_on_standalone_version_resolutio { $ck = NormCache::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); - $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 @@ -99,7 +99,7 @@ public function test_building_key_in_deps_query_causes_db_fallthrough(): void $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) { @@ -133,14 +133,14 @@ public function test_cooldown_due_invalidation_applies_to_result_depends_on_cach $postClassKey = NormCache::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 @@ -231,7 +231,7 @@ public function test_pivot_result_write_is_skipped_when_dependency_version_chang $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"; + $pivotKey = "pivot:{$authorKey}:{$tagKey}:tags:manual-pivot-build:{$cache->seg}:1"; $this->bumpVersionInRedis($pivotTableKey); @@ -253,7 +253,7 @@ public function test_related_model_payload_is_not_cached_when_pivot_result_write $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"; + $pivotKey = "pivot:{$authorKey}:{$tagKey}:tags:manual-pivot-build:{$cache->seg}:1"; $this->bumpVersionInRedis($tagKey); @@ -290,11 +290,11 @@ public function test_cooldown_due_invalidation_applies_to_scalar_cache(): void $postClassKey = NormCache::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 @@ -315,7 +315,7 @@ public function test_cooldown_due_invalidation_applies_to_pivot_cache(): void $pivotTableKey = NormCache::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()); } @@ -336,11 +336,11 @@ public function test_cooldown_due_invalidation_applies_to_aggregate_cache(): voi $postClassKey = NormCache::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 diff --git a/tests/Integration/Infrastructure/StampedeProtectionTest.php b/tests/Integration/Infrastructure/StampedeProtectionTest.php index dc9775b..184cf93 100644 --- a/tests/Integration/Infrastructure/StampedeProtectionTest.php +++ b/tests/Integration/Infrastructure/StampedeProtectionTest.php @@ -51,12 +51,12 @@ public function test_waiter_serves_from_cache_after_build_completes(): void Author::get(); - $this->redis()->incr("{nc}:test:ver:{{$ck}}:"); + $this->redis()->incr("{nc}:test:ver:{$ck}:"); $newVersion = NormCache::currentVersion(Author::class); - $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); + $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) { @@ -76,9 +76,9 @@ public function test_budget_exhausted_falls_through_to_db(): void $ck = NormCache::classKey(Author::class); $hash = $this->authorQueryHash(); - $this->redis()->incr("{nc}:test:ver:{{$ck}}:"); + $this->redis()->incr("{nc}:test:ver:{$ck}:"); $newVersion = NormCache::currentVersion(Author::class); - $this->redis()->set("{nc}:test:building:{{$ck}}:v{$newVersion}:{$hash}", '1'); + $this->redis()->set("{nc}:test:building:{$ck}:v{$newVersion}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -98,9 +98,9 @@ public function test_lock_holder_crash_falls_through_after_budget(): void $ck = NormCache::classKey(Author::class); $hash = $this->authorQueryHash(); - $this->redis()->incr("{nc}:test:ver:{{$ck}}:"); + $this->redis()->incr("{nc}:test:ver:{$ck}:"); $newVersion = NormCache::currentVersion(Author::class); - $this->redis()->set("{nc}:test:building:{{$ck}}:v{$newVersion}:{$hash}", '1'); + $this->redis()->set("{nc}:test:building:{$ck}:v{$newVersion}:{$hash}", '1'); $queryCount = 0; DB::listen(function () use (&$queryCount) { @@ -130,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("{nc}: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 bf60900..71db97b 100644 --- a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php +++ b/tests/Integration/Infrastructure/StringPrimaryKeyTest.php @@ -119,7 +119,7 @@ public function test_version_key_has_ttl_after_invalidation(): void $redis = Redis::connection('normcache-test'); $classKey = $this->cacheManager()->classKey(UuidItem::class); - $verKey = '{nc}:test:ver:{' . $classKey . '}:'; + $verKey = '{nc}:test:ver:' . $classKey . ':'; $ttl = $redis->ttl($verKey); @@ -133,7 +133,7 @@ public function test_version_key_ttl_exceeds_longest_payload_ttl(): void $redis = Redis::connection('normcache-test'); $classKey = $this->cacheManager()->classKey(UuidItem::class); - $verKey = '{nc}:test:ver:{' . $classKey . '}:'; + $verKey = '{nc}:test:ver:' . $classKey . ':'; $verTtl = $redis->ttl($verKey); $modelTtl = (int) config('normcache.ttl'); diff --git a/tests/Integration/RelationOverrideTest.php b/tests/Integration/RelationOverrideTest.php index 2d3cf50..eb99a00 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('test:query:*:posts:*'), 'Expected simple hasMany eager load to populate the normalized query-id cache' ); $this->assertNotEmpty( - $this->redisKeys('test:model:{*posts}:*'), + $this->redisKeys('test:model:*:posts:*'), 'Expected simple hasMany eager load to populate the per-id model cache' ); } diff --git a/tests/TestCase.php b/tests/TestCase.php index 5aedafa..f86ddf4 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -142,7 +142,7 @@ private function currentModelKey(CacheManager $manager, string $class, mixed $id $classKey = $manager->classKey($class); $version = $manager->currentVersion($class); - return 'model:{' . $classKey . '}:v' . $version . ':' . $id; + return 'model:' . $classKey . ':v' . $version . ':' . $id; } protected function redisKeys(string $pattern = '*'): array diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index 1dc7e1e..d12e62d 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -21,4 +21,33 @@ public function test_class_key_rejects_connection_name_containing_colon(): void $keys->classKey($model::class); } + + public function test_building_to_wake_key_carves_multi_version_key_without_braces(): void + { + $keys = new CacheKeyBuilder; + + $wake = $keys->buildingToWakeKey('building:mysql:posts:v12:v3:v9:abc123'); + + $this->assertSame('wake:mysql:posts:abc123', $wake); + } + + public function test_building_to_wake_key_carves_single_version_key(): void + { + $keys = new CacheKeyBuilder; + + $wake = $keys->buildingToWakeKey('building:mysql:posts:v1:deadbeef'); + + $this->assertSame('wake:mysql:posts:deadbeef', $wake); + } + + 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)); + } } diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index e840662..343c5f8 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -62,7 +62,7 @@ public function test_invalidate_version_schedules_once_with_cooldown(): void $manager = $this->buildManager(cooldown: 60); $redis = Redis::connection('normcache-test'); $classKey = $manager->classKey(Author::class); - $scheduledKey = "{nc}:test:scheduled:{{$classKey}}:"; + $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; $manager->invalidateVersion(new Author); $firstDueAt = $redis->get($scheduledKey); @@ -81,7 +81,7 @@ public function test_current_version_applies_due_scheduled_invalidation(): void $classKey = $manager->classKey(Author::class); Redis::connection('normcache-test')->set( - "{nc}:test:scheduled:{{$classKey}}:", + "{nc}:test:scheduled:{$classKey}:", (string) ((int) floor(microtime(true) * 1000) - 1000) ); @@ -90,7 +90,7 @@ 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('{nc}: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)); } @@ -102,10 +102,10 @@ public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void $classKey = $manager->classKey(Author::class); $store = $manager->getStore(); - $store->set("ver:{{$classKey}}:", 7, 60); + $store->set("ver:{$classKey}:", 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("{nc}:test:ver:{$classKey}:", $store->prefix("ver:{$classKey}:")); + $this->assertSame('7', Redis::connection('normcache-test')->get("{nc}:test:ver:{$classKey}:")); $this->assertSame(7, $manager->currentVersion(Author::class)); } @@ -202,7 +202,7 @@ public function test_scheduled_invalidation_key_persists_until_processed(): void $model = new Author; $classKey = $manager->classKey(Author::class); - $scheduledKey = "{nc}:test:scheduled:{{$classKey}}:"; + $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; $redis = Redis::connection('normcache-test'); $manager->invalidateVersion($model); From 72bb473847da76dc3120bd57462ff7fb4a524bf1 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sat, 27 Jun 2026 20:42:39 +1000 Subject: [PATCH 10/73] move slotting into cachebuilder --- src/CacheServiceProvider.php | 4 +- src/Support/CacheKeyBuilder.php | 52 +++++++++++++---- src/Support/RedisStore.php | 50 +++++++---------- src/Traits/HandlesInvalidation.php | 42 +++++++------- .../Cache/CacheableBuilderTest.php | 40 ++++++------- .../Cache/CrossTableDependencySafetyTest.php | 32 +++++------ tests/Integration/Cache/DependsOnTest.php | 56 +++++++++---------- .../Integration/Cache/JoinResultCacheTest.php | 24 ++++---- tests/Integration/Cache/ModelCachingTest.php | 6 +- .../Cache/ModelHydratorStampedeTest.php | 2 +- tests/Integration/Cache/OptimizationsTest.php | 20 +++---- tests/Integration/Cache/PivotCacheTest.php | 16 +++--- tests/Integration/Cache/PivotStampedeTest.php | 2 +- .../Cache/ProjectionBypassTest.php | 14 ++--- .../Integration/Cache/ScalarCollisionTest.php | 2 +- .../Integration/Cache/SimplificationTest.php | 12 ++-- .../Cache/StreamingOperationsTest.php | 4 +- .../Integration/Cache/ThroughRelationTest.php | 16 +++--- .../Integration/Cache/WhereHasCachingTest.php | 6 +- .../Contract/QueryCallbackContractTest.php | 4 +- .../Infrastructure/CacheEventsTest.php | 12 ++-- .../Infrastructure/ClusterModeTest.php | 40 ++++++------- .../LuaScriptConsistencyTest.php | 8 +-- tests/Integration/RelationOverrideTest.php | 8 +-- tests/TestCase.php | 12 ++-- tests/Unit/CacheKeyBuilderTest.php | 25 ++++++++- tests/Unit/CacheManagerTest.php | 28 +++++----- tests/Unit/RedisStoreTest.php | 55 +++++++++--------- 28 files changed, 315 insertions(+), 277 deletions(-) diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 8b5ef8c..ee3c937 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -42,8 +42,8 @@ public function register(): void $stampedeWaitMs = (int) config('normcache.stampede_wait_ms', 200); $stampedeWakeTokens = (int) config('normcache.stampede_wake_tokens', 64); - $store = new RedisStore($connection, $keyPrefix, '{nc}:', $stampedeWakeTokens); - $keys = new CacheKeyBuilder; + $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); + $store = new RedisStore($connection, $keys->nullKey(), $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); $engine = new ExecutionEngine; diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index d2d5feb..9468b64 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -38,30 +38,50 @@ class CacheKeyBuilder private static array $singleDepPairs = []; + public function __construct( + private readonly string $hashTagPrefix = '{nc}:', + private readonly string $keyPrefix = '', + ) {} + + private function full(string $body): string + { + return $this->hashTagPrefix . $this->keyPrefix . $body; + } + + public function prefixed(string $pattern): string + { + return $this->full($pattern); + } + + public function nullKey(): string + { + return $this->hashTagPrefix . 'null'; + } + // ------------------------------------------------------------------------- // Prefixes // ------------------------------------------------------------------------- public function modelPrefix(string $classKey, int|string $version): string { - return self::K_MODEL . ':' . $classKey . ':v' . $version . ':'; + return $this->full(self::K_MODEL . ':' . $classKey . ':v' . $version . ':'); } public function queryPrefix(string $classKey, ?string $tag = null): string { $base = self::K_QUERY . ':' . $classKey . ':'; - return $tag !== null ? $base . $tag . ':' : $base; + return $this->full($tag !== null ? $base . $tag . ':' : $base); } public function namespacedPrefix(string $namespace, string $classKey, ?string $tag = null): string { - return "{$namespace}:{$classKey}:" . $this->tagSegment($tag); + return $this->full("{$namespace}:{$classKey}:" . $this->tagSegment($tag)); } public function pivotBasePrefix(string $parentKey, string $relatedKey): string { - return self::K_PIVOT . ':' . $parentKey . ':' . $relatedKey . ':'; + return $this->full(self::K_PIVOT . ':' . $parentKey . ':' . $relatedKey . ':'); } public function pivotPrefix(string $parentKey, string $relatedKey, string $relation, string $constraintHash, string $seg): string @@ -71,12 +91,12 @@ public function pivotPrefix(string $parentKey, string $relatedKey, string $relat public function buildingPrefix(string $classKey): string { - return self::K_BUILDING . ':' . $classKey . ':'; + return $this->full(self::K_BUILDING . ':' . $classKey . ':'); } public function wakePrefix(string $classKey): string { - return self::K_WAKE . ':' . $classKey . ':'; + return $this->full(self::K_WAKE . ':' . $classKey . ':'); } // ------------------------------------------------------------------------- @@ -118,17 +138,17 @@ public function tableKey(string $connectionName, string $table): string public function verKey(string $classKey): string { - return self::K_VER . ':' . $classKey . ':'; + return $this->full(self::K_VER . ':' . $classKey . ':'); } public function scheduledKey(string $classKey): string { - return self::K_SCHEDULED . ':' . $classKey . ':'; + return $this->full(self::K_SCHEDULED . ':' . $classKey . ':'); } public function wakeKey(string $classKey, string $lockSuffix): string { - return self::K_WAKE . ':' . $classKey . ':' . $lockSuffix; + return $this->full(self::K_WAKE . ':' . $classKey . ':' . $lockSuffix); } // ------------------------------------------------------------------------- @@ -183,9 +203,19 @@ public function versionsFromSegment(string $seg): array public function buildingToWakeKey(string $buildingKey): string { - $parts = explode(':', $buildingKey); + $keyword = self::K_BUILDING . ':'; + $pos = strpos($buildingKey, $keyword); + $afterKeyword = $pos + strlen($keyword); + + $connEnd = strpos($buildingKey, ':', $afterKeyword); + $tableEnd = strpos($buildingKey, ':', $connEnd + 1); + + $head = substr($buildingKey, 0, $tableEnd); + $head = substr_replace($head, self::K_WAKE, $pos, strlen(self::K_BUILDING)); + + $hash = substr(strrchr($buildingKey, ':'), 1); - return self::K_WAKE . ':' . $parts[1] . ':' . $parts[2] . ':' . end($parts); + return $head . ':' . $hash; } // ------------------------------------------------------------------------- diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index d41dfaf..7ab95df 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -21,8 +21,7 @@ final class RedisStore public function __construct( string $redisConnection, - private string $keyPrefix, - private string $hashTagPrefix = '{nc}:', + private string $nullKey = '{nc}:null', private int $wakeTokenCount = 64, ) { $this->serializer = new CacheSerializer; @@ -33,19 +32,9 @@ public function __construct( // Operations — singular // ------------------------------------------------------------------------- - public function prefix(string $key): string - { - return $this->hashTagPrefix . $this->keyPrefix . $key; - } - - public function hashTagPrefix(): string - { - return $this->hashTagPrefix; - } - 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; } @@ -53,7 +42,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; } @@ -66,13 +55,13 @@ 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); } // SET NX EX — returns true if the lock was claimed. @@ -89,13 +78,13 @@ 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. @@ -116,7 +105,7 @@ 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; } @@ -130,7 +119,7 @@ public function storeRawAndRelease(string $key, string $value, int $ttl, ?string 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 @@ -146,7 +135,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; } @@ -170,7 +159,7 @@ private function mgetValues(array $keys, bool $unserialize): array $prefixed = []; foreach ($keys as $key) { - $prefixed[] = $this->prefix($key); + $prefixed[] = $key; } $raw = $this->connection->mget($prefixed); @@ -200,7 +189,7 @@ public function setMany(array $pairs, int $ttl): void if ($this->isCluster()) { foreach ($pairs as $key => $value) { - $this->connection->setex($this->prefix($key), $ttl, $this->serialize($value)); + $this->connection->setex($key, $ttl, $this->serialize($value)); } return; @@ -208,7 +197,7 @@ public function setMany(array $pairs, int $ttl): void $this->connection->pipeline(function ($pipe) use ($pairs, $ttl) { foreach ($pairs as $key => $value) { - $pipe->setex($this->prefix($key), $ttl, $this->serialize($value)); + $pipe->setex($key, $ttl, $this->serialize($value)); } }); } @@ -292,19 +281,18 @@ public function flushByPatterns(array $patterns): int return $total; } - // Prefixes $keys before passing them to EVALSHA, falling back to EVAL on NOSCRIPT. + // Passes $keys to EVALSHA as-is, falling back to EVAL on NOSCRIPT. public function script(string $script, array $keys, array $args = []): mixed { $prefixedKeys = []; foreach ($keys as $key) { - $prefixedKeys[] = $key === '' ? '' : $this->prefix($key); + $prefixedKeys[] = $key; } if ($this->isCluster()) { - $nullKey = rtrim($this->hashTagPrefix, ':') . ':null'; foreach ($prefixedKeys as $i => $prefixedKey) { if ($prefixedKey === '') { - $prefixedKeys[$i] = $nullKey; + $prefixedKeys[$i] = $this->nullKey; } } } @@ -421,7 +409,7 @@ public function scanPattern(string $pattern): array private function keysForPattern(string $pattern): array { - return $this->scanPattern($this->prefix($pattern)); + return $this->scanPattern($pattern); } private function scanKeys(string $pattern): array diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index d303c2c..8d85230 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -94,17 +94,17 @@ public function forceFlushModel(string $modelClass): void public function flushAll(): int { return $this->store->flushByPatterns([ - CacheKeyBuilder::K_QUERY . ':*', - CacheKeyBuilder::K_MODEL . ':*', - CacheKeyBuilder::K_VER . ':*', - CacheKeyBuilder::K_COUNT . ':*', - CacheKeyBuilder::K_SCALAR . ':*', - CacheKeyBuilder::K_PIVOT . ':*', - CacheKeyBuilder::K_THROUGH . ':*', - CacheKeyBuilder::K_SCHEDULED . ':*', - CacheKeyBuilder::K_BUILDING . ':*', - CacheKeyBuilder::K_WAKE . ':*', - CacheKeyBuilder::K_RESULT . ':*', + $this->keys->prefixed(CacheKeyBuilder::K_QUERY . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_MODEL . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_VER . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_COUNT . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_SCALAR . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_PIVOT . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_THROUGH . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_SCHEDULED . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_BUILDING . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_WAKE . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_RESULT . ':*'), ]); } @@ -115,11 +115,11 @@ 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 . ':*', + $this->keys->prefixed(CacheKeyBuilder::K_RESULT . ':' . $classKey . ':' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_QUERY . ':' . $classKey . ':' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_COUNT . ':' . $classKey . ':' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_SCALAR . ':' . $classKey . ':' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_THROUGH . ':' . $classKey . ':' . $tag . ':*'), ]); } @@ -128,11 +128,11 @@ public function flushTagAcrossModels(string $tag): int CacheKeyBuilder::assertValidTag($tag); return $this->store->flushByPatterns([ - CacheKeyBuilder::K_RESULT . ':*:' . $tag . ':*', - CacheKeyBuilder::K_QUERY . ':*:' . $tag . ':*', - CacheKeyBuilder::K_COUNT . ':*:' . $tag . ':*', - CacheKeyBuilder::K_SCALAR . ':*:' . $tag . ':*', - CacheKeyBuilder::K_THROUGH . ':*:' . $tag . ':*', + $this->keys->prefixed(CacheKeyBuilder::K_RESULT . ':*:' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_QUERY . ':*:' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_COUNT . ':*:' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_SCALAR . ':*:' . $tag . ':*'), + $this->keys->prefixed(CacheKeyBuilder::K_THROUGH . ':*:' . $tag . ':*'), ]); } diff --git a/tests/Integration/Cache/CacheableBuilderTest.php b/tests/Integration/Cache/CacheableBuilderTest.php index 1bdd5bc..5e5b205 100644 --- a/tests/Integration/Cache/CacheableBuilderTest.php +++ b/tests/Integration/Cache/CacheableBuilderTest.php @@ -28,7 +28,7 @@ public function test_get_writes_query_cache_key(): void Author::create(['name' => 'Alice']); Author::all(); - $this->assertNotEmpty($this->redisKeys('test:query:*')); + $this->assertNotEmpty($this->redisKeys('query:*')); } public function test_without_cache_writes_no_query_keys(): void @@ -36,7 +36,7 @@ public function test_without_cache_writes_no_query_keys(): void Author::create(['name' => 'Alice']); Author::withoutCache()->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_uncached_get_accepts_string_columns(): void @@ -54,7 +54,7 @@ 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)); @@ -67,7 +67,7 @@ public function test_query_with_join_bypasses_cache(): void ->join('posts', 'posts.author_id', '=', 'authors.id') ->get(); - $this->assertEmpty($this->redisKeys('test:query:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_query_with_group_by_bypasses_cache(): void @@ -75,7 +75,7 @@ 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 +84,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,7 +92,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:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_subquery_where_has_reflects_related_model_writes(): void @@ -200,7 +200,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 +254,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,7 +265,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:*')); + $this->assertEmpty($this->redisKeys('query:*')); } public function test_single_primary_key_query_with_order_uses_model_cache_without_query_cache(): void @@ -276,7 +276,7 @@ public function test_single_primary_key_query_with_order_uses_model_cache_withou $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 +286,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 +321,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 +399,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 @@ -726,8 +726,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 +748,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 +766,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 +789,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 +813,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); diff --git a/tests/Integration/Cache/CrossTableDependencySafetyTest.php b/tests/Integration/Cache/CrossTableDependencySafetyTest.php index 3430f86..cc00556 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,7 +97,7 @@ 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 @@ -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->assertEmpty($this->redisKeys('result:*')); } public function test_simple_aggregate_constraint_still_infers_dependencies(): void @@ -119,7 +119,7 @@ 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 @@ -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->assertEmpty($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 b63f827..e05587d 100644 --- a/tests/Integration/Cache/DependsOnTest.php +++ b/tests/Integration/Cache/DependsOnTest.php @@ -23,8 +23,8 @@ public function test_simple_depends_on_query_uses_normalized_query_cache(): void 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 @@ -80,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), @@ -99,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); @@ -131,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') @@ -172,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]); @@ -182,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)); @@ -230,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 @@ -291,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 @@ -303,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 @@ -315,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 @@ -328,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 @@ -398,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 @@ -407,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 @@ -419,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 @@ -431,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); @@ -449,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 @@ -464,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 @@ -474,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 @@ -604,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 @@ -645,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..9baf36b 100644 --- a/tests/Integration/Cache/JoinResultCacheTest.php +++ b/tests/Integration/Cache/JoinResultCacheTest.php @@ -27,7 +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:*')); + $this->assertEmpty($this->redisKeys('result:*')); } public function test_join_with_depends_on_and_no_explicit_select_returns_correct_results(): void @@ -54,7 +54,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 +94,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 +106,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 +142,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 +155,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 +181,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 +197,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 +229,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 +296,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 +307,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 40d8cd5..98dbee6 100644 --- a/tests/Integration/Cache/ModelCachingTest.php +++ b/tests/Integration/Cache/ModelCachingTest.php @@ -20,7 +20,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,11 +28,11 @@ 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 diff --git a/tests/Integration/Cache/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index bc167ae..37fac49 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -42,7 +42,7 @@ public function test_falls_back_to_database_without_releasing_someone_elses_lock $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); diff --git a/tests/Integration/Cache/OptimizationsTest.php b/tests/Integration/Cache/OptimizationsTest.php index 7343b6e..403e8a8 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')); - $store = app('normcache')->getStore(); + $manager = app('normcache'); - $keys = $redis->keys($store->prefix('query:*')); + $keys = $redis->keys($manager->keys()->prefixed('query:*')); $this->assertNotEmpty($keys); $value = $redis->get($keys[0]); @@ -101,9 +101,11 @@ public function test_corrupt_query_cache_payload_degrades_to_miss_and_repairs(): $classKey = app('normcache')->classKey(Author::class); $version = app('normcache')->currentVersion(Author::class); - $store = app('normcache')->getStore(); + $manager = app('normcache'); + $store = $manager->getStore(); + $fullQueryKey = $manager->keys()->prefixed("query:{$classKey}:v{$version}:{$hash}"); Redis::connection(config('normcache.connection'))->set( - $store->prefix("query:{$classKey}:v{$version}:{$hash}"), + $fullQueryKey, '{not-json' ); @@ -114,7 +116,7 @@ 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); } @@ -136,7 +138,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'); @@ -149,9 +151,7 @@ public function test_multi_dependency_query_corrupt_payload_degrades_to_miss_and Event::assertDispatched(QueryCacheMiss::class); $store = app('normcache')->getStore(); - $raw = $store->getRaw( - str_replace($store->prefix(''), '', $queryKey) - ); + $raw = $store->getRaw($queryKey); $repaired = $raw !== null ? json_decode($raw, true) : null; $this->assertSame([(string) $found->first()->id], $repaired); } @@ -246,6 +246,6 @@ public function test_where_key_ignores_fast_path_when_extra_dependencies_exist() $builder = Author::whereKey($a1->id)->dependsOn([Post::class]); $builder->get(); - $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 5ad5a4b..dce8f2a 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 @@ -146,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(); @@ -236,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']); @@ -246,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()); } @@ -317,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 @@ -534,7 +534,7 @@ 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'); } @@ -579,7 +579,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]; @@ -613,6 +613,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 ba207e2..0b8fd74 100644 --- a/tests/Integration/Cache/PivotStampedeTest.php +++ b/tests/Integration/Cache/PivotStampedeTest.php @@ -34,7 +34,7 @@ public function test_pivot_build_lock_is_released_after_store_and_visible_to_nex $miss = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); $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( diff --git a/tests/Integration/Cache/ProjectionBypassTest.php b/tests/Integration/Cache/ProjectionBypassTest.php index e6b3999..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:testing: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:testing: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 5cce391..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(); @@ -39,8 +39,8 @@ public function test_simple_query_with_depends_on_keeps_normalized_cache_and_hon // 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(); @@ -66,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 @@ -77,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/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..44ee73d 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()); diff --git a/tests/Integration/Cache/WhereHasCachingTest.php b/tests/Integration/Cache/WhereHasCachingTest.php index 932b836..f5dd0f2 100644 --- a/tests/Integration/Cache/WhereHasCachingTest.php +++ b/tests/Integration/Cache/WhereHasCachingTest.php @@ -41,7 +41,7 @@ public function test_simple_wherehas_hasmany_caches_correct_results(): void fn() => Author::withoutCache()->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 @@ -72,7 +72,7 @@ public function test_wherehas_with_safe_constraint_caches_correct_results(): voi fn() => Author::withoutCache()->whereHas('posts', fn($q) => $q->where('published', true))->get(), ); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_wherehas_with_safe_constraint_invalidates_on_dependency_change(): void @@ -195,7 +195,7 @@ public function test_wherehas_morphmany_caches_correct_results(): void fn() => Author::withoutCache()->whereHas('comments')->get(), ); - $this->assertNotEmpty($this->redisKeys('test:result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_wherehas_morphto_bails(): void 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/Infrastructure/CacheEventsTest.php b/tests/Integration/Infrastructure/CacheEventsTest.php index 7f31b07..6a37277 100644 --- a/tests/Integration/Infrastructure/CacheEventsTest.php +++ b/tests/Integration/Infrastructure/CacheEventsTest.php @@ -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')->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')->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')->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')->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')->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')->classKey(Author::class) . ':')); }); } } diff --git a/tests/Integration/Infrastructure/ClusterModeTest.php b/tests/Integration/Infrastructure/ClusterModeTest.php index 0ccd00a..1ed8e14 100644 --- a/tests/Integration/Infrastructure/ClusterModeTest.php +++ b/tests/Integration/Infrastructure/ClusterModeTest.php @@ -30,8 +30,8 @@ public function test_multi_dependency_normalized_query_stays_normalized_in_clust Author::query()->dependsOn([Post::class, Tag::class])->get(); - $this->assertNotEmpty($this->redisKeys('test:query:*'), 'Multi-dependency normalized queries should use query keys in cluster mode'); - $this->assertEmpty($this->redisKeys('test:result:*'), 'Multi-dependency normalized queries should not fall back to 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 @@ -195,11 +195,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 @@ -209,13 +209,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 @@ -227,12 +227,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 @@ -250,28 +250,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:*'), 'author tagged keys must be gone'); - $this->assertNotEmpty($this->redisKeys('test:query:testing:posts:homepage:*'), 'post tagged keys must survive'); + $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()->getStore()->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', ''); diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php index 898762e..f98219d 100644 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php @@ -127,7 +127,7 @@ 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]); @@ -283,8 +283,8 @@ 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]); @@ -308,7 +308,7 @@ 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); diff --git a/tests/Integration/RelationOverrideTest.php b/tests/Integration/RelationOverrideTest.php index eb99a00..c0861b8 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); @@ -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->assertEmpty($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 f86ddf4..e7138b1 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -134,7 +134,7 @@ protected function prefixedModelKey(string $class, mixed $id): string { $manager = $this->cacheManager(); - return $manager->getStore()->prefix($this->currentModelKey($manager, $class, $id)); + return $this->currentModelKey($manager, $class, $id); } private function currentModelKey(CacheManager $manager, string $class, mixed $id): string @@ -142,14 +142,14 @@ private function currentModelKey(CacheManager $manager, string $class, mixed $id $classKey = $manager->classKey($class); $version = $manager->currentVersion($class); - return 'model:' . $classKey . ':v' . $version . ':' . $id; + return $manager->keys()->modelPrefix($classKey, $version) . $id; } protected function redisKeys(string $pattern = '*'): array { - $store = $this->cacheManager()->getStore(); + $manager = $this->cacheManager(); - return $store->scanPattern($store->hashTagPrefix() . $pattern); + return $manager->getStore()->scanPattern($manager->keys()->prefixed($pattern)); } protected function cacheManager(): CacheManager @@ -184,8 +184,8 @@ protected function buildManager( $ttl ??= (int) config('normcache.ttl'); $queryTtl ??= (int) config('normcache.query_ttl'); - $store = new RedisStore($connection, $keyPrefix, '{nc}:', $stampedeWakeTokens); - $keys = new CacheKeyBuilder; + $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); + $store = new RedisStore($connection, $keys->nullKey(), $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); $engine = new ExecutionEngine; diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index d12e62d..aba9733 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -42,7 +42,7 @@ public function test_building_to_wake_key_carves_single_version_key(): void public function test_version_keys_are_brace_free(): void { - $keys = new CacheKeyBuilder; + $keys = new CacheKeyBuilder('', ''); $this->assertSame('ver:mysql:posts:', $keys->verKey('mysql:posts')); $this->assertSame('scheduled:mysql:posts:', $keys->scheduledKey('mysql:posts')); @@ -50,4 +50,27 @@ public function test_version_keys_are_brace_free(): void $this->assertSame('wake:mysql:posts:', $keys->wakePrefix('mysql:posts')); $this->assertSame('model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3)); } + + public function test_building_to_wake_key_is_prefix_agnostic_on_full_keys(): void + { + $keys = new CacheKeyBuilder; + + $wake = $keys->buildingToWakeKey('{nc}:test:building:mysql:posts:v12:v3:abc123'); + + $this->assertSame('{nc}:test:wake:mysql:posts:abc123', $wake); + } + + 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}:null', $keys->nullKey()); + $this->assertSame('{nc}:test:query:*', $keys->prefixed('query:*')); + } } diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 343c5f8..fc09f47 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -100,11 +100,13 @@ public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void $manager = $this->buildManager(); $classKey = $manager->classKey(Author::class); - $store = $manager->getStore(); + $keys = $manager->keys(); + + $fullVerKey = $keys->verKey($classKey); + $this->assertSame("{nc}:test:ver:{$classKey}:", $fullVerKey); - $store->set("ver:{$classKey}:", 7, 60); + $manager->getStore()->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, $manager->currentVersion(Author::class)); } @@ -140,17 +142,17 @@ public function test_flush_all_removes_all_package_keys_and_returns_count(): voi $store = $this->manager->getStore(); $postsKey = DB::getDefaultConnection() . ':posts'; - $store->set("query:{{$postsKey}}:v1:abc", [1, 2], 3600); - $store->set("model:{{$postsKey}}:v0: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); + $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(6, $deleted); - $this->assertEmpty($this->redisKeys('test:*')); + $this->assertEmpty($this->redisKeys('*')); } public function test_flush_all_returns_zero_when_cache_is_empty(): void @@ -168,9 +170,9 @@ public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enab $store = $manager->getStore(); $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(); diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index d423c40..a4e43fb 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -18,12 +18,7 @@ class RedisStoreTest extends TestCase protected function setUp(): void { parent::setUp(); - $this->store = new RedisStore('normcache-test', 'test:', '{nc}:'); - } - - public function test_it_prefixes_keys(): void - { - $this->assertSame('{nc}:test:foo', $this->store->prefix('foo')); + $this->store = new RedisStore('normcache-test'); } public function test_it_can_set_and_get_values(): void @@ -67,7 +62,7 @@ public function test_it_can_release_building_locks(): void public function test_release_building_pushes_configured_wake_tokens(): void { - $store = new RedisStore('normcache-test', 'test:', '{nc}:', wakeTokenCount: 3); + $store = new RedisStore('normcache-test', wakeTokenCount: 3); $store->set('build:tokens', '1', 60); $store->releaseBuilding('build:tokens', 'wake:tokens'); @@ -227,10 +222,10 @@ public function test_scan_pattern_strips_connection_prefix_from_returned_keys(): Redis::purge('normcache-test'); try { - $store = new RedisStore('normcache-test', 'test:', ''); - $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:*'); @@ -255,17 +250,17 @@ public function test_flush_by_patterns_works_with_connection_prefix(): void Redis::purge('normcache-test'); try { - $store = new RedisStore('normcache-test', 'test:', ''); - $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', ''); @@ -321,10 +316,10 @@ public function unlink($keys) } }; - $store = new RedisStore('normcache-test', 'test:', '{nc}:'); + $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([ @@ -353,23 +348,23 @@ 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:', ''); + $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_deduplicates_keys_returned_by_multiple_nodes(): void @@ -413,7 +408,7 @@ public function scan($cursor, array $options) } }; - $store = new RedisStore('normcache-test', '', ''); + $store = new RedisStore('normcache-test'); (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); $this->assertSame( @@ -428,7 +423,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', '', ''); + $store = new RedisStore('normcache-test'); $data = ['x' => 1]; $this->assertSame($data, $store->unserialize(igbinary_serialize($data))); @@ -452,7 +447,7 @@ public function test_eval_returns_correct_result_on_first_call(): void { (new ReflectionProperty(RedisStore::class, 'shas'))->setValue(null, []); - $store = new RedisStore('normcache-test', '', ''); + $store = new RedisStore('normcache-test'); $script = RedisScripts::get('fetch_version_with_cooldown'); Redis::connection('normcache-test')->setex('ver:{authors}:', 60, '7'); @@ -466,7 +461,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', '', ''); + $store = new RedisStore('normcache-test'); $script = RedisScripts::get('fetch_version_with_cooldown'); $this->assertArrayNotHasKey($script, (new ReflectionProperty(RedisStore::class, 'shas'))->getValue()); @@ -485,7 +480,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', '', ''); + $store = new RedisStore('normcache-test'); $serializer = (new ReflectionProperty($store, 'serializer'))->getValue($store); (new ReflectionProperty($serializer, 'igbinary'))->setValue($serializer, false); From f3ab0558078c12dbcef580417d687afbc62239fa Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:36:15 +1000 Subject: [PATCH 11/73] resolve and validate cache space per query --- config/normcache.php | 9 ++ src/CacheServiceProvider.php | 10 ++ src/CacheableBuilder.php | 14 ++ src/Planning/CachePlanner.php | 51 +++++- src/Spaces/CacheSpaceRegistry.php | 146 ++++++++++++++++++ src/Spaces/CacheSpaceResolver.php | 28 ++++ src/Traits/Cacheable.php | 7 + src/Values/CacheSpace.php | 12 ++ src/Values/SpaceValidationResult.php | 21 +++ tests/Fixtures/Models/SpacedPost.php | 17 ++ .../Integration/Cache/SpaceResolutionTest.php | 44 ++++++ tests/Unit/Spaces/CacheSpaceRegistryTest.php | 111 +++++++++++++ tests/Unit/Spaces/CacheSpaceResolverTest.php | 40 +++++ .../Spaces/CacheableSpacesDeclarationTest.php | 20 +++ 14 files changed, 529 insertions(+), 1 deletion(-) create mode 100644 src/Spaces/CacheSpaceRegistry.php create mode 100644 src/Spaces/CacheSpaceResolver.php create mode 100644 src/Values/CacheSpace.php create mode 100644 src/Values/SpaceValidationResult.php create mode 100644 tests/Fixtures/Models/SpacedPost.php create mode 100644 tests/Integration/Cache/SpaceResolutionTest.php create mode 100644 tests/Unit/Spaces/CacheSpaceRegistryTest.php create mode 100644 tests/Unit/Spaces/CacheSpaceResolverTest.php create mode 100644 tests/Unit/Spaces/CacheableSpacesDeclarationTest.php diff --git a/config/normcache.php b/config/normcache.php index 5c4b045..c99fda3 100644 --- a/config/normcache.php +++ b/config/normcache.php @@ -39,4 +39,13 @@ // Register the Laravel Debugbar collector for local cache inspection. 'debugbar' => env('NORMCACHE_DEBUGBAR', false), + // Redis Cluster sharding via cache spaces (see docs/space.md). Models declare + // membership with `protected static array $normCacheSpaces`. + 'spaces' => [ + // Cap on spaces per model; each write bumps a version key per space. + 'max_per_model' => 16, + + // When a query's dependencies span spaces: 'bypass' (skip caching) or 'throw'. + 'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'), + ], ]; diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index ee3c937..9abdee6 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -18,6 +18,8 @@ use NormCache\Console\FlushCommand; use NormCache\Debug\NormCacheCollector; use NormCache\Debug\NormCacheDebugBarCollector; +use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Spaces\CacheSpaceResolver; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisStore; use NormCache\Values\CacheConfig; @@ -28,6 +30,14 @@ public function register(): void { $this->mergeConfigFrom(__DIR__ . '/../config/normcache.php', 'normcache'); + $this->app->singleton(CacheSpaceRegistry::class, function () { + return new CacheSpaceRegistry((int) config('normcache.spaces.max_per_model', 16)); + }); + + $this->app->singleton(CacheSpaceResolver::class, function ($app) { + return new CacheSpaceResolver($app->make(CacheSpaceRegistry::class)); + }); + $this->app->singleton(CacheManager::class, function () { $connection = config('normcache.connection'); $ttl = (int) config('normcache.ttl'); diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index b990049..caabcae 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -51,6 +51,8 @@ class CacheableBuilder extends Builder private ?string $cacheTag = null; + private ?string $cacheSpace = null; + // ------------------------------------------------------------------------- // Configuration // ------------------------------------------------------------------------- @@ -87,6 +89,18 @@ 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)) { diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index c39c6d5..ace8da1 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -8,6 +8,8 @@ use NormCache\Enums\CacheOperation; use NormCache\Enums\PlanningMode; use NormCache\Facades\NormCache; +use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Spaces\CacheSpaceResolver; use NormCache\Values\CachePlan; use NormCache\Values\CachePlanContext; use NormCache\Values\DependencySet; @@ -61,7 +63,7 @@ public function plan( return $this->transactionBypass($builder, $model, $context); } - return match ($context->operation) { + $plan = 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, @@ -70,6 +72,53 @@ public function plan( CacheOperation::BelongsToEagerLoad, CacheOperation::MorphToEagerLoad => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), }; + + return $explain ? $plan : $this->applySpaceValidation($plan, $builder, $model, $context->operation); + } + + private ?CacheSpaceResolver $spaceResolver = null; + + private ?CacheSpaceRegistry $spaceRegistry = null; + + // Bypass (or throw) when a plan's dependencies don't co-locate in its cache space. + // Neutral for default-only apps: everything resolves to the default space. + private function applySpaceValidation( + CachePlan $plan, + CacheableBuilder $builder, + Model $model, + CacheOperation $operation, + ): CachePlan { + if (!$plan->isCacheable()) { + return $plan; + } + + $resolver = $this->spaceResolver ??= app(CacheSpaceResolver::class); + $registry = $this->spaceRegistry ??= app(CacheSpaceRegistry::class); + + $space = $resolver->resolve($model::class, $builder->getSpace()); + $validation = $registry->validateDependencies( + $space, + $plan->dependencies->models, + $plan->dependencies->tables, + ); + + if ($validation->ok) { + return $plan; + } + + $reasons = ['cross-space dependencies for space [' . $space->name . ']: ' + . implode(', ', [...$validation->invalidModels, ...$validation->invalidTables])]; + + if (config('normcache.spaces.cross_space_behavior', 'bypass') === 'throw') { + throw new \RuntimeException('NormCache: ' . $reasons[0]); + } + + return CachePlan::bypass( + operation: $operation, + dependencies: $plan->dependencies, + reasons: $reasons, + bypassReasons: ['dependency' => $reasons], + ); } private function globalBypass( diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php new file mode 100644 index 0000000..911585d --- /dev/null +++ b/src/Spaces/CacheSpaceRegistry.php @@ -0,0 +1,146 @@ + "nc", "" -> "nc:". +final class CacheSpaceRegistry +{ + public const DEFAULT_SPACE = 'default'; + + public const DEFAULT_HASH_TAG = 'nc'; + + /** @var array name => space (memoized) */ + private array $spaces = []; + + /** @var array> model class => spaces (memoized, validated) */ + private array $modelSpaces = []; + + public function __construct(private readonly int $maxPerModel = 16) {} + + public function defaultSpace(): CacheSpace + { + return $this->space(self::DEFAULT_SPACE); + } + + public function space(string $name): CacheSpace + { + return $this->spaces[$name] ??= new CacheSpace($name, $this->hashTagFor($name)); + } + + /** @return list */ + public function spacesForModel(string $modelClass): array + { + return $this->modelSpaces[$modelClass] ??= $this->resolveModelSpaces($modelClass); + } + + // Tables have no model to declare membership, so they belong to the default space. + /** @return list */ + public function spacesForTable(string $table): array + { + return [$this->defaultSpace()]; + } + + public function modelAllowedInSpace(string $modelClass, CacheSpace|string $space): bool + { + return $this->isAllowed($this->spacesForModel($modelClass), $space); + } + + public function tableAllowedInSpace(string $table, CacheSpace|string $space): bool + { + return $this->isAllowed($this->spacesForTable($table), $space); + } + + /** + * Validate a cached operation's dependencies against the active space. + * + * @param list $models + * @param list $tables + */ + public function validateDependencies(CacheSpace $space, array $models, array $tables): SpaceValidationResult + { + $invalidModels = []; + $invalidTables = []; + $dependenciesBySpace = []; + + foreach ($models as $modelClass) { + $dependenciesBySpace[$modelClass] = $this->spaceNames($this->spacesForModel($modelClass)); + if (!$this->modelAllowedInSpace($modelClass, $space)) { + $invalidModels[] = $modelClass; + } + } + + foreach ($tables as $table) { + $dependenciesBySpace[$table] = $this->spaceNames($this->spacesForTable($table)); + if (!$this->tableAllowedInSpace($table, $space)) { + $invalidTables[] = $table; + } + } + + return new SpaceValidationResult( + ok: $invalidModels === [] && $invalidTables === [], + space: $space, + invalidModels: $invalidModels, + invalidTables: $invalidTables, + dependenciesBySpace: $dependenciesBySpace, + ); + } + + /** @return list */ + private function resolveModelSpaces(string $modelClass): array + { + $names = 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_values(array_map(fn($name) => $this->space($name), $names)); + } + + private function hashTagFor(string $name): string + { + if ($name === '' || preg_match('/[:{}\s]/', $name)) { + throw new \InvalidArgumentException( + "Invalid cache space name [{$name}]: must be non-empty and contain no ':', '{', '}', or whitespace." + ); + } + + return $name === self::DEFAULT_SPACE + ? self::DEFAULT_HASH_TAG + : self::DEFAULT_HASH_TAG . ':' . $name; + } + + /** @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_values(array_map(fn(CacheSpace $s) => $s->name, $spaces)); + } +} diff --git a/src/Spaces/CacheSpaceResolver.php b/src/Spaces/CacheSpaceResolver.php new file mode 100644 index 0000000..9b6bbb7 --- /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/Traits/Cacheable.php b/src/Traits/Cacheable.php index 570b52f..8e020ef 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 // ------------------------------------------------------------------------- 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 @@ + $invalidModels + * @param list $invalidTables + * @param array> $dependenciesBySpace + */ + public function __construct( + public bool $ok, + public CacheSpace $space, + public array $invalidModels = [], + public array $invalidTables = [], + public array $dependenciesBySpace = [], + ) {} +} diff --git a/tests/Fixtures/Models/SpacedPost.php b/tests/Fixtures/Models/SpacedPost.php new file mode 100644 index 0000000..1ca2695 --- /dev/null +++ b/tests/Fixtures/Models/SpacedPost.php @@ -0,0 +1,17 @@ +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->assertTrue($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'); + } +} diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php new file mode 100644 index 0000000..f778cc6 --- /dev/null +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -0,0 +1,111 @@ +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_invalid_space_name_throws(): void + { + $this->expectException(\InvalidArgumentException::class); + + $this->registry()->space('has space'); + } + + 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 \Illuminate\Database\Eloquent\Model + { + use \NormCache\Traits\Cacheable; + + protected static array $normCacheSpaces = ['a', 'b', 'c']; + }; + + $this->expectException(\InvalidArgumentException::class); + + $this->registry(maxPerModel: 2)->spacesForModel($model::class); + } + + public function test_tables_belong_to_the_default_space(): void + { + $registry = $this->registry(); + + $this->assertSame(['default'], array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags'))); + $this->assertTrue($registry->tableAllowedInSpace('mysql:legacy_flags', 'default')); + $this->assertFalse($registry->tableAllowedInSpace('mysql:legacy_flags', 'content')); + } + + 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->ok); + $this->assertSame([], $result->invalidModels); + $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; depending on it inside content is invalid. + $result = $registry->validateDependencies($content, [SpacedPost::class, Author::class], ['mysql:legacy_flags']); + + $this->assertFalse($result->ok); + $this->assertSame([Author::class], $result->invalidModels); + $this->assertSame(['mysql:legacy_flags'], $result->invalidTables); + $this->assertSame(['default'], $result->dependenciesBySpace[Author::class]); + $this->assertSame(['content'], $result->dependenciesBySpace[SpacedPost::class]); + } +} diff --git a/tests/Unit/Spaces/CacheSpaceResolverTest.php b/tests/Unit/Spaces/CacheSpaceResolverTest.php new file mode 100644 index 0000000..d23fe63 --- /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..1dc4e54 --- /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()); + } +} From ccc72f67357da787ac7ede14a8d0abe682c24aa4 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 15:54:28 +1000 Subject: [PATCH 12/73] resolved space on the plan --- src/Planning/CachePlanner.php | 2 +- src/Support/CacheKeyBuilder.php | 89 ++++++++++--------- src/Values/CachePlan.php | 16 ++++ .../Integration/Cache/SpaceResolutionTest.php | 21 +++++ tests/Unit/CacheKeyBuilderTest.php | 21 +++++ 5 files changed, 108 insertions(+), 41 deletions(-) diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index ace8da1..71cd299 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -103,7 +103,7 @@ private function applySpaceValidation( ); if ($validation->ok) { - return $plan; + return $plan->withSpace($space); } $reasons = ['cross-space dependencies for space [' . $space->name . ']: ' diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 9468b64..594f3f8 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use NormCache\Cache\ModelHydrator; +use NormCache\Values\CacheSpace; class CacheKeyBuilder { @@ -43,60 +44,66 @@ public function __construct( private readonly string $keyPrefix = '', ) {} - private function full(string $body): string + private function full(string $body, ?CacheSpace $space = null): string { - return $this->hashTagPrefix . $this->keyPrefix . $body; + return $this->tagPrefix($space) . $this->keyPrefix . $body; } - public function prefixed(string $pattern): string + // Hash-tag prefix for a space ("{nc:content}:"), or the configured default ("{nc}:"). + private function tagPrefix(?CacheSpace $space): string { - return $this->full($pattern); + return $space === null ? $this->hashTagPrefix : '{' . $space->hashTag . '}:'; } - public function nullKey(): string + public function prefixed(string $pattern, ?CacheSpace $space = null): string { - return $this->hashTagPrefix . 'null'; + return $this->full($pattern, $space); + } + + public function nullKey(?CacheSpace $space = null): string + { + return $this->tagPrefix($space) . 'null'; } // ------------------------------------------------------------------------- // Prefixes // ------------------------------------------------------------------------- - public function modelPrefix(string $classKey, int|string $version): string + public function modelPrefix(string $classKey, int|string $version, ?CacheSpace $space = null): string { - return $this->full(self::K_MODEL . ':' . $classKey . ':v' . $version . ':'); + 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 . ':'; - return $this->full($tag !== null ? $base . $tag . ':' : $base); + 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 $this->full("{$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 $this->full(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 $this->full(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 $this->full(self::K_WAKE . ':' . $classKey . ':'); + return $this->full(self::K_WAKE . ':' . $classKey . ':', $space); } // ------------------------------------------------------------------------- @@ -136,43 +143,43 @@ public function tableKey(string $connectionName, string $table): string return "{$connectionName}:{$table}"; } - public function verKey(string $classKey): string + public function verKey(string $classKey, ?CacheSpace $space = null): string { - return $this->full(self::K_VER . ':' . $classKey . ':'); + return $this->full(self::K_VER . ':' . $classKey . ':', $space); } - public function scheduledKey(string $classKey): string + public function scheduledKey(string $classKey, ?CacheSpace $space = null): string { - return $this->full(self::K_SCHEDULED . ':' . $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 $this->full(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) . 'v' . $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; + return $this->pivotPrefix($parentKey, $relatedKey, $relation, $constraintHash, $seg, $space) . $parentId; } // ------------------------------------------------------------------------- @@ -225,12 +232,14 @@ 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): array { if ($depClasses === [] && $depTableKeys === []) { - return self::$singleDepPairs[$classKey] ??= [ - [$this->verKey($classKey)], - [$this->scheduledKey($classKey)], + $cacheKey = ($space?->name ?? '') . '|' . $classKey; + + return self::$singleDepPairs[$cacheKey] ??= [ + [$this->verKey($classKey, $space)], + [$this->scheduledKey($classKey, $space)], ]; } @@ -260,8 +269,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]; diff --git a/src/Values/CachePlan.php b/src/Values/CachePlan.php index 6c05713..d631182 100644 --- a/src/Values/CachePlan.php +++ b/src/Values/CachePlan.php @@ -20,8 +20,24 @@ public function __construct( 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, + normalizable: $this->normalizable, + columns: $this->columns, + primaryKeys: $this->primaryKeys, + reasons: $this->reasons, + bypassReasons: $this->bypassReasons, + space: $space, + ); + } + public static function normalized( CacheOperation $operation, DependencySet $dependencies, diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index a8f8273..9d9c09d 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -41,4 +41,25 @@ public function test_same_space_dependency_still_caches(): void $this->assertTrue($plan->isCacheable(), 'default-space deps must not be downgraded'); } + + 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); + } } diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index aba9733..f9d541e 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -5,9 +5,30 @@ use Illuminate\Database\Eloquent\Model; use NormCache\Support\CacheKeyBuilder; use NormCache\Tests\TestCase; +use NormCache\Values\CacheSpace; class CacheKeyBuilderTest extends TestCase { + public function test_key_methods_emit_the_space_hash_tag(): void + { + $keys = new CacheKeyBuilder('{nc}:', 'test:'); + $content = new CacheSpace('content', 'nc:content'); + + $this->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}:null', $keys->nullKey($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')); + $this->assertSame('{nc}:null', $keys->nullKey()); + } + public function test_class_key_rejects_connection_name_containing_colon(): void { $model = new class extends Model From 8fdf97251a69b22490cd496dbe796b6c6a9e0078 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:00:18 +1000 Subject: [PATCH 13/73] update version tracker --- src/Cache/VersionTracker.php | 13 +++++---- tests/Unit/Cache/VersionTrackerSpaceTest.php | 29 ++++++++++++++++++++ 2 files changed, 36 insertions(+), 6 deletions(-) create mode 100644 tests/Unit/Cache/VersionTrackerSpaceTest.php diff --git a/src/Cache/VersionTracker.php b/src/Cache/VersionTracker.php index 73d35c8..b04d86a 100644 --- a/src/Cache/VersionTracker.php +++ b/src/Cache/VersionTracker.php @@ -5,6 +5,7 @@ use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; +use NormCache\Values\CacheSpace; final class VersionTracker { @@ -13,17 +14,17 @@ public function __construct( private readonly CacheKeyBuilder $keys, ) {} - public function currentVersion(string $modelClass): int + public function currentVersion(string $modelClass, ?CacheSpace $space = null): int { return $this->normalizeVersion( - $this->fetchVersionWithCooldown($this->keys->classKey($modelClass)) + $this->fetchVersionWithCooldown($this->keys->classKey($modelClass), $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) ); } @@ -37,11 +38,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)], + [$this->keys->verKey($classKey, $space), $this->keys->scheduledKey($classKey, $space)], [(string) (int) floor(microtime(true) * 1000)] ); } diff --git a/tests/Unit/Cache/VersionTrackerSpaceTest.php b/tests/Unit/Cache/VersionTrackerSpaceTest.php new file mode 100644 index 0000000..6ccd25e --- /dev/null +++ b/tests/Unit/Cache/VersionTrackerSpaceTest.php @@ -0,0 +1,29 @@ +nullKey()); + $tracker = new VersionTracker($store, $keys); + + $content = new CacheSpace('content', 'nc:content'); + $classKey = $keys->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)); + } +} From e1e943b661b202c0535dda326a5966ef28f1688e Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:06:23 +1000 Subject: [PATCH 14/73] scope key building to the active space --- src/CacheableBuilder.php | 4 ++-- src/Support/CacheKeyBuilder.php | 19 ++++++++++++++++++- tests/Fixtures/Models/SpacedPost.php | 6 ++++-- .../Integration/Cache/SpaceResolutionTest.php | 14 ++++++++++++++ 4 files changed, 38 insertions(+), 5 deletions(-) diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index caabcae..a26a1d4 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -261,7 +261,7 @@ public function get($columns = ['*']): Collection selectAll: $columns === ['*'], )); - return match ($plan->strategy) { + return NormCache::keys()->withSpace($plan->space, fn() => match ($plan->strategy) { CacheStrategy::DirectModels => NormCache::rescue( fn() => $this->modelsExecutor()->runDirect($prepared, $plan->primaryKeys, $model, $plan->columns, $this->model), fn() => $this->getWithoutCacheFromPrepared($prepared, $columns), @@ -272,7 +272,7 @@ public function get($columns = ['*']): Collection ), CacheStrategy::VersionedResult => $this->executeResultQuery($prepared, $plan, $columns), CacheStrategy::LiveQuery => $this->bypassAndReturn($model, $plan->bypassReasons, $debugbarStart, $prepared, $columns), - }; + }); } private function executeResultQuery( diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 594f3f8..837cd1a 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -39,19 +39,36 @@ class CacheKeyBuilder 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; + } + } + private function full(string $body, ?CacheSpace $space = null): string { return $this->tagPrefix($space) . $this->keyPrefix . $body; } - // Hash-tag prefix for a space ("{nc:content}:"), or the configured default ("{nc}:"). + // 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 . '}:'; } diff --git a/tests/Fixtures/Models/SpacedPost.php b/tests/Fixtures/Models/SpacedPost.php index 1ca2695..55fc130 100644 --- a/tests/Fixtures/Models/SpacedPost.php +++ b/tests/Fixtures/Models/SpacedPost.php @@ -5,12 +5,14 @@ use Illuminate\Database\Eloquent\Model; use NormCache\Traits\Cacheable; -// Fixture declaring cache-space membership for registry tests. Never queried — -// only its static normCacheSpaces() declaration is read. +// Fixture declaring 'content' cache-space membership. Backed by the posts table so +// space wiring can be exercised end-to-end. class SpacedPost extends Model { use Cacheable; + protected $table = 'posts'; + protected $guarded = []; protected static array $normCacheSpaces = ['content']; diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 9d9c09d..8e6ca0f 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -62,4 +62,18 @@ public function test_default_model_plan_carries_default_space(): void $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()->getStore(); + + $this->assertNotEmpty( + $store->scanPattern('{nc:content}:*'), + 'SpacedPost (content) must write keys under the {nc:content} hash tag', + ); + } } From f01d347bd0160316c94a9cbe4b9a12b1d67ed0f8 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:17:27 +1000 Subject: [PATCH 15/73] space aware invalidation --- src/CacheableBuilder.php | 2 +- src/Traits/CachesScalarResults.php | 4 +- src/Traits/HandlesInvalidation.php | 77 +++++++++++++------ .../Integration/Cache/SpaceResolutionTest.php | 16 ++++ 4 files changed, 74 insertions(+), 25 deletions(-) diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index a26a1d4..86d9877 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -343,7 +343,7 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', } try { - $cachedTotal = $this->rememberPaginationTotal($prepared, $plan); + $cachedTotal = NormCache::keys()->withSpace($plan->space, fn() => $this->rememberPaginationTotal($prepared, $plan)); } catch (\Throwable $e) { NormCache::fallback($e); diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index 46bc002..f03220a 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -154,13 +154,13 @@ private function cacheScalar( return $computeValue(); } - $result = NormCache::result()->execute( + $result = NormCache::keys()->withSpace($plan->space, fn() => NormCache::result()->execute( $prepared, $plan, $kind, $columns, $computeValue - ); + )); return $result[0]; } diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 8d85230..ab8239f 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -5,8 +5,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use NormCache\CacheManager; +use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisScripts; +use NormCache\Values\CacheSpace; /** * @phpstan-require-extends CacheManager @@ -16,9 +18,23 @@ trait HandlesInvalidation /** @var array> */ private array $flushQueue = []; - /** @var array> */ + /** @var array>> conn => classKey => spaces */ private array $versionQueue = []; + private ?CacheSpaceRegistry $spaceRegistry = null; + + /** @return list the spaces a model's version key lives in */ + private function modelSpaces(string $modelClass): array + { + return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->spacesForModel($modelClass); + } + + /** @return list the spaces a table's version key lives in */ + private function tableSpaces(string $table): array + { + return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->spacesForTable($table); + } + // Invalidation public function invalidateVersion(Model $model): void @@ -31,7 +47,7 @@ public function invalidateVersion(Model $model): void $this->queueOrRun( $conn, - fn() => $this->queueVersionFlush($conn, $this->keys->classKey($model::class)), + fn() => $this->queueVersionFlush($conn, $this->keys->classKey($model::class), $this->modelSpaces($model::class)), fn() => $this->doInvalidateVersion($model::class), ); } @@ -65,7 +81,7 @@ public function flushInstance(Model $model): void $this->queueOrRun( $conn, - fn() => $this->queueVersionFlush($conn, $this->keys->classKey($class)), + fn() => $this->queueVersionFlush($conn, $this->keys->classKey($class), $this->modelSpaces($class)), fn() => $this->doInvalidateVersion($class), ); } @@ -80,15 +96,18 @@ public function invalidateTableVersion(string $connectionName, string $table): v $this->queueOrRun( $connectionName, - fn() => $this->queueVersionFlush($connectionName, $classKey), - fn() => $this->doInvalidateKey($classKey), + fn() => $this->queueVersionFlush($connectionName, $classKey, $this->tableSpaces($table)), + fn() => $this->doInvalidateTable($table, $classKey), ); } public function forceFlushModel(string $modelClass): void { $classKey = $this->keys->classKey($modelClass); - $this->store->incrementAndExpire($this->keys->verKey($classKey), $this->versionTtl()); // bypass cooldown + + foreach ($this->modelSpaces($modelClass) as $space) { + $this->store->incrementAndExpire($this->keys->verKey($classKey, $space), $this->versionTtl()); // bypass cooldown + } } public function flushAll(): int @@ -146,7 +165,7 @@ public function invalidateMultipleVersions(array $modelClasses, ?string $connect $connectionName, function () use ($connectionName, $modelClasses) { foreach ($modelClasses as $modelClass) { - $this->queueVersionFlush($connectionName, $this->keys->classKey($modelClass)); + $this->queueVersionFlush($connectionName, $this->keys->classKey($modelClass), $this->modelSpaces($modelClass)); } }, function () use ($modelClasses) { @@ -160,7 +179,7 @@ function () use ($modelClasses) { public function commitPending(string $connectionName): void { $flushes = array_keys($this->flushQueue[$connectionName] ?? []); - $versions = array_keys($this->versionQueue[$connectionName] ?? []); + $versions = $this->versionQueue[$connectionName] ?? []; unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName]); @@ -175,9 +194,11 @@ public function commitPending(string $connectionName): void $flushClassKeys = array_map(fn($class) => $this->keys->classKey($class), $flushes); - foreach ($versions as $classKey) { + foreach ($versions as $classKey => $spaces) { if (!in_array($classKey, $flushClassKeys, true)) { - $this->doInvalidateKey($classKey); + foreach ($spaces as $space) { + $this->doInvalidateKey($classKey, $space); + } } } }); @@ -208,19 +229,30 @@ private function queueOrRun(?string $connectionName, callable $queue, callable $ private function doInvalidateVersion(string $modelClass): void { - $this->doInvalidateKey($this->keys->classKey($modelClass)); + $classKey = $this->keys->classKey($modelClass); + + foreach ($this->modelSpaces($modelClass) as $space) { + $this->doInvalidateKey($classKey, $space); + } + } + + private function doInvalidateTable(string $table, string $classKey): void + { + foreach ($this->tableSpaces($table) 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 @@ -228,24 +260,24 @@ 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)], + [$this->keys->verKey($classKey, $space), $this->keys->scheduledKey($classKey, $space)], [(string) (int) floor(microtime(true) * 1000)] ); } - 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 @@ -253,8 +285,9 @@ private function queueModelFlush(string $connectionName, string $modelClass): vo $this->flushQueue[$connectionName][$modelClass] = true; } - private function queueVersionFlush(string $connectionName, string $classKey): void + /** @param list $spaces */ + private function queueVersionFlush(string $connectionName, string $classKey, array $spaces): void { - $this->versionQueue[$connectionName][$classKey] = true; + $this->versionQueue[$connectionName][$classKey] = $spaces; } } diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 8e6ca0f..3b2881e 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -76,4 +76,20 @@ public function test_spaced_model_query_writes_keys_under_its_space_tag(): void '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', + ); + } } From 1dcec42b700e572f34f1919875d3c0d74f81161c Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:26:58 +1000 Subject: [PATCH 16/73] scope relation cache to the active space --- src/CacheManager.php | 11 +++++++++++ src/Relations/CacheableBelongsTo.php | 5 ++++- src/Relations/CacheableMorphTo.php | 5 ++++- src/Relations/CachesOneOrManyThrough.php | 4 ++-- src/Relations/CachesPivotRelation.php | 6 ++++-- tests/Fixtures/Models/SpacedAuthor.php | 19 +++++++++++++++++++ tests/Fixtures/Models/SpacedPost.php | 6 ++++++ .../Integration/Cache/SpaceResolutionTest.php | 19 +++++++++++++++++++ 8 files changed, 69 insertions(+), 6 deletions(-) create mode 100644 tests/Fixtures/Models/SpacedAuthor.php diff --git a/src/CacheManager.php b/src/CacheManager.php index fc0c5bf..14ad1f0 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -11,11 +11,13 @@ use NormCache\Cache\ResultCacheReader; use NormCache\Cache\ResultExecutor; use NormCache\Cache\VersionTracker; +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\CacheSpace; use NormCache\Values\PivotCacheResult; use NormCache\Values\QueryCacheResult; use NormCache\Values\ResultCacheResult; @@ -101,6 +103,15 @@ public function classKey(string $class): string return $this->keys->classKey($class); } + // Resolve the active cache space for a model (used by relations to scope their + // cache execution to the same space the planner validated against). + public function spaceFor(string $modelClass, ?string $explicitSpace = null): CacheSpace + { + return ($this->spaceResolver ??= app(CacheSpaceResolver::class))->resolve($modelClass, $explicitSpace); + } + + private ?CacheSpaceResolver $spaceResolver = null; + public function tableKey(string $connectionName, string $table): string { return $this->keys->tableKey($connectionName, $table); diff --git a/src/Relations/CacheableBelongsTo.php b/src/Relations/CacheableBelongsTo.php index 11f7865..6a6b865 100644 --- a/src/Relations/CacheableBelongsTo.php +++ b/src/Relations/CacheableBelongsTo.php @@ -41,7 +41,10 @@ public function getEager() return $this->getFromPreparedBuilder($prepared); } - $models = NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false); + $models = NormCache::keys()->withSpace( + NormCache::spaceFor($this->related::class, $builder->getSpace()), + fn() => NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false) + ); if ($builder->getEagerLoads() !== []) { $models = $builder->eagerLoadRelations($models); diff --git a/src/Relations/CacheableMorphTo.php b/src/Relations/CacheableMorphTo.php index 28f0afd..fe97631 100644 --- a/src/Relations/CacheableMorphTo.php +++ b/src/Relations/CacheableMorphTo.php @@ -138,7 +138,10 @@ private function getResultsFromCache(string $type, Model $instance, ?array $colu $missedQuery = $queryWithMacros->mergeConstraintsFrom($this->query); } - $models = NormCache::getModels($ids, $class, $columns, null, $missedQuery, false); + $models = NormCache::keys()->withSpace( + NormCache::spaceFor($class), + fn() => NormCache::getModels($ids, $class, $columns, null, $missedQuery, false) + ); $collection = $instance->newCollection($models); $eagerLoads = $this->morphableEagerLoads[$class] ?? []; diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 2800cd4..07cf18b 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -69,7 +69,7 @@ public function get($columns = ['*']): Collection $tag = $builder->getCacheTag(); $ttl = $builder->getQueryTtl(); - return NormCache::rescue( + return NormCache::keys()->withSpace($plan->space, fn() => NormCache::rescue( fn() => NormCache::engine()->runThrough( fetch: fn() => NormCache::getThroughCache($relatedClass, $hash, $tag, $depClasses, $depTableKeys), waitForBuild: fn() => NormCache::waitForThroughBuild( @@ -117,7 +117,7 @@ public function get($columns = ['*']): Collection }, ), fn() => $this->getFromPreparedBuilder($prepared) - ); + )); } private function applyOneOfManyDependency(CacheableBuilder $query): void diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index bbd9232..d3b6d40 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -93,7 +93,9 @@ public function get($columns = ['*']): Collection $relatedClass = $this->related::class; $parentClassKey = NormCache::classKey($parentClass); - $results = NormCache::rescue( + $results = NormCache::keys()->withSpace( + NormCache::spaceFor($relatedClass, $builder->getSpace()), + fn() => NormCache::rescue( fn() => NormCache::engine()->runPivot( fetch: fn() => NormCache::getPivotCache( $parentClass, @@ -148,7 +150,7 @@ public function get($columns = ['*']): Collection }, ), fn() => $this->getFromPreparedPivotBuilder($prepared) - ); + )); return $results; } diff --git a/tests/Fixtures/Models/SpacedAuthor.php b/tests/Fixtures/Models/SpacedAuthor.php new file mode 100644 index 0000000..2d11a43 --- /dev/null +++ b/tests/Fixtures/Models/SpacedAuthor.php @@ -0,0 +1,19 @@ +belongsTo(SpacedAuthor::class, 'author_id'); + } } diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 3b2881e..1201b12 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -4,6 +4,7 @@ use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; +use NormCache\Tests\Fixtures\Models\SpacedAuthor; use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\TestCase; use NormCache\Values\CachePlanContext; @@ -92,4 +93,22 @@ public function test_spaced_model_write_invalidates_its_space_cache(): void 'content-space cache must invalidate when a content model is written', ); } + + 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()->getStore(); + $authorKeys = $store->scanPattern('{nc:content}:test:model:*authors*'); + + $this->assertNotEmpty( + $authorKeys, + 'co-located belongsTo (content) must cache the related model under {nc:content}', + ); + } } From f427e07e44eb907cf12d0083f9e9f44a643c8217 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:31:48 +1000 Subject: [PATCH 17/73] explain cross space warning --- src/CacheableBuilder.php | 10 +++++++--- src/Planning/CachePlanner.php | 20 ++++++++++++++----- .../Integration/Cache/SpaceResolutionTest.php | 13 ++++++++++++ 3 files changed, 35 insertions(+), 8 deletions(-) diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index 86d9877..cbaf00c 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -205,10 +205,14 @@ public function explain(): string 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), }; } diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 71cd299..2e116ab 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Query\Builder as QueryBuilder; +use Illuminate\Support\Facades\Log; use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; use NormCache\Enums\PlanningMode; @@ -73,7 +74,7 @@ public function plan( CacheOperation::MorphToEagerLoad => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), }; - return $explain ? $plan : $this->applySpaceValidation($plan, $builder, $model, $context->operation); + return $this->applySpaceValidation($plan, $builder, $model, $context->operation, $explain); } private ?CacheSpaceResolver $spaceResolver = null; @@ -87,6 +88,7 @@ private function applySpaceValidation( CacheableBuilder $builder, Model $model, CacheOperation $operation, + bool $explain = false, ): CachePlan { if (!$plan->isCacheable()) { return $plan; @@ -106,10 +108,18 @@ private function applySpaceValidation( return $plan->withSpace($space); } - $reasons = ['cross-space dependencies for space [' . $space->name . ']: ' - . implode(', ', [...$validation->invalidModels, ...$validation->invalidTables])]; + $offending = implode(', ', [...$validation->invalidModels, ...$validation->invalidTables]); + $reasons = ['cross-space dependencies for space [' . $space->name . ']: ' . $offending]; - if (config('normcache.spaces.cross_space_behavior', 'bypass') === 'throw') { + if (!$explain && config('app.debug', false)) { + $modelClass = $model::class; + Log::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 && config('normcache.spaces.cross_space_behavior', 'bypass') === 'throw') { throw new \RuntimeException('NormCache: ' . $reasons[0]); } @@ -118,7 +128,7 @@ private function applySpaceValidation( dependencies: $plan->dependencies, reasons: $reasons, bypassReasons: ['dependency' => $reasons], - ); + )->withSpace($space); } private function globalBypass( diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 1201b12..d1286ed 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -94,6 +94,19 @@ public function test_spaced_model_write_invalidates_its_space_cache(): void ); } + 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']); From af24f58ddf664ff77ff74f563b70d5927bfb3d32 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 16:35:43 +1000 Subject: [PATCH 18/73] optional per-space hash-tag placement override --- config/normcache.php | 6 ++++++ src/CacheServiceProvider.php | 5 ++++- src/Spaces/CacheSpaceRegistry.php | 21 +++++++++++++++++++- tests/Unit/Spaces/CacheSpaceRegistryTest.php | 8 ++++++++ 4 files changed, 38 insertions(+), 2 deletions(-) diff --git a/config/normcache.php b/config/normcache.php index c99fda3..959dab5 100644 --- a/config/normcache.php +++ b/config/normcache.php @@ -47,5 +47,11 @@ // When a query's dependencies span spaces: 'bypass' (skip caching) or 'throw'. 'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'), + + // Optional per-space hash-tag override, to pin a space to a specific cluster + // slot/node. Omit to use the convention ('content' -> {nc:content}). + 'placement' => [ + // 'catalog' => ['hash_tag' => 'nc:catalog'], + ], ], ]; diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 9abdee6..768407a 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -31,7 +31,10 @@ public function register(): void $this->mergeConfigFrom(__DIR__ . '/../config/normcache.php', 'normcache'); $this->app->singleton(CacheSpaceRegistry::class, function () { - return new CacheSpaceRegistry((int) config('normcache.spaces.max_per_model', 16)); + return new CacheSpaceRegistry( + (int) config('normcache.spaces.max_per_model', 16), + (array) config('normcache.spaces.placement', []), + ); }); $this->app->singleton(CacheSpaceResolver::class, function ($app) { diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 911585d..f7ae25d 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -20,7 +20,13 @@ final class CacheSpaceRegistry /** @var array> model class => spaces (memoized, validated) */ private array $modelSpaces = []; - public function __construct(private readonly int $maxPerModel = 16) {} + /** + * @param array $placement per-space hash-tag overrides + */ + public function __construct( + private readonly int $maxPerModel = 16, + private readonly array $placement = [], + ) {} public function defaultSpace(): CacheSpace { @@ -116,6 +122,19 @@ private function hashTagFor(string $name): string ); } + // Config override for pinning a space to a specific slot/node; else by 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 '{' or '}'." + ); + } + + return $override; + } + return $name === self::DEFAULT_SPACE ? self::DEFAULT_HASH_TAG : self::DEFAULT_HASH_TAG . ':' . $name; diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index f778cc6..3856f09 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -30,6 +30,14 @@ public function test_named_space_derives_hash_tag_by_convention(): void $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_invalid_space_name_throws(): void { $this->expectException(\InvalidArgumentException::class); From e96e8a2ccb9dd3fbc739d87fafe9b0a9911101bf Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 21:51:08 +1000 Subject: [PATCH 19/73] omit optional Lua KEYS / multispace tests --- composer.json | 1 + docker-compose.yml | 20 +++++ src/Cache/ModelHydrator.php | 2 +- src/Cache/NormalizedReader.php | 12 +-- src/Cache/ResultCacheReader.php | 13 +-- src/CacheServiceProvider.php | 2 +- src/Lua/fetch_batch_build_status.lua | 20 ++--- src/Lua/release_building.lua | 4 +- src/Lua/store_model_attrs.lua | 31 ++++--- src/Lua/store_versioned_payload.lua | 12 +-- src/Support/CacheKeyBuilder.php | 5 -- src/Support/RedisStore.php | 44 +++++---- tests/Fixtures/Models/CatalogTag.php | 18 ++++ tests/Fixtures/Models/ReportingCountry.php | 18 ++++ tests/Fixtures/Models/SpacedAuthor.php | 3 +- tests/Fixtures/Models/SpacedPost.php | 3 +- .../Cache/ModelHydratorStampedeTest.php | 10 +-- tests/Integration/Cache/PivotStampedeTest.php | 12 +-- .../LuaScriptConsistencyTest.php | 4 +- .../MultiSpaceClusterPlacementTest.php | 90 +++++++++++++++++++ .../SpaceClusterDistributionTest.php | 64 +++++++++++++ tests/TestCase.php | 2 +- tests/Unit/Cache/VersionTrackerSpaceTest.php | 2 +- tests/Unit/CacheKeyBuilderTest.php | 3 - tests/Unit/RedisStoreTest.php | 88 +++++++++--------- 25 files changed, 344 insertions(+), 139 deletions(-) create mode 100644 docker-compose.yml create mode 100644 tests/Fixtures/Models/CatalogTag.php create mode 100644 tests/Fixtures/Models/ReportingCountry.php create mode 100644 tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php create mode 100644 tests/Integration/Infrastructure/SpaceClusterDistributionTest.php 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/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/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index 9fab714..fdc30fa 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -202,7 +202,7 @@ private function fetchMissedStatus( $result = $this->store->script( RedisScripts::get('fetch_batch_build_status'), - [...$fetchKeys, $lockKey, '', $wakeKey], + [...$fetchKeys, $lockKey, $wakeKey], [$token, (string) $this->buildingLockTtl] ); diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index a7e4065..5bca1a9 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -147,13 +147,15 @@ protected function storePayload( $ttl ??= $this->queryTtl; if (!empty($versionKeys)) { + $keys = array_merge($versionKeys, [$key]); + if ($buildingKey !== null) { + $keys[] = $buildingKey; + $keys[] = $this->keys->buildingToWakeKey($buildingKey); + } + $this->store->script( RedisScripts::get('store_versioned_payload'), - array_merge($versionKeys, [ - $key, - $buildingKey ?? '', - $buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : '', - ]), + $keys, array_merge( [(string) count($versionKeys), '1', (string) $ttl], $expectedVersions, diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index adba4e6..6cd5b6b 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -124,7 +124,7 @@ public function fetchPivot( $result = $this->store->script( RedisScripts::get('fetch_batch_build_status'), - [...$missedKeys, $lockKey, '', $wakeKey], + [...$missedKeys, $lockKey, $wakeKey], [$token, (string) $this->buildingLockTtl] ); @@ -205,12 +205,15 @@ public function storeMany( return true; } + $keys = array_merge($versionKeys, array_keys($entries)); + if ($buildingKey !== null) { + $keys[] = $buildingKey; + $keys[] = $wakeKey ?? $this->keys->buildingToWakeKey($buildingKey); + } + return (bool) $this->store->script( RedisScripts::get('store_versioned_payload'), - array_merge($versionKeys, array_keys($entries), [ - $buildingKey ?? '', - $wakeKey ?? ($buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : ''), - ]), + $keys, array_merge( [(string) count($versionKeys), (string) count($entries), (string) $ttl], $expectedVersions, diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 768407a..dd11ead 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -56,7 +56,7 @@ public function register(): void $stampedeWakeTokens = (int) config('normcache.stampede_wake_tokens', 64); $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); - $store = new RedisStore($connection, $keys->nullKey(), $stampedeWakeTokens); + $store = new RedisStore($connection, $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); $engine = new ExecutionEngine; diff --git a/src/Lua/fetch_batch_build_status.lua b/src/Lua/fetch_batch_build_status.lua index 977c9cd..1d88f17 100644 --- a/src/Lua/fetch_batch_build_status.lua +++ b/src/Lua/fetch_batch_build_status.lua @@ -1,16 +1,14 @@ -- Re-checks still-missing model/pivot keys and atomically claims the build lock if --- anything is still missing. Used for both model attributes (with version) and pivot --- payloads (without version). +-- anything is still missing. Used for both model attributes and pivot payloads. -- -- KEYS[1..n] = model/pivot keys to re-check -- KEYS[n+1] = building lock key --- KEYS[n+2] = version key ('' to skip — pivot case) --- KEYS[n+3] = wake key +-- KEYS[n+2] = wake key -- ARGV[1] = lock token -- ARGV[2] = lock TTL in seconds -- --- Returns: {status, lockTokenOrFalse, version|false, rawValues} -local n = #KEYS - 3 +-- Returns: {status, lockTokenOrFalse, false, rawValues} +local n = #KEYS - 2 local chunkSize = 500 local values = {} local allHit = true @@ -34,15 +32,13 @@ for start = 1, n, chunkSize do end end -local version = KEYS[n + 2] ~= '' and (redis.call('GET', KEYS[n + 2]) or '0') or false - if allHit then - return {'hit', false, version, 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 + 3]) - return {'miss', ARGV[1], version, values} + redis.call('DEL', KEYS[n + 2]) + return {'miss', ARGV[1], false, values} end -return {'building', false, version, values} +return {'building', false, false, values} 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_model_attrs.lua b/src/Lua/store_model_attrs.lua index 09f7b28..6b4fb64 100644 --- a/src/Lua/store_model_attrs.lua +++ b/src/Lua/store_model_attrs.lua @@ -1,10 +1,10 @@ -- 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] = building lock key (or '' to skip release) --- KEYS[3] = wake key (or '' to skip) --- KEYS[4..] = model attribute keys to write +-- 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) @@ -12,21 +12,25 @@ -- 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 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 KEYS[2] == '' then return end - if token ~= '' and redis.call('GET', KEYS[2]) ~= token then return end - redis.call('DEL', KEYS[2]) - if KEYS[3] ~= '' then + 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[3], '1') + redis.call('LPUSH', KEYS[n + 3], '1') end - redis.call('EXPIRE', KEYS[3], 10) + redis.call('EXPIRE', KEYS[n + 3], 10) end end -if KEYS[2] ~= '' and token ~= '' and redis.call('GET', KEYS[2]) ~= token then +if has_lock and token ~= '' and redis.call('GET', KEYS[n + 2]) ~= token then return 0 end @@ -37,10 +41,9 @@ if current ~= ARGV[1] then end local ttl = tonumber(ARGV[2]) -local n = tonumber(ARGV[3]) for i = 1, n do - redis.call('SETEX', KEYS[3 + i], ttl, ARGV[4 + i]) + redis.call('SETEX', KEYS[1 + i], ttl, ARGV[4 + i]) end release_building() diff --git a/src/Lua/store_versioned_payload.lua b/src/Lua/store_versioned_payload.lua index 82dd5e2..3120534 100644 --- a/src/Lua/store_versioned_payload.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/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 837cd1a..04a3a6a 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -77,11 +77,6 @@ public function prefixed(string $pattern, ?CacheSpace $space = null): string return $this->full($pattern, $space); } - public function nullKey(?CacheSpace $space = null): string - { - return $this->tagPrefix($space) . 'null'; - } - // ------------------------------------------------------------------------- // Prefixes // ------------------------------------------------------------------------- diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 7ab95df..b031e43 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -21,7 +21,6 @@ final class RedisStore public function __construct( string $redisConnection, - private string $nullKey = '{nc}:null', private int $wakeTokenCount = 64, ) { $this->serializer = new CacheSerializer; @@ -90,9 +89,11 @@ public function delete(string|array $keys): void // 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] ); } @@ -110,9 +111,11 @@ public function storeRawAndRelease(string $key, string $value, int $ttl, ?string return true; } + $keys = $wakeKey !== null && $wakeKey !== '' ? [$key, $buildingKey, $wakeKey] : [$key, $buildingKey]; + return (bool) $this->script( RedisScripts::get('store_versioned_payload'), - [$key, $buildingKey, $wakeKey ?? ''], + $keys, ['0', '1', (string) $ttl, $value, $token ?? '', (string) $this->wakeTokenCount] ); } @@ -227,12 +230,19 @@ public function setManyIfVersion( 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, $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)), @@ -281,24 +291,12 @@ public function flushByPatterns(array $patterns): int return $total; } - // Passes $keys to EVALSHA as-is, falling back to EVAL on NOSCRIPT. + // 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 { - $prefixedKeys = []; - foreach ($keys as $key) { - $prefixedKeys[] = $key; - } - - if ($this->isCluster()) { - foreach ($prefixedKeys as $i => $prefixedKey) { - if ($prefixedKey === '') { - $prefixedKeys[$i] = $this->nullKey; - } - } - } - - $n = count($prefixedKeys); - $allArgs = array_merge($prefixedKeys, $args); + $n = count($keys); + $allArgs = array_merge($keys, $args); $sha = self::$shas[$script] ??= sha1($script); 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 @@ +script( RedisScripts::get('fetch_batch_build_status'), - [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + [$modelKey, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -96,7 +96,7 @@ public function test_build_status_script_reports_building_when_lock_already_held $result = $store->script( RedisScripts::get('fetch_batch_build_status'), - [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + [$modelKey, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -119,7 +119,7 @@ public function test_build_status_script_reports_hit_without_claiming_lock_when_ $result = $store->script( RedisScripts::get('fetch_batch_build_status'), - [$modelKey, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + [$modelKey, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -176,7 +176,7 @@ public function test_build_status_script_all_hit_across_multiple_mget_chunks(): $result = $store->script( RedisScripts::get('fetch_batch_build_status'), - [...$modelKeys, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + [...$modelKeys, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); @@ -215,7 +215,7 @@ public function test_build_status_script_partial_miss_across_chunk_boundary(): v $result = $store->script( RedisScripts::get('fetch_batch_build_status'), - [...$modelKeys, $lockKey, $keys->verKey($classKey), $keys->wakeKey($classKey, 'test-lock')], + [...$modelKeys, $lockKey, $keys->wakeKey($classKey, 'test-lock')], ['token', '5'] ); diff --git a/tests/Integration/Cache/PivotStampedeTest.php b/tests/Integration/Cache/PivotStampedeTest.php index 0b8fd74..71cf107 100644 --- a/tests/Integration/Cache/PivotStampedeTest.php +++ b/tests/Integration/Cache/PivotStampedeTest.php @@ -81,11 +81,11 @@ public function test_pivot_build_status_script_claims_lock_when_unheld(): void $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_batch_build_status'), - [$pivotKey, $lockKey, '', $wakeKey], + [$pivotKey, $lockKey, $wakeKey], ['token', '5'] ); @@ -102,12 +102,12 @@ public function test_pivot_build_status_script_reports_building_when_lock_alread $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_batch_build_status'), - [$pivotKey, $lockKey, '', $wakeKey], + [$pivotKey, $lockKey, $wakeKey], ['token', '5'] ); @@ -124,12 +124,12 @@ public function test_pivot_build_status_script_reports_hit_without_claiming_lock $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_batch_build_status'), - [$pivotKey, $lockKey, '', $wakeKey], + [$pivotKey, $lockKey, $wakeKey], ['token', '5'] ); diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php index f98219d..1d6b9dc 100644 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php @@ -231,7 +231,7 @@ public function test_pivot_result_write_is_skipped_when_dependency_version_chang $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"; + $pivotKey = $manager->keys()->pivotKey($authorKey, $tagKey, 'tags', 'manual-pivot-build', $cache->seg, 1); $this->bumpVersionInRedis($pivotTableKey); @@ -253,7 +253,7 @@ public function test_related_model_payload_is_not_cached_when_pivot_result_write $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"; + $pivotKey = $manager->keys()->pivotKey($authorKey, $tagKey, 'tags', 'manual-pivot-build', $cache->seg, 1); $this->bumpVersionInRedis($tagKey); diff --git a/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php b/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php new file mode 100644 index 0000000..85c8f94 --- /dev/null +++ b/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php @@ -0,0 +1,90 @@ +markTestSkipped('Requires a Redis Cluster (composer test:cluster).'); + } + + $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 port whose keyspace holds this space tag's keys, or null. + private function masterHolding(string $hashTag): ?int + { + foreach ($this->masterPorts() as $port) { + $node = new Redis; + $node->connect('127.0.0.1', $port); + + // KEYS treats { } as literal; only the owning node returns this space's keys. + if (!empty($node->keys('{' . $hashTag . '}:*'))) { + $node->close(); + + return $port; + } + + $node->close(); + } + + return null; + } + + /** @return list master node ports, from CLUSTER SLOTS */ + private function masterPorts(): array + { + $probe = new Redis; + $probe->connect('127.0.0.1', 7010); + $slots = $probe->rawCommand('CLUSTER', 'SLOTS'); + $probe->close(); + + $ports = []; + foreach ($slots as $range) { + $ports[] = (int) $range[2][1]; // [start, end, [masterIp, masterPort, id], ...] + } + + return array_values(array_unique($ports)); + } +} 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/TestCase.php b/tests/TestCase.php index e7138b1..513f1a3 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -185,7 +185,7 @@ protected function buildManager( $queryTtl ??= (int) config('normcache.query_ttl'); $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); - $store = new RedisStore($connection, $keys->nullKey(), $stampedeWakeTokens); + $store = new RedisStore($connection, $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); $engine = new ExecutionEngine; diff --git a/tests/Unit/Cache/VersionTrackerSpaceTest.php b/tests/Unit/Cache/VersionTrackerSpaceTest.php index 6ccd25e..6e6d7b2 100644 --- a/tests/Unit/Cache/VersionTrackerSpaceTest.php +++ b/tests/Unit/Cache/VersionTrackerSpaceTest.php @@ -14,7 +14,7 @@ class VersionTrackerSpaceTest extends TestCase public function test_current_version_reads_the_space_scoped_version_key(): void { $keys = new CacheKeyBuilder('{nc}:', 'test:'); - $store = new RedisStore('normcache-test', $keys->nullKey()); + $store = new RedisStore('normcache-test'); $tracker = new VersionTracker($store, $keys); $content = new CacheSpace('content', 'nc:content'); diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index f9d541e..1782622 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -17,7 +17,6 @@ public function test_key_methods_emit_the_space_hash_tag(): void $this->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}:null', $keys->nullKey($content)); $this->assertSame('{nc:content}:test:query:*', $keys->prefixed('query:*', $content)); } @@ -26,7 +25,6 @@ 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')); - $this->assertSame('{nc}:null', $keys->nullKey()); } public function test_class_key_rejects_connection_name_containing_colon(): void @@ -91,7 +89,6 @@ public function test_key_methods_emit_full_keys_with_hash_tag_and_prefix(): void $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}:null', $keys->nullKey()); $this->assertSame('{nc}:test:query:*', $keys->prefixed('query:*')); } } diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index a4e43fb..9a5cf8c 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -53,21 +53,21 @@ 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', 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 @@ -100,74 +100,74 @@ public function test_it_can_run_lua_scripts(): void public function test_it_can_set_many_if_version(): void { - $this->store->delete(['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->setManyIfVersion($attrs, 60, '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->setManyIfVersion($attrs2, 60, '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_if_version_with_lock_release(): void { - $this->store->delete(['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->setManyIfVersion($attrs, 60, '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_if_version_handles_large_script_batches(): void { - $this->store->delete(['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->setManyIfVersion($attrs, 60, '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(['id' => 9999, 'name' => 'Name 9999'], $this->store->get('{t}:key:large:9999')); } 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_model_attrs'), - array_merge(['ver:script-large', '', ''], $keys), + array_merge(['{t}:ver:script-large'], $keys), array_merge(['1', '60', (string) $count, ''], $values) ); @@ -176,26 +176,26 @@ public function test_set_many_if_version_script_chunks_internally(): void public function test_it_skips_write_but_still_releases_on_version_mismatch(): void { - $this->store->delete(['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->setManyIfVersion( - ['key:5' => ['id' => 5]], 60, 'ver:3', 1, 'lock:3', 'wake:3', 'tok' + ['{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->setManyIfVersion([], 60, '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 From e3f594d71de297b5bbc97b70d3ce22e7e1c40f24 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:06:54 +1000 Subject: [PATCH 20/73] fix static cache cross-space contamination --- src/Support/CacheKeyBuilder.php | 2 ++ tests/Unit/CacheKeyBuilderTest.php | 13 +++++++++++++ 2 files changed, 15 insertions(+) diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 04a3a6a..f82cc68 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -246,6 +246,8 @@ public function buildingToWakeKey(string $buildingKey): string */ public function depKeyPairs(string $classKey, array $depClasses, array $depTableKeys = [], ?CacheSpace $space = null): array { + $space ??= $this->activeSpace; + if ($depClasses === [] && $depTableKeys === []) { $cacheKey = ($space?->name ?? '') . '|' . $classKey; diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index 1782622..8c29fd1 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -79,6 +79,19 @@ public function test_building_to_wake_key_is_prefix_agnostic_on_full_keys(): voi $this->assertSame('{nc}:test:wake:mysql:posts:abc123', $wake); } + 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_key_methods_emit_full_keys_with_hash_tag_and_prefix(): void { $keys = new CacheKeyBuilder('{nc}:', 'test:'); From c1073970fb183b5563ebed59ecdfbae2795f7e3d Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:16:24 +1000 Subject: [PATCH 21/73] flush pattern scans across cache spaces --- src/Spaces/CacheSpaceRegistry.php | 9 +++ src/Traits/HandlesInvalidation.php | 78 ++++++++++++------- .../Integration/Cache/SpaceResolutionTest.php | 42 ++++++++++ 3 files changed, 102 insertions(+), 27 deletions(-) diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index f7ae25d..7221c9a 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -38,6 +38,15 @@ public function space(string $name): CacheSpace return $this->spaces[$name] ??= new CacheSpace($name, $this->hashTagFor($name)); } + /** @return list */ + public function materializedSpaces(): array + { + $spaces = $this->spaces; + $spaces[self::DEFAULT_SPACE] ??= $this->defaultSpace(); + + return array_values($spaces); + } + /** @return list */ public function spacesForModel(string $modelClass): array { diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index ab8239f..a405949 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -35,6 +35,12 @@ private function tableSpaces(string $table): array return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->spacesForTable($table); } + /** @return list */ + private function materializedSpaces(): array + { + return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->materializedSpaces(); + } + // Invalidation public function invalidateVersion(Model $model): void @@ -112,19 +118,19 @@ public function forceFlushModel(string $modelClass): void public function flushAll(): int { - return $this->store->flushByPatterns([ - $this->keys->prefixed(CacheKeyBuilder::K_QUERY . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_MODEL . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_VER . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_COUNT . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_SCALAR . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_PIVOT . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_THROUGH . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_SCHEDULED . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_BUILDING . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_WAKE . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_RESULT . ':*'), - ]); + return $this->store->flushByPatterns($this->prefixedForSpaces([ + CacheKeyBuilder::K_QUERY . ':*', + CacheKeyBuilder::K_MODEL . ':*', + CacheKeyBuilder::K_VER . ':*', + CacheKeyBuilder::K_COUNT . ':*', + CacheKeyBuilder::K_SCALAR . ':*', + CacheKeyBuilder::K_PIVOT . ':*', + CacheKeyBuilder::K_THROUGH . ':*', + CacheKeyBuilder::K_SCHEDULED . ':*', + CacheKeyBuilder::K_BUILDING . ':*', + CacheKeyBuilder::K_WAKE . ':*', + CacheKeyBuilder::K_RESULT . ':*', + ], $this->materializedSpaces())); } public function flushTag(string $modelClass, string $tag): int @@ -133,26 +139,26 @@ public function flushTag(string $modelClass, string $tag): int $classKey = $this->keys->classKey($modelClass); - return $this->store->flushByPatterns([ - $this->keys->prefixed(CacheKeyBuilder::K_RESULT . ':' . $classKey . ':' . $tag . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_QUERY . ':' . $classKey . ':' . $tag . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_COUNT . ':' . $classKey . ':' . $tag . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_SCALAR . ':' . $classKey . ':' . $tag . ':*'), - $this->keys->prefixed(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))); } public function flushTagAcrossModels(string $tag): int { CacheKeyBuilder::assertValidTag($tag); - return $this->store->flushByPatterns([ - $this->keys->prefixed(CacheKeyBuilder::K_RESULT . ':*:' . $tag . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_QUERY . ':*:' . $tag . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_COUNT . ':*:' . $tag . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_SCALAR . ':*:' . $tag . ':*'), - $this->keys->prefixed(CacheKeyBuilder::K_THROUGH . ':*:' . $tag . ':*'), - ]); + return $this->store->flushByPatterns($this->prefixedForSpaces([ + CacheKeyBuilder::K_RESULT . ':*:' . $tag . ':*', + CacheKeyBuilder::K_QUERY . ':*:' . $tag . ':*', + CacheKeyBuilder::K_COUNT . ':*:' . $tag . ':*', + CacheKeyBuilder::K_SCALAR . ':*:' . $tag . ':*', + CacheKeyBuilder::K_THROUGH . ':*:' . $tag . ':*', + ], $this->materializedSpaces())); } public function invalidateMultipleVersions(array $modelClasses, ?string $connectionName = null): void @@ -285,6 +291,24 @@ private function queueModelFlush(string $connectionName, string $modelClass): vo $this->flushQueue[$connectionName][$modelClass] = true; } + /** + * @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 $spaces */ private function queueVersionFlush(string $connectionName, string $classKey, array $spaces): void { diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index d1286ed..d2340b6 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -94,6 +94,48 @@ public function test_spaced_model_write_invalidates_its_space_cache(): void ); } + public function test_flush_all_removes_spaced_keys(): void + { + SpacedPost::create(['title' => 'First', 'author_id' => 1]); + + SpacedPost::query()->get(); + + $store = $this->cacheManager()->getStore(); + $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()->getStore(); + $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()->getStore(); + $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()); From 9097e8892c0b718f14082a8b7db585c0992f6f66 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 22:40:29 +1000 Subject: [PATCH 22/73] optimize cache-space validation and relation scoping --- src/CacheManager.php | 21 ++++++ src/Planning/CachePlanner.php | 16 ++++- src/Relations/CacheableBelongsTo.php | 11 +-- src/Relations/CacheableMorphTo.php | 11 +-- src/Relations/CachesOneOrManyThrough.php | 11 ++- src/Relations/CachesPivotRelation.php | 11 +-- src/Spaces/CacheSpaceRegistry.php | 75 +++++++++++++++++--- src/Support/CacheKeyBuilder.php | 5 ++ src/Values/SpaceValidationResult.php | 4 +- tests/Unit/CacheKeyBuilderTest.php | 11 +++ tests/Unit/CacheManagerTest.php | 25 +++++++ tests/Unit/Spaces/CacheSpaceRegistryTest.php | 30 ++++++-- 12 files changed, 200 insertions(+), 31 deletions(-) diff --git a/src/CacheManager.php b/src/CacheManager.php index 14ad1f0..e232284 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -110,6 +110,27 @@ public function spaceFor(string $modelClass, ?string $explicitSpace = null): Cac return ($this->spaceResolver ??= app(CacheSpaceResolver::class))->resolve($modelClass, $explicitSpace); } + public function activeSpaceFor(string $modelClass, ?string $explicitSpace = null): ?CacheSpace + { + $active = $this->keys->activeSpace(); + + if ($active === null) { + return null; + } + + if ($explicitSpace !== null && $active->name !== $explicitSpace) { + return null; + } + + foreach ($this->modelSpaces($modelClass) as $space) { + if ($space->name === $active->name) { + return $active; + } + } + + return null; + } + private ?CacheSpaceResolver $spaceResolver = null; public function tableKey(string $connectionName, string $table): string diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 2e116ab..91e14a8 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -94,17 +94,27 @@ private function applySpaceValidation( return $plan; } - $resolver = $this->spaceResolver ??= app(CacheSpaceResolver::class); $registry = $this->spaceRegistry ??= app(CacheSpaceRegistry::class); + if ($registry->dependenciesAreOnlyModel($model::class, $plan->dependencies->models, $plan->dependencies->tables)) { + $space = $builder->getSpace() === null + ? $registry->spacesForModel($model::class)[0] + : ($this->spaceResolver ??= app(CacheSpaceResolver::class))->resolve($model::class, $builder->getSpace()); + + return $plan->withSpace($space); + } + + $resolver = $this->spaceResolver ??= app(CacheSpaceResolver::class); $space = $resolver->resolve($model::class, $builder->getSpace()); + $validation = $registry->validateDependencies( $space, $plan->dependencies->models, $plan->dependencies->tables, + includeDependenciesBySpace: $explain, ); - if ($validation->ok) { + if ($validation->isValid) { return $plan->withSpace($space); } @@ -115,7 +125,7 @@ private function applySpaceValidation( $modelClass = $model::class; Log::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." + . 'which are not in that space; the query will not cache. Add them to the space or drop the dependency.' ); } diff --git a/src/Relations/CacheableBelongsTo.php b/src/Relations/CacheableBelongsTo.php index 6a6b865..83db98a 100644 --- a/src/Relations/CacheableBelongsTo.php +++ b/src/Relations/CacheableBelongsTo.php @@ -41,10 +41,13 @@ public function getEager() return $this->getFromPreparedBuilder($prepared); } - $models = NormCache::keys()->withSpace( - NormCache::spaceFor($this->related::class, $builder->getSpace()), - fn() => NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false) - ); + $keys = NormCache::keys(); + $models = NormCache::activeSpaceFor($this->related::class, $builder->getSpace()) !== null + ? NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false) + : $keys->withSpace( + NormCache::spaceFor($this->related::class, $builder->getSpace()), + fn() => NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false) + ); if ($builder->getEagerLoads() !== []) { $models = $builder->eagerLoadRelations($models); diff --git a/src/Relations/CacheableMorphTo.php b/src/Relations/CacheableMorphTo.php index fe97631..d7c4e24 100644 --- a/src/Relations/CacheableMorphTo.php +++ b/src/Relations/CacheableMorphTo.php @@ -138,10 +138,13 @@ private function getResultsFromCache(string $type, Model $instance, ?array $colu $missedQuery = $queryWithMacros->mergeConstraintsFrom($this->query); } - $models = NormCache::keys()->withSpace( - NormCache::spaceFor($class), - fn() => NormCache::getModels($ids, $class, $columns, null, $missedQuery, false) - ); + $keys = NormCache::keys(); + $models = NormCache::activeSpaceFor($class) !== null + ? NormCache::getModels($ids, $class, $columns, null, $missedQuery, false) + : $keys->withSpace( + NormCache::spaceFor($class), + fn() => NormCache::getModels($ids, $class, $columns, null, $missedQuery, false) + ); $collection = $instance->newCollection($models); $eagerLoads = $this->morphableEagerLoads[$class] ?? []; diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 07cf18b..027510d 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -69,7 +69,7 @@ public function get($columns = ['*']): Collection $tag = $builder->getCacheTag(); $ttl = $builder->getQueryTtl(); - return NormCache::keys()->withSpace($plan->space, fn() => NormCache::rescue( + $runThrough = fn() => NormCache::rescue( fn() => NormCache::engine()->runThrough( fetch: fn() => NormCache::getThroughCache($relatedClass, $hash, $tag, $depClasses, $depTableKeys), waitForBuild: fn() => NormCache::waitForThroughBuild( @@ -117,7 +117,14 @@ public function get($columns = ['*']): Collection }, ), fn() => $this->getFromPreparedBuilder($prepared) - )); + ); + + $keys = NormCache::keys(); + $activeSpace = NormCache::activeSpaceFor($relatedClass); + + return $activeSpace !== null && ($plan->space === null || $plan->space->name === $activeSpace->name) + ? $runThrough() + : $keys->withSpace($plan->space, $runThrough); } private function applyOneOfManyDependency(CacheableBuilder $query): void diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index d3b6d40..0d79bbd 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -93,9 +93,7 @@ public function get($columns = ['*']): Collection $relatedClass = $this->related::class; $parentClassKey = NormCache::classKey($parentClass); - $results = NormCache::keys()->withSpace( - NormCache::spaceFor($relatedClass, $builder->getSpace()), - fn() => NormCache::rescue( + $runPivot = fn() => NormCache::rescue( fn() => NormCache::engine()->runPivot( fetch: fn() => NormCache::getPivotCache( $parentClass, @@ -150,7 +148,12 @@ public function get($columns = ['*']): Collection }, ), fn() => $this->getFromPreparedPivotBuilder($prepared) - )); + ); + + $keys = NormCache::keys(); + $results = NormCache::activeSpaceFor($relatedClass, $builder->getSpace()) !== null + ? $runPivot() + : $keys->withSpace(NormCache::spaceFor($relatedClass, $builder->getSpace()), $runPivot); return $results; } diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 7221c9a..d606855 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -70,34 +70,58 @@ public function tableAllowedInSpace(string $table, CacheSpace|string $space): bo return $this->isAllowed($this->spacesForTable($table), $space); } + public function dependenciesAreOnlyModel(string $modelClass, array $models, array $tables): bool + { + return $models === [$modelClass] && $tables === []; + } + /** * Validate a cached operation's dependencies against the active space. * * @param list $models * @param list $tables */ - public function validateDependencies(CacheSpace $space, array $models, array $tables): SpaceValidationResult - { + public function validateDependencies( + CacheSpace $space, + array $models, + array $tables, + bool $includeDependenciesBySpace = false, + ): SpaceValidationResult { $invalidModels = []; $invalidTables = []; - $dependenciesBySpace = []; + $dependencySpaces = []; foreach ($models as $modelClass) { - $dependenciesBySpace[$modelClass] = $this->spaceNames($this->spacesForModel($modelClass)); - if (!$this->modelAllowedInSpace($modelClass, $space)) { + $spaces = $this->spacesForModel($modelClass); + + if ($includeDependenciesBySpace) { + $dependencySpaces[$modelClass] = $spaces; + } + + if (!$this->isAllowed($spaces, $space)) { $invalidModels[] = $modelClass; } } foreach ($tables as $table) { - $dependenciesBySpace[$table] = $this->spaceNames($this->spacesForTable($table)); - if (!$this->tableAllowedInSpace($table, $space)) { + $spaces = $this->spacesForTable($table); + + if ($includeDependenciesBySpace) { + $dependencySpaces[$table] = $spaces; + } + + if (!$this->isAllowed($spaces, $space)) { $invalidTables[] = $table; } } + $ok = $invalidModels === [] && $invalidTables === []; + $dependenciesBySpace = $includeDependenciesBySpace + ? $this->dependencySpaceNames($dependencySpaces) + : ($ok ? [] : $this->dependencySpaceNamesFor($models, $tables)); + return new SpaceValidationResult( - ok: $invalidModels === [] && $invalidTables === [], + isValid: $ok, space: $space, invalidModels: $invalidModels, invalidTables: $invalidTables, @@ -171,4 +195,39 @@ private function spaceNames(array $spaces): array { return array_values(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; + } + + /** + * @param list $models + * @param list $tables + * @return array> + */ + private function dependencySpaceNamesFor(array $models, array $tables): array + { + $names = []; + + foreach ($models as $modelClass) { + $names[$modelClass] = $this->spaceNames($this->spacesForModel($modelClass)); + } + + foreach ($tables as $table) { + $names[$table] = $this->spaceNames($this->spacesForTable($table)); + } + + return $names; + } } diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index f82cc68..055f2dd 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -59,6 +59,11 @@ public function withSpace(?CacheSpace $space, callable $callback): mixed } } + public function activeSpace(): ?CacheSpace + { + return $this->activeSpace; + } + private function full(string $body, ?CacheSpace $space = null): string { return $this->tagPrefix($space) . $this->keyPrefix . $body; diff --git a/src/Values/SpaceValidationResult.php b/src/Values/SpaceValidationResult.php index 3411bc0..3828398 100644 --- a/src/Values/SpaceValidationResult.php +++ b/src/Values/SpaceValidationResult.php @@ -3,7 +3,7 @@ namespace NormCache\Values; // Result of validating an operation's dependencies against one cache space. -// $dependenciesBySpace maps each dependency to the spaces it lives in, for explain(). +// $dependenciesBySpace is populated for explain() or failed validation only. final readonly class SpaceValidationResult { /** @@ -12,7 +12,7 @@ * @param array> $dependenciesBySpace */ public function __construct( - public bool $ok, + public bool $isValid, public CacheSpace $space, public array $invalidModels = [], public array $invalidTables = [], diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index 8c29fd1..098c170 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -92,6 +92,17 @@ public function test_dep_key_pairs_respects_active_space_in_static_cache(): void $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:'); diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index fc09f47..1bffae6 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -7,6 +7,7 @@ use NormCache\CacheManager; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; +use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\TestCase; class CacheManagerTest extends TestCase @@ -111,6 +112,30 @@ public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void $this->assertSame(7, $manager->currentVersion(Author::class)); } + public function test_active_space_for_returns_active_space_only_when_model_belongs_to_it(): void + { + $content = $this->manager->spaceFor(SpacedPost::class); + + $seen = $this->manager->keys()->withSpace($content, fn() => [ + $this->manager->activeSpaceFor(SpacedPost::class)?->name, + $this->manager->activeSpaceFor(Author::class)?->name, + ]); + + $this->assertSame(['content', null], $seen); + } + + public function test_active_space_for_reuses_matching_explicit_space(): void + { + $content = $this->manager->spaceFor(SpacedPost::class); + + $seen = $this->manager->keys()->withSpace( + $content, + fn() => $this->manager->activeSpaceFor(SpacedPost::class, 'content')?->name, + ); + + $this->assertSame('content', $seen); + } + // ------------------------------------------------------------------------- // Flush operations // ------------------------------------------------------------------------- diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index 3856f09..d457276 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -2,10 +2,12 @@ namespace NormCache\Tests\Unit\Spaces; +use Illuminate\Database\Eloquent\Model; use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\TestCase; +use NormCache\Traits\Cacheable; class CacheSpaceRegistryTest extends TestCase { @@ -69,9 +71,9 @@ public function test_model_declaration_drives_membership(): void public function test_model_in_too_many_spaces_throws_on_resolution(): void { - $model = new class extends \Illuminate\Database\Eloquent\Model + $model = new class extends Model { - use \NormCache\Traits\Cacheable; + use Cacheable; protected static array $normCacheSpaces = ['a', 'b', 'c']; }; @@ -90,6 +92,15 @@ public function test_tables_belong_to_the_default_space(): void $this->assertFalse($registry->tableAllowedInSpace('mysql:legacy_flags', 'content')); } + 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(); @@ -97,8 +108,19 @@ public function test_validate_dependencies_passes_when_all_allowed(): void $result = $registry->validateDependencies($content, [SpacedPost::class], []); - $this->assertTrue($result->ok); + $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]); } @@ -110,7 +132,7 @@ public function test_validate_dependencies_reports_cross_space_members(): void // Author is default-only; depending on it inside content is invalid. $result = $registry->validateDependencies($content, [SpacedPost::class, Author::class], ['mysql:legacy_flags']); - $this->assertFalse($result->ok); + $this->assertFalse($result->isValid); $this->assertSame([Author::class], $result->invalidModels); $this->assertSame(['mysql:legacy_flags'], $result->invalidTables); $this->assertSame(['default'], $result->dependenciesBySpace[Author::class]); From bf884e61e9a886b9ba0f24e91058bf0e2c817f84 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:18:59 +1000 Subject: [PATCH 23/73] cover cache spaces in versions and relation caches --- src/CacheManager.php | 2 +- src/Relations/CachesOneOrManyThrough.php | 13 +++++- src/Spaces/CacheSpaceRegistry.php | 2 +- src/Spaces/CacheSpaceResolver.php | 2 +- src/Support/CacheKeyBuilder.php | 2 +- src/Traits/HandlesInvalidation.php | 2 +- tests/Fixtures/Models/ReportingCountry.php | 6 +++ tests/Fixtures/Models/SpacedPost.php | 6 +++ tests/Integration/Cache/ModelCachingTest.php | 1 - .../Integration/Cache/SpaceResolutionTest.php | 41 +++++++++++++++++++ 10 files changed, 69 insertions(+), 8 deletions(-) diff --git a/src/CacheManager.php b/src/CacheManager.php index e232284..3a98c1d 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -140,7 +140,7 @@ public function tableKey(string $connectionName, string $table): string public function currentVersion(string $modelClass): int { - return $this->versions->currentVersion($modelClass); + return $this->versions->currentVersion($modelClass, $this->modelSpaces($modelClass)[0]); } public function currentTableVersion(string $connectionName, string $table): int diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 027510d..61b020a 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -9,6 +9,7 @@ use NormCache\Enums\CacheOperation; use NormCache\Facades\NormCache; use NormCache\Planning\QueryAnalyzer; +use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; use NormCache\Support\QueryHasher; @@ -137,10 +138,18 @@ private function applyOneOfManyDependency(CacheableBuilder $query): void private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?CachePlan { if ($this->isSimpleThroughQuery($base, $builder)) { + $space = NormCache::spaceFor($this->related::class, $builder->getSpace()); + $dependencies = new DependencySet(models: [$this->throughParent::class]); + $validation = app(CacheSpaceRegistry::class)->validateDependencies($space, $dependencies->models, $dependencies->tables); + + if (!$validation->isValid) { + return null; + } + return CachePlan::result( operation: CacheOperation::Through, - dependencies: new DependencySet(models: [$this->throughParent::class]), - ); + dependencies: $dependencies, + )->withSpace($space); } $projection = ProjectionClassifier::resolve($base, null); diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index d606855..91067a0 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -193,7 +193,7 @@ private function isAllowed(array $allowed, CacheSpace|string $space): bool */ private function spaceNames(array $spaces): array { - return array_values(array_map(fn(CacheSpace $s) => $s->name, $spaces)); + return array_map(fn(CacheSpace $s) => $s->name, $spaces); } /** diff --git a/src/Spaces/CacheSpaceResolver.php b/src/Spaces/CacheSpaceResolver.php index 9b6bbb7..a01c253 100644 --- a/src/Spaces/CacheSpaceResolver.php +++ b/src/Spaces/CacheSpaceResolver.php @@ -13,7 +13,7 @@ public function __construct(private readonly CacheSpaceRegistry $registry) {} public function resolve(string $modelClass, ?string $explicitSpace): CacheSpace { if ($explicitSpace !== null) { - if (! $this->registry->modelAllowedInSpace($modelClass, $explicitSpace)) { + 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()." ); diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 055f2dd..c94c184 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -254,7 +254,7 @@ public function depKeyPairs(string $classKey, array $depClasses, array $depTable $space ??= $this->activeSpace; if ($depClasses === [] && $depTableKeys === []) { - $cacheKey = ($space?->name ?? '') . '|' . $classKey; + $cacheKey = ($space === null ? '' : $space->name) . '|' . $classKey; return self::$singleDepPairs[$cacheKey] ??= [ [$this->verKey($classKey, $space)], diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index a405949..4148f28 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -32,7 +32,7 @@ private function modelSpaces(string $modelClass): array /** @return list the spaces a table's version key lives in */ private function tableSpaces(string $table): array { - return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->spacesForTable($table); + return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->materializedSpaces(); } /** @return list */ diff --git a/tests/Fixtures/Models/ReportingCountry.php b/tests/Fixtures/Models/ReportingCountry.php index 047815b..188ceaa 100644 --- a/tests/Fixtures/Models/ReportingCountry.php +++ b/tests/Fixtures/Models/ReportingCountry.php @@ -3,6 +3,7 @@ namespace NormCache\Tests\Fixtures\Models; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\Relations\HasManyThrough; use NormCache\Traits\Cacheable; // 'reporting'-space model on the countries table. @@ -15,4 +16,9 @@ class ReportingCountry extends Model protected $guarded = []; protected static array $normCacheSpaces = ['reporting']; + + public function spacedPosts(): HasManyThrough + { + return $this->hasManyThrough(SpacedPost::class, SpacedAuthor::class, 'country_id', 'author_id'); + } } diff --git a/tests/Fixtures/Models/SpacedPost.php b/tests/Fixtures/Models/SpacedPost.php index 5c321ed..d67a272 100644 --- a/tests/Fixtures/Models/SpacedPost.php +++ b/tests/Fixtures/Models/SpacedPost.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\MorphToMany; use NormCache\Traits\Cacheable; // 'content'-space model on the posts table. @@ -21,4 +22,9 @@ public function spacedAuthor(): BelongsTo { return $this->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/ModelCachingTest.php b/tests/Integration/Cache/ModelCachingTest.php index 98dbee6..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; diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index d2340b6..96220fa 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -3,7 +3,9 @@ namespace NormCache\Tests\Integration\Cache; use NormCache\Tests\Fixtures\Models\Author; +use NormCache\Tests\Fixtures\Models\CatalogTag; use NormCache\Tests\Fixtures\Models\Post; +use NormCache\Tests\Fixtures\Models\ReportingCountry; use NormCache\Tests\Fixtures\Models\SpacedAuthor; use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\TestCase; @@ -94,6 +96,45 @@ public function test_spaced_model_write_invalidates_its_space_cache(): void ); } + 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()->getStore(); + $this->assertNotEmpty($store->scanPattern('{nc:content}:test:through:*')); + $this->assertEmpty($store->scanPattern('{nc}: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()->getStore()->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]); From 515c28f5e6bf5e6689ecc3d2d7b87eab5327b147 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 28 Jun 2026 23:46:14 +1000 Subject: [PATCH 24/73] fix CI tests --- .../MultiSpaceClusterPlacementTest.php | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php b/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php index 85c8f94..383c70a 100644 --- a/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php +++ b/tests/Integration/Infrastructure/MultiSpaceClusterPlacementTest.php @@ -20,6 +20,10 @@ protected function setUp(): void $this->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); } @@ -52,18 +56,18 @@ public function test_three_spaces_run_and_land_on_distinct_master_nodes(): void ); } - // Master port whose keyspace holds this space tag's keys, or null. - private function masterHolding(string $hashTag): ?int + // Master node whose keyspace holds this space tag's keys, or null. + private function masterHolding(string $hashTag): ?string { - foreach ($this->masterPorts() as $port) { + foreach ($this->masterNodes() as [$host, $port]) { $node = new Redis; - $node->connect('127.0.0.1', $port); + $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 $port; + return "{$host}:{$port}"; } $node->close(); @@ -72,19 +76,29 @@ private function masterHolding(string $hashTag): ?int return null; } - /** @return list master node ports, from CLUSTER SLOTS */ - private function masterPorts(): array + /** @return list master nodes, from CLUSTER SLOTS */ + private function masterNodes(): array { + [$host, $port] = $this->clusterProbeNode(); $probe = new Redis; - $probe->connect('127.0.0.1', 7010); + $probe->connect($host, $port); $slots = $probe->rawCommand('CLUSTER', 'SLOTS'); $probe->close(); - $ports = []; + $nodes = []; foreach ($slots as $range) { - $ports[] = (int) $range[2][1]; // [start, end, [masterIp, masterPort, id], ...] + $masterPort = (int) $range[2][1]; // [start, end, [masterIp, masterPort, id], ...] + $nodes["{$host}:{$masterPort}"] = [$host, $masterPort]; } - return array_values(array_unique($ports)); + 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']]; } } From 736425dc420ab5c4d0d25806981888ccf58501c5 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 00:21:23 +1000 Subject: [PATCH 25/73] fix predis ci tests --- src/Support/RedisStore.php | 14 ++++++-- tests/Unit/RedisStoreTest.php | 63 +++++++++++++++++++++++++++++++++++ 2 files changed, 75 insertions(+), 2 deletions(-) diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index b031e43..a5f34f4 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -165,7 +165,9 @@ private function mgetValues(array $keys, bool $unserialize): array $prefixed[] = $key; } - $raw = $this->connection->mget($prefixed); + $raw = $this->connection instanceof PredisClusterConnection + ? array_map(fn($key) => $this->connection->get($key), $prefixed) + : $this->connection->mget($prefixed); $values = []; foreach ($raw as $i => $value) { @@ -265,7 +267,15 @@ public function asyncDel(array $prefixedKeys): void private function del(array $keys): void { - // PredisClusterConnection extends PredisConnection, so this covers both. + if ($this->connection instanceof PredisClusterConnection) { + foreach ($keys as $key) { + $this->connection->del($key); + } + + return; + } + + // Standalone Predis accepts Laravel's array form. if ($this->connection instanceof PredisConnection) { $this->connection->del($keys); diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index 9a5cf8c..cd7ca5a 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -9,6 +9,7 @@ use NormCache\Support\RedisStore; use NormCache\Tests\TestCase; use Predis\Client as PredisClient; +use Predis\NotSupportedException; use ReflectionProperty; class RedisStoreTest extends TestCase @@ -80,6 +81,40 @@ public function test_it_can_get_many_values(): void $this->assertSame(['bar', 'qux', null], $results); } + public function test_predis_cluster_get_many_reads_each_key_without_mget_array_command(): void + { + $connection = new class extends PredisClusterConnection + { + public array $reads = []; + + public array $values = []; + + public function __construct() {} + + public function mget($keys) + { + throw new NotSupportedException("Cannot use 'MGET' with redis-cluster."); + } + + public function get($key) + { + $this->reads[] = $key; + + return $this->values[$key] ?? null; + } + }; + + $store = new RedisStore('normcache-test'); + $connection->values = [ + 'foo' => $store->serialize('bar'), + 'baz' => $store->serialize('qux'), + ]; + (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); + + $this->assertSame(['bar', 'qux', null], $store->getMany(['foo', 'baz', 'missing'])); + $this->assertSame(['foo', 'baz', 'missing'], $connection->reads); + } + public function test_it_can_set_many_values(): void { $this->store->setMany(['foo' => 'bar', 'baz' => 'qux'], 60); @@ -212,6 +247,34 @@ public function test_it_can_flush_by_patterns(): void $this->assertSame('c', $this->store->get('bar:1')); } + public function test_predis_cluster_delete_sends_one_key_per_command(): void + { + $connection = new class extends PredisClusterConnection + { + public array $deleted = []; + + public function __construct() {} + + public function del($keys) + { + if (is_array($keys)) { + throw new NotSupportedException("Cannot use 'DEL' with redis-cluster."); + } + + $this->deleted[] = $keys; + + return 1; + } + }; + + $store = new RedisStore('normcache-test'); + (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); + + $store->delete(['foo:1', 'foo:2']); + + $this->assertSame(['foo:1', 'foo:2'], $connection->deleted); + } + public function test_scan_pattern_strips_connection_prefix_from_returned_keys(): void { if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { From 91b099d9e1c3258681bc79995508f5f41563ce2f Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 13:15:10 +1000 Subject: [PATCH 26/73] redis-backed cache space registry for cluster flushes --- README.md | 6 ++ config/normcache.php | 10 ++- src/CacheServiceProvider.php | 8 ++- src/Console/FlushCommand.php | 9 ++- src/Spaces/CacheSpaceRegistry.php | 75 ++++++++++++++++--- src/Support/RedisStore.php | 76 ++++++++++++++++++++ src/Traits/HandlesInvalidation.php | 46 +++++++++--- tests/Unit/CacheManagerTest.php | 42 ++++++++++- tests/Unit/RedisStoreTest.php | 76 ++++++++++++++++++++ tests/Unit/Spaces/CacheSpaceRegistryTest.php | 21 ++++++ 10 files changed, 341 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 1cbc36a..7c63add 100644 --- a/README.md +++ b/README.md @@ -158,11 +158,13 @@ Post::withoutAggregateCache()->withCount('comments')->get(); // skip aggregate c ```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(); +NormCache::flushAll('content'); ``` If you mutate cacheable tables outside Eloquent, flush manually after the write: @@ -233,6 +235,10 @@ Publish `config/normcache.php` to tune these options (each is also configurable - **`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`. +- **`spaces.cross_space_behavior`** — `bypass` skips caching when dependencies are not registered in the active space; `throw` raises an exception instead. +- **`spaces.placement`** — Optional hash-tag overrides for pinning spaces to specific Redis Cluster slots. + +In Redis Cluster mode, broad flushes use the recorded space registry and scan the owning master for each space hash tag when the client exposes slot-owner lookup. Standalone Redis uses wildcard hash-tag scans and does not maintain registry metadata. --- diff --git a/config/normcache.php b/config/normcache.php index 959dab5..3e916be 100644 --- a/config/normcache.php +++ b/config/normcache.php @@ -39,17 +39,15 @@ // Register the Laravel Debugbar collector for local cache inspection. 'debugbar' => env('NORMCACHE_DEBUGBAR', false), - // Redis Cluster sharding via cache spaces (see docs/space.md). Models declare - // membership with `protected static array $normCacheSpaces`. + // Redis Cluster sharding via model-declared cache spaces. 'spaces' => [ - // Cap on spaces per model; each write bumps a version key per space. + // Max spaces per model. Writes bump one version key per space. 'max_per_model' => 16, - // When a query's dependencies span spaces: 'bypass' (skip caching) or 'throw'. + // Cross-space dependency handling: 'bypass' or 'throw'. 'cross_space_behavior' => env('NORMCACHE_CROSS_SPACE_BEHAVIOR', 'bypass'), - // Optional per-space hash-tag override, to pin a space to a specific cluster - // slot/node. Omit to use the convention ('content' -> {nc:content}). + // Optional space => hash-tag override. Default: content => {nc:content}. 'placement' => [ // 'catalog' => ['hash_tag' => 'nc:catalog'], ], diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index dd11ead..afd0d3d 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -31,9 +31,13 @@ public function register(): void $this->mergeConfigFrom(__DIR__ . '/../config/normcache.php', 'normcache'); $this->app->singleton(CacheSpaceRegistry::class, function () { + $metadataStore = new RedisStore((string) config('normcache.connection'), (int) config('normcache.stampede_wake_tokens', 64)); + return new CacheSpaceRegistry( - (int) config('normcache.spaces.max_per_model', 16), - (array) config('normcache.spaces.placement', []), + maxPerModel: (int) config('normcache.spaces.max_per_model', 16), + placement: (array) config('normcache.spaces.placement', []), + metadataStore: $metadataStore->isCluster() ? $metadataStore : null, + metadataKeyPrefix: (string) config('normcache.key_prefix', ''), ); }); 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/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 91067a0..ec0ed7d 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -2,12 +2,12 @@ namespace NormCache\Spaces; +use NormCache\Support\RedisStore; use NormCache\Values\CacheSpace; use NormCache\Values\SpaceValidationResult; -// Resolves the cache spaces a model/table belongs to. Membership is declared on the -// model (Cacheable::normCacheSpaces()); no declaration = the default space. Space name -// maps to a hash tag by convention: 'default' -> "nc", "" -> "nc:". +// Model-declared cache-space registry. Non-default spaces are persisted for +// Redis Cluster flush discovery; undeclared models use the default space. final class CacheSpaceRegistry { public const DEFAULT_SPACE = 'default'; @@ -26,6 +26,8 @@ final class CacheSpaceRegistry 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 @@ -35,7 +37,12 @@ public function defaultSpace(): CacheSpace public function space(string $name): CacheSpace { - return $this->spaces[$name] ??= new CacheSpace($name, $this->hashTagFor($name)); + if (!isset($this->spaces[$name])) { + $this->spaces[$name] = new CacheSpace($name, $this->hashTagFor($name)); + $this->rememberSpace($name); + } + + return $this->spaces[$name]; } /** @return list */ @@ -47,13 +54,25 @@ public function materializedSpaces(): array return array_values($spaces); } + /** @return list */ + public function knownSpaces(): array + { + $names = array_values(array_unique([ + self::DEFAULT_SPACE, + ...$this->metadataSpaces(), + ...array_keys($this->spaces), + ])); + + return array_values(array_map(fn(string $name) => $this->space($name), $names)); + } + /** @return list */ public function spacesForModel(string $modelClass): array { return $this->modelSpaces[$modelClass] ??= $this->resolveModelSpaces($modelClass); } - // Tables have no model to declare membership, so they belong to the default space. + // Non-model table dependencies are default-space only for now. /** @return list */ public function spacesForTable(string $table): array { @@ -76,7 +95,7 @@ public function dependenciesAreOnlyModel(string $modelClass, array $models, arra } /** - * Validate a cached operation's dependencies against the active space. + * Validate operation dependencies against the active space. * * @param list $models * @param list $tables @@ -149,13 +168,13 @@ private function resolveModelSpaces(string $modelClass): array private function hashTagFor(string $name): string { - if ($name === '' || preg_match('/[:{}\s]/', $name)) { + if (!$this->validSpaceName($name)) { throw new \InvalidArgumentException( "Invalid cache space name [{$name}]: must be non-empty and contain no ':', '{', '}', or whitespace." ); } - // Config override for pinning a space to a specific slot/node; else by convention. + // Placement override; otherwise use the standard hash-tag convention. $override = $this->placement[$name]['hash_tag'] ?? null; if ($override !== null) { @@ -173,6 +192,46 @@ private function hashTagFor(string $name): string : 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 []; + } + + try { + return array_values(array_filter( + $this->metadataStore->setMembers($this->metadataSpacesKey()), + fn(string $name) => $this->validSpaceName($name), + )); + } catch (\Throwable) { + 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) { + // Registry writes must not block the read path. + } + } + + private function metadataSpacesKey(): string + { + return '{nc:meta}:' . $this->metadataKeyPrefix . 'spaces'; + } + /** @param list $allowed */ private function isAllowed(array $allowed, CacheSpace|string $space): bool { diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index a5f34f4..42328f9 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -8,6 +8,7 @@ use Illuminate\Redis\Connections\PredisClusterConnection; use Illuminate\Redis\Connections\PredisConnection; use Illuminate\Support\Facades\Redis; +use Predis\Cluster\RedisStrategy; use Predis\NotSupportedException; final class RedisStore @@ -63,6 +64,28 @@ public function setRaw(string $key, string $value, int $ttl): void $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. public function setNxEx(string $key, string $value, int $ttl): bool { @@ -443,6 +466,11 @@ function ($chunk) use (&$keys) { private function scanPredisClusterKeys(string $pattern): array { + $targeted = $this->scanPredisClusterSlotKeys($pattern); + if ($targeted !== null) { + return $targeted; + } + $keys = []; $connectionPrefix = $this->connectionPrefix(); @@ -458,6 +486,41 @@ function ($chunk) use (&$keys) { 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 = []; @@ -478,6 +541,19 @@ function ($chunk) use (&$keys) { return $keys; } + private function concreteHashTag(string $pattern): ?string + { + if (!preg_match('/\{([^{}]+)\}/', $pattern, $matches)) { + return null; + } + + $tag = $matches[1]; + + return str_contains($tag, '*') || str_contains($tag, '?') || $tag === '' + ? null + : $tag; + } + /** * @param \Closure(mixed &): mixed $scanner * @param \Closure(array): void $processor diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 4148f28..f93337c 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -32,13 +32,13 @@ private function modelSpaces(string $modelClass): array /** @return list the spaces a table's version key lives in */ private function tableSpaces(string $table): array { - return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->materializedSpaces(); + return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->knownSpaces(); } /** @return list */ - private function materializedSpaces(): array + private function knownSpaces(): array { - return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->materializedSpaces(); + return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->knownSpaces(); } // Invalidation @@ -116,9 +116,9 @@ public function forceFlushModel(string $modelClass): void } } - public function flushAll(): int + public function flushAll(CacheSpace|string|null $space = null): int { - return $this->store->flushByPatterns($this->prefixedForSpaces([ + $patterns = [ CacheKeyBuilder::K_QUERY . ':*', CacheKeyBuilder::K_MODEL . ':*', CacheKeyBuilder::K_VER . ':*', @@ -130,7 +130,19 @@ public function flushAll(): int CacheKeyBuilder::K_BUILDING . ':*', CacheKeyBuilder::K_WAKE . ':*', CacheKeyBuilder::K_RESULT . ':*', - ], $this->materializedSpaces())); + ]; + + if (!$this->store->isCluster()) { + if ($space === null) { + return $this->store->flushByPatterns($this->prefixedForAnySpace($patterns)); + } + } + + $spaces = $space === null + ? $this->knownSpaces() + : [is_string($space) ? ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->space($space) : $space]; + + return $this->store->flushByPatterns($this->prefixedForSpaces($patterns, $spaces)); } public function flushTag(string $modelClass, string $tag): int @@ -152,13 +164,19 @@ public function flushTagAcrossModels(string $tag): int { CacheKeyBuilder::assertValidTag($tag); - return $this->store->flushByPatterns($this->prefixedForSpaces([ + $patterns = [ CacheKeyBuilder::K_RESULT . ':*:' . $tag . ':*', CacheKeyBuilder::K_QUERY . ':*:' . $tag . ':*', CacheKeyBuilder::K_COUNT . ':*:' . $tag . ':*', CacheKeyBuilder::K_SCALAR . ':*:' . $tag . ':*', CacheKeyBuilder::K_THROUGH . ':*:' . $tag . ':*', - ], $this->materializedSpaces())); + ]; + + 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 @@ -309,6 +327,18 @@ private function prefixedForSpaces(array $patterns, array $spaces): array 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 { diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 1bffae6..b928575 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use NormCache\CacheManager; +use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\Fixtures\Models\SpacedPost; @@ -185,6 +186,45 @@ 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 + { + $this->app->forgetInstance(CacheSpaceRegistry::class); + + $store = $this->manager->getStore(); + $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_standalone_space_resolution_does_not_write_registry_metadata(): void + { + $this->app->forgetInstance(CacheSpaceRegistry::class); + + $this->app->make(CacheSpaceRegistry::class)->spacesForModel(SpacedPost::class); + + $this->assertSame([], $this->manager->getStore()->scanPattern('{nc:meta}:test:spaces')); + } + + public function test_flush_all_can_target_one_space(): void + { + $store = $this->manager->getStore(); + $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:'); @@ -202,7 +242,7 @@ public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enab $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', ''); diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index cd7ca5a..9a430d8 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -430,6 +430,82 @@ public function test_flush_by_patterns_scans_all_nodes_on_predis_cluster(): void $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 { $connection = new class extends PredisClusterConnection diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index d457276..bb57f38 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -40,6 +40,27 @@ public function test_placement_config_overrides_the_hash_tag(): void $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_placement_keys_are_not_registry_entries_until_materialized(): void + { + $registry = new CacheSpaceRegistry(16, ['catalog' => ['hash_tag' => 'shard7']]); + + $this->assertSame( + ['default'], + array_map(fn($s) => $s->name, $registry->knownSpaces()), + ); + } + public function test_invalid_space_name_throws(): void { $this->expectException(\InvalidArgumentException::class); From 14bef403f8a68d66562433daa37977b3042b6638 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:07:55 +1000 Subject: [PATCH 27/73] fix versioned cache writes and cluster test --- src/Cache/ModelHydrator.php | 13 ++-- src/Cache/NormalizedCacheReader.php | 4 +- src/Cache/NormalizedReader.php | 9 ++- src/Cache/NormalizedThroughReader.php | 4 +- src/CacheManager.php | 8 +-- src/Relations/CachesOneOrManyThrough.php | 7 ++- src/Relations/CachesPivotRelation.php | 6 +- .../Cache/ModelHydratorStampedeTest.php | 14 +++-- .../LuaScriptConsistencyTest.php | 4 +- tests/Unit/CacheManagerTest.php | 62 +++++++++++++++++++ 10 files changed, 101 insertions(+), 30 deletions(-) diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index fdc30fa..4a1f560 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -86,7 +86,7 @@ public function getModels( // Pre-supplied $raw skips the version GET; resolve lazily only if there are misses. if ($raw === null) { - $modelVersion = $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); + $modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace()); $raw = $this->store->getMany($this->modelKeysFor($classKey, $modelVersion, $ids)); } else { $modelVersion = 0; // deferred @@ -107,7 +107,7 @@ public function getModels( } if ($containedInQueryHit) { - $modelVersion = $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); + $modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace()); } if ($reporting) { @@ -117,7 +117,7 @@ public function getModels( ]); } - [$lockKey, $wakeKey, $token] = $this->buildLockTriple($classKey, $missed); + [$lockKey, $wakeKey, $token] = $this->buildLockTriple($classKey, $modelVersion, $missed); [$status, $missed] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $modelVersion, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); @@ -162,12 +162,13 @@ 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(); diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index 7e3f78b..fcaef8e 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -75,11 +75,11 @@ protected function buildResult( }; } - public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): void + public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): bool { $ids = array_map('strval', $ids); - $this->storePayload( + return $this->storePayload( $key, json_encode($ids, JSON_THROW_ON_ERROR), $ttl, diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index 5bca1a9..b3bee5d 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -143,7 +143,7 @@ protected function storePayload( array $versionKeys, array $expectedVersions, ?string $buildingToken, - ): void { + ): bool { $ttl ??= $this->queryTtl; if (!empty($versionKeys)) { @@ -153,7 +153,7 @@ protected function storePayload( $keys[] = $this->keys->buildingToWakeKey($buildingKey); } - $this->store->script( + return (bool) $this->store->script( RedisScripts::get('store_versioned_payload'), $keys, array_merge( @@ -163,14 +163,13 @@ protected function storePayload( ) ); - return; } if ($buildingKey === null) { - return; + return false; } - $this->store->storeRawAndRelease( + return $this->store->storeRawAndRelease( $key, $payload, $ttl, diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 94681a8..64eeefb 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -67,12 +67,12 @@ protected function buildResult( }; } - public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): void + public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): bool { $ids = array_map('strval', $ids); $payload = json_encode(['i' => $ids, 't' => $throughKeys], JSON_THROW_ON_ERROR); - $this->storePayload( + return $this->storePayload( $key, $payload, $ttl, diff --git a/src/CacheManager.php b/src/CacheManager.php index 3a98c1d..518e350 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -217,9 +217,9 @@ public function storeQueryIds(string $key, array $ids, ?int $ttl = null, ?string $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 + public function storeThroughIds(string $key, array $ids, array $throughKeys, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null): bool { - $this->throughReader->store($key, $ids, $throughKeys, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); + return $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 @@ -237,14 +237,14 @@ public function storeResultCache(string $key, array $payload, ?string $buildingK return $this->resultReader->store($key, $payload, $buildingKey, $ttl, $wakeKey, $versionKeys, $expectedVersions, $buildingToken); } - public function cacheModelAttrs(string $modelClass, array $modelAttrs): void + public function storeModelAttrs(string $modelClass, array $modelAttrs): void { if (empty($modelAttrs)) { return; } $classKey = $this->keys->classKey($modelClass); - $modelVersion = $this->versions->normalizeVersion($this->store->getRaw($this->keys->verKey($classKey))); + $modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace()); $attrsByKey = []; foreach ($modelAttrs as $id => $attrs) { diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 61b020a..9109a37 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -94,11 +94,14 @@ public function get($columns = ['*']): Collection $cachePayload = $this->cachePayloadFromResult($rawModels); NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs) { - NormCache::storeThroughIds( + $stored = NormCache::storeThroughIds( $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken ); - NormCache::cacheModelAttrs($relatedClass, $modelAttrs); + + if ($stored) { + NormCache::storeModelAttrs($relatedClass, $modelAttrs); + } }); return $prepared->applyAfterCallbacks($rawModels); diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 0d79bbd..0b003fd 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -253,12 +253,14 @@ private function populatePivotCache( $pivotEntriesByKey[$keyMap[$parentId]] = $entries; } - NormCache::storeManyVersionedResults( + $stored = NormCache::storeManyVersionedResults( $pivotEntriesByKey, ttl: $ttl, versionKeys: $versionKeys, expectedVersions: $expectedVersions, buildingKey: $buildingKey, wakeKey: $wakeKey, buildingToken: $buildingToken, ); - NormCache::cacheModelAttrs($relatedClass, $modelAttrs); + if ($stored) { + NormCache::storeModelAttrs($relatedClass, $modelAttrs); + } } private function hydrateFromPivotCache( diff --git a/tests/Integration/Cache/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index 6b7ca71..011d7d9 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -18,10 +18,12 @@ public function test_acquires_lock_fetches_once_caches_and_releases_lock_on_miss $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); @@ -44,8 +46,10 @@ public function test_falls_back_to_database_without_releasing_someone_elses_lock $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(); $this->assertTrue($store->setNxEx($lockKey, 'other-token', 5)); diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php index 1d6b9dc..c008ee1 100644 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php @@ -218,7 +218,7 @@ public function test_related_model_payload_is_not_cached_when_through_result_wri $cache->versionKeys, $cache->expectedVersions, )) { - $manager->cacheModelAttrs(Post::class, [1 => ['id' => 1, 'title' => 'Old']]); + $manager->storeModelAttrs(Post::class, [1 => ['id' => 1, 'title' => 'Old']]); } $this->assertNull($this->modelCacheEntry(Post::class, 1)); @@ -264,7 +264,7 @@ public function test_related_model_payload_is_not_cached_when_pivot_result_write $cache->versionKeys, $cache->expectedVersions, )) { - $manager->cacheModelAttrs(Tag::class, [1 => ['id' => 1, 'name' => 'Old']]); + $manager->storeModelAttrs(Tag::class, [1 => ['id' => 1, 'name' => 'Old']]); } $this->assertNull($this->modelCacheEntry(Tag::class, 1)); diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index b928575..270f8df 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -188,6 +188,10 @@ public function test_flush_all_returns_zero_when_cache_is_empty(): void 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->getStore(); @@ -203,6 +207,10 @@ public function test_flush_all_uses_wildcard_hash_tag_patterns_on_standalone_aft public function test_standalone_space_resolution_does_not_write_registry_metadata(): void { + if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { + $this->markTestSkipped('Redis Cluster mode intentionally writes space registry metadata for flush discovery.'); + } + $this->app->forgetInstance(CacheSpaceRegistry::class); $this->app->make(CacheSpaceRegistry::class)->spacesForModel(SpacedPost::class); @@ -280,6 +288,60 @@ 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->classKey(Author::class); + $store = $manager->getStore(); + + $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->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()->getStore(); + $classKey = $this->cacheManager()->classKey(Author::class); + + $versionKey = "ver:{{$classKey}}:"; + $throughKey = "through:{{$classKey}}:v5:through-hash"; + $buildingKey = "building:{{$classKey}}:v5:through-hash"; + + $store->setRaw($versionKey, '5', 3600); + $store->setRaw($buildingKey, '1', 3600); + $store->increment($versionKey); + + $stored = $this->cacheManager()->storeThroughIds( + $throughKey, + [1], + ['through-1'], + 3600, + $buildingKey, + [$versionKey], + ['5'], + ); + + $this->assertFalse($stored); + $this->assertNull($store->getRaw($throughKey)); + $this->assertNull($store->getRaw($buildingKey)); + } + public function test_store_query_ids_skips_write_on_version_mismatch(): void { $store = $this->cacheManager()->getStore(); From b185e68d24916e04026f686446b6c77a36bbbc2b Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:23:57 +1000 Subject: [PATCH 28/73] fix guarded relation model writes and phpstan issues --- src/CacheManager.php | 22 +++++++++++++---- src/Relations/CachesOneOrManyThrough.php | 8 +++--- src/Relations/CachesPivotRelation.php | 11 +++++++-- src/Spaces/CacheSpaceRegistry.php | 6 ++--- src/Support/RedisStore.php | 2 +- tests/Unit/CacheManagerTest.php | 31 ++++++++++++++++++++++++ 6 files changed, 66 insertions(+), 14 deletions(-) diff --git a/src/CacheManager.php b/src/CacheManager.php index 518e350..1855457 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -143,6 +143,11 @@ public function currentVersion(string $modelClass): int return $this->versions->currentVersion($modelClass, $this->modelSpaces($modelClass)[0]); } + public function currentVersionInSpace(string $modelClass, string $space): int + { + return $this->versions->currentVersion($modelClass, $this->spaceFor($modelClass, $space)); + } + public function currentTableVersion(string $connectionName, string $table): int { return $this->versions->currentTableVersion($connectionName, $table); @@ -237,25 +242,32 @@ public function storeResultCache(string $key, array $payload, ?string $buildingK return $this->resultReader->store($key, $payload, $buildingKey, $ttl, $wakeKey, $versionKeys, $expectedVersions, $buildingToken); } - public function storeModelAttrs(string $modelClass, array $modelAttrs): void + public function storeModelAttrs(string $modelClass, array $modelAttrs, ?CacheSpace $space = null): void + { + $space ??= $this->keys->activeSpace(); + $modelVersion = $this->versions->currentVersion($modelClass, $space); + + $this->storeModelAttrsForVersion($modelClass, $modelAttrs, $modelVersion, $space); + } + + public function storeModelAttrsForVersion(string $modelClass, array $modelAttrs, int $expectedVersion, ?CacheSpace $space = null): void { if (empty($modelAttrs)) { return; } $classKey = $this->keys->classKey($modelClass); - $modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace()); $attrsByKey = []; foreach ($modelAttrs as $id => $attrs) { - $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; + $attrsByKey[$this->keys->modelPrefix($classKey, $expectedVersion, $space) . $id] = $attrs; } $this->store->setManyIfVersion( $attrsByKey, $this->config->ttl, - $this->keys->verKey($classKey), - $modelVersion + $this->keys->verKey($classKey, $space), + $expectedVersion ); } diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 9109a37..68bdbfd 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -92,15 +92,17 @@ public function get($columns = ['*']): Collection } $cachePayload = $this->cachePayloadFromResult($rawModels); + $space = NormCache::keys()->activeSpace(); + $relatedVersion = isset($result->expectedVersions[0]) ? (int) $result->expectedVersions[0] : null; - NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs) { + NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $relatedVersion, $space) { $stored = NormCache::storeThroughIds( $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken ); - if ($stored) { - NormCache::storeModelAttrs($relatedClass, $modelAttrs); + if ($stored && $relatedVersion !== null) { + NormCache::storeModelAttrsForVersion($relatedClass, $modelAttrs, $relatedVersion, $space); } }); diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 0b003fd..0f0ac32 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -258,8 +258,15 @@ private function populatePivotCache( buildingKey: $buildingKey, wakeKey: $wakeKey, buildingToken: $buildingToken, ); - if ($stored) { - NormCache::storeModelAttrs($relatedClass, $modelAttrs); + $relatedVersion = isset($expectedVersions[0]) ? (int) $expectedVersions[0] : null; + + if ($stored && $relatedVersion !== null) { + NormCache::storeModelAttrsForVersion( + $relatedClass, + $modelAttrs, + $relatedVersion, + NormCache::keys()->activeSpace(), + ); } } diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index ec0ed7d..a54e232 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -63,7 +63,7 @@ public function knownSpaces(): array ...array_keys($this->spaces), ])); - return array_values(array_map(fn(string $name) => $this->space($name), $names)); + return array_map(fn(string $name) => $this->space($name), $names); } /** @return list */ @@ -222,8 +222,8 @@ private function rememberSpace(string $name): void try { $this->metadataStore->addToSet($this->metadataSpacesKey(), [$name]); - } catch (\Throwable) { - // Registry writes must not block the read path. + } catch (\Throwable $e) { + report($e); } } diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 42328f9..7c16209 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -549,7 +549,7 @@ private function concreteHashTag(string $pattern): ?string $tag = $matches[1]; - return str_contains($tag, '*') || str_contains($tag, '?') || $tag === '' + return str_contains($tag, '*') || str_contains($tag, '?') ? null : $tag; } diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 270f8df..286dd03 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -342,6 +342,37 @@ public function test_store_through_ids_returns_false_on_version_mismatch(): void $this->assertNull($store->getRaw($buildingKey)); } + public function test_store_model_attrs_for_version_skips_stale_version_write(): void + { + $manager = $this->cacheManager(); + $store = $manager->getStore(); + $classKey = $manager->classKey(Author::class); + + $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); + $store->increment($manager->keys()->verKey($classKey)); + + $manager->storeModelAttrsForVersion(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->getStore(); + $classKey = $manager->classKey(Author::class); + + $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); + + $manager->storeModelAttrsForVersion(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(); From 57ddf46605f08d818ee7416e7b66a105b906eb90 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 15:43:34 +1000 Subject: [PATCH 29/73] fix relation version lookup and placement spaces --- src/CacheManager.php | 10 ++++++++++ src/Relations/CachesOneOrManyThrough.php | 7 ++++++- src/Relations/CachesPivotRelation.php | 7 ++++++- src/Spaces/CacheSpaceRegistry.php | 1 + tests/Unit/CacheManagerTest.php | 13 +++++++++++++ tests/Unit/Spaces/CacheSpaceRegistryTest.php | 4 ++-- 6 files changed, 38 insertions(+), 4 deletions(-) diff --git a/src/CacheManager.php b/src/CacheManager.php index 1855457..47c8d98 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -250,6 +250,16 @@ public function storeModelAttrs(string $modelClass, array $modelAttrs, ?CacheSpa $this->storeModelAttrsForVersion($modelClass, $modelAttrs, $modelVersion, $space); } + public function expectedVersionForModel(string $modelClass, array $versionKeys, array $expectedVersions, ?CacheSpace $space = null): ?int + { + $classKey = $this->keys->classKey($modelClass); + $index = array_search($this->keys->verKey($classKey, $space), $versionKeys, true); + + return $index === false || !isset($expectedVersions[$index]) + ? null + : (int) $expectedVersions[$index]; + } + public function storeModelAttrsForVersion(string $modelClass, array $modelAttrs, int $expectedVersion, ?CacheSpace $space = null): void { if (empty($modelAttrs)) { diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 68bdbfd..8c5c8b3 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -93,7 +93,12 @@ public function get($columns = ['*']): Collection $cachePayload = $this->cachePayloadFromResult($rawModels); $space = NormCache::keys()->activeSpace(); - $relatedVersion = isset($result->expectedVersions[0]) ? (int) $result->expectedVersions[0] : null; + $relatedVersion = NormCache::expectedVersionForModel( + $relatedClass, + $result->versionKeys, + $result->expectedVersions, + $space, + ); NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $relatedVersion, $space) { $stored = NormCache::storeThroughIds( diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 0f0ac32..3f8ee36 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -258,7 +258,12 @@ private function populatePivotCache( buildingKey: $buildingKey, wakeKey: $wakeKey, buildingToken: $buildingToken, ); - $relatedVersion = isset($expectedVersions[0]) ? (int) $expectedVersions[0] : null; + $relatedVersion = NormCache::expectedVersionForModel( + $relatedClass, + $versionKeys, + $expectedVersions, + NormCache::keys()->activeSpace(), + ); if ($stored && $relatedVersion !== null) { NormCache::storeModelAttrsForVersion( diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index a54e232..1078322 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -59,6 +59,7 @@ public function knownSpaces(): array { $names = array_values(array_unique([ self::DEFAULT_SPACE, + ...array_keys($this->placement), ...$this->metadataSpaces(), ...array_keys($this->spaces), ])); diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 286dd03..15c9dc8 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -342,6 +342,19 @@ public function test_store_through_ids_returns_false_on_version_mismatch(): void $this->assertNull($store->getRaw($buildingKey)); } + public function test_expected_version_for_model_uses_matching_version_key(): void + { + $manager = $this->cacheManager(); + $authorKey = $manager->classKey(Author::class); + $postKey = $manager->classKey(Post::class); + + $this->assertSame(9, $manager->expectedVersionForModel( + Author::class, + [$manager->keys()->verKey($postKey), $manager->keys()->verKey($authorKey)], + ['4', '9'], + )); + } + public function test_store_model_attrs_for_version_skips_stale_version_write(): void { $manager = $this->cacheManager(); diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index bb57f38..74e9585 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -51,12 +51,12 @@ public function test_known_spaces_include_default_and_materialized_spaces(): voi ); } - public function test_placement_keys_are_not_registry_entries_until_materialized(): void + public function test_known_spaces_include_configured_placement_spaces(): void { $registry = new CacheSpaceRegistry(16, ['catalog' => ['hash_tag' => 'shard7']]); $this->assertSame( - ['default'], + ['default', 'catalog'], array_map(fn($s) => $s->name, $registry->knownSpaces()), ); } From ee9bf10795709cc2f60cbf0816bc0f5978010a55 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:03:05 +1000 Subject: [PATCH 30/73] simplify space scoping and versioned cache writes --- src/Cache/ModelsExecutor.php | 5 ++-- src/Cache/NormalizedCacheReader.php | 8 +++-- src/Cache/NormalizedReader.php | 17 +++++++---- src/Cache/NormalizedThroughReader.php | 8 +++-- src/Cache/ResultCacheReader.php | 8 ++--- src/CacheManager.php | 38 +++++++++++++++++++++--- src/Relations/CacheableBelongsTo.php | 12 ++++---- src/Relations/CacheableMorphTo.php | 12 ++++---- src/Relations/CachesOneOrManyThrough.php | 27 +++++++---------- src/Relations/CachesPivotRelation.php | 21 ++++--------- src/Spaces/CacheSpaceRegistry.php | 22 +++++++++----- src/Support/CacheKeyBuilder.php | 17 ----------- src/Traits/HandlesInvalidation.php | 6 ++-- src/Values/QueryCacheResult.php | 1 + src/Values/ThroughCacheResult.php | 1 + tests/Unit/CacheKeyBuilderTest.php | 27 ----------------- 16 files changed, 109 insertions(+), 121 deletions(-) diff --git a/src/Cache/ModelsExecutor.php b/src/Cache/ModelsExecutor.php index 4b57284..02be718 100644 --- a/src/Cache/ModelsExecutor.php +++ b/src/Cache/ModelsExecutor.php @@ -65,7 +65,7 @@ public function runNormalized( $ids = $this->resolveIds( $result->key, $base, $queryTtl, $prototype, - $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken + $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken, $result->wakeKey ); return $executionBuilder->finalizeResult( @@ -107,9 +107,10 @@ private function resolveIds( array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null, + ?string $wakeKey = null, ): array { $ids = $this->buildIds($base, $prototype); - NormCache::storeQueryIds($key, $ids, $queryTtl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); + NormCache::storeQueryIds($key, $ids, $queryTtl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken, $wakeKey); return $ids; } diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index fcaef8e..7aa125e 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -60,6 +60,7 @@ protected function buildResult( string $queryKey, string $buildingKey, string $lockToken, + string $wakeKey, array $versionKeys, array $expectedVersions, ?array $ids = null, @@ -69,13 +70,13 @@ protected function buildResult( 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::Miss => new QueryCacheResult(CacheStatus::Miss, $queryKey, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions, $wakeKey), LuaStatus::Building => new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []), - LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions), + LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions), }; } - public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): bool + public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken, ?string $wakeKey = null): bool { $ids = array_map('strval', $ids); @@ -87,6 +88,7 @@ public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, $versionKeys, $expectedVersions, $buildingToken, + $wakeKey, ); } } diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index b3bee5d..80e512e 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -49,6 +49,7 @@ abstract protected function buildResult( string $queryKey, string $buildingKey, string $lockToken, + string $wakeKey, array $versionKeys, array $expectedVersions, ?array $ids = null, @@ -79,6 +80,7 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $seg = (string) ($result[1] ?? ''); $queryKey = $queryPrefix . $seg . ':' . $hash; $buildingKey = $this->keys->buildingPrefix($classKey) . $seg . ':' . $hash; + $wakeKey = $this->keys->wakePrefix($classKey) . $hash; $expectedVersions = $this->keys->versionsFromSegment($seg); [$status, $ids, $extra] = $this->decodePayload($result, $queryKey); @@ -92,6 +94,7 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $queryKey, $buildingKey, (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), + $wakeKey, $versionKeys, $expectedVersions, $ids, @@ -122,16 +125,17 @@ protected function claimMissAfterCorruptHit( string $queryKey, string $buildingKey, string $lockToken, + string $wakeKey, array $versionKeys, array $expectedVersions, ): QueryCacheResult|ThroughCacheResult { if ($this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { - $this->store->delete($this->keys->buildingToWakeKey($buildingKey)); + $this->store->delete($wakeKey); - return $this->buildResult(LuaStatus::Miss, $queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions); + return $this->buildResult(LuaStatus::Miss, $queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions); } - return $this->buildResult(LuaStatus::Building, $queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions); + return $this->buildResult(LuaStatus::Building, $queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions); } // Store an encoded payload, version-guarded, releasing the build lock. @@ -143,6 +147,7 @@ protected function storePayload( array $versionKeys, array $expectedVersions, ?string $buildingToken, + ?string $wakeKey = null, ): bool { $ttl ??= $this->queryTtl; @@ -150,7 +155,9 @@ protected function storePayload( $keys = array_merge($versionKeys, [$key]); if ($buildingKey !== null) { $keys[] = $buildingKey; - $keys[] = $this->keys->buildingToWakeKey($buildingKey); + if ($wakeKey !== null) { + $keys[] = $wakeKey; + } } return (bool) $this->store->script( @@ -174,7 +181,7 @@ protected function storePayload( $payload, $ttl, $buildingKey, - $this->keys->buildingToWakeKey($buildingKey), + $wakeKey, $buildingToken ); } diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 64eeefb..9d07546 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -52,6 +52,7 @@ protected function buildResult( string $queryKey, string $buildingKey, string $lockToken, + string $wakeKey, array $versionKeys, array $expectedVersions, ?array $ids = null, @@ -61,13 +62,13 @@ protected function buildResult( return match ($status) { LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $queryKey, $ids, $extra, $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::Miss => new ThroughCacheResult(CacheStatus::Miss, $queryKey, null, null, null, $buildingKey, $lockToken, $versionKeys, $expectedVersions, $wakeKey), LuaStatus::Building => new ThroughCacheResult(CacheStatus::Building, null, null, null, null, null, null, [], []), - LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $versionKeys, $expectedVersions), + LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions), }; } - public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken): bool + public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken, ?string $wakeKey = null): bool { $ids = array_map('strval', $ids); $payload = json_encode(['i' => $ids, 't' => $throughKeys], JSON_THROW_ON_ERROR); @@ -80,6 +81,7 @@ public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?s $versionKeys, $expectedVersions, $buildingToken, + $wakeKey, ); } } diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index 6cd5b6b..24d7d1e 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -190,9 +190,7 @@ public function store( } return $this->store->storeSerializedAndRelease( - $key, $payload, $ttl, $buildingKey, - $wakeKey ?? ($buildingKey !== null ? $this->keys->buildingToWakeKey($buildingKey) : null), - $buildingToken + $key, $payload, $ttl, $buildingKey, $wakeKey, $buildingToken ); } @@ -208,7 +206,9 @@ public function storeMany( $keys = array_merge($versionKeys, array_keys($entries)); if ($buildingKey !== null) { $keys[] = $buildingKey; - $keys[] = $wakeKey ?? $this->keys->buildingToWakeKey($buildingKey); + if ($wakeKey !== null) { + $keys[] = $wakeKey; + } } return (bool) $this->store->script( diff --git a/src/CacheManager.php b/src/CacheManager.php index 47c8d98..d6e36a4 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -131,6 +131,20 @@ public function activeSpaceFor(string $modelClass, ?string $explicitSpace = null return null; } + public function withSpace(CacheSpace $space, callable $callback): mixed + { + $active = $this->keys->activeSpace(); + + return $active !== null && $active->name === $space->name + ? $callback() + : $this->keys->withSpace($space, $callback); + } + + public function withSpaceForModel(string $modelClass, ?string $explicitSpace, callable $callback): mixed + { + return $this->withSpace($this->spaceFor($modelClass, $explicitSpace), $callback); + } + private ?CacheSpaceResolver $spaceResolver = null; public function tableKey(string $connectionName, string $table): string @@ -217,14 +231,14 @@ public function hydrateResult(array $payload, string|Model $model, bool $cached // Storage // ------------------------------------------------------------------------- - public function storeQueryIds(string $key, array $ids, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null): void + public function storeQueryIds(string $key, array $ids, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null, ?string $wakeKey = null): void { - $this->queryReader->store($key, $ids, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); + $this->queryReader->store($key, $ids, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken, $wakeKey); } - public function storeThroughIds(string $key, array $ids, array $throughKeys, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null): bool + public function storeThroughIds(string $key, array $ids, array $throughKeys, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null, ?string $wakeKey = null): bool { - return $this->throughReader->store($key, $ids, $throughKeys, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken); + return $this->throughReader->store($key, $ids, $throughKeys, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken, $wakeKey); } public function storeVersionedResult(string $key, mixed $payload, ?int $ttl = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null): bool @@ -260,6 +274,22 @@ public function expectedVersionForModel(string $modelClass, array $versionKeys, : (int) $expectedVersions[$index]; } + public function storeModelAttrsForVersionedResult( + string $modelClass, + array $modelAttrs, + array $versionKeys, + array $expectedVersions, + ?CacheSpace $space = null, + ): void { + $expectedVersion = $this->expectedVersionForModel($modelClass, $versionKeys, $expectedVersions, $space); + + if ($expectedVersion === null) { + return; + } + + $this->storeModelAttrsForVersion($modelClass, $modelAttrs, $expectedVersion, $space); + } + public function storeModelAttrsForVersion(string $modelClass, array $modelAttrs, int $expectedVersion, ?CacheSpace $space = null): void { if (empty($modelAttrs)) { diff --git a/src/Relations/CacheableBelongsTo.php b/src/Relations/CacheableBelongsTo.php index 83db98a..d43f226 100644 --- a/src/Relations/CacheableBelongsTo.php +++ b/src/Relations/CacheableBelongsTo.php @@ -41,13 +41,11 @@ public function getEager() return $this->getFromPreparedBuilder($prepared); } - $keys = NormCache::keys(); - $models = NormCache::activeSpaceFor($this->related::class, $builder->getSpace()) !== null - ? NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false) - : $keys->withSpace( - NormCache::spaceFor($this->related::class, $builder->getSpace()), - fn() => NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false) - ); + $models = NormCache::withSpaceForModel( + $this->related::class, + $builder->getSpace(), + fn() => NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false), + ); if ($builder->getEagerLoads() !== []) { $models = $builder->eagerLoadRelations($models); diff --git a/src/Relations/CacheableMorphTo.php b/src/Relations/CacheableMorphTo.php index d7c4e24..c3f6546 100644 --- a/src/Relations/CacheableMorphTo.php +++ b/src/Relations/CacheableMorphTo.php @@ -138,13 +138,11 @@ private function getResultsFromCache(string $type, Model $instance, ?array $colu $missedQuery = $queryWithMacros->mergeConstraintsFrom($this->query); } - $keys = NormCache::keys(); - $models = NormCache::activeSpaceFor($class) !== null - ? NormCache::getModels($ids, $class, $columns, null, $missedQuery, false) - : $keys->withSpace( - NormCache::spaceFor($class), - fn() => NormCache::getModels($ids, $class, $columns, null, $missedQuery, false) - ); + $models = NormCache::withSpaceForModel( + $class, + null, + fn() => NormCache::getModels($ids, $class, $columns, null, $missedQuery, false), + ); $collection = $instance->newCollection($models); $eagerLoads = $this->morphableEagerLoads[$class] ?? []; diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 8c5c8b3..2dd91e6 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -93,21 +93,21 @@ public function get($columns = ['*']): Collection $cachePayload = $this->cachePayloadFromResult($rawModels); $space = NormCache::keys()->activeSpace(); - $relatedVersion = NormCache::expectedVersionForModel( - $relatedClass, - $result->versionKeys, - $result->expectedVersions, - $space, - ); - NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $relatedVersion, $space) { + NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $space) { $stored = NormCache::storeThroughIds( $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, - $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken + $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken, $result->wakeKey ); - if ($stored && $relatedVersion !== null) { - NormCache::storeModelAttrsForVersion($relatedClass, $modelAttrs, $relatedVersion, $space); + if ($stored) { + NormCache::storeModelAttrsForVersionedResult( + $relatedClass, + $modelAttrs, + $result->versionKeys, + $result->expectedVersions, + $space, + ); } }); @@ -130,12 +130,7 @@ public function get($columns = ['*']): Collection fn() => $this->getFromPreparedBuilder($prepared) ); - $keys = NormCache::keys(); - $activeSpace = NormCache::activeSpaceFor($relatedClass); - - return $activeSpace !== null && ($plan->space === null || $plan->space->name === $activeSpace->name) - ? $runThrough() - : $keys->withSpace($plan->space, $runThrough); + return NormCache::withSpace($plan->space, $runThrough); } private function applyOneOfManyDependency(CacheableBuilder $query): void diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 3f8ee36..218a5ef 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -150,12 +150,7 @@ public function get($columns = ['*']): Collection fn() => $this->getFromPreparedPivotBuilder($prepared) ); - $keys = NormCache::keys(); - $results = NormCache::activeSpaceFor($relatedClass, $builder->getSpace()) !== null - ? $runPivot() - : $keys->withSpace(NormCache::spaceFor($relatedClass, $builder->getSpace()), $runPivot); - - return $results; + return NormCache::withSpaceForModel($relatedClass, $builder->getSpace(), $runPivot); } private function shouldUsePivotCache( @@ -258,18 +253,12 @@ private function populatePivotCache( buildingKey: $buildingKey, wakeKey: $wakeKey, buildingToken: $buildingToken, ); - $relatedVersion = NormCache::expectedVersionForModel( - $relatedClass, - $versionKeys, - $expectedVersions, - NormCache::keys()->activeSpace(), - ); - - if ($stored && $relatedVersion !== null) { - NormCache::storeModelAttrsForVersion( + if ($stored) { + NormCache::storeModelAttrsForVersionedResult( $relatedClass, $modelAttrs, - $relatedVersion, + $versionKeys, + $expectedVersions, NormCache::keys()->activeSpace(), ); } diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 1078322..78fa87b 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -37,12 +37,7 @@ public function defaultSpace(): CacheSpace public function space(string $name): CacheSpace { - if (!isset($this->spaces[$name])) { - $this->spaces[$name] = new CacheSpace($name, $this->hashTagFor($name)); - $this->rememberSpace($name); - } - - return $this->spaces[$name]; + return $this->materializeSpace($name); } /** @return list */ @@ -64,7 +59,7 @@ public function knownSpaces(): array ...array_keys($this->spaces), ])); - return array_map(fn(string $name) => $this->space($name), $names); + return array_map(fn(string $name) => $this->materializeSpace($name, remember: false), $names); } /** @return list */ @@ -167,6 +162,19 @@ private function resolveModelSpaces(string $modelClass): array return array_values(array_map(fn($name) => $this->space($name), $names)); } + private function materializeSpace(string $name, bool $remember = true): CacheSpace + { + if (!isset($this->spaces[$name])) { + $this->spaces[$name] = new CacheSpace($name, $this->hashTagFor($name)); + + if ($remember) { + $this->rememberSpace($name); + } + } + + return $this->spaces[$name]; + } + private function hashTagFor(string $name): string { if (!$this->validSpaceName($name)) { diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index c94c184..574b8b9 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -225,23 +225,6 @@ public function versionsFromSegment(string $seg): array return $parts; } - public function buildingToWakeKey(string $buildingKey): string - { - $keyword = self::K_BUILDING . ':'; - $pos = strpos($buildingKey, $keyword); - $afterKeyword = $pos + strlen($keyword); - - $connEnd = strpos($buildingKey, ':', $afterKeyword); - $tableEnd = strpos($buildingKey, ':', $connEnd + 1); - - $head = substr($buildingKey, 0, $tableEnd); - $head = substr_replace($head, self::K_WAKE, $pos, strlen(self::K_BUILDING)); - - $hash = substr(strrchr($buildingKey, ':'), 1); - - return $head . ':' . $hash; - } - // ------------------------------------------------------------------------- // Dependency Resolvers // ------------------------------------------------------------------------- diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index f93337c..fff3d7c 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -30,7 +30,7 @@ private function modelSpaces(string $modelClass): array } /** @return list the spaces a table's version key lives in */ - private function tableSpaces(string $table): array + private function tableInvalidationSpaces(string $table): array { return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->knownSpaces(); } @@ -102,7 +102,7 @@ public function invalidateTableVersion(string $connectionName, string $table): v $this->queueOrRun( $connectionName, - fn() => $this->queueVersionFlush($connectionName, $classKey, $this->tableSpaces($table)), + fn() => $this->queueVersionFlush($connectionName, $classKey, $this->tableInvalidationSpaces($table)), fn() => $this->doInvalidateTable($table, $classKey), ); } @@ -262,7 +262,7 @@ private function doInvalidateVersion(string $modelClass): void private function doInvalidateTable(string $table, string $classKey): void { - foreach ($this->tableSpaces($table) as $space) { + foreach ($this->tableInvalidationSpaces($table) as $space) { $this->doInvalidateKey($classKey, $space); } } diff --git a/src/Values/QueryCacheResult.php b/src/Values/QueryCacheResult.php index e482c20..c1bd45d 100644 --- a/src/Values/QueryCacheResult.php +++ b/src/Values/QueryCacheResult.php @@ -15,5 +15,6 @@ public function __construct( public ?string $buildingToken, public array $versionKeys, public array $expectedVersions, + public ?string $wakeKey = null, ) {} } diff --git a/src/Values/ThroughCacheResult.php b/src/Values/ThroughCacheResult.php index cf14193..11193b5 100644 --- a/src/Values/ThroughCacheResult.php +++ b/src/Values/ThroughCacheResult.php @@ -16,5 +16,6 @@ public function __construct( public ?string $buildingToken, public array $versionKeys, public array $expectedVersions, + public ?string $wakeKey = null, ) {} } diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index 098c170..9e2672c 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -41,24 +41,6 @@ public function test_class_key_rejects_connection_name_containing_colon(): void $keys->classKey($model::class); } - public function test_building_to_wake_key_carves_multi_version_key_without_braces(): void - { - $keys = new CacheKeyBuilder; - - $wake = $keys->buildingToWakeKey('building:mysql:posts:v12:v3:v9:abc123'); - - $this->assertSame('wake:mysql:posts:abc123', $wake); - } - - public function test_building_to_wake_key_carves_single_version_key(): void - { - $keys = new CacheKeyBuilder; - - $wake = $keys->buildingToWakeKey('building:mysql:posts:v1:deadbeef'); - - $this->assertSame('wake:mysql:posts:deadbeef', $wake); - } - public function test_version_keys_are_brace_free(): void { $keys = new CacheKeyBuilder('', ''); @@ -70,15 +52,6 @@ public function test_version_keys_are_brace_free(): void $this->assertSame('model:mysql:posts:v3:', $keys->modelPrefix('mysql:posts', 3)); } - public function test_building_to_wake_key_is_prefix_agnostic_on_full_keys(): void - { - $keys = new CacheKeyBuilder; - - $wake = $keys->buildingToWakeKey('{nc}:test:building:mysql:posts:v12:v3:abc123'); - - $this->assertSame('{nc}:test:wake:mysql:posts:abc123', $wake); - } - public function test_dep_key_pairs_respects_active_space_in_static_cache(): void { $keys = new CacheKeyBuilder('{nc}:', 'test:'); From dbe59842922906242296facebd9a3f017ca998c2 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:35:52 +1000 Subject: [PATCH 31/73] extract BuildContext --- src/Cache/NormalizedCacheReader.php | 18 ++++----- src/Cache/NormalizedReader.php | 53 +++++++++++---------------- src/Cache/NormalizedThroughReader.php | 18 ++++----- src/CacheManager.php | 30 ++------------- src/CacheableBuilder.php | 4 +- src/Traits/CachesScalarResults.php | 2 +- src/Values/BuildContext.php | 15 ++++++++ tests/Unit/CacheManagerTest.php | 49 ++++++++++++++----------- 8 files changed, 85 insertions(+), 104 deletions(-) create mode 100644 src/Values/BuildContext.php diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index 7aa125e..cb8670d 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -4,6 +4,7 @@ use NormCache\Enums\CacheStatus; use NormCache\Enums\LuaStatus; +use NormCache\Values\BuildContext; use NormCache\Values\QueryCacheResult; /** @@ -57,22 +58,17 @@ private function resolveIdsPayload(mixed $payload, string $queryKey): ?array protected function buildResult( LuaStatus $status, - string $queryKey, - string $buildingKey, - string $lockToken, - string $wakeKey, - array $versionKeys, - array $expectedVersions, + BuildContext $build, ?array $ids = null, - mixed $extra = null, + ?array $throughKeys = 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, $wakeKey), + LuaStatus::Hit => new QueryCacheResult(CacheStatus::Hit, $build->queryKey, $ids, $models ?? [], null, null, [], []), + LuaStatus::Empty => new QueryCacheResult(CacheStatus::Empty, $build->queryKey, [], [], null, null, [], []), + LuaStatus::Miss => new QueryCacheResult(CacheStatus::Miss, $build->queryKey, null, null, $build->buildingKey, $build->lockToken, $build->versionKeys, $build->expectedVersions, $build->wakeKey), LuaStatus::Building => new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []), - LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions), + LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($build), }; } diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index 80e512e..4288650 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -7,6 +7,7 @@ use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; +use NormCache\Values\BuildContext; use NormCache\Values\QueryCacheResult; use NormCache\Values\ThroughCacheResult; @@ -32,10 +33,10 @@ public function __construct( abstract protected function queryPrefix(string $classKey, ?string $tag): string; /** - * Decode the Lua payload to [status, ids, extra]. ids: list on hit, [] empty, - * null otherwise. extra: reader-specific (e.g. through keys). + * Decode the Lua payload to [status, ids, throughKeys]. ids: list on hit, + * [] empty, null otherwise. throughKeys is only used by through relations. * - * @return array{0: LuaStatus, 1: ?array, 2: mixed} + * @return array{0: LuaStatus, 1: ?array, 2: ?array} */ abstract protected function decodePayload(array $result, string $queryKey): array; @@ -46,14 +47,9 @@ abstract protected function decodePayload(array $result, string $queryKey): arra */ abstract protected function buildResult( LuaStatus $status, - string $queryKey, - string $buildingKey, - string $lockToken, - string $wakeKey, - array $versionKeys, - array $expectedVersions, + BuildContext $build, ?array $ids = null, - mixed $extra = null, + ?array $throughKeys = null, ?array $models = null, ): QueryCacheResult|ThroughCacheResult; @@ -83,7 +79,7 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $wakeKey = $this->keys->wakePrefix($classKey) . $hash; $expectedVersions = $this->keys->versionsFromSegment($seg); - [$status, $ids, $extra] = $this->decodePayload($result, $queryKey); + [$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; @@ -91,14 +87,16 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep return $this->buildResult( $status, - $queryKey, - $buildingKey, - (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), - $wakeKey, - $versionKeys, - $expectedVersions, + new BuildContext( + queryKey: $queryKey, + buildingKey: $buildingKey, + lockToken: (string) (($status === LuaStatus::Miss ? $result[2] : null) ?? $lockToken), + wakeKey: $wakeKey, + versionKeys: $versionKeys, + expectedVersions: $expectedVersions, + ), $ids, - $extra, + $throughKeys, $models, ); } @@ -122,20 +120,15 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr * @return TResult */ protected function claimMissAfterCorruptHit( - string $queryKey, - string $buildingKey, - string $lockToken, - string $wakeKey, - array $versionKeys, - array $expectedVersions, + BuildContext $build, ): QueryCacheResult|ThroughCacheResult { - if ($this->store->setNxEx($buildingKey, $lockToken, $this->buildingLockTtl)) { - $this->store->delete($wakeKey); + if ($this->store->setNxEx($build->buildingKey, $build->lockToken, $this->buildingLockTtl)) { + $this->store->delete($build->wakeKey); - return $this->buildResult(LuaStatus::Miss, $queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions); + return $this->buildResult(LuaStatus::Miss, $build); } - return $this->buildResult(LuaStatus::Building, $queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions); + return $this->buildResult(LuaStatus::Building, $build); } // Store an encoded payload, version-guarded, releasing the build lock. @@ -155,9 +148,7 @@ protected function storePayload( $keys = array_merge($versionKeys, [$key]); if ($buildingKey !== null) { $keys[] = $buildingKey; - if ($wakeKey !== null) { - $keys[] = $wakeKey; - } + $keys[] = $wakeKey; } return (bool) $this->store->script( diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 9d07546..50a2343 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -5,6 +5,7 @@ use NormCache\Enums\CacheStatus; use NormCache\Enums\LuaStatus; use NormCache\Support\CacheKeyBuilder; +use NormCache\Values\BuildContext; use NormCache\Values\ThroughCacheResult; /** @@ -49,22 +50,17 @@ protected function decodePayload(array $result, string $queryKey): array protected function buildResult( LuaStatus $status, - string $queryKey, - string $buildingKey, - string $lockToken, - string $wakeKey, - array $versionKeys, - array $expectedVersions, + BuildContext $build, ?array $ids = null, - mixed $extra = null, + ?array $throughKeys = null, ?array $models = null, ): ThroughCacheResult { return match ($status) { - LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $queryKey, $ids, $extra, $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, $wakeKey), + LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $build->queryKey, $ids, $throughKeys, $models ?? [], null, null, [], []), + LuaStatus::Empty => new ThroughCacheResult(CacheStatus::Empty, $build->queryKey, [], [], [], null, null, [], []), + LuaStatus::Miss => new ThroughCacheResult(CacheStatus::Miss, $build->queryKey, null, null, null, $build->buildingKey, $build->lockToken, $build->versionKeys, $build->expectedVersions, $build->wakeKey), LuaStatus::Building => new ThroughCacheResult(CacheStatus::Building, null, null, null, null, null, null, [], []), - LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($queryKey, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions), + LuaStatus::Corrupt => $this->claimMissAfterCorruptHit($build), }; } diff --git a/src/CacheManager.php b/src/CacheManager.php index d6e36a4..38186c8 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -110,29 +110,12 @@ public function spaceFor(string $modelClass, ?string $explicitSpace = null): Cac return ($this->spaceResolver ??= app(CacheSpaceResolver::class))->resolve($modelClass, $explicitSpace); } - public function activeSpaceFor(string $modelClass, ?string $explicitSpace = null): ?CacheSpace + public function withSpace(?CacheSpace $space, callable $callback): mixed { - $active = $this->keys->activeSpace(); - - if ($active === null) { - return null; - } - - if ($explicitSpace !== null && $active->name !== $explicitSpace) { - return null; - } - - foreach ($this->modelSpaces($modelClass) as $space) { - if ($space->name === $active->name) { - return $active; - } + if ($space === null) { + return $this->keys->withSpace(null, $callback); } - return null; - } - - public function withSpace(CacheSpace $space, callable $callback): mixed - { $active = $this->keys->activeSpace(); return $active !== null && $active->name === $space->name @@ -157,11 +140,6 @@ public function currentVersion(string $modelClass): int return $this->versions->currentVersion($modelClass, $this->modelSpaces($modelClass)[0]); } - public function currentVersionInSpace(string $modelClass, string $space): int - { - return $this->versions->currentVersion($modelClass, $this->spaceFor($modelClass, $space)); - } - public function currentTableVersion(string $connectionName, string $table): int { return $this->versions->currentTableVersion($connectionName, $table); @@ -264,7 +242,7 @@ public function storeModelAttrs(string $modelClass, array $modelAttrs, ?CacheSpa $this->storeModelAttrsForVersion($modelClass, $modelAttrs, $modelVersion, $space); } - public function expectedVersionForModel(string $modelClass, array $versionKeys, array $expectedVersions, ?CacheSpace $space = null): ?int + private function expectedVersionForModel(string $modelClass, array $versionKeys, array $expectedVersions, ?CacheSpace $space = null): ?int { $classKey = $this->keys->classKey($modelClass); $index = array_search($this->keys->verKey($classKey, $space), $versionKeys, true); diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index cbaf00c..f0b7550 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -265,7 +265,7 @@ public function get($columns = ['*']): Collection selectAll: $columns === ['*'], )); - return NormCache::keys()->withSpace($plan->space, fn() => match ($plan->strategy) { + return NormCache::withSpace($plan->space, fn() => match ($plan->strategy) { CacheStrategy::DirectModels => NormCache::rescue( fn() => $this->modelsExecutor()->runDirect($prepared, $plan->primaryKeys, $model, $plan->columns, $this->model), fn() => $this->getWithoutCacheFromPrepared($prepared, $columns), @@ -347,7 +347,7 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', } try { - $cachedTotal = NormCache::keys()->withSpace($plan->space, fn() => $this->rememberPaginationTotal($prepared, $plan)); + $cachedTotal = NormCache::withSpace($plan->space, fn() => $this->rememberPaginationTotal($prepared, $plan)); } catch (\Throwable $e) { NormCache::fallback($e); diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index f03220a..baeaae5 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -154,7 +154,7 @@ private function cacheScalar( return $computeValue(); } - $result = NormCache::keys()->withSpace($plan->space, fn() => NormCache::result()->execute( + $result = NormCache::withSpace($plan->space, fn() => NormCache::result()->execute( $prepared, $plan, $kind, diff --git a/src/Values/BuildContext.php b/src/Values/BuildContext.php new file mode 100644 index 0000000..cc41614 --- /dev/null +++ b/src/Values/BuildContext.php @@ -0,0 +1,15 @@ +assertSame(7, $manager->currentVersion(Author::class)); } - public function test_active_space_for_returns_active_space_only_when_model_belongs_to_it(): void - { - $content = $this->manager->spaceFor(SpacedPost::class); - - $seen = $this->manager->keys()->withSpace($content, fn() => [ - $this->manager->activeSpaceFor(SpacedPost::class)?->name, - $this->manager->activeSpaceFor(Author::class)?->name, - ]); - - $this->assertSame(['content', null], $seen); - } - - public function test_active_space_for_reuses_matching_explicit_space(): void + 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->activeSpaceFor(SpacedPost::class, 'content')?->name, + fn() => $this->manager->withSpace($content, fn() => $this->manager->keys()->activeSpace()?->name), ); $this->assertSame('content', $seen); @@ -322,6 +310,7 @@ public function test_store_through_ids_returns_false_on_version_mismatch(): void $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); @@ -335,6 +324,8 @@ public function test_store_through_ids_returns_false_on_version_mismatch(): void $buildingKey, [$versionKey], ['5'], + null, + $wakeKey, ); $this->assertFalse($stored); @@ -342,17 +333,27 @@ public function test_store_through_ids_returns_false_on_version_mismatch(): void $this->assertNull($store->getRaw($buildingKey)); } - public function test_expected_version_for_model_uses_matching_version_key(): void + public function test_store_model_attrs_for_versioned_result_uses_matching_version_key(): void { $manager = $this->cacheManager(); + $store = $manager->getStore(); $authorKey = $manager->classKey(Author::class); $postKey = $manager->classKey(Post::class); - $this->assertSame(9, $manager->expectedVersionForModel( + $store->setRaw($manager->keys()->verKey($authorKey), '9', 3600); + $store->setRaw($manager->keys()->verKey($postKey), '4', 3600); + + $manager->storeModelAttrsForVersionedResult( Author::class, + [1 => ['id' => 1, 'name' => 'Fresh']], [$manager->keys()->verKey($postKey), $manager->keys()->verKey($authorKey)], ['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 @@ -394,12 +395,13 @@ public function test_store_query_ids_skips_write_on_version_mismatch(): void $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()->storeQueryIds($queryKey, [1, 2, 3], 3600, $buildingKey, [$versionKey], ['5'], null, $wakeKey); $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'); @@ -413,11 +415,12 @@ public function test_store_query_ids_writes_when_version_matches(): void $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()->storeQueryIds($queryKey, [4, 5, 6], 3600, $buildingKey, [$versionKey], ['5'], null, $wakeKey); $this->assertNotNull($store->getRaw($queryKey), 'CAS writes when version still matches'); $this->assertNull($store->getRaw($buildingKey), 'Building lock released after successful write'); @@ -431,11 +434,12 @@ public function test_store_query_ids_does_not_write_or_release_when_building_tok $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()->storeQueryIds($queryKey, [7, 8, 9], 3600, $buildingKey, [$versionKey], ['5'], 'old-owner', $wakeKey); $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'); @@ -455,16 +459,17 @@ public function test_store_query_ids_skips_write_without_building_key_and_versio $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 + public function test_store_query_ids_writes_normally_with_building_key_and_wake_key(): void { $store = $this->manager->getStore(); $classKey = $this->manager->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->storeQueryIds($key, ['1', '2'], 60, $buildingKey, [], [], 'token', $wakeKey); $this->assertNotNull($store->getRaw($key), 'Non-CAS write should proceed when buildingKey is set'); } From eb5826cc64667a840c2b14b7839b331339d88039 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 16:56:19 +1000 Subject: [PATCH 32/73] remove unneeded guards --- src/Cache/ModelHydrator.php | 28 +++++++++-------------- src/Cache/NormalizedReader.php | 40 ++++++++++----------------------- src/CacheManager.php | 17 ++++---------- tests/Unit/CacheManagerTest.php | 10 --------- 4 files changed, 27 insertions(+), 68 deletions(-) diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index 4a1f560..8c92f2f 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -407,9 +407,7 @@ private function fetchAndCacheUsingEloquent( } } - if ($writeCache || $buildingKey !== null) { - $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); - } + $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); return $loaded->all(); } @@ -465,9 +463,7 @@ private function fetchAndCacheUsingClosure( $models[$id] = $instance; } - if ($writeCache || $buildingKey !== null) { - $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); - } + $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); return $models; } @@ -480,17 +476,15 @@ private function storeModelAttrs( ?string $wakeKey, ?string $token, ): void { - if ($attrsByKey !== [] || $buildingKey !== null) { - $this->store->setManyIfVersion( - $attrsByKey, - $this->ttl, - $this->keys->verKey($classKey), - $modelVersion, - $buildingKey, - $wakeKey, - $token - ); - } + $this->store->setManyIfVersion( + $attrsByKey, + $this->ttl, + $this->keys->verKey($classKey), + $modelVersion, + $buildingKey, + $wakeKey, + $token + ); } public static function reset(): void diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index 4288650..b69226b 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -144,36 +144,20 @@ protected function storePayload( ): bool { $ttl ??= $this->queryTtl; - if (!empty($versionKeys)) { - $keys = array_merge($versionKeys, [$key]); - if ($buildingKey !== null) { - $keys[] = $buildingKey; - $keys[] = $wakeKey; - } - - return (bool) $this->store->script( - RedisScripts::get('store_versioned_payload'), - $keys, - array_merge( - [(string) count($versionKeys), '1', (string) $ttl], - $expectedVersions, - [$payload, $buildingToken ?? '', (string) $this->wakeTokenCount] - ) - ); - - } - - if ($buildingKey === null) { - return false; + $keys = array_merge($versionKeys, [$key]); + if ($buildingKey !== null) { + $keys[] = $buildingKey; + $keys[] = $wakeKey; } - return $this->store->storeRawAndRelease( - $key, - $payload, - $ttl, - $buildingKey, - $wakeKey, - $buildingToken + return (bool) $this->store->script( + RedisScripts::get('store_versioned_payload'), + $keys, + array_merge( + [(string) count($versionKeys), '1', (string) $ttl], + $expectedVersions, + [$payload, $buildingToken ?? '', (string) $this->wakeTokenCount] + ) ); } diff --git a/src/CacheManager.php b/src/CacheManager.php index 38186c8..4fe0382 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -242,16 +242,6 @@ public function storeModelAttrs(string $modelClass, array $modelAttrs, ?CacheSpa $this->storeModelAttrsForVersion($modelClass, $modelAttrs, $modelVersion, $space); } - private function expectedVersionForModel(string $modelClass, array $versionKeys, array $expectedVersions, ?CacheSpace $space = null): ?int - { - $classKey = $this->keys->classKey($modelClass); - $index = array_search($this->keys->verKey($classKey, $space), $versionKeys, true); - - return $index === false || !isset($expectedVersions[$index]) - ? null - : (int) $expectedVersions[$index]; - } - public function storeModelAttrsForVersionedResult( string $modelClass, array $modelAttrs, @@ -259,13 +249,14 @@ public function storeModelAttrsForVersionedResult( array $expectedVersions, ?CacheSpace $space = null, ): void { - $expectedVersion = $this->expectedVersionForModel($modelClass, $versionKeys, $expectedVersions, $space); + $classKey = $this->keys->classKey($modelClass); + $index = array_search($this->keys->verKey($classKey, $space), $versionKeys, true); - if ($expectedVersion === null) { + if ($index === false || !isset($expectedVersions[$index])) { return; } - $this->storeModelAttrsForVersion($modelClass, $modelAttrs, $expectedVersion, $space); + $this->storeModelAttrsForVersion($modelClass, $modelAttrs, (int) $expectedVersions[$index], $space); } public function storeModelAttrsForVersion(string $modelClass, array $modelAttrs, int $expectedVersion, ?CacheSpace $space = null): void diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 8c277ef..11a2d49 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -449,16 +449,6 @@ 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 - { - $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_and_wake_key(): void { $store = $this->manager->getStore(); From f8f649e6c958d18c56ddebfdaea4cafd3d36b208 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 29 Jun 2026 17:38:30 +1000 Subject: [PATCH 33/73] add space specific tests --- tests/Fixtures/Models/MultiSpacePost.php | 18 ++ .../Infrastructure/ClusterSpacesTest.php | 250 ++++++++++++++++++ .../Concerns/InteractsWithClusterRedis.php | 141 ++++++++++ 3 files changed, 409 insertions(+) create mode 100644 tests/Fixtures/Models/MultiSpacePost.php create mode 100644 tests/Integration/Infrastructure/ClusterSpacesTest.php create mode 100644 tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php diff --git a/tests/Fixtures/Models/MultiSpacePost.php b/tests/Fixtures/Models/MultiSpacePost.php new file mode 100644 index 0000000..1e598e2 --- /dev/null +++ b/tests/Fixtures/Models/MultiSpacePost.php @@ -0,0 +1,18 @@ +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:*'); + } +} diff --git a/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php b/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php new file mode 100644 index 0000000..eb43879 --- /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()->getStore()->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']]; + } +} From 4d562d4047fe450e9851f5245d435126a8b29210 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:19:14 +1000 Subject: [PATCH 34/73] invalidation / wake key colocation --- .../Infrastructure/ClusterSpacesTest.php | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/tests/Integration/Infrastructure/ClusterSpacesTest.php b/tests/Integration/Infrastructure/ClusterSpacesTest.php index 1cf0c6c..1015c31 100644 --- a/tests/Integration/Infrastructure/ClusterSpacesTest.php +++ b/tests/Integration/Infrastructure/ClusterSpacesTest.php @@ -247,4 +247,66 @@ public function test_flush_all_clears_default_and_known_spaces_on_cluster(): voi $this->assertNoKeysForHashTag('nc:catalog', 'test:*'); $this->assertNoKeysForHashTag('nc:reporting', 'test:*'); } + + public function test_scheduled_invalidation_keys_are_space_scoped(): void + { + $this->cacheManager()->config()->cooldown = 5; + + $author = SpacedAuthor::create(['name' => 'Ann']); + $post = SpacedPost::create(['title' => 'First', 'author_id' => $author->id]); + + SpacedPost::query()->get(); + + $this->assertNoCrossSlot(fn() => $post->update(['title' => 'Second'])); + + $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->assertNoKeysForHashTag('nc', 'test:scheduled:*'); + + $contentSlot = $this->redisClusterSlot('nc:content'); + foreach ($scheduledKeys as $key) { + preg_match('/^\{([^}]+)\}:/', $key, $m); + $this->assertSame($contentSlot, $this->redisClusterSlot($m[1] ?? '')); + } + + // fetch_version_with_cooldown passes ver + scheduled as Lua KEYS[]; 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()); + } } From 4e699e0c6709fd786d6dfab5ae8258ca4bf048a6 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 2 Jul 2026 12:58:41 +1000 Subject: [PATCH 35/73] fix connection inference, space deduplication, key builder reset, and dead 'Raw' where branch --- src/CacheServiceProvider.php | 1 + src/Planning/QueryAnalyzer.php | 2 +- src/Spaces/CacheSpaceRegistry.php | 6 ++++-- src/Traits/HandlesInvalidation.php | 6 ++++++ tests/Unit/BypassReasonsTest.php | 2 +- tests/Unit/CachePlannerTest.php | 2 +- tests/Unit/QueryAnalyzerTest.php | 2 +- 7 files changed, 15 insertions(+), 6 deletions(-) diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index afd0d3d..a8b9edf 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -109,6 +109,7 @@ 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(); $manager = $this->app->make(CacheManager::class); $manager->discardAllPending(); $manager->enable(); diff --git a/src/Planning/QueryAnalyzer.php b/src/Planning/QueryAnalyzer.php index a9ca7d8..9e736a6 100644 --- a/src/Planning/QueryAnalyzer.php +++ b/src/Planning/QueryAnalyzer.php @@ -212,7 +212,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; } diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 78fa87b..ba3a84d 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -147,7 +147,9 @@ public function validateDependencies( /** @return list */ private function resolveModelSpaces(string $modelClass): array { - $names = method_exists($modelClass, 'normCacheSpaces') ? $modelClass::normCacheSpaces() : []; + $names = array_values(array_unique( + method_exists($modelClass, 'normCacheSpaces') ? $modelClass::normCacheSpaces() : [] + )); if ($names === []) { return [$this->defaultSpace()]; @@ -159,7 +161,7 @@ private function resolveModelSpaces(string $modelClass): array ); } - return array_values(array_map(fn($name) => $this->space($name), $names)); + return array_map(fn($name) => $this->space($name), $names); } private function materializeSpace(string $name, bool $remember = true): CacheSpace diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index fff3d7c..86d22cf 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -185,6 +185,12 @@ public function invalidateMultipleVersions(array $modelClasses, ?string $connect return; } + // Infer connection from the first model so callers inside DB::transaction() get + // deferred invalidation even without an explicit connection name. + $connectionName ??= $modelClasses !== [] + ? ($this->keys->prototype(reset($modelClasses))->getConnectionName() ?? DB::getDefaultConnection()) + : null; + $this->queueOrRun( $connectionName, function () use ($connectionName, $modelClasses) { 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/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index f63ca1f..07e3da3 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -237,7 +237,7 @@ public function test_exists_where_combined_with_raw_where_bypasses_even_with_inf 'boolean' => 'and', ]; $prepared->base->wheres[] = [ - 'type' => 'Raw', + 'type' => 'raw', 'sql' => 'LOWER(name) = LOWER(name)', 'boolean' => 'and', ]; diff --git a/tests/Unit/QueryAnalyzerTest.php b/tests/Unit/QueryAnalyzerTest.php index 6e0b40b..a7b89d8 100644 --- a/tests/Unit/QueryAnalyzerTest.php +++ b/tests/Unit/QueryAnalyzerTest.php @@ -29,7 +29,7 @@ public function test_nested_wheres_are_scanned_once_for_raw_and_exists_flags(): { $nested = $this->makeBaseQuery(); $nested->wheres = [ - ['type' => 'Raw', 'sql' => 'LOWER(name) = ?'], + ['type' => 'raw', 'sql' => 'LOWER(name) = ?'], ['type' => 'Exists', 'query' => $this->makeBaseQuery()], ]; From e632f44955e280f9f9ba474bbc2f742f9b57a7ca Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:16:07 +1000 Subject: [PATCH 36/73] add empty parent relationship loading test --- tests/Integration/Cache/PivotCacheTest.php | 30 ++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/tests/Integration/Cache/PivotCacheTest.php b/tests/Integration/Cache/PivotCacheTest.php index dce8f2a..691adcc 100644 --- a/tests/Integration/Cache/PivotCacheTest.php +++ b/tests/Integration/Cache/PivotCacheTest.php @@ -538,6 +538,36 @@ public function test_pivot_cache_used_when_projection_is_table_wildcard(): void $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']); From 85dc3514e4756698be2549ad38743e3a94d0db36 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:50:02 +1000 Subject: [PATCH 37/73] fix dependsOn() accumulation / pivot invlidation --- src/CacheableBuilder.php | 28 +++++++++++-------- src/Relations/InvalidatesPivotCache.php | 2 +- src/Traits/CachesScalarResults.php | 8 ++++-- src/Traits/HandlesInvalidation.php | 27 ++++++++++++++++++ .../Cache/CacheableBuilderTest.php | 24 ++++++++++++++++ .../Integration/Cache/SpaceResolutionTest.php | 18 ++++++++++++ 6 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index f0b7550..fd3df69 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -107,29 +107,33 @@ public function dependsOn(array $modelClasses): static 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; } 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/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index baeaae5..c20abee 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -13,6 +13,7 @@ use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; use NormCache\Values\CachePlanContext; +use NormCache\Values\DependencySet; /** * @mixin CacheableBuilder @@ -137,9 +138,10 @@ private function cacheScalar( $computeValue = $compute === null ? $fallback : fn() => $compute($base); - $inferredDependencies = $executionBuilder->inferAggregateDependencies()->merge( - (new QueryAnalyzer)->inferJoinDependencies($base, $executionBuilder->getModel()->getConnection()->getName()) - ); + $joinDeps = !empty($base->joins) + ? (new QueryAnalyzer)->inferJoinDependencies($base, $executionBuilder->getModel()->getConnection()->getName()) + : DependencySet::empty(); + $inferredDependencies = $executionBuilder->inferAggregateDependencies()->merge($joinDeps); $plan = $executionBuilder->cachePlan($base, CachePlanContext::scalar( $kind->value, $columns, diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 86d22cf..8371cd2 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -107,6 +107,33 @@ public function invalidateTableVersion(string $connectionName, string $table): v ); } + /** 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; + } + + $classKey = $this->keys->tableKey($connectionName, $table); + $spaces = []; + foreach ($modelClasses as $modelClass) { + foreach ($this->modelSpaces($modelClass) 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 { $classKey = $this->keys->classKey($modelClass); diff --git a/tests/Integration/Cache/CacheableBuilderTest.php b/tests/Integration/Cache/CacheableBuilderTest.php index 5e5b205..406040d 100644 --- a/tests/Integration/Cache/CacheableBuilderTest.php +++ b/tests/Integration/Cache/CacheableBuilderTest.php @@ -848,4 +848,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/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 96220fa..098996d 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -207,4 +207,22 @@ public function test_co_located_relation_eager_load_caches_under_the_space_tag() '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(\NormCache\Spaces\CacheSpaceRegistry::class); + $registry->space('content'); + $registry->space('catalog'); + $registry->space('reporting'); + + $store = $this->cacheManager()->getStore(); + + $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*')); + } } From 8cf70b5b3ff25e0b68010e65edb94818f365af0e Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 2 Jul 2026 13:50:35 +1000 Subject: [PATCH 38/73] fix lint --- tests/Integration/Cache/PivotCacheTest.php | 4 ++-- tests/Integration/Cache/SpaceResolutionTest.php | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/tests/Integration/Cache/PivotCacheTest.php b/tests/Integration/Cache/PivotCacheTest.php index 691adcc..afaef2f 100644 --- a/tests/Integration/Cache/PivotCacheTest.php +++ b/tests/Integration/Cache/PivotCacheTest.php @@ -541,8 +541,8 @@ public function test_pivot_cache_used_when_projection_is_table_wildcard(): void 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']); + $bob = Author::create(['name' => 'Bob']); + $tag = Tag::create(['name' => 'Fiction']); $alice->tags()->attach($tag->id); // Warm: both authors loaded; Bob has no tags. diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 098996d..ba3433e 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -2,6 +2,7 @@ namespace NormCache\Tests\Integration\Cache; +use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\CatalogTag; use NormCache\Tests\Fixtures\Models\Post; @@ -210,7 +211,7 @@ public function test_co_located_relation_eager_load_caches_under_the_space_tag() public function test_pivot_invalidation_only_bumps_spaces_of_involved_models(): void { - $registry = app(\NormCache\Spaces\CacheSpaceRegistry::class); + $registry = app(CacheSpaceRegistry::class); $registry->space('content'); $registry->space('catalog'); $registry->space('reporting'); @@ -218,7 +219,7 @@ public function test_pivot_invalidation_only_bumps_spaces_of_involved_models(): $store = $this->cacheManager()->getStore(); $post = SpacedPost::create(['title' => 'Draft', 'author_id' => 1]); - $tag = CatalogTag::create(['name' => 'PHP']); + $tag = CatalogTag::create(['name' => 'PHP']); $post->catalogTags()->attach($tag->id); $this->assertNotEmpty($store->scanPattern('{nc:content}:test:ver:*taggables*')); From 9fa83b5872f90080ad426e66dbfd118e0ead4848 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 2 Jul 2026 17:36:34 +1000 Subject: [PATCH 39/73] flush instance before save() so Eloquent observers see a cache miss --- src/Traits/Cacheable.php | 18 ++++++++++++++++++ .../Invalidation/ModelInvalidationTest.php | 5 +++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/src/Traits/Cacheable.php b/src/Traits/Cacheable.php index 8e020ef..323a1e1 100644 --- a/src/Traits/Cacheable.php +++ b/src/Traits/Cacheable.php @@ -62,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)); } @@ -127,6 +134,17 @@ private function invalidateAfterSave(bool $existsBefore, mixed $originalKey = nu NormCache::flushInstance($this); } + private function isPendingRestoreSave(): bool + { + if (!method_exists($this, 'getDeletedAtColumn')) { + return false; + } + + $col = $this->getDeletedAtColumn(); + + return $this->isDirty($col) && $this->getAttribute($col) === null; + } + private function isRestoreSave(bool $existsBefore): bool { if (!$existsBefore || !method_exists($this, 'getDeletedAtColumn')) { diff --git a/tests/Integration/Invalidation/ModelInvalidationTest.php b/tests/Integration/Invalidation/ModelInvalidationTest.php index 9f439f5..207109b 100644 --- a/tests/Integration/Invalidation/ModelInvalidationTest.php +++ b/tests/Integration/Invalidation/ModelInvalidationTest.php @@ -286,10 +286,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.' ); } From b98a43c380fec22a9a0d1bbc065f5813215f094b Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 3 Jul 2026 17:09:23 +1000 Subject: [PATCH 40/73] fix cache invalidation and space handling issues --- src/Cache/NormalizedReader.php | 31 ++++++------- src/Cache/ResultCacheReader.php | 13 +++++- src/CacheServiceProvider.php | 6 +-- src/Lua/fetch_versioned_payload.lua | 39 ++++++++++------- src/Planning/CachePlanner.php | 2 +- src/Relations/CachesOneOrManyThrough.php | 19 ++++---- src/Relations/CachesPivotRelation.php | 11 +++-- src/Spaces/CacheSpaceRegistry.php | 11 ++++- src/Support/CacheKeyBuilder.php | 7 ++- src/Support/RedisStore.php | 9 +--- src/Traits/CachesScalarResults.php | 4 ++ src/Traits/HandlesInvalidation.php | 56 +++++++++++++++--------- tests/TestCase.php | 6 +-- 13 files changed, 132 insertions(+), 82 deletions(-) diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index b69226b..0a0457a 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -27,24 +27,15 @@ public function __construct( protected readonly int $buildingLockTtl, protected readonly int $stampedeWaitMs = 200, protected readonly int $wakeTokenCount = 64, + protected readonly bool $cooldownEnabled = false, ) {} - // Cache-key prefix for this reader's namespace (query vs through). abstract protected function queryPrefix(string $classKey, ?string $tag): string; - /** - * Decode the Lua payload to [status, ids, throughKeys]. ids: list on hit, - * [] empty, null otherwise. throughKeys is only used by through relations. - * - * @return array{0: LuaStatus, 1: ?array, 2: ?array} - */ + /** @return array{0: LuaStatus, 1: ?array, 2: ?array} */ abstract protected function decodePayload(array $result, string $queryKey): array; - /** - * Build the concrete result DTO for this reader. - * - * @return TResult - */ + /** @return TResult */ abstract protected function buildResult( LuaStatus $status, BuildContext $build, @@ -65,12 +56,20 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $result = $this->store->script( RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $scheduledKeys, [ + array_merge($versionKeys, $this->cooldownEnabled ? $scheduledKeys : [], [ $queryPrefix, $this->keys->buildingPrefix($classKey), $this->keys->wakePrefix($classKey), ]), - [$hash, $hash, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] + [ + $hash, + $hash, + (int) floor(microtime(true) * 1000), + $this->buildingLockTtl, + $lockToken, + (string) count($versionKeys), + $this->cooldownEnabled ? '1' : '0', + ] ); $seg = (string) ($result[1] ?? ''); @@ -147,7 +146,9 @@ protected function storePayload( $keys = array_merge($versionKeys, [$key]); if ($buildingKey !== null) { $keys[] = $buildingKey; - $keys[] = $wakeKey; + if ($wakeKey !== null) { + $keys[] = $wakeKey; + } } return (bool) $this->store->script( diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index 24d7d1e..d3e0546 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -20,6 +20,7 @@ public function __construct( private readonly int $buildingLockTtl, private readonly int $stampedeWaitMs, private readonly int $wakeTokenCount = 64, + private readonly bool $cooldownEnabled = false, ) {} public function fetch( @@ -252,8 +253,16 @@ private function luaFetchVersionedResult( ): array { return $this->store->script( RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $scheduledKeys, [$resultPrefix, $buildingPrefix, $wakePrefix]), - [$hash, $lockSuffix, (int) floor(microtime(true) * 1000), $this->buildingLockTtl, $lockToken] + array_merge($versionKeys, $this->cooldownEnabled ? $scheduledKeys : [], [$resultPrefix, $buildingPrefix, $wakePrefix]), + [ + $hash, + $lockSuffix, + (int) floor(microtime(true) * 1000), + $this->buildingLockTtl, + $lockToken, + (string) count($versionKeys), + $this->cooldownEnabled ? '1' : '0', + ] ); } diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index a8b9edf..23342ba 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -62,7 +62,7 @@ public function register(): void $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); $store = new RedisStore($connection, $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0); $engine = new ExecutionEngine; $config = new CacheConfig( ttl: $ttl, @@ -75,9 +75,9 @@ public function register(): void ); return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, diff --git a/src/Lua/fetch_versioned_payload.lua b/src/Lua/fetch_versioned_payload.lua index cd7d948..325bbc6 100644 --- a/src/Lua/fetch_versioned_payload.lua +++ b/src/Lua/fetch_versioned_payload.lua @@ -2,32 +2,39 @@ -- Used for: normalized query (single and multi-dep), through-relation, and result cache. -- -- KEYS[1..n] = version keys --- KEYS[n+1..2n] = scheduled keys (one per version key, same order) --- KEYS[2n+1] = payload key prefix --- KEYS[2n+2] = building key prefix --- KEYS[2n+3] = wake prefix +-- 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 = (#KEYS - 3) / 2 +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' - 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]) + 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 @@ -36,14 +43,14 @@ end local seg = 'v' .. vers[1] for i = 2, n do seg = seg .. ':v' .. vers[i] end -local data = redis.call('GET', KEYS[2 * n + 1] .. seg .. ':' .. ARGV[1]) +local data = redis.call('GET', KEYS[prefix_index] .. seg .. ':' .. ARGV[1]) if data then return {'hit', seg, data} end -local building_key = KEYS[2 * n + 2] .. seg .. ':' .. ARGV[2] +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[2 * n + 3] .. ARGV[2]) + redis.call('DEL', KEYS[prefix_index + 2] .. ARGV[2]) return {'miss', seg, ARGV[5]} end diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 91e14a8..8ef672a 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -83,7 +83,7 @@ public function plan( // Bypass (or throw) when a plan's dependencies don't co-locate in its cache space. // Neutral for default-only apps: everything resolves to the default space. - private function applySpaceValidation( + public function applySpaceValidation( CachePlan $plan, CacheableBuilder $builder, Model $model, diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 2dd91e6..ec24fac 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -8,8 +8,8 @@ use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; use NormCache\Facades\NormCache; +use NormCache\Planning\CachePlanner; use NormCache\Planning\QueryAnalyzer; -use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; use NormCache\Support\QueryHasher; @@ -145,16 +145,19 @@ private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?Cach if ($this->isSimpleThroughQuery($base, $builder)) { $space = NormCache::spaceFor($this->related::class, $builder->getSpace()); $dependencies = new DependencySet(models: [$this->throughParent::class]); - $validation = app(CacheSpaceRegistry::class)->validateDependencies($space, $dependencies->models, $dependencies->tables); - - if (!$validation->isValid) { - return null; - } - - return CachePlan::result( + $plan = CachePlan::result( operation: CacheOperation::Through, dependencies: $dependencies, )->withSpace($space); + + $plan = (new CachePlanner)->applySpaceValidation( + $plan, + $builder, + $this->related, + CacheOperation::Through, + ); + + return $plan->usesResultCache() ? $plan : null; } $projection = ProjectionClassifier::resolve($base, null); diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 218a5ef..73ec264 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -13,6 +13,7 @@ use NormCache\Support\ProjectionClassifier; use NormCache\Support\QueryHasher; use NormCache\Values\CachePlanContext; +use NormCache\Values\CacheSpace; use NormCache\Values\PreparedQuery; /** @mixin BelongsToMany */ @@ -119,7 +120,9 @@ 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) { + $space = NormCache::keys()->activeSpace(); + + NormCache::attempt(function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $pivotResult, $shouldCacheRelatedModels, $ttl, $space) { $relatedKey = NormCache::classKey($relatedClass); $keyMap = []; foreach ($cacheParentIds as $parentId) { @@ -131,7 +134,8 @@ public function get($columns = ['*']): Collection $this->populatePivotCache( $models, $keyMap, $relatedClass, $shouldCacheRelatedModels, $pivotResult->versionKeys, $pivotResult->expectedVersions, $ttl, - $pivotResult->buildingKey, $pivotResult->wakeKey, $pivotResult->buildingToken + $pivotResult->buildingKey, $pivotResult->wakeKey, $pivotResult->buildingToken, + $space, ); }); }, @@ -215,6 +219,7 @@ 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, + ?CacheSpace $space = null, ): void { $pivotMap = array_fill_keys(array_keys($keyMap), []); $modelAttrs = []; @@ -259,7 +264,7 @@ private function populatePivotCache( $modelAttrs, $versionKeys, $expectedVersions, - NormCache::keys()->activeSpace(), + $space, ); } } diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index ba3a84d..4784749 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -20,6 +20,9 @@ final class CacheSpaceRegistry /** @var array> model class => spaces (memoized, validated) */ private array $modelSpaces = []; + /** @var list|null */ + private ?array $metadataSpaceNames = null; + /** * @param array $placement per-space hash-tag overrides */ @@ -215,13 +218,17 @@ private function metadataSpaces(): array return []; } + if ($this->metadataSpaceNames !== null) { + return $this->metadataSpaceNames; + } + try { - return array_values(array_filter( + return $this->metadataSpaceNames = array_values(array_filter( $this->metadataStore->setMembers($this->metadataSpacesKey()), fn(string $name) => $this->validSpaceName($name), )); } catch (\Throwable) { - return []; + return $this->metadataSpaceNames = []; } } diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 574b8b9..9e69e0d 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -237,7 +237,7 @@ public function depKeyPairs(string $classKey, array $depClasses, array $depTable $space ??= $this->activeSpace; if ($depClasses === [] && $depTableKeys === []) { - $cacheKey = ($space === null ? '' : $space->name) . '|' . $classKey; + $cacheKey = $this->singleDepPairCacheKey($classKey, $space); return self::$singleDepPairs[$cacheKey] ??= [ [$this->verKey($classKey, $space)], @@ -306,6 +306,11 @@ public function resultBuildIdentityHash(string $namespace, ?string $tag, string // Private implementation // ------------------------------------------------------------------------- + private function singleDepPairCacheKey(string $classKey, ?CacheSpace $space): string + { + return $this->keyPrefix . '|' . $this->tagPrefix($space) . '|' . $classKey; + } + private function resolveClassKey(string $class): string { $model = self::prototype($class); diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 7c16209..c6db03c 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -183,14 +183,9 @@ private function mgetValues(array $keys, bool $unserialize): array return []; } - $prefixed = []; - foreach ($keys as $key) { - $prefixed[] = $key; - } - $raw = $this->connection instanceof PredisClusterConnection - ? array_map(fn($key) => $this->connection->get($key), $prefixed) - : $this->connection->mget($prefixed); + ? array_map(fn($key) => $this->connection->get($key), $keys) + : $this->connection->mget($keys); $values = []; foreach ($raw as $i => $value) { diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index c20abee..69af65b 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -124,6 +124,10 @@ private function cacheScalar( array $columns = [], ?\Closure $compute = null, ): mixed { + if ($this->isCacheSkipped() || !NormCache::isEnabled()) { + return $fallback(); + } + if (ProjectionClassifier::hasCalculatedColumns($columns)) { return $fallback(); } diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 8371cd2..ada1379 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -32,7 +32,7 @@ private function modelSpaces(string $modelClass): array /** @return list the spaces a table's version key lives in */ private function tableInvalidationSpaces(string $table): array { - return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->knownSpaces(); + return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->spacesForTable($table); } /** @return list */ @@ -208,29 +208,33 @@ public function flushTagAcrossModels(string $tag): int public function invalidateMultipleVersions(array $modelClasses, ?string $connectionName = null): void { - if (!$this->isEnabled()) { + if (!$this->isEnabled() || $modelClasses === []) { return; } - // Infer connection from the first model so callers inside DB::transaction() get - // deferred invalidation even without an explicit connection name. - $connectionName ??= $modelClasses !== [] - ? ($this->keys->prototype(reset($modelClasses))->getConnectionName() ?? DB::getDefaultConnection()) - : null; + $groups = []; - $this->queueOrRun( - $connectionName, - function () use ($connectionName, $modelClasses) { - foreach ($modelClasses as $modelClass) { - $this->queueVersionFlush($connectionName, $this->keys->classKey($modelClass), $this->modelSpaces($modelClass)); - } - }, - function () use ($modelClasses) { - foreach ($modelClasses as $modelClass) { - $this->doInvalidateVersion($modelClass); - } - }, - ); + 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->queueVersionFlush($conn, $this->keys->classKey($modelClass), $this->modelSpaces($modelClass)); + } + }, + function () use ($classes) { + foreach ($classes as $modelClass) { + $this->doInvalidateVersion($modelClass); + } + }, + ); + } } public function commitPending(string $connectionName): void @@ -375,6 +379,16 @@ private function prefixedForAnySpace(array $patterns): array /** @param list $spaces */ private function queueVersionFlush(string $connectionName, string $classKey, array $spaces): void { - $this->versionQueue[$connectionName][$classKey] = $spaces; + $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); } } diff --git a/tests/TestCase.php b/tests/TestCase.php index 513f1a3..18e8333 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -187,7 +187,7 @@ protected function buildManager( $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); $store = new RedisStore($connection, $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0); $engine = new ExecutionEngine; $config = new CacheConfig( ttl: $ttl, @@ -200,9 +200,9 @@ protected function buildManager( ); return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, From 31c0cfe17b9effa91e94ded0e1420dcfa54a3689 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:12:44 +1000 Subject: [PATCH 41/73] refactor cache internals and fix stale cooldown toggle in readers --- src/Cache/ExecutionEngine.php | 44 ++--------- src/Cache/NormalizedReader.php | 14 +++- src/Cache/ResultCacheReader.php | 15 +++- src/CacheManager.php | 7 +- src/CacheServiceProvider.php | 10 ++- src/CacheableBuilder.php | 78 ++++++++++--------- src/Relations/CacheableBelongsTo.php | 4 +- src/Relations/CachesOneOrManyThrough.php | 9 +-- src/Relations/CachesPivotRelation.php | 8 +- src/Relations/CollectsRelatedModels.php | 45 ----------- src/Support/CacheKeyBuilder.php | 2 + src/Traits/CachesScalarResults.php | 10 +-- src/Traits/HandlesInvalidation.php | 25 ++---- .../Infrastructure/LuaScriptBehaviorTest.php | 4 + tests/TestCase.php | 10 ++- tests/Unit/CacheableBuilderPlanTest.php | 53 +++++++++++++ .../Unit/CacheableBuilderSharedStateTest.php | 33 ++++++++ 17 files changed, 194 insertions(+), 177 deletions(-) delete mode 100644 src/Relations/CollectsRelatedModels.php create mode 100644 tests/Unit/CacheableBuilderPlanTest.php create mode 100644 tests/Unit/CacheableBuilderSharedStateTest.php diff --git a/src/Cache/ExecutionEngine.php b/src/Cache/ExecutionEngine.php index 4a032ec..003ba69 100644 --- a/src/Cache/ExecutionEngine.php +++ b/src/Cache/ExecutionEngine.php @@ -119,47 +119,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/NormalizedReader.php b/src/Cache/NormalizedReader.php index 0a0457a..b9088f9 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -8,6 +8,7 @@ use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; use NormCache\Values\BuildContext; +use NormCache\Values\CacheConfig; use NormCache\Values\QueryCacheResult; use NormCache\Values\ThroughCacheResult; @@ -25,11 +26,17 @@ public function __construct( protected readonly VersionTracker $versions, protected readonly int $queryTtl, protected readonly int $buildingLockTtl, + protected readonly CacheConfig $config, protected readonly int $stampedeWaitMs = 200, protected readonly int $wakeTokenCount = 64, - protected readonly bool $cooldownEnabled = false, ) {} + // Live runtime toggle; only payload reads honor it — pivot/standalone version reads always check scheduled keys. + protected function cooldownEnabled(): bool + { + return $this->config->cooldown > 0; + } + abstract protected function queryPrefix(string $classKey, ?string $tag): string; /** @return array{0: LuaStatus, 1: ?array, 2: ?array} */ @@ -53,10 +60,11 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); $queryPrefix = $this->queryPrefix($classKey, $tag); $lockToken = $this->versions->buildLockToken(); + $cooldown = $this->cooldownEnabled(); $result = $this->store->script( RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $this->cooldownEnabled ? $scheduledKeys : [], [ + array_merge($versionKeys, $cooldown ? $scheduledKeys : [], [ $queryPrefix, $this->keys->buildingPrefix($classKey), $this->keys->wakePrefix($classKey), @@ -68,7 +76,7 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $this->buildingLockTtl, $lockToken, (string) count($versionKeys), - $this->cooldownEnabled ? '1' : '0', + $cooldown ? '1' : '0', ] ); diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index d3e0546..5e1bfe1 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -7,6 +7,7 @@ use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; +use NormCache\Values\CacheConfig; use NormCache\Values\PivotCacheResult; use NormCache\Values\ResultCacheResult; @@ -18,11 +19,17 @@ public function __construct( private readonly VersionTracker $versions, private readonly int $queryTtl, private readonly int $buildingLockTtl, + private readonly CacheConfig $config, private readonly int $stampedeWaitMs, private readonly int $wakeTokenCount = 64, - private readonly bool $cooldownEnabled = false, ) {} + // Live runtime toggle; only payload reads honor it — pivot/standalone version reads always check scheduled keys. + private function cooldownEnabled(): bool + { + return $this->config->cooldown > 0; + } + public function fetch( string $modelClass, array $depClasses, string $hash, ?string $tag, array $depTableKeys, @@ -251,9 +258,11 @@ private function luaFetchVersionedResult( string $resultPrefix, string $buildingPrefix, string $wakePrefix, string $hash, string $lockSuffix, string $lockToken ): array { + $cooldown = $this->cooldownEnabled(); + return $this->store->script( RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $this->cooldownEnabled ? $scheduledKeys : [], [$resultPrefix, $buildingPrefix, $wakePrefix]), + array_merge($versionKeys, $cooldown ? $scheduledKeys : [], [$resultPrefix, $buildingPrefix, $wakePrefix]), [ $hash, $lockSuffix, @@ -261,7 +270,7 @@ private function luaFetchVersionedResult( $this->buildingLockTtl, $lockToken, (string) count($versionKeys), - $this->cooldownEnabled ? '1' : '0', + $cooldown ? '1' : '0', ] ); } diff --git a/src/CacheManager.php b/src/CacheManager.php index 4fe0382..704925e 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -11,6 +11,7 @@ use NormCache\Cache\ResultCacheReader; use NormCache\Cache\ResultExecutor; use NormCache\Cache\VersionTracker; +use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Spaces\CacheSpaceResolver; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\FallbackHandler; @@ -38,6 +39,8 @@ public function __construct( private readonly RedisStore $store, private readonly CacheKeyBuilder $keys, private readonly CacheConfig $config, + private readonly CacheSpaceResolver $spaceResolver, + private readonly CacheSpaceRegistry $spaceRegistry, ) {} // ------------------------------------------------------------------------- @@ -107,7 +110,7 @@ public function classKey(string $class): string // cache execution to the same space the planner validated against). public function spaceFor(string $modelClass, ?string $explicitSpace = null): CacheSpace { - return ($this->spaceResolver ??= app(CacheSpaceResolver::class))->resolve($modelClass, $explicitSpace); + return $this->spaceResolver->resolve($modelClass, $explicitSpace); } public function withSpace(?CacheSpace $space, callable $callback): mixed @@ -128,8 +131,6 @@ public function withSpaceForModel(string $modelClass, ?string $explicitSpace, ca return $this->withSpace($this->spaceFor($modelClass, $explicitSpace), $callback); } - private ?CacheSpaceResolver $spaceResolver = null; - public function tableKey(string $connectionName, string $table): string { return $this->keys->tableKey($connectionName, $table); diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 23342ba..5deb17e 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -45,7 +45,7 @@ public function register(): void return new CacheSpaceResolver($app->make(CacheSpaceRegistry::class)); }); - $this->app->singleton(CacheManager::class, function () { + $this->app->singleton(CacheManager::class, function ($app) { $connection = config('normcache.connection'); $ttl = (int) config('normcache.ttl'); $queryTtl = (int) config('normcache.query_ttl'); @@ -62,7 +62,6 @@ public function register(): void $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); $store = new RedisStore($connection, $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0); $engine = new ExecutionEngine; $config = new CacheConfig( ttl: $ttl, @@ -73,11 +72,12 @@ public function register(): void dispatchEvents: $events, stampedeWakeTokens: $stampedeWakeTokens, ); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens); return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, @@ -85,6 +85,8 @@ public function register(): void store: $store, keys: $keys, config: $config, + spaceResolver: $app->make(CacheSpaceResolver::class), + spaceRegistry: $app->make(CacheSpaceRegistry::class), ); }); diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index fd3df69..f994152 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -197,15 +197,9 @@ 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(DependencySet $inferred) => CachePlanContext::models( + ProjectionClassifier::resolve($prepared->base, ['*']), + $inferred, selectAll: true, ), PlanningMode::Explain); @@ -250,20 +244,9 @@ 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(DependencySet $inferred) => CachePlanContext::models( ProjectionClassifier::resolve($base, $columns), $inferred, selectAll: $columns === ['*'], @@ -272,11 +255,11 @@ public function get($columns = ['*']): Collection return NormCache::withSpace($plan->space, fn() => match ($plan->strategy) { CacheStrategy::DirectModels => NormCache::rescue( fn() => $this->modelsExecutor()->runDirect($prepared, $plan->primaryKeys, $model, $plan->columns, $this->model), - fn() => $this->getWithoutCacheFromPrepared($prepared, $columns), + fn() => $this->collectFromPrepared($prepared, $columns), ), CacheStrategy::NormalizedQuery => NormCache::rescue( fn() => $this->modelsExecutor()->runNormalized($prepared, $plan, $model, $plan->columns, $this->cacheTag, $this->queryTtl, $debugbarStart, $this->model), - fn() => $this->getWithoutCacheFromPrepared($prepared, $columns) + fn() => $this->collectFromPrepared($prepared, $columns) ), CacheStrategy::VersionedResult => $this->executeResultQuery($prepared, $plan, $columns), CacheStrategy::LiveQuery => $this->bypassAndReturn($model, $plan->bypassReasons, $debugbarStart, $prepared, $columns), @@ -297,7 +280,7 @@ private function executeResultQuery( $columns, function () use ($prepared, $columns) { if ($this->hasAggregateColumns()) { - $rawModels = $this->getWithoutCacheFromPrepared($prepared, $columns, false); + $rawModels = $this->collectFromPrepared($prepared, $columns, false); return $this->resultPayloadFromEloquentModels($rawModels); } @@ -323,7 +306,7 @@ private function bypassAndReturn( ): Collection { CacheReporter::queryBypassed($model, $bypassReasons, $debugbarStart); - return $this->getWithoutCacheFromPrepared($prepared, $columns); + return $this->collectFromPrepared($prepared, $columns); } public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null): LengthAwarePaginator @@ -335,14 +318,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(DependencySet $inferred) => CachePlanContext::paginationCount($inferred)); if ($plan->strategy === CacheStrategy::LiveQuery) { CacheReporter::queryBypassed($this->model::class, $plan->bypassReasons, $debugbarStart); @@ -467,19 +443,24 @@ public function prepareScopedQuery(): PreparedQuery return new PreparedQuery($builder, $builder->getQuery()); } - private function getWithoutCacheFromPrepared( + public function collectFromPrepared( PreparedQuery $prepared, - array $columns, + array $columns = ['*'], bool $applyAfterCallbacks = true, + ?\Closure $beforeEagerLoad = null, ): Collection { $builder = $prepared->builder; $models = $builder->getModels($columns); + if ($beforeEagerLoad !== null) { + $beforeEagerLoad($models); + } + if (count($models) > 0) { $models = $builder->eagerLoadRelations($models); } - $collection = $this->model->newCollection($models); + $collection = $builder->getModel()->newCollection($models); return $applyAfterCallbacks ? $prepared->applyAfterCallbacks($collection) @@ -510,7 +491,23 @@ public function cachePlan( return $this->planner()->plan($this, $base, $context, $planningMode); } - private function planner(): CachePlanner + /** @param \Closure(DependencySet): CachePlanContext $context */ + public function planPrepared( + PreparedQuery $prepared, + \Closure $context, + PlanningMode $mode = PlanningMode::Hot, + ): CachePlan { + $builder = $prepared->builder; + $base = $prepared->base; + + $joinDeps = !empty($base->joins) + ? (new QueryAnalyzer)->inferJoinDependencies($base, $builder->getModel()->getConnection()->getName()) + : DependencySet::empty(); + + return $builder->cachePlan($base, $context($builder->inferAggregateDependencies()->merge($joinDeps)), $mode); + } + + public function planner(): CachePlanner { return self::$sharedPlanner ??= new CachePlanner; } @@ -520,6 +517,13 @@ private function modelsExecutor(): ModelsExecutor return self::$sharedModelsExecutor ??= new ModelsExecutor; } + // Drop memoized services on tenant/Octane resets; the planner caches app() lookups. + public static function resetSharedState(): void + { + self::$sharedPlanner = null; + self::$sharedModelsExecutor = null; + } + private function rememberPaginationTotal(PreparedQuery $prepared, CachePlan $plan): int { [$value] = NormCache::result()->execute( diff --git a/src/Relations/CacheableBelongsTo.php b/src/Relations/CacheableBelongsTo.php index d43f226..67d9075 100644 --- a/src/Relations/CacheableBelongsTo.php +++ b/src/Relations/CacheableBelongsTo.php @@ -15,8 +15,6 @@ class CacheableBelongsTo extends BelongsTo { - use CollectsRelatedModels; - private array $eagerKeys = []; public function addEagerConstraints(array $models): void @@ -122,6 +120,6 @@ private function getFromPreparedBuilder(PreparedQuery $prepared): Collection return $this->related->newCollection(); } - return $this->collectFromPreparedBuilder($prepared); + return $prepared->builder->collectFromPrepared($prepared); } } diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index ec24fac..5b151a0 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -8,7 +8,6 @@ use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; use NormCache\Facades\NormCache; -use NormCache\Planning\CachePlanner; use NormCache\Planning\QueryAnalyzer; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; @@ -22,8 +21,6 @@ trait CachesOneOrManyThrough { - use CollectsRelatedModels; - public function get($columns = ['*']): Collection { if (!$this->query instanceof CacheableBuilder) { @@ -71,7 +68,7 @@ public function get($columns = ['*']): Collection $ttl = $builder->getQueryTtl(); $runThrough = fn() => NormCache::rescue( - fn() => NormCache::engine()->runThrough( + fn() => NormCache::engine()->runNormalized( fetch: fn() => NormCache::getThroughCache($relatedClass, $hash, $tag, $depClasses, $depTableKeys), waitForBuild: fn() => NormCache::waitForThroughBuild( $relatedClass, $hash, $tag, $depClasses, $depTableKeys @@ -150,7 +147,7 @@ private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?Cach dependencies: $dependencies, )->withSpace($space); - $plan = (new CachePlanner)->applySpaceValidation( + $plan = $builder->planner()->applySpaceValidation( $plan, $builder, $this->related, @@ -232,6 +229,6 @@ private function hydrateFromIds( private function getFromPreparedBuilder(PreparedQuery $prepared, bool $applyAfterCallbacks = true): Collection { - return $this->collectFromPreparedBuilder($prepared, $applyAfterCallbacks); + return $prepared->builder->collectFromPrepared($prepared, applyAfterCallbacks: $applyAfterCallbacks); } } diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 73ec264..cc0c3a2 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -19,8 +19,6 @@ /** @mixin BelongsToMany */ trait CachesPivotRelation { - use CollectsRelatedModels; - private array $eagerParentIds = []; private bool $inEagerLoad = false; @@ -324,10 +322,10 @@ private function getFromPreparedPivotBuilder( PreparedQuery $prepared, bool $applyAfterCallbacks = true, ): Collection { - return $this->collectFromPreparedBuilder( + return $prepared->builder->collectFromPrepared( $prepared, - $applyAfterCallbacks, - fn(array $models) => $this->hydratePivotRelation($models), + applyAfterCallbacks: $applyAfterCallbacks, + beforeEagerLoad: fn(array $models) => $this->hydratePivotRelation($models), ); } 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/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 9e69e0d..d756692 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use NormCache\Cache\ModelHydrator; +use NormCache\CacheableBuilder; use NormCache\Values\CacheSpace; class CacheKeyBuilder @@ -141,6 +142,7 @@ public static function reset(): void self::$singleDepPairs = []; ModelHydrator::reset(); + CacheableBuilder::resetSharedState(); } public static function prototype(string $class): Model diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index 69af65b..7452cd9 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -9,7 +9,6 @@ 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\Values\CachePlanContext; @@ -137,19 +136,14 @@ private function cacheScalar( } $prepared = $this->prepareCacheExecution(); - $executionBuilder = $prepared->builder; $base = $prepared->base; $computeValue = $compute === null ? $fallback : fn() => $compute($base); - $joinDeps = !empty($base->joins) - ? (new QueryAnalyzer)->inferJoinDependencies($base, $executionBuilder->getModel()->getConnection()->getName()) - : DependencySet::empty(); - $inferredDependencies = $executionBuilder->inferAggregateDependencies()->merge($joinDeps); - $plan = $executionBuilder->cachePlan($base, CachePlanContext::scalar( + $plan = $this->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::scalar( $kind->value, $columns, - $inferredDependencies, + $inferred, )); if (!$plan->isCacheable()) { diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index ada1379..a2709a7 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use NormCache\CacheManager; -use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisScripts; use NormCache\Values\CacheSpace; @@ -21,24 +20,22 @@ trait HandlesInvalidation /** @var array>> conn => classKey => spaces */ private array $versionQueue = []; - private ?CacheSpaceRegistry $spaceRegistry = null; - /** @return list the spaces a model's version key lives in */ private function modelSpaces(string $modelClass): array { - return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->spacesForModel($modelClass); + return $this->spaceRegistry->spacesForModel($modelClass); } /** @return list the spaces a table's version key lives in */ private function tableInvalidationSpaces(string $table): array { - return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->spacesForTable($table); + return $this->spaceRegistry->spacesForTable($table); } /** @return list */ private function knownSpaces(): array { - return ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->knownSpaces(); + return $this->spaceRegistry->knownSpaces(); } // Invalidation @@ -76,20 +73,10 @@ public function flushModel(Model|string $model): void ); } + /** 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; - - $this->queueOrRun( - $conn, - fn() => $this->queueVersionFlush($conn, $this->keys->classKey($class), $this->modelSpaces($class)), - fn() => $this->doInvalidateVersion($class), - ); + $this->invalidateVersion($model); } public function invalidateTableVersion(string $connectionName, string $table): void @@ -167,7 +154,7 @@ public function flushAll(CacheSpace|string|null $space = null): int $spaces = $space === null ? $this->knownSpaces() - : [is_string($space) ? ($this->spaceRegistry ??= app(CacheSpaceRegistry::class))->space($space) : $space]; + : [is_string($space) ? $this->spaceRegistry->space($space) : $space]; return $this->store->flushByPatterns($this->prefixedForSpaces($patterns, $spaces)); } diff --git a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php index 586314c..e69808f 100644 --- a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php +++ b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php @@ -77,6 +77,9 @@ public function test_pending_cooldown_fires_version_bump_on_read(): void 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); @@ -96,6 +99,7 @@ public function test_non_numeric_scheduled_key_is_cleaned_up_without_version_bum Author::get(); $version = NormCache::currentVersion(Author::class); + $this->cacheManager()->config()->cooldown = 1; $this->setKey("scheduled:{$ck}:", 'garbage'); Author::get(); diff --git a/tests/TestCase.php b/tests/TestCase.php index 18e8333..bc09477 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -19,6 +19,8 @@ use NormCache\Cache\VersionTracker; use NormCache\CacheManager; use NormCache\CacheServiceProvider; +use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Spaces\CacheSpaceResolver; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisStore; use NormCache\Values\CacheConfig; @@ -187,7 +189,6 @@ protected function buildManager( $keys = new CacheKeyBuilder('{nc}:', $keyPrefix); $store = new RedisStore($connection, $stampedeWakeTokens); $versions = new VersionTracker($store, $keys); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0); $engine = new ExecutionEngine; $config = new CacheConfig( ttl: $ttl, @@ -198,11 +199,12 @@ protected function buildManager( dispatchEvents: $dispatchEvents, stampedeWakeTokens: $stampedeWakeTokens, ); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens); return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $stampedeWaitMs, $stampedeWakeTokens, $cooldown > 0), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, @@ -210,6 +212,8 @@ protected function buildManager( store: $store, keys: $keys, config: $config, + spaceResolver: app(CacheSpaceResolver::class), + spaceRegistry: app(CacheSpaceRegistry::class), ); } diff --git a/tests/Unit/CacheableBuilderPlanTest.php b/tests/Unit/CacheableBuilderPlanTest.php new file mode 100644 index 0000000..9e69e9f --- /dev/null +++ b/tests/Unit/CacheableBuilderPlanTest.php @@ -0,0 +1,53 @@ +where('name', 'A'); + $prepared = $builder->prepareCacheExecution(); + + $viaSeam = $builder->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::models( + ProjectionClassifier::resolve($prepared->base, ['*']), + $inferred, + selectAll: true, + )); + + $direct = $builder->cachePlan($prepared->base, CachePlanContext::models( + ProjectionClassifier::resolve($prepared->base, ['*']), + $builder->inferAggregateDependencies(), + selectAll: true, + )); + + $this->assertEquals($direct, $viaSeam); + } + + public function test_plan_prepared_merges_join_dependencies(): void + { + $builder = Post::query()->join('authors', 'authors.id', '=', 'posts.author_id'); + $prepared = $builder->prepareCacheExecution(); + + $captured = null; + $builder->planPrepared($prepared, function (DependencySet $inferred) use ($prepared, &$captured) { + $captured = $inferred; + + return CachePlanContext::models( + ProjectionClassifier::resolve($prepared->base, ['*']), + $inferred, + selectAll: true, + ); + }); + + $this->assertNotNull($captured); + $this->assertNotEquals(DependencySet::empty(), $captured); + } +} diff --git a/tests/Unit/CacheableBuilderSharedStateTest.php b/tests/Unit/CacheableBuilderSharedStateTest.php new file mode 100644 index 0000000..d7ac34d --- /dev/null +++ b/tests/Unit/CacheableBuilderSharedStateTest.php @@ -0,0 +1,33 @@ +setValue(null, new CachePlanner); + $executor->setValue(null, new ModelsExecutor); + + CacheKeyBuilder::reset(); + + $this->assertNull($planner->getValue()); + $this->assertNull($executor->getValue()); + } + + public function test_builders_share_one_planner_instance(): void + { + $this->assertSame(Author::query()->planner(), Post::query()->planner()); + } +} From 5fca76258f291f7ba6f3cf5a597eed2671b1371e Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:10:31 +1000 Subject: [PATCH 42/73] Decouple pure unit tests from Redis setup --- tests/Unit/CacheKeyBuilderTest.php | 4 +- tests/Unit/CachePlannerTest.php | 4 +- tests/Unit/CacheableBuilderPlanTest.php | 4 +- .../Unit/CacheableBuilderSharedStateTest.php | 4 +- tests/Unit/QueryHasherTest.php | 4 +- tests/Unit/RedisScriptsTest.php | 4 +- .../CachesRelationAggregatesAliasTest.php | 4 +- .../RelationDependencyClassifierTest.php | 4 +- tests/Unit/Spaces/CacheSpaceRegistryTest.php | 4 +- tests/Unit/Spaces/CacheSpaceResolverTest.php | 4 +- .../Spaces/CacheableSpacesDeclarationTest.php | 4 +- tests/Unit/Support/CacheSerializerTest.php | 4 +- tests/UnitTestCase.php | 49 +++++++++++++++++++ 13 files changed, 73 insertions(+), 24 deletions(-) create mode 100644 tests/UnitTestCase.php diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index 9e2672c..d794d7a 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -4,10 +4,10 @@ use Illuminate\Database\Eloquent\Model; use NormCache\Support\CacheKeyBuilder; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; use NormCache\Values\CacheSpace; -class CacheKeyBuilderTest extends TestCase +class CacheKeyBuilderTest extends UnitTestCase { public function test_key_methods_emit_the_space_hash_tag(): void { diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index 07e3da3..17ca931 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -8,11 +8,11 @@ use NormCache\Planning\DependencyResolver; 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 { diff --git a/tests/Unit/CacheableBuilderPlanTest.php b/tests/Unit/CacheableBuilderPlanTest.php index 9e69e9f..8c7f7a9 100644 --- a/tests/Unit/CacheableBuilderPlanTest.php +++ b/tests/Unit/CacheableBuilderPlanTest.php @@ -5,11 +5,11 @@ use NormCache\Support\ProjectionClassifier; 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 CacheableBuilderPlanTest extends TestCase +class CacheableBuilderPlanTest extends UnitTestCase { public function test_plan_prepared_matches_direct_plan(): void { diff --git a/tests/Unit/CacheableBuilderSharedStateTest.php b/tests/Unit/CacheableBuilderSharedStateTest.php index d7ac34d..63541d0 100644 --- a/tests/Unit/CacheableBuilderSharedStateTest.php +++ b/tests/Unit/CacheableBuilderSharedStateTest.php @@ -8,9 +8,9 @@ use NormCache\Support\CacheKeyBuilder; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; -class CacheableBuilderSharedStateTest extends TestCase +class CacheableBuilderSharedStateTest extends UnitTestCase { public function test_reset_clears_shared_builder_state(): void { diff --git a/tests/Unit/QueryHasherTest.php b/tests/Unit/QueryHasherTest.php index 112c744..4991359 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 { diff --git a/tests/Unit/RedisScriptsTest.php b/tests/Unit/RedisScriptsTest.php index 0f4dc82..9b1dfc1 100644 --- a/tests/Unit/RedisScriptsTest.php +++ b/tests/Unit/RedisScriptsTest.php @@ -3,10 +3,10 @@ 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', 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 index 664442c..947cc2a 100644 --- a/tests/Unit/Relations/RelationDependencyClassifierTest.php +++ b/tests/Unit/Relations/RelationDependencyClassifierTest.php @@ -5,9 +5,9 @@ use NormCache\Relations\RelationDependencyClassifier; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; -class RelationDependencyClassifierTest extends TestCase +class RelationDependencyClassifierTest extends UnitTestCase { public function test_classifies_simple_hasmany_relation(): void { diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index 74e9585..b9b8b25 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -6,10 +6,10 @@ use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\SpacedPost; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; use NormCache\Traits\Cacheable; -class CacheSpaceRegistryTest extends TestCase +class CacheSpaceRegistryTest extends UnitTestCase { private function registry(int $maxPerModel = 16): CacheSpaceRegistry { diff --git a/tests/Unit/Spaces/CacheSpaceResolverTest.php b/tests/Unit/Spaces/CacheSpaceResolverTest.php index d23fe63..ece148e 100644 --- a/tests/Unit/Spaces/CacheSpaceResolverTest.php +++ b/tests/Unit/Spaces/CacheSpaceResolverTest.php @@ -6,9 +6,9 @@ use NormCache\Spaces\CacheSpaceResolver; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\SpacedPost; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; -class CacheSpaceResolverTest extends TestCase +class CacheSpaceResolverTest extends UnitTestCase { private function resolver(): CacheSpaceResolver { diff --git a/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php b/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php index 1dc4e54..fce5b9e 100644 --- a/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php +++ b/tests/Unit/Spaces/CacheableSpacesDeclarationTest.php @@ -4,9 +4,9 @@ use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\SpacedPost; -use NormCache\Tests\TestCase; +use NormCache\Tests\UnitTestCase; -class CacheableSpacesDeclarationTest extends TestCase +class CacheableSpacesDeclarationTest extends UnitTestCase { public function test_model_without_declaration_returns_empty(): void { 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/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); + } +} From 51a9027639a599b4c19d72bedb514e058c6cec0f Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:43:21 +1000 Subject: [PATCH 43/73] centralize Lua script marshalling in RedisStore --- src/Cache/ModelHydrator.php | 7 +-- src/Cache/NormalizedReader.php | 64 +++++++--------------- src/Cache/ResultCacheReader.php | 85 +++++++----------------------- src/Cache/VersionTracker.php | 8 ++- src/CacheServiceProvider.php | 6 +-- src/Support/RedisStore.php | 84 ++++++++++++++++++++++++++++- src/Traits/HandlesInvalidation.php | 8 ++- src/Values/CacheConfig.php | 6 +++ tests/TestCase.php | 6 +-- 9 files changed, 139 insertions(+), 135 deletions(-) diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index 8c92f2f..205726d 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -10,7 +10,6 @@ use NormCache\Support\AttributeProjector; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\CacheReporter; -use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; final class ModelHydrator @@ -201,11 +200,7 @@ private function fetchMissedStatus( ): array { $fetchKeys = $this->modelKeysFor($classKey, $modelVersion, $idsToFetch); - $result = $this->store->script( - RedisScripts::get('fetch_batch_build_status'), - [...$fetchKeys, $lockKey, $wakeKey], - [$token, (string) $this->buildingLockTtl] - ); + $result = $this->store->fetchBatchBuildStatus($fetchKeys, $lockKey, $wakeKey, $token, $this->buildingLockTtl); $raw = $this->store->unserializeMany($result[3] ?? []); diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index b9088f9..8bab29a 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -5,7 +5,6 @@ use NormCache\Enums\CacheStatus; use NormCache\Enums\LuaStatus; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; use NormCache\Values\BuildContext; use NormCache\Values\CacheConfig; @@ -28,15 +27,8 @@ public function __construct( protected readonly int $buildingLockTtl, protected readonly CacheConfig $config, protected readonly int $stampedeWaitMs = 200, - protected readonly int $wakeTokenCount = 64, ) {} - // Live runtime toggle; only payload reads honor it — pivot/standalone version reads always check scheduled keys. - protected function cooldownEnabled(): bool - { - return $this->config->cooldown > 0; - } - abstract protected function queryPrefix(string $classKey, ?string $tag): string; /** @return array{0: LuaStatus, 1: ?array, 2: ?array} */ @@ -60,24 +52,18 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); $queryPrefix = $this->queryPrefix($classKey, $tag); $lockToken = $this->versions->buildLockToken(); - $cooldown = $this->cooldownEnabled(); - - $result = $this->store->script( - RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $cooldown ? $scheduledKeys : [], [ - $queryPrefix, - $this->keys->buildingPrefix($classKey), - $this->keys->wakePrefix($classKey), - ]), - [ - $hash, - $hash, - (int) floor(microtime(true) * 1000), - $this->buildingLockTtl, - $lockToken, - (string) count($versionKeys), - $cooldown ? '1' : '0', - ] + + $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] ?? ''); @@ -149,24 +135,14 @@ protected function storePayload( ?string $buildingToken, ?string $wakeKey = null, ): bool { - $ttl ??= $this->queryTtl; - - $keys = array_merge($versionKeys, [$key]); - if ($buildingKey !== null) { - $keys[] = $buildingKey; - if ($wakeKey !== null) { - $keys[] = $wakeKey; - } - } - - return (bool) $this->store->script( - RedisScripts::get('store_versioned_payload'), - $keys, - array_merge( - [(string) count($versionKeys), '1', (string) $ttl], - $expectedVersions, - [$payload, $buildingToken ?? '', (string) $this->wakeTokenCount] - ) + return $this->store->storeVersionedPayload( + [$key => $payload], + $ttl ?? $this->queryTtl, + $versionKeys, + $expectedVersions, + $buildingKey, + $wakeKey, + $buildingToken, ); } diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index 5e1bfe1..1f1a2ac 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -5,7 +5,6 @@ use NormCache\Enums\CacheStatus; use NormCache\Enums\LuaStatus; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; use NormCache\Values\CacheConfig; use NormCache\Values\PivotCacheResult; @@ -21,15 +20,8 @@ public function __construct( private readonly int $buildingLockTtl, private readonly CacheConfig $config, private readonly int $stampedeWaitMs, - private readonly int $wakeTokenCount = 64, ) {} - // Live runtime toggle; only payload reads honor it — pivot/standalone version reads always check scheduled keys. - private function cooldownEnabled(): bool - { - return $this->config->cooldown > 0; - } - public function fetch( string $modelClass, array $depClasses, string $hash, ?string $tag, array $depTableKeys, @@ -41,12 +33,17 @@ public function fetch( $wakeKey = $this->keys->wakeKey($classKey, $lockSuffix); $lockToken = $this->versions->buildLockToken(); - $result = $this->luaFetchVersionedResult( - $versionKeys, $scheduledKeys, + $result = $this->store->fetchVersionedPayload( + $versionKeys, + $scheduledKeys, $this->keys->namespacedPrefix($namespace, $classKey, $tag), $this->keys->buildingPrefix($classKey), $this->keys->wakePrefix($classKey), - $hash, $lockSuffix, $lockToken + $hash, + $lockSuffix, + $lockToken, + $this->buildingLockTtl, + $this->config->cooldownEnabled(), ); $status = LuaStatus::fromLua($result[0] ?? null); @@ -107,7 +104,7 @@ public function fetchPivot( $relatedKey = $this->keys->classKey($relatedClass); [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($relatedKey, [], [$pivotTableKey ?? $parentKey]); - $seg = $this->luaFetchVersionedPivotSegment($versionKeys, $scheduledKeys); + $seg = $this->store->fetchVersionedPivotSegment($versionKeys, $scheduledKeys); $pivotKeys = []; foreach ($parentIds as $id) { @@ -130,11 +127,7 @@ public function fetchPivot( $missedKeys[] = $this->keys->pivotKey($parentKey, $relatedKey, $relation, $constraintHash, $seg, $id); } - $result = $this->store->script( - RedisScripts::get('fetch_batch_build_status'), - [...$missedKeys, $lockKey, $wakeKey], - [$token, (string) $this->buildingLockTtl] - ); + $result = $this->store->fetchBatchBuildStatus($missedKeys, $lockKey, $wakeKey, $token, $this->buildingLockTtl); $raw = $this->store->unserializeMany($result[3] ?? []); foreach ($missed as $i => $id) { @@ -211,23 +204,14 @@ public function storeMany( return true; } - $keys = array_merge($versionKeys, array_keys($entries)); - if ($buildingKey !== null) { - $keys[] = $buildingKey; - if ($wakeKey !== null) { - $keys[] = $wakeKey; - } - } - - return (bool) $this->store->script( - RedisScripts::get('store_versioned_payload'), - $keys, - 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] - ) + return $this->store->storeVersionedPayload( + array_map(fn($p) => $this->store->serialize($p), $entries), + $ttl, + $versionKeys, + $expectedVersions, + $buildingKey, + $wakeKey, + $buildingToken, ); } @@ -252,37 +236,4 @@ public function waitForBuild( 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 { - $cooldown = $this->cooldownEnabled(); - - return $this->store->script( - RedisScripts::get('fetch_versioned_payload'), - array_merge($versionKeys, $cooldown ? $scheduledKeys : [], [$resultPrefix, $buildingPrefix, $wakePrefix]), - [ - $hash, - $lockSuffix, - (int) floor(microtime(true) * 1000), - $this->buildingLockTtl, - $lockToken, - (string) count($versionKeys), - $cooldown ? '1' : '0', - ] - ); - } - - 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/VersionTracker.php b/src/Cache/VersionTracker.php index b04d86a..2e83ec2 100644 --- a/src/Cache/VersionTracker.php +++ b/src/Cache/VersionTracker.php @@ -3,7 +3,6 @@ namespace NormCache\Cache; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; use NormCache\Support\RedisStore; use NormCache\Values\CacheSpace; @@ -40,10 +39,9 @@ public function normalizeVersion(mixed $value = null): int private function fetchVersionWithCooldown(string $classKey, ?CacheSpace $space = null): mixed { - return $this->store->script( - RedisScripts::get('fetch_version_with_cooldown'), - [$this->keys->verKey($classKey, $space), $this->keys->scheduledKey($classKey, $space)], - [(string) (int) floor(microtime(true) * 1000)] + return $this->store->fetchVersionWithCooldown( + $this->keys->verKey($classKey, $space), + $this->keys->scheduledKey($classKey, $space) ); } } diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 5deb17e..c30427a 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -72,12 +72,12 @@ public function register(): void dispatchEvents: $events, stampedeWakeTokens: $stampedeWakeTokens, ); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs); return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index c6db03c..cfd5a16 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -134,12 +134,92 @@ public function storeRawAndRelease(string $key, string $value, int $ttl, ?string return true; } - $keys = $wakeKey !== null && $wakeKey !== '' ? [$key, $buildingKey, $wakeKey] : [$key, $buildingKey]; + 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_versioned_payload'), $keys, - ['0', '1', (string) $ttl, $value, $token ?? '', (string) $this->wakeTokenCount] + 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)] ); } diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index a2709a7..1975243 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -6,7 +6,6 @@ use Illuminate\Support\Facades\DB; use NormCache\CacheManager; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisScripts; use NormCache\Values\CacheSpace; /** @@ -314,10 +313,9 @@ private function resolveCurrentVersion(string $classKey, ?CacheSpace $space = nu return $this->store->getRaw($this->keys->verKey($classKey, $space)); } - return $this->store->script( - RedisScripts::get('fetch_version_with_cooldown'), - [$this->keys->verKey($classKey, $space), $this->keys->scheduledKey($classKey, $space)], - [(string) (int) floor(microtime(true) * 1000)] + return $this->store->fetchVersionWithCooldown( + $this->keys->verKey($classKey, $space), + $this->keys->scheduledKey($classKey, $space) ); } diff --git a/src/Values/CacheConfig.php b/src/Values/CacheConfig.php index 26c4feb..71cfc52 100644 --- a/src/Values/CacheConfig.php +++ b/src/Values/CacheConfig.php @@ -17,4 +17,10 @@ public function __construct( public bool $dispatchEvents = true, public int $stampedeWakeTokens = 64, ) {} + + // Live runtime toggle; only payload reads honor it — pivot/standalone version reads always check scheduled keys. + public function cooldownEnabled(): bool + { + return $this->cooldown > 0; + } } diff --git a/tests/TestCase.php b/tests/TestCase.php index bc09477..a904342 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -199,12 +199,12 @@ protected function buildManager( dispatchEvents: $dispatchEvents, stampedeWakeTokens: $stampedeWakeTokens, ); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens); + $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs); return new CacheManager( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), + queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs, $stampedeWakeTokens), + throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), result: new ResultExecutor($engine, $resultReader, $config), hydrator: new ModelHydrator($store, $keys, $versions, $ttl, $fireRetrieved, $buildingLockTtl, $stampedeWaitMs), versions: $versions, From ec32d366641c71a7f73e9142d8ab46fc42b564bc Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:20:36 +1000 Subject: [PATCH 44/73] refactor cache internals into typed value objects and RedisScanner --- phpstan.neon | 3 + src/Cache/ModelHydrator.php | 194 +++++++--------- src/Cache/ModelsExecutor.php | 2 +- src/Cache/NormalizedCacheReader.php | 8 +- src/Cache/NormalizedThroughReader.php | 8 +- src/Cache/ResultCacheReader.php | 21 +- src/Cache/ResultExecutor.php | 17 +- src/CacheableBuilder.php | 7 +- src/Relations/CachesOneOrManyThrough.php | 6 +- src/Relations/CachesPivotRelation.php | 4 +- src/Relations/CachesRelationAggregates.php | 21 +- src/Relations/CachesRelationExistence.php | 21 +- .../RelationDependencyClassifier.php | 17 +- src/Support/RedisScanner.php | 208 ++++++++++++++++++ src/Support/RedisStore.php | 199 +---------------- src/Values/BuildContext.php | 5 + src/Values/BuildHandle.php | 15 ++ src/Values/ModelFetchContext.php | 29 +++ src/Values/PivotCacheResult.php | 6 +- src/Values/PreparedQuery.php | 12 + src/Values/QueryCacheResult.php | 6 +- src/Values/RelationDependency.php | 40 ++++ src/Values/ResultCacheResult.php | 6 +- src/Values/ThroughCacheResult.php | 6 +- .../Cache/ModelHydratorStampedeTest.php | 22 +- tests/Integration/Cache/PivotCacheTest.php | 6 +- tests/Integration/Cache/PivotStampedeTest.php | 20 +- .../LuaScriptConsistencyTest.php | 40 ++-- tests/Unit/Cache/ExecutionEngineTest.php | 37 ++-- .../RelationDependencyClassifierTest.php | 15 +- tests/Unit/Values/ResultValueObjectsTest.php | 26 +-- 31 files changed, 537 insertions(+), 490 deletions(-) create mode 100644 src/Support/RedisScanner.php create mode 100644 src/Values/BuildHandle.php create mode 100644 src/Values/ModelFetchContext.php create mode 100644 src/Values/RelationDependency.php 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/ModelHydrator.php b/src/Cache/ModelHydrator.php index 205726d..fd1643f 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -11,6 +11,7 @@ use NormCache\Support\CacheKeyBuilder; use NormCache\Support\CacheReporter; use NormCache\Support\RedisStore; +use NormCache\Values\ModelFetchContext; final class ModelHydrator { @@ -76,7 +77,8 @@ 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); @@ -92,68 +94,77 @@ public function getModels( } ['hits' => $hits, 'missed' => $missed] = $this->hydrateModels($ids, $modelClass, $raw, $projection, $prototype); - $lockKey = $wakeKey = $token = null; - 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 ($containedInQueryHit) { - $modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace()); + if ($versionDeferred) { + $context->modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace()); } 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, $modelVersion, $missed); + [$context->lockKey, $context->wakeKey, $context->token] = $this->buildLockTriple($classKey, $context->modelVersion, $missed); - [$status, $missed] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $modelVersion, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $context); if ($status === 'building' && $missed !== []) { - $this->store->brpop($wakeKey, $this->stampedeWaitMs / 1000.0); + $this->store->brpop($context->wakeKey, $this->stampedeWaitMs / 1000.0); - [$status, $missed] = $this->fetchMissedStatus($missed, $modelClass, $classKey, $modelVersion, $projection, $prototype, $lockKey, $wakeKey, $token, $hits); + [$status, $missed] = $this->fetchMissedStatus($missed, $context); } if ($status === 'miss') { try { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, true, $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 ($status === 'hit' && $missed !== []) { // Corrupt payload: Lua reported hit but deserialization failed. Only the lock owner overwrites. - if ($this->store->setNxEx($lockKey, $token, $this->buildingLockTtl)) { + if ($this->store->setNxEx($context->lockKey, $context->token, $this->buildingLockTtl)) { try { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, true, $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; } } else { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, false); + $this->fetchAndMerge($missed, $context, false); } } elseif ($status === 'building' && $missed !== []) { - $this->fetchAndMerge($missed, $modelClass, $classKey, $projection, $missedQuery, $preserveQueryShape, $hits, $modelVersion, false); + $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]; } } @@ -186,28 +197,18 @@ private function modelKeysFor(string $classKey, int $modelVersion, array $ids): } // 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, - int $modelVersion, - ?array $projection, - ?Model $prototype, - string $lockKey, - string $wakeKey, - string $token, - array &$hits, - ): array { - $fetchKeys = $this->modelKeysFor($classKey, $modelVersion, $idsToFetch); + private function fetchMissedStatus(array $idsToFetch, ModelFetchContext $context): array + { + $fetchKeys = $this->modelKeysFor($context->classKey, $context->modelVersion, $idsToFetch); - $result = $this->store->fetchBatchBuildStatus($fetchKeys, $lockKey, $wakeKey, $token, $this->buildingLockTtl); + $result = $this->store->fetchBatchBuildStatus($fetchKeys, $context->lockKey, $context->wakeKey, $context->token, $this->buildingLockTtl); $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 === []) { @@ -217,31 +218,16 @@ private function fetchMissedStatus( return [$result[0], $stillMissed]; } - private function fetchAndMerge( - array $missed, - string $modelClass, - string $classKey, - ?array $projection, - ?EloquentBuilder $missedQuery, - bool $preserveQueryShape, - array &$hits, - int $modelVersion, - bool $writeCache, - ?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, $writeCache, $buildingKey, $wakeKey, $token - ); + $fetched = $this->fetchFromDatabaseAndCache($missed, $context, $writeCache); foreach ($fetched as $id => $model) { - $hits[$id] = $model; + $context->hits[$id] = $model; } } @@ -336,49 +322,28 @@ 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, - bool $writeCache, - ?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, $writeCache, $buildingKey, $wakeKey, $token - ); + return $this->fetchAndCacheUsingEloquent($missed, $query, $context, $writeCache); } - return $this->fetchAndCacheUsingClosure( - $missed, $modelClass, $classKey, $projection, $query, $query->getModel(), $modelVersion, $writeCache, $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, + ModelFetchContext $context, bool $writeCache, - ?string $buildingKey = null, - ?string $wakeKey = null, - ?string $token = null, ): array { - $prototype = CacheKeyBuilder::prototype($modelClass); + $prototype = CacheKeyBuilder::prototype($context->modelClass); $pk = $prototype->getKeyName(); $qualifiedPk = $prototype->getQualifiedKeyName(); $loaded = $query->whereIn($qualifiedPk, $missed) @@ -386,45 +351,39 @@ private function fetchAndCacheUsingEloquent( ->keyBy($pk); $attrsByKey = []; - $deletedAtCol = CacheKeyBuilder::deletedAtColumn($modelClass); - $hydrate = $projection !== null ? self::hydrateClosure() : null; + $deletedAtCol = CacheKeyBuilder::deletedAtColumn($context->modelClass); + $hydrate = $context->projection !== null ? self::hydrateClosure() : null; foreach ($loaded as $id => $model) { $attrs = $model->getRawOriginal(); $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); if ($writeCache && !$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; + $attrsByKey[$this->keys->modelPrefix($context->classKey, $context->modelVersion) . $id] = $attrs; } if ($hydrate !== null) { - $hydrate($model, AttributeProjector::projectAttributes($attrs, $projection), false); + $hydrate($model, AttributeProjector::projectAttributes($attrs, $context->projection), false); } } - $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); + $this->storeModelAttrs($attrsByKey, $context, $writeCache); return $loaded->all(); } private function fetchAndCacheUsingClosure( array $missed, - string $modelClass, - string $classKey, - ?array $projection, EloquentBuilder $query, - Model $prototype, - int $modelVersion, + ModelFetchContext $context, bool $writeCache, - ?string $buildingKey = null, - ?string $wakeKey = null, - ?string $token = null, ): array { + $prototype = $query->getModel(); $pk = $prototype->getKeyName(); $qualifiedPk = $prototype->getQualifiedKeyName(); - $deletedAtCol = CacheKeyBuilder::deletedAtColumn($modelClass); + $deletedAtCol = CacheKeyBuilder::deletedAtColumn($context->modelClass); $hydrate = self::hydrateClosure(); - $connectionName = $query->getModel()->getConnectionName(); + $connectionName = $prototype->getConnectionName(); $rows = $query->whereIn($qualifiedPk, $missed) ->toBase() @@ -444,11 +403,11 @@ private function fetchAndCacheUsingClosure( $isTrashed = $deletedAtCol && isset($attrs[$deletedAtCol]); if ($writeCache && !$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($classKey, $modelVersion) . $id] = $attrs; + $attrsByKey[$this->keys->modelPrefix($context->classKey, $context->modelVersion) . $id] = $attrs; } - $returnAttrs = $projection !== null - ? AttributeProjector::projectAttributes($attrs, $projection) + $returnAttrs = $context->projection !== null + ? AttributeProjector::projectAttributes($attrs, $context->projection) : $attrs; $instance = clone $prototype; @@ -458,27 +417,22 @@ private function fetchAndCacheUsingClosure( $models[$id] = $instance; } - $this->storeModelAttrs($attrsByKey, $classKey, $modelVersion, $buildingKey, $wakeKey, $token); + $this->storeModelAttrs($attrsByKey, $context, $writeCache); return $models; } - private function storeModelAttrs( - array $attrsByKey, - string $classKey, - int $modelVersion, - ?string $buildingKey, - ?string $wakeKey, - ?string $token, - ): void { + // 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 + { $this->store->setManyIfVersion( $attrsByKey, $this->ttl, - $this->keys->verKey($classKey), - $modelVersion, - $buildingKey, - $wakeKey, - $token + $this->keys->verKey($context->classKey), + $context->modelVersion, + $writeCache ? $context->lockKey : null, + $writeCache ? $context->wakeKey : null, + $writeCache ? $context->token : null, ); } diff --git a/src/Cache/ModelsExecutor.php b/src/Cache/ModelsExecutor.php index 02be718..3971fc3 100644 --- a/src/Cache/ModelsExecutor.php +++ b/src/Cache/ModelsExecutor.php @@ -65,7 +65,7 @@ public function runNormalized( $ids = $this->resolveIds( $result->key, $base, $queryTtl, $prototype, - $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken, $result->wakeKey + $result->build->buildingKey, $result->build->versionKeys, $result->build->expectedVersions, $result->build->buildingToken, $result->build->wakeKey ); return $executionBuilder->finalizeResult( diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheReader.php index cb8670d..8f619b4 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheReader.php @@ -64,10 +64,10 @@ protected function buildResult( ?array $models = null, ): QueryCacheResult { return match ($status) { - LuaStatus::Hit => new QueryCacheResult(CacheStatus::Hit, $build->queryKey, $ids, $models ?? [], null, null, [], []), - LuaStatus::Empty => new QueryCacheResult(CacheStatus::Empty, $build->queryKey, [], [], null, null, [], []), - LuaStatus::Miss => new QueryCacheResult(CacheStatus::Miss, $build->queryKey, null, null, $build->buildingKey, $build->lockToken, $build->versionKeys, $build->expectedVersions, $build->wakeKey), - LuaStatus::Building => new QueryCacheResult(CacheStatus::Building, null, null, null, null, null, [], []), + 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), }; } diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/NormalizedThroughReader.php index 50a2343..25bd336 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/NormalizedThroughReader.php @@ -56,10 +56,10 @@ protected function buildResult( ?array $models = null, ): ThroughCacheResult { return match ($status) { - LuaStatus::Hit => new ThroughCacheResult(CacheStatus::Hit, $build->queryKey, $ids, $throughKeys, $models ?? [], null, null, [], []), - LuaStatus::Empty => new ThroughCacheResult(CacheStatus::Empty, $build->queryKey, [], [], [], null, null, [], []), - LuaStatus::Miss => new ThroughCacheResult(CacheStatus::Miss, $build->queryKey, null, null, null, $build->buildingKey, $build->lockToken, $build->versionKeys, $build->expectedVersions, $build->wakeKey), - LuaStatus::Building => new ThroughCacheResult(CacheStatus::Building, null, null, null, null, null, null, [], []), + 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), }; } diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index 1f1a2ac..451c319 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -6,6 +6,7 @@ use NormCache\Enums\LuaStatus; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisStore; +use NormCache\Values\BuildHandle; use NormCache\Values\CacheConfig; use NormCache\Values\PivotCacheResult; use NormCache\Values\ResultCacheResult; @@ -73,7 +74,7 @@ private function toResultCacheResult( 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); + return new ResultCacheResult(CacheStatus::Hit, $resultKey, $unserialized, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); } // Corrupt payload (not an array), treat as miss. @@ -81,7 +82,7 @@ private function toResultCacheResult( } if ($status === LuaStatus::Building) { - return new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); + return new ResultCacheResult(CacheStatus::Building, null, null); } // Standard miss or corrupt hit: attempt to claim building lock. @@ -90,10 +91,10 @@ private function toResultCacheResult( $this->store->delete($wakeKey); } - return new ResultCacheResult(CacheStatus::Miss, $resultKey, null, $buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions); + return new ResultCacheResult(CacheStatus::Miss, $resultKey, null, new BuildHandle($buildingKey, $lockToken, $wakeKey, $versionKeys, $expectedVersions)); } - return new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); + return new ResultCacheResult(CacheStatus::Building, null, null); } public function fetchPivot( @@ -116,7 +117,7 @@ public function fetchPivot( $missed = array_keys(array_filter($data, fn($payload) => !is_array($payload))); if ($missed === []) { - return new PivotCacheResult($seg, $data, $versionKeys, $expectedVersions); + return new PivotCacheResult($seg, $data, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); } [$lockKey, $wakeKey] = $this->pivotLockKeys($relatedKey, $relation, $constraintHash, $parentIds, $seg); @@ -137,18 +138,20 @@ public function fetchPivot( } if (array_filter($data, fn($payload) => !is_array($payload)) === []) { - return new PivotCacheResult($seg, $data, $versionKeys, $expectedVersions); + return new PivotCacheResult($seg, $data, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); } if ($result[0] === 'miss') { return new PivotCacheResult( - $seg, $data, $versionKeys, $expectedVersions, - CacheStatus::Miss, $lockKey, $token, $wakeKey + $seg, $data, + new BuildHandle($lockKey, $token, $wakeKey, $versionKeys, $expectedVersions), + CacheStatus::Miss ); } return new PivotCacheResult( - $seg, $data, $versionKeys, $expectedVersions, + $seg, $data, + new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions), CacheStatus::Building ); } diff --git a/src/Cache/ResultExecutor.php b/src/Cache/ResultExecutor.php index b7accce..e0c55cf 100644 --- a/src/Cache/ResultExecutor.php +++ b/src/Cache/ResultExecutor.php @@ -79,12 +79,12 @@ private function storeResult(ResultCacheResult $result, mixed $payload, ?int $tt $this->resultReader->store( $result->key, is_array($payload) ? $payload : [$payload], - $result->buildingKey, + $result->build->buildingKey, $ttl, - $result->wakeKey, - $result->versionKeys, - $result->expectedVersions, - $result->buildingToken + $result->build->wakeKey, + $result->build->versionKeys, + $result->build->expectedVersions, + $result->build->buildingToken ); } @@ -102,12 +102,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/CacheableBuilder.php b/src/CacheableBuilder.php index f994152..0392cb1 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -285,12 +285,7 @@ function () use ($prepared, $columns) { return $this->resultPayloadFromEloquentModels($rawModels); } - $resultBase = clone $prepared->base; - if (empty($resultBase->columns) && $columns !== ['*']) { - $resultBase->columns = $columns; - } - - return $this->buildResultPayloadFromQuery($resultBase); + return $this->buildResultPayloadFromQuery($prepared->baseWithColumns($columns)); } ); diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 5b151a0..35c377e 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -94,15 +94,15 @@ public function get($columns = ['*']): Collection NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $space) { $stored = NormCache::storeThroughIds( $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, - $result->buildingKey, $result->versionKeys, $result->expectedVersions, $result->buildingToken, $result->wakeKey + $result->build->buildingKey, $result->build->versionKeys, $result->build->expectedVersions, $result->build->buildingToken, $result->build->wakeKey ); if ($stored) { NormCache::storeModelAttrsForVersionedResult( $relatedClass, $modelAttrs, - $result->versionKeys, - $result->expectedVersions, + $result->build->versionKeys, + $result->build->expectedVersions, $space, ); } diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index cc0c3a2..5b53530 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -131,8 +131,8 @@ public function get($columns = ['*']): Collection } $this->populatePivotCache( $models, $keyMap, $relatedClass, $shouldCacheRelatedModels, - $pivotResult->versionKeys, $pivotResult->expectedVersions, $ttl, - $pivotResult->buildingKey, $pivotResult->wakeKey, $pivotResult->buildingToken, + $pivotResult->build->versionKeys, $pivotResult->build->expectedVersions, $ttl, + $pivotResult->build->buildingKey, $pivotResult->build->wakeKey, $pivotResult->build->buildingToken, $space, ); }); diff --git a/src/Relations/CachesRelationAggregates.php b/src/Relations/CachesRelationAggregates.php index 3c54552..ab7b4f2 100644 --- a/src/Relations/CachesRelationAggregates.php +++ b/src/Relations/CachesRelationAggregates.php @@ -6,6 +6,7 @@ use Illuminate\Support\Arr; use Illuminate\Support\Str; use NormCache\Values\DependencySet; +use NormCache\Values\RelationDependency; trait CachesRelationAggregates { @@ -49,27 +50,17 @@ public function withAggregate($relations, $column, $function = null): static $names[] = $name; - $entry = $this->classifyAggregate($name, $constraint); + $dependency = $this->classifyAggregate($name, $constraint); - if ($entry === null) { + if ($dependency === 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']; - } - - array_push($dependencies, ...$entry['constraintModels']); - array_push($tableDependencies, ...$entry['constraintTables']); + array_push($dependencies, ...$dependency->modelDependencies()); + array_push($tableDependencies, ...$dependency->tableDependencies()); } $result = parent::withAggregate($relations, $column, $function); @@ -118,7 +109,7 @@ private function clearAggregateTracking(bool $failed = false): void private function classifyAggregate( string $name, ?callable $constraint, - ): ?array { + ): ?RelationDependency { if (str_contains($name, '.')) { return null; } diff --git a/src/Relations/CachesRelationExistence.php b/src/Relations/CachesRelationExistence.php index dbd7ee8..654e94e 100644 --- a/src/Relations/CachesRelationExistence.php +++ b/src/Relations/CachesRelationExistence.php @@ -10,6 +10,7 @@ use Illuminate\Database\Eloquent\Relations\MorphTo; use NormCache\CacheableBuilder; use NormCache\Values\DependencySet; +use NormCache\Values\RelationDependency; /** * @mixin CacheableBuilder @@ -32,22 +33,12 @@ public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ? $this->totalHasCalls++; if (is_string($relation) && !str_contains($relation, '.')) { - $entry = $this->classifyExistenceRelation($relation, $callback); + $dependency = $this->classifyExistenceRelation($relation, $callback); - if ($entry !== null) { + if ($dependency !== null) { $this->simpleHasCalls++; - $this->existenceDependencies[] = $entry['relatedClass']; - - if ($entry['throughClass'] ?? null) { - $this->existenceDependencies[] = $entry['throughClass']; - } - - if ($entry['tableKey'] ?? null) { - $this->existenceTableDependencies[] = $entry['tableKey']; - } - - array_push($this->existenceDependencies, ...$entry['constraintModels']); - array_push($this->existenceTableDependencies, ...$entry['constraintTables']); + array_push($this->existenceDependencies, ...$dependency->modelDependencies()); + array_push($this->existenceTableDependencies, ...$dependency->tableDependencies()); } } @@ -70,7 +61,7 @@ public function inferExistenceDependencies(): DependencySet ); } - private function classifyExistenceRelation(string $name, ?callable $constraint): ?array + private function classifyExistenceRelation(string $name, ?callable $constraint): ?RelationDependency { $relation = $this->getRelationWithoutConstraints($name); diff --git a/src/Relations/RelationDependencyClassifier.php b/src/Relations/RelationDependencyClassifier.php index be5714a..a21f10f 100644 --- a/src/Relations/RelationDependencyClassifier.php +++ b/src/Relations/RelationDependencyClassifier.php @@ -10,13 +10,14 @@ use NormCache\Planning\QueryAnalyzer; use NormCache\Traits\Cacheable; use NormCache\Values\CachePlanContext; +use NormCache\Values\RelationDependency; final class RelationDependencyClassifier { /** * @param Relation<*, *, *> $relation */ - public function classify(Relation $relation, ?callable $constraint): ?array + public function classify(Relation $relation, ?callable $constraint): ?RelationDependency { $relatedClass = $relation->getRelated()::class; @@ -93,13 +94,13 @@ public function classify(Relation $relation, ?callable $constraint): ?array ); } - return [ - 'relatedClass' => $relatedClass, - 'throughClass' => $throughClass, - 'tableKey' => $tableKey, - 'constraintModels' => $constraintModels, - 'constraintTables' => array_values(array_unique([...$constraintTables, ...$relationExtraTables])), - ]; + return new RelationDependency( + relatedClass: $relatedClass, + throughClass: $throughClass, + tableKey: $tableKey, + constraintModels: $constraintModels, + constraintTables: array_values(array_unique([...$constraintTables, ...$relationExtraTables])), + ); } private static function relatedIsCacheable(string $class): bool diff --git a/src/Support/RedisScanner.php b/src/Support/RedisScanner.php new file mode 100644 index 0000000..0615115 --- /dev/null +++ b/src/Support/RedisScanner.php @@ -0,0 +1,208 @@ +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 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; + } + + 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 cfd5a16..0228b8e 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -8,7 +8,6 @@ use Illuminate\Redis\Connections\PredisClusterConnection; use Illuminate\Redis\Connections\PredisConnection; use Illuminate\Support\Facades\Redis; -use Predis\Cluster\RedisStrategy; use Predis\NotSupportedException; final class RedisStore @@ -17,6 +16,8 @@ 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 = []; @@ -388,7 +389,7 @@ public function flushByPatterns(array $patterns): int $total = 0; foreach ($patterns as $pattern) { - $keys = $this->keysForPattern($pattern); + $keys = $this->scanPattern($pattern); if (!empty($keys)) { $total += count($keys); @@ -468,200 +469,8 @@ public function isCluster(): bool || $this->connection instanceof PredisClusterConnection; } - 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 ''; - } - 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($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 - { - $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; - } - - 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; - } - - /** - * @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/Values/BuildContext.php b/src/Values/BuildContext.php index cc41614..122118b 100644 --- a/src/Values/BuildContext.php +++ b/src/Values/BuildContext.php @@ -12,4 +12,9 @@ public function __construct( public array $versionKeys, public array $expectedVersions, ) {} + + public function handle(): BuildHandle + { + return new BuildHandle($this->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..8af5100 --- /dev/null +++ b/src/Values/BuildHandle.php @@ -0,0 +1,15 @@ + 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..da8f2df 100644 --- a/src/Values/PreparedQuery.php +++ b/src/Values/PreparedQuery.php @@ -14,6 +14,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) { diff --git a/src/Values/QueryCacheResult.php b/src/Values/QueryCacheResult.php index c1bd45d..bbdd6f4 100644 --- a/src/Values/QueryCacheResult.php +++ b/src/Values/QueryCacheResult.php @@ -11,10 +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 ?string $wakeKey = null, + public BuildHandle $build = new BuildHandle, ) {} } diff --git a/src/Values/RelationDependency.php b/src/Values/RelationDependency.php new file mode 100644 index 0000000..331785f --- /dev/null +++ b/src/Values/RelationDependency.php @@ -0,0 +1,40 @@ + $constraintModels + * @param list $constraintTables + */ + public function __construct( + public string $relatedClass, + public ?string $throughClass = null, + public ?string $tableKey = null, + public array $constraintModels = [], + public array $constraintTables = [], + ) {} + + /** @return list */ + public function modelDependencies(): array + { + return [ + $this->relatedClass, + ...($this->throughClass !== null ? [$this->throughClass] : []), + ...$this->constraintModels, + ]; + } + + /** @return list */ + public function tableDependencies(): array + { + return [ + ...($this->tableKey !== null ? [$this->tableKey] : []), + ...$this->constraintTables, + ]; + } +} 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/ThroughCacheResult.php b/src/Values/ThroughCacheResult.php index 11193b5..b916792 100644 --- a/src/Values/ThroughCacheResult.php +++ b/src/Values/ThroughCacheResult.php @@ -12,10 +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 ?string $wakeKey = null, + public BuildHandle $build = new BuildHandle, ) {} } diff --git a/tests/Integration/Cache/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index 011d7d9..cfd387b 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -9,6 +9,7 @@ use NormCache\Support\RedisScripts; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\TestCase; +use NormCache\Values\ModelFetchContext; class ModelHydratorStampedeTest extends TestCase { @@ -147,16 +148,27 @@ public function test_fetch_missed_status_resolves_via_retry_mget_without_claimin $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, $version, 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([], $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'); } diff --git a/tests/Integration/Cache/PivotCacheTest.php b/tests/Integration/Cache/PivotCacheTest.php index afaef2f..d9ee76f 100644 --- a/tests/Integration/Cache/PivotCacheTest.php +++ b/tests/Integration/Cache/PivotCacheTest.php @@ -123,7 +123,7 @@ public function test_pivot_eager_load_falls_back_to_database_when_build_lock_is_ // 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); + $this->assertNotNull($claimed->build->buildingKey); DB::enableQueryLog(); $authors = Author::with('tags')->get(); @@ -134,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::getStore()->getRaw($claimed->build->buildingKey), 'The foreign build lock must remain untouched' ); } diff --git a/tests/Integration/Cache/PivotStampedeTest.php b/tests/Integration/Cache/PivotStampedeTest.php index 71cf107..d1ef673 100644 --- a/tests/Integration/Cache/PivotStampedeTest.php +++ b/tests/Integration/Cache/PivotStampedeTest.php @@ -21,7 +21,7 @@ public function test_concurrent_pivot_miss_second_caller_sees_building_status(): $second = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); $this->assertSame(CacheStatus::Miss, $first->status); - $this->assertNotNull($first->buildingKey); + $this->assertNotNull($first->build->buildingKey); $this->assertSame(CacheStatus::Building, $second->status); } @@ -39,14 +39,14 @@ public function test_pivot_build_lock_is_released_after_store_and_visible_to_nex $manager->storeManyVersionedResults( [$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, + versionKeys: $miss->build->versionKeys, + expectedVersions: $miss->build->expectedVersions, + buildingKey: $miss->build->buildingKey, + wakeKey: $miss->build->wakeKey, + buildingToken: $miss->build->buildingToken, ); - $this->assertNull($manager->getStore()->getRaw($miss->buildingKey), 'Building lock must be released after store'); + $this->assertNull($manager->getStore()->getRaw($miss->build->buildingKey), 'Building lock must be released after store'); $hit = $manager->getPivotCache(Author::class, Tag::class, 'tags', [$author->id]); $this->assertSame(CacheStatus::Hit, $hit->status); @@ -64,13 +64,13 @@ public function test_pivot_falls_back_to_database_without_releasing_someone_else $store = $manager->getStore(); // 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); $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 diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php index c008ee1..efb61e6 100644 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php @@ -157,15 +157,15 @@ public function test_result_cache_write_is_skipped_when_dependency_version_chang $manager->storeResultCache( $miss->key, [['id' => 1, 'name' => 'Old']], - $miss->buildingKey, + $miss->build->buildingKey, 60, - $miss->wakeKey, - $miss->versionKeys, - $miss->expectedVersions, + $miss->build->wakeKey, + $miss->build->versionKeys, + $miss->build->expectedVersions, ); $this->assertNull($manager->getStore()->get($miss->key)); - $this->assertNull($manager->getStore()->getRaw($miss->buildingKey)); + $this->assertNull($manager->getStore()->getRaw($miss->build->buildingKey)); } public function test_namespaced_result_write_is_skipped_when_dependency_version_changes_during_build(): void @@ -179,8 +179,8 @@ public function test_namespaced_result_write_is_skipped_when_dependency_version_ $cache->key, [10], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build->versionKeys, + $cache->build->expectedVersions, ); $this->assertNull($manager->getStore()->get($cache->key)); @@ -197,8 +197,8 @@ public function test_through_result_write_is_skipped_when_dependency_version_cha $cache->key, ['ids' => [1], 'throughKeys' => [1 => 1]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build->versionKeys, + $cache->build->expectedVersions, ); $this->assertNull($manager->getStore()->get($cache->key)); @@ -215,8 +215,8 @@ public function test_related_model_payload_is_not_cached_when_through_result_wri $cache->key, ['ids' => [1], 'throughKeys' => [1 => 1]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build->versionKeys, + $cache->build->expectedVersions, )) { $manager->storeModelAttrs(Post::class, [1 => ['id' => 1, 'title' => 'Old']]); } @@ -239,8 +239,8 @@ public function test_pivot_result_write_is_skipped_when_dependency_version_chang $pivotKey, [['id' => 1, 'pivot' => []]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build->versionKeys, + $cache->build->expectedVersions, ); $this->assertNull($manager->getStore()->get($pivotKey)); @@ -261,8 +261,8 @@ public function test_related_model_payload_is_not_cached_when_pivot_result_write $pivotKey, [['id' => 1, 'pivot' => []]], 60, - $cache->versionKeys, - $cache->expectedVersions, + $cache->build->versionKeys, + $cache->build->expectedVersions, )) { $manager->storeModelAttrs(Tag::class, [1 => ['id' => 1, 'name' => 'Old']]); } @@ -359,12 +359,12 @@ public function test_late_writer_does_not_commit_outdated_version_as_current(): $committed = NormCache::storeResultCache( $buildLock->key, ['data' => 'outdated'], - $buildLock->buildingKey, + $buildLock->build->buildingKey, null, - $buildLock->wakeKey, - $buildLock->versionKeys, - $buildLock->expectedVersions, // Worker A thinks this is still current - $buildLock->buildingToken + $buildLock->build->wakeKey, + $buildLock->build->versionKeys, + $buildLock->build->expectedVersions, // Worker A thinks this is still current + $buildLock->build->buildingToken ); $this->assertFalse($committed, 'Late writer should have its commit rejected'); diff --git a/tests/Unit/Cache/ExecutionEngineTest.php b/tests/Unit/Cache/ExecutionEngineTest.php index 33ebffd..0bc2130 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; @@ -26,7 +27,7 @@ protected function setUp(): void public function test_run_result_calls_on_hit_and_returns_collection(): void { - $hit = new ResultCacheResult(CacheStatus::Hit, 'k', ['data'], null, null, null, [], []); + $hit = new ResultCacheResult(CacheStatus::Hit, 'k', ['data']); $hitCalled = false; $result = $this->executor->runResult( @@ -48,7 +49,7 @@ public function test_run_result_calls_on_hit_and_returns_collection(): void public function test_run_result_calls_on_miss_store_and_returns_models(): void { - $miss = new ResultCacheResult(CacheStatus::Miss, 'k', null, null, null, null, [], []); + $miss = new ResultCacheResult(CacheStatus::Miss, 'k', null); $storeCalled = false; $models = new Collection; @@ -69,7 +70,7 @@ public function test_run_result_calls_on_miss_store_and_returns_models(): void public function test_run_result_calls_on_build_when_wait_returns_null(): void { - $building = new ResultCacheResult(CacheStatus::Building, null, null, null, null, null, [], []); + $building = new ResultCacheResult(CacheStatus::Building, null, null); $buildCalled = false; $this->executor->runResult( @@ -90,8 +91,8 @@ public function test_run_result_calls_on_build_when_wait_returns_null(): void 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, [], []); + $building = new ResultCacheResult(CacheStatus::Building, null, null); + $hit = new ResultCacheResult(CacheStatus::Hit, 'k', ['data']); $hitCalled = false; $this->executor->runResult( @@ -116,7 +117,7 @@ public function test_run_result_retries_hit_after_successful_wait(): void 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 +138,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 +163,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 +189,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 +209,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 +229,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 +249,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 +274,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 +294,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 +313,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 +328,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/Relations/RelationDependencyClassifierTest.php b/tests/Unit/Relations/RelationDependencyClassifierTest.php index 947cc2a..e4f07c5 100644 --- a/tests/Unit/Relations/RelationDependencyClassifierTest.php +++ b/tests/Unit/Relations/RelationDependencyClassifierTest.php @@ -13,18 +13,21 @@ public function test_classifies_simple_hasmany_relation(): void { $entry = (new RelationDependencyClassifier)->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']); + $this->assertSame(Post::class, $entry->relatedClass); + $this->assertNull($entry->throughClass); + $this->assertNull($entry->tableKey); + $this->assertSame([], $entry->constraintModels); + $this->assertSame([], $entry->constraintTables); + $this->assertSame([Post::class], $entry->modelDependencies()); + $this->assertSame([], $entry->tableDependencies()); } public function test_belongs_to_many_relation_includes_pivot_table_key(): void { $entry = (new RelationDependencyClassifier)->classify((new Author)->tags(), null); - $this->assertNotNull($entry['tableKey']); + $this->assertNotNull($entry->tableKey); + $this->assertContains($entry->tableKey, $entry->tableDependencies()); } public function test_lock_for_update_relation_definition_is_not_classifiable(): void 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()); From 01555f166e41558a0d141bb58bae0f3aed757274 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:25:42 +1000 Subject: [PATCH 45/73] move dependency inference into planner --- src/CacheableBuilder.php | 7 +------ src/Planning/CachePlanner.php | 9 +++++++++ 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index 0392cb1..8ed58bb 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -17,7 +17,6 @@ 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\CacheKeyBuilder; @@ -495,11 +494,7 @@ public function planPrepared( $builder = $prepared->builder; $base = $prepared->base; - $joinDeps = !empty($base->joins) - ? (new QueryAnalyzer)->inferJoinDependencies($base, $builder->getModel()->getConnection()->getName()) - : DependencySet::empty(); - - return $builder->cachePlan($base, $context($builder->inferAggregateDependencies()->merge($joinDeps)), $mode); + return $builder->cachePlan($base, $context($builder->planner()->inferPreparedDependencies($builder, $base)), $mode); } public function planner(): CachePlanner diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 8ef672a..73e6bfd 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -43,6 +43,15 @@ public function __construct( private readonly DependencyResolver $dependencies = new DependencyResolver, ) {} + public function inferPreparedDependencies(CacheableBuilder $builder, QueryBuilder $base): DependencySet + { + $joinDeps = !empty($base->joins) + ? $this->analyzer->inferJoinDependencies($base, $builder->getModel()->getConnection()->getName()) + : DependencySet::empty(); + + return $builder->inferAggregateDependencies()->merge($joinDeps); + } + public function plan( CacheableBuilder $builder, QueryBuilder $base, From 338bea9ae0767ab1e2403978d301c3aad0fab6e6 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 6 Jul 2026 22:45:04 +1000 Subject: [PATCH 46/73] update readme / changelog for v3 --- CHANGELOG.md | 29 +++++ README.md | 317 +++++++++++++++++++-------------------------------- 2 files changed, 146 insertions(+), 200 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c8346d..4edc5ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- +## [3.0.0] — 2026-07-06 + +### Added + +- Added cache spaces for Redis Cluster sharding: models can declare `$normCacheSpaces`, queries can select `->space()`, and spaces can define placement/cross-space behavior in config. +- Added space-targeted flushing via `NormCache::flushAll('space')` and `php artisan normcache:flush --space=...`, backed by registry metadata for broad cluster flushes. + +### Changed + +- Replaced the old slotting/sharding path with model-declared cache spaces. +- Changed model payload storage to versioned keys shaped like `model:{classKey}:v{version}:{id}`, removing the need for members-set model tracking. +- Simplified Redis/Lua internals by centralizing script marshalling in `RedisStore` and sharing normalized query/through-relation read flow through `NormalizedReader`. +- Simplified planning internals by moving dependency inference into planner/dependency collaborators. + +### Fixed + +- Fixed cache-space consistency across version lookup, invalidation, model payloads, query/result keys, relation caches, pivot/through caches, build locks, and wake keys. +- Fixed Redis Cluster cross-slot issues in space-aware Lua paths and broad flush scans. +- Fixed relation and pivot invalidation edge cases, including relation version lookup, guarded relation model writes, `dependsOn()` accumulation, and pivot invalidation. +- Fixed pre-save invalidation timing so Eloquent observers see a cache miss after writes. +- Fixed connection/table key safety by rejecting connection names containing `:` and resetting key-builder state across spaces. + +### Removed + +- Removed the old slotting/sharding configuration and internals. +- Removed leftover stale-serving code after the `2.4.0` stale-serving removal. + +--- + ## [2.4.0] — 2026-06-25 ### Removed diff --git a/README.md b/README.md index 7c63add..f1c184c 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,13 @@ # 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). - -``` -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:... } -``` +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. **Requirements:** PHP 8.2+, Laravel 12/13, Redis 4.0+ @@ -22,39 +15,23 @@ model:{posts}:12 → { id:12, title:..., body:... } - [Installation](#installation) - [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 +40,9 @@ class Post extends Model } ``` ---- - ## Usage -### Basic Queries +Normal Eloquent reads are cached automatically for cacheable models: ```php Post::all(); @@ -76,253 +51,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 +### 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 - -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`, `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 - -`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 -``` - -### 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. +use NormCache\Facades\NormCache; -### Manual Flush +NormCache::flushModel(Post::class); +NormCache::flushAll(); +NormCache::flushAll('content'); -```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(); -NormCache::flushAll('content'); -``` - -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: +## Cache spaces -| 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 | +Cache spaces are Normcache's Redis Cluster sharding boundary. Each space maps to one Redis hash tag, and every cached plan runs its Lua operations inside the active space. -Everything else — `JOIN`, `GROUP BY`, `DISTINCT`, subquery `WHERE`, and calculated columns — is also cacheable with `dependsOn()`. +Models without a declaration use the default space (`{nc}`). Declare named spaces with `$normCacheSpaces`: ---- - -## Limitations - -- 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`. -- **`spaces.cross_space_behavior`** — `bypass` skips caching when dependencies are not registered in the active space; `throw` raises an exception instead. -- **`spaces.placement`** — Optional hash-tag overrides for pinning spaces to specific Redis Cluster slots. - -In Redis Cluster mode, broad flushes use the recorded space registry and scan the owning master for each space hash tag when the client exposes slot-owner lookup. Standalone Redis uses wildcard hash-tag scans and does not maintain registry metadata. - ---- + 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 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()` are default-space only, so named-space queries should prefer cacheable model dependencies where possible. -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. - ---- +In Redis Cluster mode, broad flushes use the recorded space registry to scan each known space. Standalone Redis uses wildcard hash-tag scans. -## 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 From 3ae300695e9b1d04190c4d8b1f1ebbbc38f2f338 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 9 Jul 2026 20:32:59 +1000 Subject: [PATCH 47/73] split ModelHydrator statics into RawAttributes/ScalarTransformer / type lua status --- CHANGELOG.md | 7 + src/Cache/ExecutionEngine.php | 36 --- src/Cache/ModelHydrator.php | 276 ++++-------------- src/Cache/NormalizedReader.php | 6 +- src/Cache/ResultCacheReader.php | 2 +- src/Planning/CachePlanner.php | 25 +- src/Relations/CachesOneOrManyThrough.php | 6 +- src/Relations/CachesPivotRelation.php | 8 +- src/Spaces/CacheSpaceRegistry.php | 14 - src/Support/ProjectionClassifier.php | 15 - src/Support/RawAttributes.php | 55 ++++ src/Support/RedisStore.php | 21 -- src/Support/ScalarTransformer.php | 145 +++++++++ src/Traits/CachesScalarResults.php | 6 +- src/Values/CachePlan.php | 11 +- src/Values/QueryInspection.php | 5 - tests/Integration/Cache/CastAttributeTest.php | 2 +- .../Cache/ModelHydratorStampedeTest.php | 3 +- tests/Unit/Cache/ExecutionEngineTest.php | 90 ------ tests/Unit/CachePlannerTest.php | 2 +- tests/Unit/RedisStoreTest.php | 8 - tests/Unit/Spaces/CacheSpaceRegistryTest.php | 2 - .../Unit/Support/ProjectionClassifierTest.php | 8 - 23 files changed, 286 insertions(+), 467 deletions(-) create mode 100644 src/Support/RawAttributes.php create mode 100644 src/Support/ScalarTransformer.php diff --git a/CHANGELOG.md b/CHANGELOG.md index 4edc5ae..c9e1cb5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- Extracted `ModelHydrator`'s static Eloquent-internals utilities into `Support\RawAttributes` (bound closures) and `Support\ScalarTransformer` (cached scalar cast/mutator handling), and unified the two miss-path fetchers' cache-write bookkeeping. +- Removed the write-only `CachePlan::$reasons` field; flat reason lists are now derived via `CachePlan::flatReasons()`. +- Typed the remaining raw Lua status strings (`ModelHydrator`, pivot fetch) through the `LuaStatus` enum. +- Removed dead internal surface: `ExecutionEngine::runResult()`, `QueryInspection::hasNormalizationBypass()`, `CacheSpaceRegistry::materializedSpaces()`/`tableAllowedInSpace()`, `ProjectionClassifier::containsWildcard()`, `RedisStore::setMany()`. + --- ## [3.0.0] — 2026-07-06 diff --git a/src/Cache/ExecutionEngine.php b/src/Cache/ExecutionEngine.php index 003ba69..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 diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index fd1643f..35b8ab4 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -5,52 +5,19 @@ 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\RawAttributes; use NormCache\Support\RedisStore; use NormCache\Values\ModelFetchContext; final class ModelHydrator { - private static ?\Closure $hydrateClosure = null; - - private static ?\Closure $setAttributeDirectClosure = null; - private static array $overridesNewFromBuilder = []; - 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, - ]; - public function __construct( private readonly RedisStore $store, private readonly CacheKeyBuilder $keys, @@ -131,13 +98,13 @@ classKey: $classKey, [$status, $missed] = $this->fetchMissedStatus($missed, $context); - if ($status === 'building' && $missed !== []) { + if ($status === LuaStatus::Building && $missed !== []) { $this->store->brpop($context->wakeKey, $this->stampedeWaitMs / 1000.0); [$status, $missed] = $this->fetchMissedStatus($missed, $context); } - if ($status === 'miss') { + if ($status === LuaStatus::Miss) { try { $this->fetchAndMerge($missed, $context, true); } catch (\Throwable $e) { @@ -145,7 +112,7 @@ classKey: $classKey, throw $e; } - } elseif ($status === 'hit' && $missed !== []) { + } 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 { @@ -157,7 +124,7 @@ classKey: $classKey, } else { $this->fetchAndMerge($missed, $context, false); } - } elseif ($status === 'building' && $missed !== []) { + } elseif ($status === LuaStatus::Building && $missed !== []) { $this->fetchAndMerge($missed, $context, false); } @@ -197,6 +164,7 @@ private function modelKeysFor(string $classKey, int $modelVersion, array $ids): } // Re-checks still-missing ids and atomically claims the build lock if anything's still missing. + /** @return array{0: LuaStatus, 1: list} */ private function fetchMissedStatus(array $idsToFetch, ModelFetchContext $context): array { $fetchKeys = $this->modelKeysFor($context->classKey, $context->modelVersion, $idsToFetch); @@ -212,10 +180,10 @@ private function fetchMissedStatus(array $idsToFetch, ModelFetchContext $context } if ($stillMissed === []) { - return ['hit', []]; + return [LuaStatus::Hit, []]; } - return [$result[0], $stillMissed]; + return [LuaStatus::fromLua($result[0] ?? null), $stillMissed]; } private function fetchAndMerge(array $missed, ModelFetchContext $context, bool $writeCache): void @@ -236,7 +204,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) { @@ -259,45 +227,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 = []; @@ -345,31 +279,19 @@ private function fetchAndCacheUsingEloquent( ): array { $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($context->modelClass); - $hydrate = $context->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 ($writeCache && !$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($context->classKey, $context->modelVersion) . $id] = $attrs; - } - if ($hydrate !== null) { $hydrate($model, AttributeProjector::projectAttributes($attrs, $context->projection), false); } - } - - $this->storeModelAttrs($attrsByKey, $context, $writeCache); - return $loaded->all(); + return array_key_exists($pk, $attrs) ? [$attrs[$pk], $attrs, $model] : null; + }); } private function fetchAndCacheUsingClosure( @@ -380,30 +302,18 @@ private function fetchAndCacheUsingClosure( ): array { $prototype = $query->getModel(); $pk = $prototype->getKeyName(); - $qualifiedPk = $prototype->getQualifiedKeyName(); - $deletedAtCol = CacheKeyBuilder::deletedAtColumn($context->modelClass); - $hydrate = self::hydrateClosure(); + $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 ($writeCache && !$isTrashed) { - $attrsByKey[$this->keys->modelPrefix($context->classKey, $context->modelVersion) . $id] = $attrs; + return null; } $returnAttrs = $context->projection !== null @@ -414,7 +324,36 @@ private function fetchAndCacheUsingClosure( $instance->setConnection($connectionName); $hydrate($instance, $returnAttrs, true); - $models[$id] = $instance; + return [$attrs[$pk], $attrs, $instance]; + }); + } + + /** + * 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 + { + $deletedAtCol = CacheKeyBuilder::deletedAtColumn($context->modelClass); + $prefix = $this->keys->modelPrefix($context->classKey, $context->modelVersion); + $attrsByKey = []; + $models = []; + + foreach ($rows as $row) { + $result = $each($row); + + if ($result === null) { + continue; + } + + [$id, $attrs, $model] = $result; + + if ($writeCache && !($deletedAtCol && isset($attrs[$deletedAtCol]))) { + $attrsByKey[$prefix . $id] = $attrs; + } + + $models[$id] = $model; } $this->storeModelAttrs($attrsByKey, $context, $writeCache); @@ -449,119 +388,6 @@ private function overridesNewFromBuilder(Model $model): bool (new \ReflectionMethod($model, 'newFromBuilder'))->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 - ); - } - - 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 - ); - } - private function prepareMissedQuery(string $modelClass, ?CacheableBuilder $missedQuery, bool $preserveQueryShape): EloquentBuilder { if ($missedQuery === null || !$preserveQueryShape || !$this->canPreserveQueryShape($missedQuery->getQuery())) { diff --git a/src/Cache/NormalizedReader.php b/src/Cache/NormalizedReader.php index 8bab29a..21706c5 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/NormalizedReader.php @@ -68,8 +68,8 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep $seg = (string) ($result[1] ?? ''); $queryKey = $queryPrefix . $seg . ':' . $hash; - $buildingKey = $this->keys->buildingPrefix($classKey) . $seg . ':' . $hash; - $wakeKey = $this->keys->wakePrefix($classKey) . $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); @@ -100,7 +100,7 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep public function waitForBuild(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult|ThroughCacheResult|null { $classKey = $this->keys->classKey($modelClass); - $this->store->brpop($this->keys->wakePrefix($classKey) . $hash, $this->stampedeWaitMs / 1000.0); + $this->store->brpop($this->keys->wakeKey($classKey, $hash), $this->stampedeWaitMs / 1000.0); $result = $this->fetch($modelClass, $hash, $tag, $depClasses, $depTableKeys); diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheReader.php index 451c319..d093825 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheReader.php @@ -141,7 +141,7 @@ public function fetchPivot( return new PivotCacheResult($seg, $data, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); } - if ($result[0] === 'miss') { + if (LuaStatus::fromLua($result[0] ?? null) === LuaStatus::Miss) { return new PivotCacheResult( $seg, $data, new BuildHandle($lockKey, $token, $wakeKey, $versionKeys, $expectedVersions), diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 73e6bfd..27b81a9 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -104,18 +104,13 @@ public function applySpaceValidation( } $registry = $this->spaceRegistry ??= app(CacheSpaceRegistry::class); + $resolver = $this->spaceResolver ??= app(CacheSpaceResolver::class); + $space = $resolver->resolve($model::class, $builder->getSpace()); if ($registry->dependenciesAreOnlyModel($model::class, $plan->dependencies->models, $plan->dependencies->tables)) { - $space = $builder->getSpace() === null - ? $registry->spacesForModel($model::class)[0] - : ($this->spaceResolver ??= app(CacheSpaceResolver::class))->resolve($model::class, $builder->getSpace()); - return $plan->withSpace($space); } - $resolver = $this->spaceResolver ??= app(CacheSpaceResolver::class); - $space = $resolver->resolve($model::class, $builder->getSpace()); - $validation = $registry->validateDependencies( $space, $plan->dependencies->models, @@ -145,7 +140,6 @@ public function applySpaceValidation( return CachePlan::bypass( operation: $operation, dependencies: $plan->dependencies, - reasons: $reasons, bypassReasons: ['dependency' => $reasons], )->withSpace($space); } @@ -167,7 +161,6 @@ private function globalBypass( return CachePlan::bypass( operation: $context->operation, dependencies: $this->dependencies->resolveBase($builder, $model, $context), - reasons: $reasons, bypassReasons: ['opted_out' => $reasons], ); } @@ -182,7 +175,6 @@ private function transactionBypass( return CachePlan::bypass( operation: $context->operation, dependencies: $this->dependencies->resolveBase($builder, $model, $context), - reasons: $reasons, bypassReasons: ['safety' => $reasons], ); } @@ -322,13 +314,11 @@ private function planModels( if ($dependencies->safe && $this->hasResultDependencies($context, $hasExplicit)) { if ($this->requiresExplicitSelectForJoinResult($builder, $base, $context)) { $this->dependencies->warnUnderDeclared($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']], ); } @@ -493,13 +483,11 @@ private function safetyBypass( } $bypassReasons = $this->mergedBypassReasons($context, $inspection, $insideTransaction); - $reasons = $bypassReasons['safety'] ?? []; return CachePlan::bypass( operation: $context->operation, dependencies: $dependencies, - reasons: $reasons, - bypassReasons: ['safety' => $reasons], + bypassReasons: ['safety' => $bypassReasons['safety'] ?? []], ); } @@ -567,11 +555,6 @@ private function bypassPlan( return CachePlan::bypass( operation: $context->operation, dependencies: $dependencies, - reasons: array_values(array_unique([ - ...$dependencies->reasons, - ...($bypassReasons['dependency'] ?? []), - ...($bypassReasons['normalization'] ?? []), - ])), bypassReasons: $bypassReasons, ); } diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 35c377e..95c0aba 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -4,7 +4,6 @@ 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; @@ -12,6 +11,7 @@ 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; @@ -209,8 +209,8 @@ private function hydrateFromIds( $models = NormCache::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); diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 5b53530..83d16d5 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -6,12 +6,12 @@ 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\CacheReporter; use NormCache\Support\ProjectionClassifier; use NormCache\Support\QueryHasher; +use NormCache\Support\RawAttributes; use NormCache\Values\CachePlanContext; use NormCache\Values\CacheSpace; use NormCache\Values\PreparedQuery; @@ -281,7 +281,7 @@ private function hydrateFromPivotCache( } $modelsById = []; - $getAttribute = ModelHydrator::getAttributeDirectClosure(); + $getAttribute = RawAttributes::getAttributeClosure(); $keyName = $this->related->getKeyName(); foreach (NormCache::getModels(array_keys($uniqueRelatedIds), $relatedClass, $selectedRelatedColumns) as $model) { $modelsById[$getAttribute($model, $keyName)] = $model; @@ -300,7 +300,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); @@ -340,7 +340,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/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 4784749..e392c2f 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -43,15 +43,6 @@ public function space(string $name): CacheSpace return $this->materializeSpace($name); } - /** @return list */ - public function materializedSpaces(): array - { - $spaces = $this->spaces; - $spaces[self::DEFAULT_SPACE] ??= $this->defaultSpace(); - - return array_values($spaces); - } - /** @return list */ public function knownSpaces(): array { @@ -83,11 +74,6 @@ public function modelAllowedInSpace(string $modelClass, CacheSpace|string $space return $this->isAllowed($this->spacesForModel($modelClass), $space); } - public function tableAllowedInSpace(string $table, CacheSpace|string $space): bool - { - return $this->isAllowed($this->spacesForTable($table), $space); - } - public function dependenciesAreOnlyModel(string $modelClass, array $models, array $tables): bool { return $models === [$modelClass] && $tables === []; 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/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/RedisStore.php b/src/Support/RedisStore.php index 0228b8e..e137517 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -285,27 +285,6 @@ 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($key, $ttl, $this->serialize($value)); - } - - return; - } - - $this->connection->pipeline(function ($pipe) use ($pairs, $ttl) { - foreach ($pairs as $key => $value) { - $pipe->setex($key, $ttl, $this->serialize($value)); - } - }); - } - // CAS write of model attribute entries; releases the build lock as part of the write when given. public function setManyIfVersion( array $attrsByKey, 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/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index 7452cd9..0af2834 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -5,12 +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\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; +use NormCache\Support\ScalarTransformer; use NormCache\Values\CachePlanContext; use NormCache\Values\DependencySet; @@ -177,7 +177,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 +199,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/Values/CachePlan.php b/src/Values/CachePlan.php index d631182..e9248b4 100644 --- a/src/Values/CachePlan.php +++ b/src/Values/CachePlan.php @@ -8,7 +8,6 @@ final readonly class CachePlan { /** - * @param list $reasons * @param array> $bypassReasons */ public function __construct( @@ -18,7 +17,6 @@ public function __construct( public bool $normalizable = false, public ?array $columns = null, public ?array $primaryKeys = null, - public array $reasons = [], public array $bypassReasons = [], public ?CacheSpace $space = null, ) {} @@ -32,7 +30,6 @@ public function withSpace(CacheSpace $space): self normalizable: $this->normalizable, columns: $this->columns, primaryKeys: $this->primaryKeys, - reasons: $this->reasons, bypassReasons: $this->bypassReasons, space: $space, ); @@ -74,14 +71,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, ); } @@ -107,6 +102,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/QueryInspection.php b/src/Values/QueryInspection.php index c787159..3fbc34f 100644 --- a/src/Values/QueryInspection.php +++ b/src/Values/QueryInspection.php @@ -66,11 +66,6 @@ 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; 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/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index cfd387b..97eccf4 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -5,6 +5,7 @@ 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; @@ -165,7 +166,7 @@ classKey: $classKey, $method = new \ReflectionMethod($hydrator, 'fetchMissedStatus'); [$status, $missed] = $method->invokeArgs($hydrator, [[$author->id], $context]); - $this->assertSame('hit', $status); + $this->assertSame(LuaStatus::Hit, $status); $this->assertSame([], $missed); $this->assertArrayHasKey($author->id, $context->hits); $this->assertSame('Carol', $context->hits[$author->id]->name); diff --git a/tests/Unit/Cache/ExecutionEngineTest.php b/tests/Unit/Cache/ExecutionEngineTest.php index 0bc2130..7c53673 100644 --- a/tests/Unit/Cache/ExecutionEngineTest.php +++ b/tests/Unit/Cache/ExecutionEngineTest.php @@ -21,96 +21,6 @@ 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']); - $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); - $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); - $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); - $hit = new ResultCacheResult(CacheStatus::Hit, 'k', ['data']); - $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 // ------------------------------------------------------------------------- diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index 17ca931..d45ed01 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -44,7 +44,7 @@ public function test_successful_hot_plan_does_not_build_reason_strings(): void ); $this->assertTrue($plan->isNormalized()); - $this->assertSame([], $plan->reasons); + $this->assertSame([], $plan->flatReasons()); $this->assertSame([], $plan->bypassReasons); } diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index 9a430d8..ee39fbb 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -115,14 +115,6 @@ public function get($key) $this->assertSame(['foo', 'baz', 'missing'], $connection->reads); } - public function test_it_can_set_many_values(): void - { - $this->store->setMany(['foo' => 'bar', 'baz' => 'qux'], 60); - - $this->assertSame('bar', $this->store->get('foo')); - $this->assertSame('qux', $this->store->get('baz')); - } - public function test_it_can_run_lua_scripts(): void { $script = "return redis.call('GET', KEYS[1])"; diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index b9b8b25..1f3dbf6 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -109,8 +109,6 @@ public function test_tables_belong_to_the_default_space(): void $registry = $this->registry(); $this->assertSame(['default'], array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags'))); - $this->assertTrue($registry->tableAllowedInSpace('mysql:legacy_flags', 'default')); - $this->assertFalse($registry->tableAllowedInSpace('mysql:legacy_flags', 'content')); } public function test_single_base_model_dependencies_do_not_need_validation(): void 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')); From de924952390c6753dfab441a7e05cd84201bd0da Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Thu, 9 Jul 2026 21:38:48 +1000 Subject: [PATCH 48/73] fix table space registration --- CHANGELOG.md | 31 ++++---- README.md | 45 +++++++----- src/Spaces/CacheSpaceRegistry.php | 70 ++++++++++++++++++- src/Traits/HandlesInvalidation.php | 12 ++-- .../Integration/Cache/SpaceResolutionTest.php | 25 +++++++ tests/Unit/Spaces/CacheSpaceRegistryTest.php | 20 +++++- 6 files changed, 157 insertions(+), 46 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e1cb5..d190bfe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,41 +9,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Changed - -- Extracted `ModelHydrator`'s static Eloquent-internals utilities into `Support\RawAttributes` (bound closures) and `Support\ScalarTransformer` (cached scalar cast/mutator handling), and unified the two miss-path fetchers' cache-write bookkeeping. -- Removed the write-only `CachePlan::$reasons` field; flat reason lists are now derived via `CachePlan::flatReasons()`. -- Typed the remaining raw Lua status strings (`ModelHydrator`, pivot fetch) through the `LuaStatus` enum. -- Removed dead internal surface: `ExecutionEngine::runResult()`, `QueryInspection::hasNormalizationBypass()`, `CacheSpaceRegistry::materializedSpaces()`/`tableAllowedInSpace()`, `ProjectionClassifier::containsWildcard()`, `RedisStore::setMany()`. +No unreleased changes. --- -## [3.0.0] — 2026-07-06 +## [3.0.0] — 2026-07-09 ### Added -- Added cache spaces for Redis Cluster sharding: models can declare `$normCacheSpaces`, queries can select `->space()`, and spaces can define placement/cross-space behavior in config. -- Added space-targeted flushing via `NormCache::flushAll('space')` and `php artisan normcache:flush --space=...`, backed by registry metadata for broad cluster flushes. +- **Cache spaces for Redis Cluster sharding:** models can declare `$normCacheSpaces`, queries can select `->space()`, and each space maps to its own Redis hash tag. +- **Space-targeted flushing:** `NormCache::flushAll('space')` and `php artisan normcache:flush --space=...` now flush one cache space. ### Changed -- Replaced the old slotting/sharding path with model-declared cache spaces. -- Changed model payload storage to versioned keys shaped like `model:{classKey}:v{version}:{id}`, removing the need for members-set model tracking. -- Simplified Redis/Lua internals by centralizing script marshalling in `RedisStore` and sharing normalized query/through-relation read flow through `NormalizedReader`. -- Simplified planning internals by moving dependency inference into planner/dependency collaborators. +- **BREAKING:** replaced the old slotting/sharding configuration with model-declared cache spaces. +- Model payloads are now stored under versioned keys (`model:{classKey}:v{version}:{id}`), so invalidation bumps versions instead of scanning model member sets. +- `dependsOnTables()` now works in named cache spaces; raw table dependencies are registered in the active space and invalidated with that space's table version. ### Fixed -- Fixed cache-space consistency across version lookup, invalidation, model payloads, query/result keys, relation caches, pivot/through caches, build locks, and wake keys. -- Fixed Redis Cluster cross-slot issues in space-aware Lua paths and broad flush scans. -- Fixed relation and pivot invalidation edge cases, including relation version lookup, guarded relation model writes, `dependsOn()` accumulation, and pivot invalidation. +- Fixed cache-space consistency across query, result, model, relation, pivot, through, build-lock, wake, and invalidation keys. +- Fixed Redis Cluster cross-slot errors in space-aware cache paths and broad flush scans. +- Fixed relation and pivot invalidation edge cases, including guarded relation writes, empty parent relation loads, and repeated builder use. - Fixed pre-save invalidation timing so Eloquent observers see a cache miss after writes. -- Fixed connection/table key safety by rejecting connection names containing `:` and resetting key-builder state across spaces. +- Fixed connection/table key safety by rejecting connection names containing `:`. +- Tightened versioned writes so concurrent invalidation cannot leave stale query, result, pivot, through, or model payloads behind. ### Removed - Removed the old slotting/sharding configuration and internals. -- Removed leftover stale-serving code after the `2.4.0` stale-serving removal. +- Removed leftover stale-serving internals after the `2.4.0` `stale_version_depth` removal. --- diff --git a/README.md b/README.md index f1c184c..c0ae4b8 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Normcache caches query results as ID lists and stores model attributes in versio ## Table of Contents - [Installation](#installation) +- [What's new in 3.0](#whats-new-in-30) - [Usage](#usage) - [Invalidation](#invalidation) - [Cache spaces](#cache-spaces) @@ -40,6 +41,16 @@ class Post extends Model } ``` +## What's new in 3.0 + +Normcache 3.0 is a Redis Cluster-oriented release. + +- Cache spaces replace the old slotting/sharding configuration. +- Space-targeted flushes are available through `flushAll('space')` and `normcache:flush --space=...`. +- Raw table dependencies from `dependsOnTables()` work in named spaces. +- Model payload invalidation now relies on versioned keys instead of members-set scans. +- `stale_version_depth` remains removed; rebuild coordination uses build locks and wake tokens. + ## Usage Normal Eloquent reads are cached automatically for cacheable models: @@ -168,7 +179,7 @@ Post::query() ->get(); ``` -If a 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()` are default-space only, so named-space queries should prefer cacheable model dependencies where possible. +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()` are registered in the active space and invalidated with that space's table version. Configure placement when you need to control Redis Cluster hash tags: @@ -194,22 +205,22 @@ php artisan vendor:publish --tag=normcache-config Common options: -| 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. | +| 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. | ## Bypasses and limitations diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index e392c2f..3ae7a3d 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -20,6 +20,9 @@ final class CacheSpaceRegistry /** @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; @@ -62,11 +65,10 @@ public function spacesForModel(string $modelClass): array return $this->modelSpaces[$modelClass] ??= $this->resolveModelSpaces($modelClass); } - // Non-model table dependencies are default-space only for now. /** @return list */ public function spacesForTable(string $table): array { - return [$this->defaultSpace()]; + return $this->tableSpaces[$table] ??= $this->resolveTableSpaces($table); } public function modelAllowedInSpace(string $modelClass, CacheSpace|string $space): bool @@ -110,6 +112,10 @@ public function validateDependencies( foreach ($tables as $table) { $spaces = $this->spacesForTable($table); + if (!$this->isAllowed($spaces, $space)) { + $spaces = $this->rememberTableSpace($table, $space); + } + if ($includeDependenciesBySpace) { $dependencySpaces[$table] = $spaces; } @@ -153,6 +159,17 @@ private function resolveModelSpaces(string $modelClass): array return array_map(fn($name) => $this->space($name), $names); } + /** @return list */ + private function resolveTableSpaces(string $table): array + { + $names = array_values(array_unique([ + self::DEFAULT_SPACE, + ...$this->metadataTableSpaces($table), + ])); + + return array_map(fn(string $name) => $this->materializeSpace($name, remember: false), $names); + } + private function materializeSpace(string $name, bool $remember = true): CacheSpace { if (!isset($this->spaces[$name])) { @@ -218,6 +235,23 @@ private function metadataSpaces(): array } } + /** @return list */ + private function metadataTableSpaces(string $table): array + { + if ($this->metadataStore === null) { + return []; + } + + try { + return array_values(array_filter( + $this->metadataStore->setMembers($this->metadataTableSpacesKey($table)), + fn(string $name) => $this->validSpaceName($name), + )); + } catch (\Throwable) { + return []; + } + } + private function rememberSpace(string $name): void { if ($this->metadataStore === null || $name === self::DEFAULT_SPACE) { @@ -231,11 +265,43 @@ private function rememberSpace(string $name): void } } + /** @return list */ + private function rememberTableSpace(string $table, CacheSpace $space): array + { + $spaces = $this->spacesForTable($table); + + if (!$this->isAllowed($spaces, $space)) { + $spaces[] = $space; + $this->tableSpaces[$table] = $spaces; + $this->persistTableSpace($table, $space); + } + + return $spaces; + } + + private function persistTableSpace(string $table, CacheSpace $space): void + { + if ($this->metadataStore === null || $space->name === self::DEFAULT_SPACE) { + return; + } + + try { + $this->metadataStore->addToSet($this->metadataTableSpacesKey($table), [$space->name]); + } catch (\Throwable $e) { + report($e); + } + } + 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 { diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 1975243..d0be6db 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -26,9 +26,9 @@ private function modelSpaces(string $modelClass): array } /** @return list the spaces a table's version key lives in */ - private function tableInvalidationSpaces(string $table): array + private function tableInvalidationSpaces(string $tableKey): array { - return $this->spaceRegistry->spacesForTable($table); + return $this->spaceRegistry->spacesForTable($tableKey); } /** @return list */ @@ -88,8 +88,8 @@ public function invalidateTableVersion(string $connectionName, string $table): v $this->queueOrRun( $connectionName, - fn() => $this->queueVersionFlush($connectionName, $classKey, $this->tableInvalidationSpaces($table)), - fn() => $this->doInvalidateTable($table, $classKey), + fn() => $this->queueVersionFlush($connectionName, $classKey, $this->tableInvalidationSpaces($classKey)), + fn() => $this->doInvalidateTable($classKey), ); } @@ -283,9 +283,9 @@ private function doInvalidateVersion(string $modelClass): void } } - private function doInvalidateTable(string $table, string $classKey): void + private function doInvalidateTable(string $classKey): void { - foreach ($this->tableInvalidationSpaces($table) as $space) { + foreach ($this->tableInvalidationSpaces($classKey) as $space) { $this->doInvalidateKey($classKey, $space); } } diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index ba3433e..159c3d0 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -2,6 +2,7 @@ namespace NormCache\Tests\Integration\Cache; +use Illuminate\Support\Facades\DB; use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\CatalogTag; @@ -46,6 +47,30 @@ public function test_same_space_dependency_still_caches(): void $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()->getStore()->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( diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index 1f3dbf6..9f2d3be 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -104,13 +104,26 @@ public function test_model_in_too_many_spaces_throws_on_resolution(): void $this->registry(maxPerModel: 2)->spacesForModel($model::class); } - public function test_tables_belong_to_the_default_space(): void + 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_table_dependency_is_valid_in_the_active_space(): void + { + $registry = $this->registry(); + $content = $registry->space('content'); + + $result = $registry->validateDependencies($content, [], ['mysql:legacy_flags'], includeDependenciesBySpace: true); + + $this->assertTrue($result->isValid); + $this->assertSame([], $result->invalidTables); + $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); + $this->assertContains('content', array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags'))); + } + public function test_single_base_model_dependencies_do_not_need_validation(): void { $registry = $this->registry(); @@ -148,13 +161,14 @@ public function test_validate_dependencies_reports_cross_space_members(): void $registry = $this->registry(); $content = $registry->space('content'); - // Author is default-only; depending on it inside content is invalid. + // Author is default-only; raw table deps are registered into 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(['mysql:legacy_flags'], $result->invalidTables); + $this->assertSame([], $result->invalidTables); $this->assertSame(['default'], $result->dependenciesBySpace[Author::class]); $this->assertSame(['content'], $result->dependenciesBySpace[SpacedPost::class]); + $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); } } From bd1f0477a19e7a0d1f4b754c5ed71b34c3d25c91 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 10 Jul 2026 13:30:34 +1000 Subject: [PATCH 49/73] rename fallbackHandler to cacheFallback --- src/Cache/ResultExecutor.php | 4 +- src/CacheManager.php | 19 -------- src/CacheableBuilder.php | 9 ++-- src/Relations/CachesOneOrManyThrough.php | 37 ++++++++------- src/Relations/CachesPivotRelation.php | 37 ++++++++------- src/Support/CacheFallback.php | 49 ++++++++++++++++++++ src/Support/FallbackHandler.php | 43 ------------------ src/Traits/HandlesInvalidation.php | 28 +++++++----- tests/Unit/CacheManagerTest.php | 58 ------------------------ 9 files changed, 115 insertions(+), 169 deletions(-) create mode 100644 src/Support/CacheFallback.php delete mode 100644 src/Support/FallbackHandler.php diff --git a/src/Cache/ResultExecutor.php b/src/Cache/ResultExecutor.php index e0c55cf..33c091e 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; @@ -40,7 +40,7 @@ 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), diff --git a/src/CacheManager.php b/src/CacheManager.php index 704925e..0ddd64a 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -14,7 +14,6 @@ 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; @@ -280,22 +279,4 @@ public function storeModelAttrsForVersion(string $modelClass, array $modelAttrs, $expectedVersion ); } - - // ------------------------------------------------------------------------- - // Flow - // ------------------------------------------------------------------------- - public function rescue(callable $operation, callable $fallback): mixed - { - return FallbackHandler::rescue($this->config, $operation, $fallback); - } - - public function attempt(callable $operation): bool - { - return FallbackHandler::attempt($this->config, $operation); - } - - public function fallback(\Throwable $e): void - { - FallbackHandler::fallback($this->config, $e); - } } diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index 8ed58bb..3c39169 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -19,6 +19,7 @@ use NormCache\Planning\CachePlanner; use NormCache\Relations\CachesRelationAggregates; use NormCache\Relations\CachesRelationExistence; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheKeyBuilder; use NormCache\Support\CacheReporter; use NormCache\Support\ProjectionClassifier; @@ -252,11 +253,13 @@ public function get($columns = ['*']): Collection )); return NormCache::withSpace($plan->space, fn() => match ($plan->strategy) { - CacheStrategy::DirectModels => NormCache::rescue( + CacheStrategy::DirectModels => CacheFallback::rescue( + NormCache::config(), fn() => $this->modelsExecutor()->runDirect($prepared, $plan->primaryKeys, $model, $plan->columns, $this->model), fn() => $this->collectFromPrepared($prepared, $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->collectFromPrepared($prepared, $columns) ), @@ -323,7 +326,7 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', try { $cachedTotal = NormCache::withSpace($plan->space, fn() => $this->rememberPaginationTotal($prepared, $plan)); } catch (\Throwable $e) { - NormCache::fallback($e); + CacheFallback::fallback(NormCache::config(), $e); $cachedTotal = null; } diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 35c377e..7860e03 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -9,6 +9,7 @@ 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; @@ -67,7 +68,8 @@ public function get($columns = ['*']): Collection $tag = $builder->getCacheTag(); $ttl = $builder->getQueryTtl(); - $runThrough = fn() => NormCache::rescue( + $runThrough = fn() => CacheFallback::rescue( + NormCache::config(), fn() => NormCache::engine()->runNormalized( fetch: fn() => NormCache::getThroughCache($relatedClass, $hash, $tag, $depClasses, $depTableKeys), waitForBuild: fn() => NormCache::waitForThroughBuild( @@ -91,22 +93,25 @@ public function get($columns = ['*']): Collection $cachePayload = $this->cachePayloadFromResult($rawModels); $space = NormCache::keys()->activeSpace(); - NormCache::attempt(function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $space) { - $stored = NormCache::storeThroughIds( - $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, - $result->build->buildingKey, $result->build->versionKeys, $result->build->expectedVersions, $result->build->buildingToken, $result->build->wakeKey - ); - - if ($stored) { - NormCache::storeModelAttrsForVersionedResult( - $relatedClass, - $modelAttrs, - $result->build->versionKeys, - $result->build->expectedVersions, - $space, + CacheFallback::attempt( + NormCache::config(), + function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $space) { + $stored = NormCache::storeThroughIds( + $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, + $result->build->buildingKey, $result->build->versionKeys, $result->build->expectedVersions, $result->build->buildingToken, $result->build->wakeKey ); - } - }); + + if ($stored) { + NormCache::storeModelAttrsForVersionedResult( + $relatedClass, + $modelAttrs, + $result->build->versionKeys, + $result->build->expectedVersions, + $space, + ); + } + }, + ); return $prepared->applyAfterCallbacks($rawModels); }, diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 5b53530..78bbc34 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -9,6 +9,7 @@ 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; @@ -92,7 +93,8 @@ public function get($columns = ['*']): Collection $relatedClass = $this->related::class; $parentClassKey = NormCache::classKey($parentClass); - $runPivot = fn() => NormCache::rescue( + $runPivot = fn() => CacheFallback::rescue( + NormCache::config(), fn() => NormCache::engine()->runPivot( fetch: fn() => NormCache::getPivotCache( $parentClass, @@ -120,22 +122,25 @@ public function get($columns = ['*']): Collection onStore: function ($models, $pivotResult) use ($cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $shouldCacheRelatedModels, $ttl) { $space = NormCache::keys()->activeSpace(); - NormCache::attempt(function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $pivotResult, $shouldCacheRelatedModels, $ttl, $space) { - $relatedKey = NormCache::classKey($relatedClass); - $keyMap = []; - foreach ($cacheParentIds as $parentId) { - $keyMap[$parentId] = NormCache::keys()->pivotKey( - $parentClassKey, $relatedKey, $this->relationName, - $constraintHash, $pivotResult->seg, $parentId + CacheFallback::attempt( + NormCache::config(), + function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $pivotResult, $shouldCacheRelatedModels, $ttl, $space) { + $relatedKey = NormCache::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, + $pivotResult->build->versionKeys, $pivotResult->build->expectedVersions, $ttl, + $pivotResult->build->buildingKey, $pivotResult->build->wakeKey, $pivotResult->build->buildingToken, + $space, ); - } - $this->populatePivotCache( - $models, $keyMap, $relatedClass, $shouldCacheRelatedModels, - $pivotResult->build->versionKeys, $pivotResult->build->expectedVersions, $ttl, - $pivotResult->build->buildingKey, $pivotResult->build->wakeKey, $pivotResult->build->buildingToken, - $space, - ); - }); + }, + ); }, onHit: function ($pivotResult) use ($relatedClass, $selectedRelatedColumns, $parentClass, $parentClassKey, $cacheParentIds, $debugbarStart, $prepared) { CacheReporter::queryHit($parentClass, "pivot:{$parentClassKey}:{$this->relationName}", 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/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/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 1975243..96a3fe8 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -5,6 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use NormCache\CacheManager; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheKeyBuilder; use NormCache\Values\CacheSpace; @@ -234,21 +235,24 @@ public function commitPending(string $connectionName): void return; } - $this->attempt(function () use ($flushes, $versions) { - foreach ($flushes as $modelClass) { - $this->forceFlushModel($modelClass); - } + CacheFallback::attempt( + $this->config, + function () use ($flushes, $versions) { + foreach ($flushes as $modelClass) { + $this->forceFlushModel($modelClass); + } - $flushClassKeys = array_map(fn($class) => $this->keys->classKey($class), $flushes); + $flushClassKeys = array_map(fn($class) => $this->keys->classKey($class), $flushes); - foreach ($versions as $classKey => $spaces) { - if (!in_array($classKey, $flushClassKeys, true)) { - foreach ($spaces as $space) { - $this->doInvalidateKey($classKey, $space); + foreach ($versions as $classKey => $spaces) { + if (!in_array($classKey, $flushClassKeys, true)) { + foreach ($spaces as $space) { + $this->doInvalidateKey($classKey, $space); + } } } - } - }); + }, + ); } public function discardPending(string $connectionName): void @@ -271,7 +275,7 @@ private function queueOrRun(?string $connectionName, callable $queue, callable $ return; } - $this->attempt($immediate); + CacheFallback::attempt($this->config, $immediate); } private function doInvalidateVersion(string $modelClass): void diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 11a2d49..5765b49 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -535,66 +535,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()); - } } From 9c235d577552dd94cc0a63885f7a5736df86146b Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:20:37 +1000 Subject: [PATCH 50/73] simplify cache planning internals and remove unused planner metadata --- CHANGELOG.md | 14 +----- src/Planning/CachePlanner.php | 45 +++++++------------ src/Relations/CacheableMorphTo.php | 2 +- src/Relations/CachesOneOrManyThrough.php | 1 - src/Traits/CachesScalarResults.php | 1 - src/Values/CachePlan.php | 6 --- src/Values/CachePlanContext.php | 11 +++-- .../Integration/Cache/SpaceResolutionTest.php | 2 + tests/Unit/CachePlannerTest.php | 13 +++--- 9 files changed, 31 insertions(+), 64 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9e1cb5..f1eebb6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,17 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -## [Unreleased] - -### Changed - -- Extracted `ModelHydrator`'s static Eloquent-internals utilities into `Support\RawAttributes` (bound closures) and `Support\ScalarTransformer` (cached scalar cast/mutator handling), and unified the two miss-path fetchers' cache-write bookkeeping. -- Removed the write-only `CachePlan::$reasons` field; flat reason lists are now derived via `CachePlan::flatReasons()`. -- Typed the remaining raw Lua status strings (`ModelHydrator`, pivot fetch) through the `LuaStatus` enum. -- Removed dead internal surface: `ExecutionEngine::runResult()`, `QueryInspection::hasNormalizationBypass()`, `CacheSpaceRegistry::materializedSpaces()`/`tableAllowedInSpace()`, `ProjectionClassifier::containsWildcard()`, `RedisStore::setMany()`. - ---- - ## [3.0.0] — 2026-07-06 ### Added @@ -30,7 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced the old slotting/sharding path with model-declared cache spaces. - Changed model payload storage to versioned keys shaped like `model:{classKey}:v{version}:{id}`, removing the need for members-set model tracking. - Simplified Redis/Lua internals by centralizing script marshalling in `RedisStore` and sharing normalized query/through-relation read flow through `NormalizedReader`. -- Simplified planning internals by moving dependency inference into planner/dependency collaborators. +- Simplified planning, model hydration, and Lua status handling, including moving dependency inference into planner/dependency collaborators. ### Fixed @@ -44,6 +33,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Removed the old slotting/sharding configuration and internals. - Removed leftover stale-serving code after the `2.4.0` stale-serving removal. +- Removed unused planner metadata (`CachePlan::$normalizable` and `CachePlanContext::$kind`) and the obsolete scalar-kind and morph-type factory arguments. --- diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 27b81a9..f5480e2 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -18,7 +18,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 @@ -28,7 +28,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 @@ -74,16 +74,16 @@ public function plan( } $plan = 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::Scalar => $this->planScalarLike($builder, $model, $base, $context, $insideTransaction, $explain, self::SIMPLE_RESULT_FAST_PATH_BLOCKERS), + CacheOperation::PaginationCount => $this->planScalarLike($builder, $model, $base, $context, $insideTransaction, $explain, self::SIMPLE_PAGINATION_FAST_PATH_BLOCKERS), CacheOperation::Pivot, - CacheOperation::Through => $this->planRelationResult($builder, $model, $base, $context, $insideTransaction, $explain), + CacheOperation::Through => $this->planRelationResult($builder, $base, $context, $insideTransaction, $explain), CacheOperation::Models, CacheOperation::BelongsToEagerLoad, CacheOperation::MorphToEagerLoad => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), }; - return $this->applySpaceValidation($plan, $builder, $model, $context->operation, $explain); + return $this->applySpaceValidation($plan, $builder, $model, $explain); } private ?CacheSpaceResolver $spaceResolver = null; @@ -96,7 +96,6 @@ public function applySpaceValidation( CachePlan $plan, CacheableBuilder $builder, Model $model, - CacheOperation $operation, bool $explain = false, ): CachePlan { if (!$plan->isCacheable()) { @@ -138,7 +137,7 @@ public function applySpaceValidation( } return CachePlan::bypass( - operation: $operation, + operation: $plan->operation, dependencies: $plan->dependencies, bypassReasons: ['dependency' => $reasons], )->withSpace($space); @@ -188,8 +187,6 @@ private function planScalarLike( bool $explain, int $simpleBypassFlags, ): CachePlan { - $explicitModels = $builder->explicitDependencies(); - $explicitTables = $builder->explicitTableDependencies(); $hasExplicit = $builder->hasExplicitDependencies(); if (!$explain && !$insideTransaction && !$hasExplicit && $context->contextReasons === []) { @@ -201,12 +198,9 @@ private function planScalarLike( } return $this->planInspectedResult( - model: $model, + builder: $builder, base: $base, context: $context, - explicitModels: $explicitModels, - explicitTables: $explicitTables, - hasExplicit: $hasExplicit, insideTransaction: $insideTransaction, explain: $explain, scalarLike: true, @@ -215,23 +209,15 @@ private function planScalarLike( 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, + builder: $builder, base: $base, context: $context, - explicitModels: $explicitModels, - explicitTables: $explicitTables, - hasExplicit: $hasExplicit, insideTransaction: $insideTransaction, explain: $explain, strictRelation: true, @@ -322,7 +308,7 @@ private function planModels( ); } - return $this->resultPlan($modelTable, $base, $context, $inspection, $dependencies, $normalizable); + return $this->resultPlan($modelTable, $base, $context, $inspection, $dependencies); } return $this->bypassPlan( @@ -334,20 +320,21 @@ private function planModels( } private function planInspectedResult( - Model $model, + CacheableBuilder $builder, QueryBuilder $base, CachePlanContext $context, - ?array $explicitModels, - array $explicitTables, - bool $hasExplicit, bool $insideTransaction, bool $explain, bool $scalarLike = false, bool $strictRelation = false, ): CachePlan { + $model = $builder->getModel(); $modelClass = $model::class; $modelTable = $model->getTable(); $inferred = $context->inferredDependencies; + $explicitModels = $builder->explicitDependencies(); + $explicitTables = $builder->explicitTableDependencies(); + $hasExplicit = $builder->hasExplicitDependencies(); $inspection = $this->inspect($model, $base, $context, collectTables: $explain); $dependencies = $this->dependencies->resolve( @@ -515,14 +502,12 @@ private function resultPlan( CachePlanContext $context, QueryInspection $inspection, DependencySet $dependencies, - bool $normalizable = false, ): CachePlan { $this->dependencies->warnUnderDeclared($modelTable, $base, $inspection, $dependencies); return CachePlan::result( operation: $context->operation, dependencies: $dependencies, - normalizable: $normalizable, columns: $context->columns, primaryKeys: $inspection->primaryKeys, ); diff --git a/src/Relations/CacheableMorphTo.php b/src/Relations/CacheableMorphTo.php index c3f6546..3f0372c 100644 --- a/src/Relations/CacheableMorphTo.php +++ b/src/Relations/CacheableMorphTo.php @@ -97,7 +97,7 @@ private function cacheableInstanceForType(string $type): ?Model return $instance; } - $plan = $builder->cachePlan($base, CachePlanContext::morphToEagerLoad($type)); + $plan = $builder->cachePlan($base, CachePlanContext::morphToEagerLoad()); return $plan->isNormalized() ? $instance : null; } diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index b869b9c..ddaedf3 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -156,7 +156,6 @@ private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?Cach $plan, $builder, $this->related, - CacheOperation::Through, ); return $plan->usesResultCache() ? $plan : null; diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index 0af2834..d12212d 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -141,7 +141,6 @@ private function cacheScalar( ? $fallback : fn() => $compute($base); $plan = $this->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::scalar( - $kind->value, $columns, $inferred, )); diff --git a/src/Values/CachePlan.php b/src/Values/CachePlan.php index e9248b4..c1c166a 100644 --- a/src/Values/CachePlan.php +++ b/src/Values/CachePlan.php @@ -14,7 +14,6 @@ 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 $bypassReasons = [], @@ -27,7 +26,6 @@ public function withSpace(CacheSpace $space): self strategy: $this->strategy, operation: $this->operation, dependencies: $this->dependencies, - normalizable: $this->normalizable, columns: $this->columns, primaryKeys: $this->primaryKeys, bypassReasons: $this->bypassReasons, @@ -45,7 +43,6 @@ public static function normalized( strategy: CacheStrategy::NormalizedQuery, operation: $operation, dependencies: $dependencies, - normalizable: true, columns: $columns, primaryKeys: $primaryKeys, ); @@ -54,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 { @@ -62,7 +58,6 @@ public static function result( strategy: CacheStrategy::VersionedResult, operation: $operation, dependencies: $dependencies, - normalizable: $normalizable, columns: $columns, primaryKeys: $primaryKeys, ); @@ -91,7 +86,6 @@ public static function direct( strategy: CacheStrategy::DirectModels, operation: $operation, dependencies: $dependencies, - normalizable: true, columns: $columns, primaryKeys: $primaryKeys, ); diff --git a/src/Values/CachePlanContext.php b/src/Values/CachePlanContext.php index e4033ab..af8a558 100644 --- a/src/Values/CachePlanContext.php +++ b/src/Values/CachePlanContext.php @@ -13,7 +13,6 @@ public function __construct( public ?array $columns = null, ?DependencySet $inferredDependencies = null, public array $contextReasons = [], - public ?string $kind = null, public bool $selectAll = false, ) { $this->inferredDependencies = $inferredDependencies ?? DependencySet::empty(); @@ -25,14 +24,14 @@ public static function models(?array $columns = null, ?DependencySet $inferred = return new self(CacheOperation::Models, $columns, $inferred, selectAll: $selectAll); } - public static function scalar(string $kind, array $columns = [], ?DependencySet $inferred = null, array $contextReasons = []): self + public static function scalar(array $columns = [], ?DependencySet $inferred = null, array $contextReasons = []): self { - return new self(CacheOperation::Scalar, $columns, $inferred, $contextReasons, $kind); + return new self(CacheOperation::Scalar, $columns, $inferred, $contextReasons); } public static function paginationCount(?DependencySet $inferred = null): self { - return new self(CacheOperation::PaginationCount, null, $inferred, kind: 'pagination_count'); + return new self(CacheOperation::PaginationCount, null, $inferred); } public static function belongsToEagerLoad(array $columns = []): self @@ -40,9 +39,9 @@ public static function belongsToEagerLoad(array $columns = []): self return new self(CacheOperation::BelongsToEagerLoad, $columns); } - public static function morphToEagerLoad(string $type): self + public static function morphToEagerLoad(): self { - return new self(CacheOperation::MorphToEagerLoad, kind: $type); + return new self(CacheOperation::MorphToEagerLoad); } public static function pivot(array $columns = [], ?DependencySet $inferred = null): self diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index ba3433e..4774851 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -2,6 +2,7 @@ namespace NormCache\Tests\Integration\Cache; +use NormCache\Enums\CacheOperation; use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\CatalogTag; @@ -33,6 +34,7 @@ public function test_cross_space_dependency_bypasses(): void ->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('dependency')); } diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index d45ed01..d64edcb 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -89,7 +89,7 @@ public function test_simple_scalar_query_uses_versioned_result_strategy(): void $plan = (new CachePlanner)->plan( $prepared->builder, $prepared->base, - CachePlanContext::scalar('count', ['*']), + CachePlanContext::scalar(['*']), ); $this->assertTrue($plan->usesResultCache()); @@ -104,7 +104,7 @@ public function test_scalar_query_with_raw_dependency_clause_bypasses(): void $plan = (new CachePlanner)->plan( $prepared->builder, $prepared->base, - CachePlanContext::scalar('count', ['*']), + CachePlanContext::scalar(['*']), ); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); @@ -119,7 +119,7 @@ public function test_scalar_query_with_raw_order_bypasses(): void $plan = (new CachePlanner)->plan( $prepared->builder, $prepared->base, - CachePlanContext::scalar('value', ['name']), + CachePlanContext::scalar(['name']), ); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); @@ -135,7 +135,7 @@ public function test_grouped_scalar_query_preserves_result_cache_behavior(): voi $plan = (new CachePlanner)->plan( $prepared->builder, $prepared->base, - CachePlanContext::scalar('count', ['name']), + CachePlanContext::scalar(['name']), ); $this->assertTrue($plan->usesResultCache()); @@ -151,7 +151,6 @@ public function test_scalar_context_reason_skips_fast_path(): void $prepared->builder, $prepared->base, CachePlanContext::scalar( - 'count', ['*'], contextReasons: ['opted_out' => ['test bypass']], ), @@ -169,7 +168,7 @@ public function test_scalar_join_without_dependencies_bypasses(): void $plan = (new CachePlanner)->plan( $prepared->builder, $prepared->base, - CachePlanContext::scalar('count', ['*']), + CachePlanContext::scalar(['*']), ); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); @@ -183,7 +182,7 @@ public function test_locked_scalar_query_bypasses(): void $plan = (new CachePlanner)->plan( $prepared->builder, $prepared->base, - CachePlanContext::scalar('count', ['*']), + CachePlanContext::scalar(['*']), ); $this->assertSame(CacheStrategy::LiveQuery, $plan->strategy); From 0a929f9aeeba36b8d0e48d0142a5be7ab46387c4 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 10 Jul 2026 16:59:24 +1000 Subject: [PATCH 51/73] replace cache readers with repositories and expose manager collaborators --- CHANGELOG.md | 4 +- src/Cache/ModelCacheRepository.php | 68 ++++++ src/Cache/ModelsExecutor.php | 28 +-- ...ader.php => NormalizedCacheRepository.php} | 23 +-- ...heReader.php => ResultCacheRepository.php} | 49 +++-- src/Cache/ResultExecutor.php | 14 +- ...hReader.php => ThroughCacheRepository.php} | 25 +-- ...eader.php => VersionedCacheRepository.php} | 46 ++--- src/CacheManager.php | 194 +++--------------- src/CacheServiceProvider.php | 21 +- src/CacheableBuilder.php | 2 +- src/Facades/NormCache.php | 6 + src/Planning/DependencyResolver.php | 25 ++- src/Planning/QueryAnalyzer.php | 2 +- src/Relations/CacheableBelongsTo.php | 2 +- src/Relations/CacheableMorphTo.php | 2 +- src/Relations/CachesOneOrManyThrough.php | 25 ++- src/Relations/CachesPivotRelation.php | 43 ++-- .../RelationDependencyClassifier.php | 2 +- src/Values/CachePlanContext.php | 18 +- tests/Fixtures/Models/ReportingAuthor.php | 17 ++ tests/Fixtures/Models/ReportingCountry.php | 5 + tests/Integration/Cache/CacheAccuracyTest.php | 6 +- .../Cache/CacheableBuilderTest.php | 2 +- .../ModelHydratorClosureColdHydrationTest.php | 16 +- .../Cache/ModelHydratorStampedeTest.php | 20 +- tests/Integration/Cache/OptimizationsTest.php | 8 +- tests/Integration/Cache/PivotCacheTest.php | 8 +- tests/Integration/Cache/PivotStampedeTest.php | 30 ++- .../Integration/Cache/SpaceResolutionTest.php | 31 ++- .../Integration/Cache/ThroughRelationTest.php | 2 +- .../Infrastructure/CacheEventsTest.php | 12 +- .../Infrastructure/ClusterModeTest.php | 4 +- .../Concerns/InteractsWithClusterRedis.php | 2 +- .../Infrastructure/LuaScriptBehaviorTest.php | 6 +- .../LuaScriptConsistencyTest.php | 108 +++++----- .../Infrastructure/StampedeProtectionTest.php | 8 +- .../Infrastructure/StringPrimaryKeyTest.php | 4 +- .../SoftDeleteInvalidationTest.php | 6 +- tests/TestCase.php | 29 +-- tests/Unit/CacheManagerTest.php | 129 +++++++----- 41 files changed, 532 insertions(+), 520 deletions(-) create mode 100644 src/Cache/ModelCacheRepository.php rename src/Cache/{NormalizedCacheReader.php => NormalizedCacheRepository.php} (80%) rename src/Cache/{ResultCacheReader.php => ResultCacheRepository.php} (86%) rename src/Cache/{NormalizedThroughReader.php => ThroughCacheRepository.php} (78%) rename src/Cache/{NormalizedReader.php => VersionedCacheRepository.php} (82%) create mode 100644 tests/Fixtures/Models/ReportingAuthor.php diff --git a/CHANGELOG.md b/CHANGELOG.md index f1eebb6..40f766e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,14 +18,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Replaced the old slotting/sharding path with model-declared cache spaces. - Changed model payload storage to versioned keys shaped like `model:{classKey}:v{version}:{id}`, removing the need for members-set model tracking. -- Simplified Redis/Lua internals by centralizing script marshalling in `RedisStore` and sharing normalized query/through-relation read flow through `NormalizedReader`. +- Simplified Redis/Lua internals by centralizing script marshalling in `RedisStore` and sharing normalized query/through-relation access through `VersionedCacheRepository`. - Simplified planning, model hydration, and Lua status handling, including moving dependency inference into planner/dependency collaborators. ### Fixed - Fixed cache-space consistency across version lookup, invalidation, model payloads, query/result keys, relation caches, pivot/through caches, build locks, and wake keys. - Fixed Redis Cluster cross-slot issues in space-aware Lua paths and broad flush scans. -- Fixed relation and pivot invalidation edge cases, including relation version lookup, guarded relation model writes, `dependsOn()` accumulation, and pivot invalidation. +- Fixed relation and pivot invalidation edge cases, including through-model space validation, relation version lookup, guarded relation model writes, `dependsOn()` accumulation, and pivot invalidation. - Fixed pre-save invalidation timing so Eloquent observers see a cache miss after writes. - Fixed connection/table key safety by rejecting connection names containing `:` and resetting key-builder state across spaces. diff --git a/src/Cache/ModelCacheRepository.php b/src/Cache/ModelCacheRepository.php new file mode 100644 index 0000000..3d185fe --- /dev/null +++ b/src/Cache/ModelCacheRepository.php @@ -0,0 +1,68 @@ +keys->activeSpace(); + $modelVersion = $this->versions->currentVersion($modelClass, $space); + + $this->storeForVersion($modelClass, $modelAttrs, $modelVersion, $space); + } + + public function storeForBuild( + string $modelClass, + array $modelAttrs, + BuildHandle $build, + ?CacheSpace $space = null, + ): void { + $classKey = $this->keys->classKey($modelClass); + $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); + } + + public function storeForVersion( + string $modelClass, + array $modelAttrs, + int $expectedVersion, + ?CacheSpace $space = null, + ): void { + if (empty($modelAttrs)) { + return; + } + + $classKey = $this->keys->classKey($modelClass); + $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/ModelsExecutor.php b/src/Cache/ModelsExecutor.php index 3971fc3..7049108 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 $executionBuilder->finalizeResult(NormCache::hydrator()->getModels( $primaryKeys, $model, $selectedCols, @@ -50,13 +51,13 @@ public function runNormalized( $depTableKeys = $plan->dependencies->tables; 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), + waitForBuild: fn() => NormCache::queries()->waitForBuild($model, $hash, $cacheTag, $depClasses, $depTableKeys), 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), + NormCache::hydrator()->getModels($this->buildIds($base, $prototype), $model, $selectedCols, null, $executionBuilder, true, $prototype), $prepared ); }, @@ -64,12 +65,15 @@ public function runNormalized( CacheReporter::queryMiss($model, $result->key, $debugbarStart, ['kind' => 'ids']); $ids = $this->resolveIds( - $result->key, $base, $queryTtl, $prototype, - $result->build->buildingKey, $result->build->versionKeys, $result->build->expectedVersions, $result->build->buildingToken, $result->build->wakeKey + $result->key, + $base, + $queryTtl, + $prototype, + $result->build, ); return $executionBuilder->finalizeResult( - NormCache::getModels($ids, $model, $selectedCols, null, $executionBuilder, true, $prototype), + NormCache::hydrator()->getModels($ids, $model, $selectedCols, null, $executionBuilder, true, $prototype), $prepared ); }, @@ -81,7 +85,7 @@ public function runNormalized( ]); return $executionBuilder->finalizeResult( - NormCache::getModels($result->ids, $model, $selectedCols, $result->models, $executionBuilder, true, $prototype), + NormCache::hydrator()->getModels($result->ids, $model, $selectedCols, $result->models, $executionBuilder, true, $prototype), $prepared ); }, @@ -103,14 +107,10 @@ private function resolveIds( QueryBuilder $base, ?int $queryTtl, Model $prototype, - ?string $buildingKey = null, - array $versionKeys = [], - array $expectedVersions = [], - ?string $buildingToken = null, - ?string $wakeKey = null, + BuildHandle $build, ): array { $ids = $this->buildIds($base, $prototype); - NormCache::storeQueryIds($key, $ids, $queryTtl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken, $wakeKey); + NormCache::queries()->store($key, $ids, $queryTtl, $build); return $ids; } diff --git a/src/Cache/NormalizedCacheReader.php b/src/Cache/NormalizedCacheRepository.php similarity index 80% rename from src/Cache/NormalizedCacheReader.php rename to src/Cache/NormalizedCacheRepository.php index 8f619b4..42638eb 100644 --- a/src/Cache/NormalizedCacheReader.php +++ b/src/Cache/NormalizedCacheRepository.php @@ -5,14 +5,11 @@ use NormCache\Enums\CacheStatus; use NormCache\Enums\LuaStatus; use NormCache\Values\BuildContext; +use NormCache\Values\BuildHandle; use NormCache\Values\QueryCacheResult; -/** - * Query cache: payload is a bare id list, hydrated from per-model attribute keys. - * - * @extends NormalizedReader - */ -final class NormalizedCacheReader extends NormalizedReader +/** @extends VersionedCacheRepository */ +final class NormalizedCacheRepository extends VersionedCacheRepository { protected function queryPrefix(string $classKey, ?string $tag): string { @@ -72,19 +69,19 @@ protected function buildResult( }; } - public function store(string $key, array $ids, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken, ?string $wakeKey = null): bool - { + 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, - $buildingKey, - $versionKeys, - $expectedVersions, - $buildingToken, - $wakeKey, + $build, ); } } diff --git a/src/Cache/ResultCacheReader.php b/src/Cache/ResultCacheRepository.php similarity index 86% rename from src/Cache/ResultCacheReader.php rename to src/Cache/ResultCacheRepository.php index d093825..bbc0f73 100644 --- a/src/Cache/ResultCacheReader.php +++ b/src/Cache/ResultCacheRepository.php @@ -11,7 +11,7 @@ use NormCache\Values\PivotCacheResult; use NormCache\Values\ResultCacheResult; -final class ResultCacheReader +final class ResultCacheRepository { public function __construct( private readonly RedisStore $store, @@ -77,7 +77,6 @@ private function toResultCacheResult( return new ResultCacheResult(CacheStatus::Hit, $resultKey, $unserialized, new BuildHandle(versionKeys: $versionKeys, expectedVersions: $expectedVersions)); } - // Corrupt payload (not an array), treat as miss. $alreadyClaimed = false; } @@ -85,7 +84,6 @@ private function toResultCacheResult( return new ResultCacheResult(CacheStatus::Building, 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); @@ -170,7 +168,6 @@ public function waitForPivotBuild( 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; @@ -184,46 +181,56 @@ private function pivotLockKeys(string $relatedKey, string $relation, string $con } public function store( - string $key, mixed $payload, ?string $buildingKey, ?int $ttl, - ?string $wakeKey, array $versionKeys, array $expectedVersions, ?string $buildingToken + string $key, + mixed $payload, + ?int $ttl, + BuildHandle $build, ): bool { $ttl ??= $this->queryTtl; - if ($versionKeys !== []) { - return $this->storeEntry($key, $payload, $ttl, $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken); + if ($build->versionKeys !== []) { + return $this->storeEntry($key, $payload, $ttl, $build); } return $this->store->storeSerializedAndRelease( - $key, $payload, $ttl, $buildingKey, $wakeKey, $buildingToken + $key, + $payload, + $ttl, + $build->buildingKey, + $build->wakeKey, + $build->buildingToken, ); } public function storeMany( - array $entries, int $ttl, - array $versionKeys, array $expectedVersions, - ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null + 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, - $versionKeys, - $expectedVersions, - $buildingKey, - $wakeKey, - $buildingToken, + $build->versionKeys, + $build->expectedVersions, + $build->buildingKey, + $build->wakeKey, + $build->buildingToken, ); } public function storeEntry( - string $key, mixed $payload, int $ttl, - array $versionKeys, array $expectedVersions, - ?string $buildingKey = null, ?string $wakeKey = null, ?string $buildingToken = null + string $key, + mixed $payload, + ?int $ttl = null, + BuildHandle $build = new BuildHandle, ): bool { - return $this->storeMany([$key => $payload], $ttl, $versionKeys, $expectedVersions, $buildingKey, $wakeKey, $buildingToken); + return $this->storeMany([$key => $payload], $ttl, $build); } public function waitForBuild( diff --git a/src/Cache/ResultExecutor.php b/src/Cache/ResultExecutor.php index 33c091e..953bfee 100644 --- a/src/Cache/ResultExecutor.php +++ b/src/Cache/ResultExecutor.php @@ -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, ) {} @@ -43,8 +43,8 @@ public function execute( $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), + waitForBuild: fn() => $this->results->waitForBuild($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace), compute: fn() => ['value' => $compute(), 'cached' => false], onStore: function ($value, $result) use ($modelClass, $ttl, $debugbarStart, $kind) { CacheReporter::queryMiss($modelClass, $result->key, $debugbarStart, ['kind' => $kind->value]); @@ -76,15 +76,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->build->buildingKey, $ttl, - $result->build->wakeKey, - $result->build->versionKeys, - $result->build->expectedVersions, - $result->build->buildingToken + $result->build, ); } diff --git a/src/Cache/NormalizedThroughReader.php b/src/Cache/ThroughCacheRepository.php similarity index 78% rename from src/Cache/NormalizedThroughReader.php rename to src/Cache/ThroughCacheRepository.php index 25bd336..6230cc8 100644 --- a/src/Cache/NormalizedThroughReader.php +++ b/src/Cache/ThroughCacheRepository.php @@ -6,15 +6,11 @@ use NormCache\Enums\LuaStatus; use NormCache\Support\CacheKeyBuilder; use NormCache\Values\BuildContext; +use NormCache\Values\BuildHandle; use NormCache\Values\ThroughCacheResult; -/** - * Through cache (HasManyThrough/HasOneThrough): payload is `{i, t}` — id list - * plus per-row through keys Eloquent needs to re-stitch the relation. - * - * @extends NormalizedReader - */ -final class NormalizedThroughReader extends NormalizedReader +/** @extends VersionedCacheRepository */ +final class ThroughCacheRepository extends VersionedCacheRepository { protected function queryPrefix(string $classKey, ?string $tag): string { @@ -64,8 +60,13 @@ protected function buildResult( }; } - public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?string $buildingKey, array $versionKeys, array $expectedVersions, ?string $buildingToken, ?string $wakeKey = null): bool - { + 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); @@ -73,11 +74,7 @@ public function store(string $key, array $ids, array $throughKeys, ?int $ttl, ?s $key, $payload, $ttl, - $buildingKey, - $versionKeys, - $expectedVersions, - $buildingToken, - $wakeKey, + $build, ); } } diff --git a/src/Cache/NormalizedReader.php b/src/Cache/VersionedCacheRepository.php similarity index 82% rename from src/Cache/NormalizedReader.php rename to src/Cache/VersionedCacheRepository.php index 21706c5..ff0d1d8 100644 --- a/src/Cache/NormalizedReader.php +++ b/src/Cache/VersionedCacheRepository.php @@ -7,17 +7,13 @@ use NormCache\Support\CacheKeyBuilder; use NormCache\Support\RedisStore; use NormCache\Values\BuildContext; +use NormCache\Values\BuildHandle; use NormCache\Values\CacheConfig; use NormCache\Values\QueryCacheResult; use NormCache\Values\ThroughCacheResult; -/** - * Shared fetch/store/wait flow for the normalized id-list caches. Subclasses - * supply only the key prefix, payload codec, and result DTO via three hooks. - * - * @template TResult of QueryCacheResult|ThroughCacheResult - */ -abstract class NormalizedReader +/** @template TResult of QueryCacheResult|ThroughCacheResult */ +abstract class VersionedCacheRepository { public function __construct( protected readonly RedisStore $store, @@ -43,9 +39,7 @@ abstract protected function buildResult( ?array $models = null, ): QueryCacheResult|ThroughCacheResult; - /** - * @return TResult - */ + /** @return TResult */ public function fetch(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult|ThroughCacheResult { $classKey = $this->keys->classKey($modelClass); @@ -94,9 +88,7 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep ); } - /** - * @return TResult|null - */ + /** @return TResult|null */ public function waitForBuild(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult|ThroughCacheResult|null { $classKey = $this->keys->classKey($modelClass); @@ -107,14 +99,9 @@ public function waitForBuild(string $modelClass, string $hash, ?string $tag, arr return $result->status === CacheStatus::Building ? null : $result; } - /** - * Corrupt hit: claim the build lock to rebuild, else report Building. - * - * @return TResult - */ - protected function claimMissAfterCorruptHit( - BuildContext $build, - ): QueryCacheResult|ThroughCacheResult { + /** @return TResult */ + protected function claimMissAfterCorruptHit(BuildContext $build): QueryCacheResult|ThroughCacheResult + { if ($this->store->setNxEx($build->buildingKey, $build->lockToken, $this->buildingLockTtl)) { $this->store->delete($build->wakeKey); @@ -124,25 +111,20 @@ protected function claimMissAfterCorruptHit( return $this->buildResult(LuaStatus::Building, $build); } - // Store an encoded payload, version-guarded, releasing the build lock. protected function storePayload( string $key, string $payload, ?int $ttl, - ?string $buildingKey, - array $versionKeys, - array $expectedVersions, - ?string $buildingToken, - ?string $wakeKey = null, + BuildHandle $build, ): bool { return $this->store->storeVersionedPayload( [$key => $payload], $ttl ?? $this->queryTtl, - $versionKeys, - $expectedVersions, - $buildingKey, - $wakeKey, - $buildingToken, + $build->versionKeys, + $build->expectedVersions, + $build->buildingKey, + $build->wakeKey, + $build->buildingToken, ); } diff --git a/src/CacheManager.php b/src/CacheManager.php index 0ddd64a..9fd3547 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -2,14 +2,13 @@ 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; @@ -18,19 +17,16 @@ use NormCache\Traits\HandlesInvalidation; use NormCache\Values\CacheConfig; use NormCache\Values\CacheSpace; -use NormCache\Values\PivotCacheResult; -use NormCache\Values\QueryCacheResult; -use NormCache\Values\ResultCacheResult; -use NormCache\Values\ThroughCacheResult; 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, @@ -56,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; @@ -90,7 +111,7 @@ public function disable(): void // Accessors // ------------------------------------------------------------------------- - public function getStore(): RedisStore + public function store(): RedisStore { return $this->store; } @@ -100,13 +121,6 @@ public function keys(): CacheKeyBuilder return $this->keys; } - public function classKey(string $class): string - { - return $this->keys->classKey($class); - } - - // Resolve the active cache space for a model (used by relations to scope their - // cache execution to the same space the planner validated against). public function spaceFor(string $modelClass, ?string $explicitSpace = null): CacheSpace { return $this->spaceResolver->resolve($modelClass, $explicitSpace); @@ -130,11 +144,6 @@ public function withSpaceForModel(string $modelClass, ?string $explicitSpace, ca return $this->withSpace($this->spaceFor($modelClass, $explicitSpace), $callback); } - 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, $this->modelSpaces($modelClass)[0]); @@ -144,139 +153,4 @@ 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 - { - return $this->resultReader->fetchPivot($parentClass, $relatedClass, $relation, $parentIds, $constraintHash, $pivotTableKey); - } - - public function waitForPivotBuild(string $parentClass, string $relatedClass, string $relation, array $parentIds, string $constraintHash, ?string $pivotTableKey): ?PivotCacheResult - { - 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, ?string $wakeKey = null): void - { - $this->queryReader->store($key, $ids, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken, $wakeKey); - } - - public function storeThroughIds(string $key, array $ids, array $throughKeys, ?int $ttl = null, ?string $buildingKey = null, array $versionKeys = [], array $expectedVersions = [], ?string $buildingToken = null, ?string $wakeKey = null): bool - { - return $this->throughReader->store($key, $ids, $throughKeys, $ttl, $buildingKey, $versionKeys, $expectedVersions, $buildingToken, $wakeKey); - } - - 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 storeModelAttrs(string $modelClass, array $modelAttrs, ?CacheSpace $space = null): void - { - $space ??= $this->keys->activeSpace(); - $modelVersion = $this->versions->currentVersion($modelClass, $space); - - $this->storeModelAttrsForVersion($modelClass, $modelAttrs, $modelVersion, $space); - } - - public function storeModelAttrsForVersionedResult( - string $modelClass, - array $modelAttrs, - array $versionKeys, - array $expectedVersions, - ?CacheSpace $space = null, - ): void { - $classKey = $this->keys->classKey($modelClass); - $index = array_search($this->keys->verKey($classKey, $space), $versionKeys, true); - - if ($index === false || !isset($expectedVersions[$index])) { - return; - } - - $this->storeModelAttrsForVersion($modelClass, $modelAttrs, (int) $expectedVersions[$index], $space); - } - - public function storeModelAttrsForVersion(string $modelClass, array $modelAttrs, int $expectedVersion, ?CacheSpace $space = null): void - { - if (empty($modelAttrs)) { - return; - } - - $classKey = $this->keys->classKey($modelClass); - - $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/CacheServiceProvider.php b/src/CacheServiceProvider.php index c30427a..b13b910 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -9,11 +9,12 @@ use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; 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\Console\FlushCommand; use NormCache\Debug\NormCacheCollector; @@ -72,13 +73,17 @@ public function register(): void dispatchEvents: $events, stampedeWakeTokens: $stampedeWakeTokens, ); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs); + $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( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), - resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), - result: new ResultExecutor($engine, $resultReader, $config), + 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, diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index 3c39169..bba36fd 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -405,7 +405,7 @@ public function hydrateResultPayload( bool $cached, PreparedQuery $prepared, ): Collection { - return $this->finalizeResult(NormCache::hydrateResult($payload, $this->model, $cached), $prepared); + return $this->finalizeResult(NormCache::hydrator()->hydrateResult($payload, $this->model, $cached), $prepared); } public function finalizeResult(array $models, PreparedQuery $prepared): Collection 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/Planning/DependencyResolver.php b/src/Planning/DependencyResolver.php index ae1dd4a..d3d86d2 100644 --- a/src/Planning/DependencyResolver.php +++ b/src/Planning/DependencyResolver.php @@ -30,15 +30,21 @@ public function resolve( bool $hasExplicit, ): DependencySet { $inferred = $context->inferredDependencies; + $required = $context->requiredDependencies; if ($hasExplicit) { return new DependencySet( models: array_keys(array_flip([ $modelClass, ...$inferred->models, + ...$required->models, ...($explicitModels ?? []), ])), - tables: array_values(array_unique([...$inferred->tables, ...$explicitTables])), + tables: array_values(array_unique([ + ...$inferred->tables, + ...$required->tables, + ...$explicitTables, + ])), ); } @@ -52,21 +58,30 @@ public function resolve( if (($hasDependencyBypass && !$exempt) || isset($context->contextReasons['dependency']) - || !$inferred->safe) { + || !$inferred->safe + || !$required->safe) { return DependencySet::unsafe(array_values(array_unique([ ...($inspection !== null ? BypassReasons::fromInspection($inspection)['dependency'] ?? [] : []), ...($context->contextReasons['dependency'] ?? []), ...$inferred->reasons, + ...$required->reasons, ]))); } - if ($inferred->hasNoDependencies()) { + if ($inferred->hasNoDependencies() && $required->hasNoDependencies()) { return DependencySet::singleModel($modelClass); } return new DependencySet( - models: array_keys(array_flip([$modelClass, ...$inferred->models])), - tables: $inferred->tables, + models: array_keys(array_flip([ + $modelClass, + ...$inferred->models, + ...$required->models, + ])), + tables: array_values(array_unique([ + ...$inferred->tables, + ...$required->tables, + ])), ); } diff --git a/src/Planning/QueryAnalyzer.php b/src/Planning/QueryAnalyzer.php index 9e736a6..5d832e1 100644 --- a/src/Planning/QueryAnalyzer.php +++ b/src/Planning/QueryAnalyzer.php @@ -132,7 +132,7 @@ public function inferJoinDependencies(QueryBuilder $base, string $connection): D return DependencySet::unsafe('join table alias could not be inferred'); } - $tables[] = NormCache::tableKey($connection, self::stripAlias($join->table)); + $tables[] = NormCache::keys()->tableKey($connection, self::stripAlias($join->table)); } return new DependencySet(tables: array_values(array_unique($tables))); diff --git a/src/Relations/CacheableBelongsTo.php b/src/Relations/CacheableBelongsTo.php index 67d9075..59d87c8 100644 --- a/src/Relations/CacheableBelongsTo.php +++ b/src/Relations/CacheableBelongsTo.php @@ -42,7 +42,7 @@ public function getEager() $models = NormCache::withSpaceForModel( $this->related::class, $builder->getSpace(), - fn() => NormCache::getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false), + fn() => NormCache::hydrator()->getModels($this->eagerKeys, $this->related::class, $columns, null, $builder, false), ); if ($builder->getEagerLoads() !== []) { diff --git a/src/Relations/CacheableMorphTo.php b/src/Relations/CacheableMorphTo.php index 3f0372c..b76c90d 100644 --- a/src/Relations/CacheableMorphTo.php +++ b/src/Relations/CacheableMorphTo.php @@ -141,7 +141,7 @@ private function getResultsFromCache(string $type, Model $instance, ?array $colu $models = NormCache::withSpaceForModel( $class, null, - fn() => NormCache::getModels($ids, $class, $columns, null, $missedQuery, false), + fn() => NormCache::hydrator()->getModels($ids, $class, $columns, null, $missedQuery, false), ); $collection = $instance->newCollection($models); diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index ddaedf3..66fd3c5 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -71,8 +71,8 @@ public function get($columns = ['*']): Collection $runThrough = fn() => CacheFallback::rescue( NormCache::config(), fn() => NormCache::engine()->runNormalized( - fetch: fn() => NormCache::getThroughCache($relatedClass, $hash, $tag, $depClasses, $depTableKeys), - waitForBuild: fn() => NormCache::waitForThroughBuild( + 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), @@ -96,17 +96,19 @@ public function get($columns = ['*']): Collection CacheFallback::attempt( NormCache::config(), function () use ($cachePayload, $result, $relatedClass, $ttl, $modelAttrs, $space) { - $stored = NormCache::storeThroughIds( - $result->key, $cachePayload['ids'], $cachePayload['throughKeys'], $ttl, - $result->build->buildingKey, $result->build->versionKeys, $result->build->expectedVersions, $result->build->buildingToken, $result->build->wakeKey + $stored = NormCache::through()->store( + $result->key, + $cachePayload['ids'], + $cachePayload['throughKeys'], + $ttl, + $result->build, ); if ($stored) { - NormCache::storeModelAttrsForVersionedResult( + NormCache::models()->storeForBuild( $relatedClass, $modelAttrs, - $result->build->versionKeys, - $result->build->expectedVersions, + $result->build, $space, ); } @@ -146,7 +148,7 @@ private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?Cach { if ($this->isSimpleThroughQuery($base, $builder)) { $space = NormCache::spaceFor($this->related::class, $builder->getSpace()); - $dependencies = new DependencySet(models: [$this->throughParent::class]); + $dependencies = DependencySet::singleModel($this->throughParent::class); $plan = CachePlan::result( operation: CacheOperation::Through, dependencies: $dependencies, @@ -165,7 +167,8 @@ private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?Cach $plan = $builder->cachePlan($base, CachePlanContext::through( $projection ?? [], - $builder->inferAggregateDependencies() + $builder->inferAggregateDependencies(), + DependencySet::singleModel($this->throughParent::class), )); return $plan->usesResultCache() ? $plan : null; @@ -210,7 +213,7 @@ 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 = RawAttributes::getAttributeClosure(); diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index fda3b3f..10be966 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -13,6 +13,7 @@ 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; @@ -91,12 +92,12 @@ public function get($columns = ['*']): Collection $parentClass = $this->parent::class; $relatedClass = $this->related::class; - $parentClassKey = NormCache::classKey($parentClass); + $parentClassKey = NormCache::keys()->classKey($parentClass); $runPivot = fn() => CacheFallback::rescue( NormCache::config(), fn() => NormCache::engine()->runPivot( - fetch: fn() => NormCache::getPivotCache( + fetch: fn() => NormCache::results()->fetchPivot( $parentClass, $relatedClass, $this->relationName, @@ -104,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), @@ -125,7 +126,7 @@ public function get($columns = ['*']): Collection CacheFallback::attempt( NormCache::config(), function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $constraintHash, $pivotResult, $shouldCacheRelatedModels, $ttl, $space) { - $relatedKey = NormCache::classKey($relatedClass); + $relatedKey = NormCache::keys()->classKey($relatedClass); $keyMap = []; foreach ($cacheParentIds as $parentId) { $keyMap[$parentId] = NormCache::keys()->pivotKey( @@ -134,9 +135,12 @@ function () use ($models, $cacheParentIds, $parentClassKey, $relatedClass, $cons ); } $this->populatePivotCache( - $models, $keyMap, $relatedClass, $shouldCacheRelatedModels, - $pivotResult->build->versionKeys, $pivotResult->build->expectedVersions, $ttl, - $pivotResult->build->buildingKey, $pivotResult->build->wakeKey, $pivotResult->build->buildingToken, + $models, + $keyMap, + $relatedClass, + $shouldCacheRelatedModels, + $ttl, + $pivotResult->build, $space, ); }, @@ -199,7 +203,7 @@ private function shouldUsePivotCache( private function pivotTableKey(): string { - return NormCache::tableKey( + return NormCache::keys()->tableKey( $this->parent->getConnection()->getName(), $this->table ); @@ -219,9 +223,12 @@ 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), []); @@ -256,17 +263,17 @@ private function populatePivotCache( $pivotEntriesByKey[$keyMap[$parentId]] = $entries; } - $stored = NormCache::storeManyVersionedResults( - $pivotEntriesByKey, ttl: $ttl, versionKeys: $versionKeys, expectedVersions: $expectedVersions, - buildingKey: $buildingKey, wakeKey: $wakeKey, buildingToken: $buildingToken, + $stored = NormCache::results()->storeMany( + $pivotEntriesByKey, + $ttl, + $build, ); if ($stored) { - NormCache::storeModelAttrsForVersionedResult( + NormCache::models()->storeForBuild( $relatedClass, $modelAttrs, - $versionKeys, - $expectedVersions, + $build, $space, ); } @@ -288,7 +295,7 @@ private function hydrateFromPivotCache( $modelsById = []; $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; } diff --git a/src/Relations/RelationDependencyClassifier.php b/src/Relations/RelationDependencyClassifier.php index a21f10f..8d3b4f1 100644 --- a/src/Relations/RelationDependencyClassifier.php +++ b/src/Relations/RelationDependencyClassifier.php @@ -88,7 +88,7 @@ public function classify(Relation $relation, ?callable $constraint): ?RelationDe $tableKey = null; if ($relation instanceof BelongsToMany) { - $tableKey = NormCache::tableKey( + $tableKey = NormCache::keys()->tableKey( $relation->getParent()->getConnection()->getName(), $relation->getTable(), ); diff --git a/src/Values/CachePlanContext.php b/src/Values/CachePlanContext.php index af8a558..730c08f 100644 --- a/src/Values/CachePlanContext.php +++ b/src/Values/CachePlanContext.php @@ -8,14 +8,18 @@ { public DependencySet $inferredDependencies; + public DependencySet $requiredDependencies; + public function __construct( public CacheOperation $operation, public ?array $columns = null, ?DependencySet $inferredDependencies = null, public array $contextReasons = [], 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 */ @@ -49,8 +53,16 @@ public static function pivot(array $columns = [], ?DependencySet $inferred = nul return new self(CacheOperation::Pivot, $columns, $inferred); } - public static function through(array $columns = [], ?DependencySet $inferred = null): self - { - return new self(CacheOperation::Through, $columns, $inferred); + public static function through( + array $columns = [], + ?DependencySet $inferred = null, + ?DependencySet $required = null, + ): self { + return new self( + CacheOperation::Through, + $columns, + $inferred, + requiredDependencies: $required, + ); } } diff --git a/tests/Fixtures/Models/ReportingAuthor.php b/tests/Fixtures/Models/ReportingAuthor.php new file mode 100644 index 0000000..f19497e --- /dev/null +++ b/tests/Fixtures/Models/ReportingAuthor.php @@ -0,0 +1,17 @@ +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/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 406040d..1a2a7d0 100644 --- a/tests/Integration/Cache/CacheableBuilderTest.php +++ b/tests/Integration/Cache/CacheableBuilderTest.php @@ -833,7 +833,7 @@ public function test_malformed_scalar_cache_payload_is_recomputed_and_repaired() // 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); } diff --git a/tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php b/tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php index bf91794..af6bcce 100644 --- a/tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php +++ b/tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php @@ -27,7 +27,7 @@ protected function tearDown(): void private function makeHydrator(): ModelHydrator { - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $keys = new CacheKeyBuilder; $versions = new VersionTracker($store, $keys); @@ -119,7 +119,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 +135,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 +178,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 +191,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); @@ -304,7 +304,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 +325,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 +356,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(); diff --git a/tests/Integration/Cache/ModelHydratorStampedeTest.php b/tests/Integration/Cache/ModelHydratorStampedeTest.php index 97eccf4..0f77f7a 100644 --- a/tests/Integration/Cache/ModelHydratorStampedeTest.php +++ b/tests/Integration/Cache/ModelHydratorStampedeTest.php @@ -28,7 +28,7 @@ public function test_acquires_lock_fetches_once_caches_and_releases_lock_on_miss $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(); @@ -37,7 +37,7 @@ 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 @@ -53,10 +53,10 @@ public function test_falls_back_to_database_without_releasing_someone_elses_lock $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); @@ -71,7 +71,7 @@ public function test_falls_back_to_database_without_releasing_someone_elses_lock 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); @@ -92,7 +92,7 @@ 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); @@ -115,7 +115,7 @@ 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); @@ -137,7 +137,7 @@ 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); @@ -176,7 +176,7 @@ classKey: $classKey, 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); @@ -211,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); diff --git a/tests/Integration/Cache/OptimizationsTest.php b/tests/Integration/Cache/OptimizationsTest.php index 403e8a8..0b459d8 100644 --- a/tests/Integration/Cache/OptimizationsTest.php +++ b/tests/Integration/Cache/OptimizationsTest.php @@ -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,11 @@ 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->getStore(); + $store = $manager->store(); $fullQueryKey = $manager->keys()->prefixed("query:{$classKey}:v{$version}:{$hash}"); Redis::connection(config('normcache.connection'))->set( $fullQueryKey, @@ -150,7 +150,7 @@ public function test_multi_dependency_query_corrupt_payload_degrades_to_miss_and $this->assertCount(1, $found); Event::assertDispatched(QueryCacheMiss::class); - $store = app('normcache')->getStore(); + $store = app('normcache')->store(); $raw = $store->getRaw($queryKey); $repaired = $raw !== null ? json_decode($raw, true) : null; $this->assertSame([(string) $found->first()->id], $repaired); diff --git a/tests/Integration/Cache/PivotCacheTest.php b/tests/Integration/Cache/PivotCacheTest.php index d9ee76f..2ce669a 100644 --- a/tests/Integration/Cache/PivotCacheTest.php +++ b/tests/Integration/Cache/PivotCacheTest.php @@ -119,10 +119,10 @@ public function test_pivot_eager_load_falls_back_to_database_when_build_lock_is_ $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); + $claimed = NormCache::results()->fetchPivot(Author::class, Tag::class, 'tags', [$author->id], $constraintHash, $pivotTableKey); $this->assertNotNull($claimed->build->buildingKey); DB::enableQueryLog(); @@ -135,7 +135,7 @@ public function test_pivot_eager_load_falls_back_to_database_when_build_lock_is_ $this->assertSame(['Fiction'], $authors->first()->tags->pluck('name')->all()); $this->assertSame( $claimed->build->buildingToken, - NormCache::getStore()->getRaw($claimed->build->buildingKey), + NormCache::store()->getRaw($claimed->build->buildingKey), 'The foreign build lock must remain untouched' ); } @@ -597,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()); } diff --git a/tests/Integration/Cache/PivotStampedeTest.php b/tests/Integration/Cache/PivotStampedeTest.php index d1ef673..bf8c023 100644 --- a/tests/Integration/Cache/PivotStampedeTest.php +++ b/tests/Integration/Cache/PivotStampedeTest.php @@ -17,8 +17,8 @@ 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->build->buildingKey); @@ -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 = $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->build->versionKeys, - expectedVersions: $miss->build->expectedVersions, - buildingKey: $miss->build->buildingKey, - wakeKey: $miss->build->wakeKey, - buildingToken: $miss->build->buildingToken, + build: $miss->build, ); - $this->assertNull($manager->getStore()->getRaw($miss->build->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,15 +55,15 @@ 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->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->build->buildingKey), 'Must not release a lock held by another process'); @@ -76,7 +72,7 @@ public function test_pivot_falls_back_to_database_without_releasing_someone_else 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'); @@ -97,7 +93,7 @@ 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'); @@ -119,7 +115,7 @@ 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'); diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 4774851..cdce897 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -7,6 +7,7 @@ use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\CatalogTag; use NormCache\Tests\Fixtures\Models\Post; +use NormCache\Tests\Fixtures\Models\ReportingAuthor; use NormCache\Tests\Fixtures\Models\ReportingCountry; use NormCache\Tests\Fixtures\Models\SpacedAuthor; use NormCache\Tests\Fixtures\Models\SpacedPost; @@ -75,7 +76,7 @@ public function test_spaced_model_query_writes_keys_under_its_space_tag(): void SpacedPost::query()->get(); - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $this->assertNotEmpty( $store->scanPattern('{nc:content}:*'), @@ -116,11 +117,25 @@ public function test_simple_through_relation_caches_under_related_space(): void $this->assertSame(['Hello'], $country->spacedPosts()->get()->pluck('title')->all()); - $store = $this->cacheManager()->getStore(); + $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]); @@ -130,7 +145,7 @@ public function test_spaced_pivot_relation_invalidates_when_pivot_table_changes( $first = SpacedPost::query()->with('catalogTags')->get()->first()->catalogTags->pluck('name')->all(); $this->assertSame(['First'], $first); - $this->assertNotEmpty($this->cacheManager()->getStore()->scanPattern('{nc:catalog}:test:pivot:*')); + $this->assertNotEmpty($this->cacheManager()->store()->scanPattern('{nc:catalog}:test:pivot:*')); $post->catalogTags()->attach($secondTag->id); @@ -144,7 +159,7 @@ public function test_flush_all_removes_spaced_keys(): void SpacedPost::query()->get(); - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $this->assertNotEmpty($store->scanPattern('{nc:content}:test:*')); $this->cacheManager()->flushAll(); @@ -158,7 +173,7 @@ public function test_flush_tag_removes_spaced_tagged_keys(): void SpacedPost::query()->tag('homepage')->get(); - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $this->assertNotEmpty($store->scanPattern('{nc:content}:test:query:*:homepage:*')); $this->cacheManager()->flushTag(SpacedPost::class, 'homepage'); @@ -172,7 +187,7 @@ public function test_flush_tag_across_models_removes_spaced_tagged_keys(): void SpacedPost::query()->tag('deploy')->get(); - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $this->assertNotEmpty($store->scanPattern('{nc:content}:test:query:*:deploy:*')); $this->cacheManager()->flushTagAcrossModels('deploy'); @@ -202,7 +217,7 @@ public function test_co_located_relation_eager_load_caches_under_the_space_tag() $this->assertSame('Ann', $post->spacedAuthor->name); - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $authorKeys = $store->scanPattern('{nc:content}:test:model:*authors*'); $this->assertNotEmpty( @@ -218,7 +233,7 @@ public function test_pivot_invalidation_only_bumps_spaces_of_involved_models(): $registry->space('catalog'); $registry->space('reporting'); - $store = $this->cacheManager()->getStore(); + $store = $this->cacheManager()->store(); $post = SpacedPost::create(['title' => 'Draft', 'author_id' => 1]); $tag = CatalogTag::create(['name' => 'PHP']); diff --git a/tests/Integration/Cache/ThroughRelationTest.php b/tests/Integration/Cache/ThroughRelationTest.php index 44ee73d..c75e942 100644 --- a/tests/Integration/Cache/ThroughRelationTest.php +++ b/tests/Integration/Cache/ThroughRelationTest.php @@ -321,7 +321,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/Infrastructure/CacheEventsTest.php b/tests/Integration/Infrastructure/CacheEventsTest.php index 6a37277..f545db8 100644 --- a/tests/Integration/Infrastructure/CacheEventsTest.php +++ b/tests/Integration/Infrastructure/CacheEventsTest.php @@ -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, app('normcache')->keys()->prefixed('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, app('normcache')->keys()->prefixed('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, app('normcache')->keys()->prefixed('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, app('normcache')->keys()->prefixed('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, app('normcache')->keys()->prefixed('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, app('normcache')->keys()->prefixed('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 1ed8e14..4fb0999 100644 --- a/tests/Integration/Infrastructure/ClusterModeTest.php +++ b/tests/Integration/Infrastructure/ClusterModeTest.php @@ -263,7 +263,7 @@ public function test_prefix_sensitive_scan_and_flush_operations_work_in_cluster_ $this->assertEmpty($this->redisKeys('query:*:homepage:*')); $this->assertNotEmpty($this->redisKeys('query:*')); - $removed = $this->cacheManager()->getStore()->flushByPatterns([$this->cacheManager()->keys()->prefixed('query:*')]); + $removed = $this->cacheManager()->store()->flushByPatterns([$this->cacheManager()->keys()->prefixed('query:*')]); $this->assertGreaterThan(0, $removed); $this->assertEmpty($this->redisKeys('query:*')); @@ -315,7 +315,7 @@ 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); + $classKey = $this->cacheManager()->keys()->classKey(Author::class); $verKey = '{nc}:test:ver:' . $classKey . ':'; $ttl = $redis->ttl($verKey); diff --git a/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php b/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php index eb43879..3bdf6d9 100644 --- a/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php +++ b/tests/Integration/Infrastructure/Concerns/InteractsWithClusterRedis.php @@ -92,7 +92,7 @@ protected function redisClusterSlot(string $hashTag): int /** @return list */ protected function keysForHashTag(string $hashTag, string $suffix = '*'): array { - return array_values($this->cacheManager()->getStore()->scanPattern('{' . $hashTag . '}:' . $suffix)); + return array_values($this->cacheManager()->store()->scanPattern('{' . $hashTag . '}:' . $suffix)); } protected function assertAnyKeysForHashTag(string $hashTag, string $suffix = '*'): void diff --git a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php index e69808f..4193438 100644 --- a/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php +++ b/tests/Integration/Infrastructure/LuaScriptBehaviorTest.php @@ -46,7 +46,7 @@ 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(); @@ -72,7 +72,7 @@ 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); @@ -94,7 +94,7 @@ public function test_non_numeric_scheduled_key_is_cleaned_up_without_version_bum { Author::create(['name' => 'Alice']); - $ck = NormCache::classKey(Author::class); + $ck = NormCache::keys()->classKey(Author::class); Author::get(); $version = NormCache::currentVersion(Author::class); diff --git a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php index efb61e6..9274310 100644 --- a/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php +++ b/tests/Integration/Infrastructure/LuaScriptConsistencyTest.php @@ -59,7 +59,7 @@ 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'); $pastMs = (int) (microtime(true) * 1000) - 5000; @@ -75,7 +75,7 @@ public function test_cooldown_fires_version_bump_on_standalone_version_resolutio 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'); @@ -94,7 +94,7 @@ 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); @@ -131,7 +131,7 @@ public function test_cooldown_due_invalidation_applies_to_result_depends_on_cach $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); @@ -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->build->buildingKey, 60, - $miss->build->wakeKey, - $miss->build->versionKeys, - $miss->build->expectedVersions, + $miss->build, ); - $this->assertNull($manager->getStore()->get($miss->key)); - $this->assertNull($manager->getStore()->getRaw($miss->build->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->build->versionKeys, - $cache->build->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->build->versionKeys, - $cache->build->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->build->versionKeys, - $cache->build->expectedVersions, + $cache->build, )) { - $manager->storeModelAttrs(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); + $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->build->versionKeys, - $cache->build->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); + $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->build->versionKeys, - $cache->build->expectedVersions, + $cache->build, )) { - $manager->storeModelAttrs(Tag::class, [1 => ['id' => 1, 'name' => 'Old']]); + $manager->models()->store(Tag::class, [1 => ['id' => 1, 'name' => 'Old']]); } $this->assertNull($this->modelCacheEntry(Tag::class, 1)); @@ -288,7 +280,7 @@ public function test_cooldown_due_invalidation_applies_to_scalar_cache(): void $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); @@ -313,7 +305,7 @@ public function test_cooldown_due_invalidation_applies_to_pivot_cache(): void $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); @@ -334,7 +326,7 @@ 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); @@ -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->build->buildingKey, null, - $buildLock->build->wakeKey, - $buildLock->build->versionKeys, - $buildLock->build->expectedVersions, // Worker A thinks this is still current - $buildLock->build->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/StampedeProtectionTest.php b/tests/Integration/Infrastructure/StampedeProtectionTest.php index 184cf93..23231bd 100644 --- a/tests/Integration/Infrastructure/StampedeProtectionTest.php +++ b/tests/Integration/Infrastructure/StampedeProtectionTest.php @@ -46,7 +46,7 @@ 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(); @@ -73,7 +73,7 @@ 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("{nc}:test:ver:{$ck}:"); @@ -95,7 +95,7 @@ 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("{nc}:test:ver:{$ck}:"); @@ -118,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; diff --git a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php b/tests/Integration/Infrastructure/StringPrimaryKeyTest.php index 71db97b..2a79a81 100644 --- a/tests/Integration/Infrastructure/StringPrimaryKeyTest.php +++ b/tests/Integration/Infrastructure/StringPrimaryKeyTest.php @@ -118,7 +118,7 @@ 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); + $classKey = $this->cacheManager()->keys()->classKey(UuidItem::class); $verKey = '{nc}:test:ver:' . $classKey . ':'; $ttl = $redis->ttl($verKey); @@ -132,7 +132,7 @@ 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); + $classKey = $this->cacheManager()->keys()->classKey(UuidItem::class); $verKey = '{nc}:test:ver:' . $classKey . ':'; $verTtl = $redis->ttl($verKey); 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/TestCase.php b/tests/TestCase.php index a904342..a0271ee 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -11,11 +11,12 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; 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\CacheManager; use NormCache\CacheServiceProvider; @@ -123,13 +124,13 @@ protected function modelCacheEntry(string $class, mixed $id): mixed { $manager = $this->cacheManager(); - return $manager->getStore()->get($this->currentModelKey($manager, $class, $id)); + return $manager->store()->get($this->currentModelKey($manager, $class, $id)); } protected function evictModelCache(string $class, mixed $id): void { $manager = $this->cacheManager(); - $manager->getStore()->delete($this->currentModelKey($manager, $class, $id)); + $manager->store()->delete($this->currentModelKey($manager, $class, $id)); } protected function prefixedModelKey(string $class, mixed $id): string @@ -141,7 +142,7 @@ protected function prefixedModelKey(string $class, mixed $id): string private function currentModelKey(CacheManager $manager, string $class, mixed $id): string { - $classKey = $manager->classKey($class); + $classKey = $manager->keys()->classKey($class); $version = $manager->currentVersion($class); return $manager->keys()->modelPrefix($classKey, $version) . $id; @@ -151,7 +152,7 @@ protected function redisKeys(string $pattern = '*'): array { $manager = $this->cacheManager(); - return $manager->getStore()->scanPattern($manager->keys()->prefixed($pattern)); + return $manager->store()->scanPattern($manager->keys()->prefixed($pattern)); } protected function cacheManager(): CacheManager @@ -199,13 +200,17 @@ protected function buildManager( dispatchEvents: $dispatchEvents, stampedeWakeTokens: $stampedeWakeTokens, ); - $resultReader = new ResultCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs); + $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( - queryReader: new NormalizedCacheReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), - resultReader: $resultReader, - throughReader: new NormalizedThroughReader($store, $keys, $versions, $queryTtl, $buildingLockTtl, $config, $stampedeWaitMs), - result: new ResultExecutor($engine, $resultReader, $config), + 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, diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 5765b49..ee2dbe7 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -10,6 +10,7 @@ use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\TestCase; +use NormCache\Values\BuildHandle; class CacheManagerTest extends TestCase { @@ -27,10 +28,10 @@ protected function setUp(): void 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('a', 'alpha', 60); + $this->manager->store()->set('c', 'gamma', 60); - $result = $this->manager->getStore()->getMany(['a', 'b', 'c']); + $result = $this->manager->store()->getMany(['a', 'b', 'c']); $this->assertSame(['alpha', null, 'gamma'], $result); } @@ -63,7 +64,7 @@ public function test_invalidate_version_schedules_once_with_cooldown(): void { $manager = $this->buildManager(cooldown: 60); $redis = Redis::connection('normcache-test'); - $classKey = $manager->classKey(Author::class); + $classKey = $manager->keys()->classKey(Author::class); $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; $manager->invalidateVersion(new Author); @@ -81,7 +82,7 @@ public function test_current_version_applies_due_scheduled_invalidation(): void { $manager = $this->buildManager(cooldown: 60); - $classKey = $manager->classKey(Author::class); + $classKey = $manager->keys()->classKey(Author::class); Redis::connection('normcache-test')->set( "{nc}:test:scheduled:{$classKey}:", (string) ((int) floor(microtime(true) * 1000) - 1000) @@ -101,13 +102,13 @@ public function test_keys_are_prefixed_with_hash_tag_and_key_prefix(): void { $manager = $this->buildManager(); - $classKey = $manager->classKey(Author::class); + $classKey = $manager->keys()->classKey(Author::class); $keys = $manager->keys(); $fullVerKey = $keys->verKey($classKey); $this->assertSame("{nc}:test:ver:{$classKey}:", $fullVerKey); - $manager->getStore()->set($fullVerKey, 7, 60); + $manager->store()->set($fullVerKey, 7, 60); $this->assertSame('7', Redis::connection('normcache-test')->get("{nc}:test:ver:{$classKey}:")); $this->assertSame(7, $manager->currentVersion(Author::class)); @@ -131,7 +132,7 @@ public function test_with_space_reuses_matching_active_space(): void public function test_flush_model_bumps_version_and_clears_related_keys(): void { - $store = $this->manager->getStore(); + $store = $this->manager->store(); $postsKey = DB::getDefaultConnection() . ':posts'; $authorsKey = DB::getDefaultConnection() . ':authors'; @@ -153,7 +154,7 @@ public function test_flush_model_bumps_version_and_clears_related_keys(): void 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("{nc}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); @@ -182,7 +183,7 @@ public function test_flush_all_uses_wildcard_hash_tag_patterns_on_standalone_aft $this->app->forgetInstance(CacheSpaceRegistry::class); - $store = $this->manager->getStore(); + $store = $this->manager->store(); $postsKey = DB::getDefaultConnection() . ':posts'; $store->set("{nc:content}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); @@ -203,12 +204,12 @@ public function test_standalone_space_resolution_does_not_write_registry_metadat $this->app->make(CacheSpaceRegistry::class)->spacesForModel(SpacedPost::class); - $this->assertSame([], $this->manager->getStore()->scanPattern('{nc:meta}:test:spaces')); + $this->assertSame([], $this->manager->store()->scanPattern('{nc:meta}:test:spaces')); } public function test_flush_all_can_target_one_space(): void { - $store = $this->manager->getStore(); + $store = $this->manager->store(); $postsKey = DB::getDefaultConnection() . ':posts'; $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1], 3600); @@ -228,7 +229,7 @@ public function test_flush_all_removes_keys_when_redis_connection_prefix_is_enab $manager = $this->buildManager(); - $store = $manager->getStore(); + $store = $manager->store(); $postsKey = DB::getDefaultConnection() . ':posts'; $store->set("{nc}:test:query:{{$postsKey}}:v1:abc", [1, 2], 3600); @@ -264,7 +265,7 @@ public function test_scheduled_invalidation_key_persists_until_processed(): void $manager = $this->buildManager(cooldown: 1); $model = new Author; - $classKey = $manager->classKey(Author::class); + $classKey = $manager->keys()->classKey(Author::class); $scheduledKey = "{nc}:test:scheduled:{$classKey}:"; $redis = Redis::connection('normcache-test'); @@ -280,8 +281,8 @@ public function test_get_models_applies_due_cooldown_before_reading_model_cache( { $author = Author::create(['name' => 'Fresh']); $manager = $this->buildManager(cooldown: 60); - $classKey = $manager->classKey(Author::class); - $store = $manager->getStore(); + $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, [ @@ -295,7 +296,7 @@ public function test_get_models_applies_due_cooldown_before_reading_model_cache( 3600, ); - $models = $manager->getModels([$author->id], Author::class, null, null, Author::query()); + $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)); @@ -304,8 +305,8 @@ public function test_get_models_applies_due_cooldown_before_reading_model_cache( public function test_store_through_ids_returns_false_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}}:"; $throughKey = "through:{{$classKey}}:v5:through-hash"; @@ -316,16 +317,12 @@ public function test_store_through_ids_returns_false_on_version_mismatch(): void $store->setRaw($buildingKey, '1', 3600); $store->increment($versionKey); - $stored = $this->cacheManager()->storeThroughIds( + $stored = $this->cacheManager()->through()->store( $throughKey, [1], ['through-1'], 3600, - $buildingKey, - [$versionKey], - ['5'], - null, - $wakeKey, + new BuildHandle($buildingKey, null, $wakeKey, [$versionKey], ['5']), ); $this->assertFalse($stored); @@ -336,18 +333,20 @@ public function test_store_through_ids_returns_false_on_version_mismatch(): void public function test_store_model_attrs_for_versioned_result_uses_matching_version_key(): void { $manager = $this->cacheManager(); - $store = $manager->getStore(); - $authorKey = $manager->classKey(Author::class); - $postKey = $manager->classKey(Post::class); + $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->storeModelAttrsForVersionedResult( + $manager->models()->storeForBuild( Author::class, [1 => ['id' => 1, 'name' => 'Fresh']], - [$manager->keys()->verKey($postKey), $manager->keys()->verKey($authorKey)], - ['4', '9'], + new BuildHandle( + versionKeys: [$manager->keys()->verKey($postKey), $manager->keys()->verKey($authorKey)], + expectedVersions: ['4', '9'], + ), ); $this->assertSame( @@ -359,13 +358,13 @@ public function test_store_model_attrs_for_versioned_result_uses_matching_versio public function test_store_model_attrs_for_version_skips_stale_version_write(): void { $manager = $this->cacheManager(); - $store = $manager->getStore(); - $classKey = $manager->classKey(Author::class); + $store = $manager->store(); + $classKey = $manager->keys()->classKey(Author::class); $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); $store->increment($manager->keys()->verKey($classKey)); - $manager->storeModelAttrsForVersion(Author::class, [1 => ['id' => 1, 'name' => 'Stale']], 5); + $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')); @@ -374,12 +373,12 @@ public function test_store_model_attrs_for_version_skips_stale_version_write(): public function test_store_model_attrs_for_version_writes_when_version_matches(): void { $manager = $this->cacheManager(); - $store = $manager->getStore(); - $classKey = $manager->classKey(Author::class); + $store = $manager->store(); + $classKey = $manager->keys()->classKey(Author::class); $store->setRaw($manager->keys()->verKey($classKey), '5', 3600); - $manager->storeModelAttrsForVersion(Author::class, [1 => ['id' => 1, 'name' => 'Fresh']], 5); + $manager->models()->storeForVersion(Author::class, [1 => ['id' => 1, 'name' => 'Fresh']], 5); $this->assertSame( ['id' => 1, 'name' => 'Fresh'], @@ -389,8 +388,8 @@ public function test_store_model_attrs_for_version_writes_when_version_matches() 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"; @@ -401,7 +400,12 @@ public function test_store_query_ids_skips_write_on_version_mismatch(): void $store->setRaw($buildingKey, '1', 3600); $store->increment($versionKey); // now 6 - $this->cacheManager()->storeQueryIds($queryKey, [1, 2, 3], 3600, $buildingKey, [$versionKey], ['5'], null, $wakeKey); + $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'); @@ -409,8 +413,8 @@ 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"; @@ -420,7 +424,12 @@ public function test_store_query_ids_writes_when_version_matches(): void $store->setRaw($versionKey, '5', 3600); $store->setRaw($buildingKey, '1', 3600); - $this->cacheManager()->storeQueryIds($queryKey, [4, 5, 6], 3600, $buildingKey, [$versionKey], ['5'], null, $wakeKey); + $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'); @@ -428,8 +437,8 @@ 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"; @@ -439,7 +448,12 @@ public function test_store_query_ids_does_not_write_or_release_when_building_tok $store->setRaw($versionKey, '5', 3600); $store->setRaw($buildingKey, 'new-owner', 3600); - $this->cacheManager()->storeQueryIds($queryKey, [7, 8, 9], 3600, $buildingKey, [$versionKey], ['5'], 'old-owner', $wakeKey); + $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'); @@ -451,15 +465,20 @@ public function test_store_query_ids_does_not_write_or_release_when_building_tok public function test_store_query_ids_writes_normally_with_building_key_and_wake_key(): 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', $wakeKey); + $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'); } @@ -507,8 +526,8 @@ public function test_invalidate_multiple_versions_inside_transaction_queues_vers 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"; @@ -518,15 +537,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); From ab9a6b7c45a8e35461d2253311658e231bb06a7b Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 10 Jul 2026 17:08:23 +1000 Subject: [PATCH 52/73] quick denpendency resolver fix --- src/Planning/DependencyResolver.php | 2 ++ src/Values/BuildHandle.php | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/Planning/DependencyResolver.php b/src/Planning/DependencyResolver.php index d3d86d2..de1e730 100644 --- a/src/Planning/DependencyResolver.php +++ b/src/Planning/DependencyResolver.php @@ -45,6 +45,8 @@ public function resolve( ...$required->tables, ...$explicitTables, ])), + safe: $required->safe, + reasons: $required->reasons, ); } diff --git a/src/Values/BuildHandle.php b/src/Values/BuildHandle.php index 8af5100..25f217d 100644 --- a/src/Values/BuildHandle.php +++ b/src/Values/BuildHandle.php @@ -2,7 +2,7 @@ namespace NormCache\Values; -/** Build-lock and version-guard state a reader hands back so the store path can write and release. */ +/** Build-lock and version-guard state a repository hands back so the store path can write and release. */ final readonly class BuildHandle { public function __construct( From 88f76a5ebe3f938565faef82aee86dad11651480 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 10 Jul 2026 21:30:19 +1000 Subject: [PATCH 53/73] Fix unit test --- tests/Integration/Cache/SpaceResolutionTest.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 4bba8aa..150a2f2 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -2,6 +2,7 @@ namespace NormCache\Tests\Integration\Cache; +use Illuminate\Support\Facades\DB; use NormCache\Enums\CacheOperation; use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; @@ -63,7 +64,7 @@ public function test_named_space_query_can_cache_with_raw_table_dependency(): vo $this->assertSame(['Hello'], $query()->pluck('title')->all()); $this->assertNotEmpty( - $this->cacheManager()->getStore()->scanPattern('{nc:content}:test:result:*'), + $this->cacheManager()->store()->scanPattern('{nc:content}:test:result:*'), 'named-space raw table dependencies should cache under the active space', ); From 843db745d536e18eca8feafaee38cf0d76d6cbcd Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 10 Jul 2026 22:39:22 +1000 Subject: [PATCH 54/73] mget for predis cluster --- src/Support/RedisStore.php | 22 ++++++++++++-- tests/Unit/RedisStoreTest.php | 55 ++++++++++++++++++----------------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index e137517..496794a 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -265,7 +265,7 @@ private function mgetValues(array $keys, bool $unserialize): array } $raw = $this->connection instanceof PredisClusterConnection - ? array_map(fn($key) => $this->connection->get($key), $keys) + ? $this->connection->command('mget', $keys) : $this->connection->mget($keys); $values = []; @@ -346,8 +346,8 @@ public function asyncDel(array $prefixedKeys): void private function del(array $keys): void { if ($this->connection instanceof PredisClusterConnection) { - foreach ($keys as $key) { - $this->connection->del($key); + foreach ($this->groupByHashTag($keys) as $group) { + $this->connection->command('del', $group); } return; @@ -363,6 +363,22 @@ private function del(array $keys): void $this->connection->unlink($keys); } + /** @return list> */ + private function groupByHashTag(array $keys): array + { + $groups = []; + + foreach ($keys as $key) { + if (preg_match('/\{([^{}]+)\}/', $key, $matches) === 1) { + $groups['tag:' . $matches[1]][] = $key; + } else { + $groups['key:' . $key][] = $key; + } + } + + return array_values($groups); + } + public function flushByPatterns(array $patterns): int { $total = 0; diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index ee39fbb..895488a 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -9,7 +9,6 @@ use NormCache\Support\RedisStore; use NormCache\Tests\TestCase; use Predis\Client as PredisClient; -use Predis\NotSupportedException; use ReflectionProperty; class RedisStoreTest extends TestCase @@ -81,38 +80,35 @@ public function test_it_can_get_many_values(): void $this->assertSame(['bar', 'qux', null], $results); } - public function test_predis_cluster_get_many_reads_each_key_without_mget_array_command(): void + public function test_predis_cluster_get_many_uses_one_same_slot_mget_command(): void { $connection = new class extends PredisClusterConnection { - public array $reads = []; + public array $commands = []; public array $values = []; public function __construct() {} - public function mget($keys) + public function command($method, array $parameters = []) { - throw new NotSupportedException("Cannot use 'MGET' with redis-cluster."); - } - - public function get($key) - { - $this->reads[] = $key; + $this->commands[] = [$method, $parameters]; - return $this->values[$key] ?? null; + return array_map(fn($key) => $this->values[$key] ?? null, $parameters); } }; $store = new RedisStore('normcache-test'); $connection->values = [ - 'foo' => $store->serialize('bar'), - 'baz' => $store->serialize('qux'), + '{nc}:foo' => $store->serialize('bar'), + '{nc}:baz' => $store->serialize('qux'), ]; (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - $this->assertSame(['bar', 'qux', null], $store->getMany(['foo', 'baz', 'missing'])); - $this->assertSame(['foo', 'baz', 'missing'], $connection->reads); + $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 @@ -239,32 +235,39 @@ public function test_it_can_flush_by_patterns(): void $this->assertSame('c', $this->store->get('bar:1')); } - public function test_predis_cluster_delete_sends_one_key_per_command(): void + public function test_predis_cluster_delete_batches_keys_by_hash_tag(): void { $connection = new class extends PredisClusterConnection { - public array $deleted = []; + public array $commands = []; public function __construct() {} - public function del($keys) + public function command($method, array $parameters = []) { - if (is_array($keys)) { - throw new NotSupportedException("Cannot use 'DEL' with redis-cluster."); - } - - $this->deleted[] = $keys; + $this->commands[] = [$method, $parameters]; - return 1; + return count($parameters); } }; $store = new RedisStore('normcache-test'); (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); - $store->delete(['foo:1', 'foo:2']); + $store->delete([ + '{nc}:foo:1', + '{nc:content}:foo:1', + '{nc}:foo:2', + 'untagged:1', + 'untagged:2', + ]); - $this->assertSame(['foo:1', 'foo:2'], $connection->deleted); + $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 From de84c4fa9c7f2928c3f048694b70f383bcacb7b0 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Fri, 10 Jul 2026 23:54:31 +1000 Subject: [PATCH 55/73] centralize cache construction / extract space validation --- src/CacheManagerFactory.php | 84 +++++++++++++++++++ src/CacheServiceProvider.php | 75 +++++------------ src/CacheableBuilder.php | 2 +- src/Planning/CachePlanSpaceValidator.php | 81 ++++++++++++++++++ src/Planning/CachePlanner.php | 59 ++----------- .../Infrastructure/ClusterModeTest.php | 57 +++++++++++++ tests/TestCase.php | 63 ++++---------- tests/Unit/CacheManagerTest.php | 29 +++++++ tests/Unit/CachePlanSpaceValidatorTest.php | 54 ++++++++++++ 9 files changed, 349 insertions(+), 155 deletions(-) create mode 100644 src/CacheManagerFactory.php create mode 100644 src/Planning/CachePlanSpaceValidator.php create mode 100644 tests/Unit/CachePlanSpaceValidatorTest.php 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 b13b910..29b8567 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -8,22 +8,15 @@ use Illuminate\Queue\Events\Looping; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; -use NormCache\Cache\ExecutionEngine; -use NormCache\Cache\ModelCacheRepository; -use NormCache\Cache\ModelHydrator; -use NormCache\Cache\NormalizedCacheRepository; -use NormCache\Cache\ResultCacheRepository; -use NormCache\Cache\ResultExecutor; -use NormCache\Cache\ThroughCacheRepository; -use NormCache\Cache\VersionTracker; 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 { @@ -46,55 +39,29 @@ public function register(): void return new CacheSpaceResolver($app->make(CacheSpaceRegistry::class)); }); - $this->app->singleton(CacheManager::class, function ($app) { - $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'); - $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); - - $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, + $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'), ); - $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: $app->make(CacheSpaceResolver::class), - spaceRegistry: $app->make(CacheSpaceRegistry::class), + }); + + $this->app->singleton(CachePlanner::class, function ($app) { + return new CachePlanner(spaceValidator: $app->make(CachePlanSpaceValidator::class)); + }); + + $this->app->singleton(CacheManagerFactory::class, function ($app) { + return new CacheManagerFactory( + $app->make(CacheSpaceRegistry::class), + $app->make(CacheSpaceResolver::class), ); }); + $this->app->singleton(CacheManager::class, fn($app) => $app->make(CacheManagerFactory::class)->make()); + $this->app->alias(CacheManager::class, 'normcache'); } diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index bba36fd..2b84155 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -502,7 +502,7 @@ public function planPrepared( public function planner(): CachePlanner { - return self::$sharedPlanner ??= new CachePlanner; + return self::$sharedPlanner ??= app(CachePlanner::class); } private function modelsExecutor(): ModelsExecutor diff --git a/src/Planning/CachePlanSpaceValidator.php b/src/Planning/CachePlanSpaceValidator.php new file mode 100644 index 0000000..c4819aa --- /dev/null +++ b/src/Planning/CachePlanSpaceValidator.php @@ -0,0 +1,81 @@ +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) { + return $plan->withSpace($space); + } + + $offending = implode(', ', [...$validation->invalidModels, ...$validation->invalidTables]); + $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: ['dependency' => [$reason]], + )->withSpace($space); + } +} diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index f5480e2..2465728 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -4,13 +4,10 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Query\Builder as QueryBuilder; -use Illuminate\Support\Facades\Log; use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; use NormCache\Enums\PlanningMode; use NormCache\Facades\NormCache; -use NormCache\Spaces\CacheSpaceRegistry; -use NormCache\Spaces\CacheSpaceResolver; use NormCache\Values\CachePlan; use NormCache\Values\CachePlanContext; use NormCache\Values\DependencySet; @@ -41,6 +38,7 @@ 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 inferPreparedDependencies(CacheableBuilder $builder, QueryBuilder $base): DependencySet @@ -83,64 +81,21 @@ public function plan( CacheOperation::MorphToEagerLoad => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), }; - return $this->applySpaceValidation($plan, $builder, $model, $explain); + return $this->spaceValidator()->validate($plan, $builder, $model, $explain); } - private ?CacheSpaceResolver $spaceResolver = null; - - private ?CacheSpaceRegistry $spaceRegistry = null; - - // Bypass (or throw) when a plan's dependencies don't co-locate in its cache space. - // Neutral for default-only apps: everything resolves to the default space. public function applySpaceValidation( CachePlan $plan, CacheableBuilder $builder, Model $model, bool $explain = false, ): CachePlan { - if (!$plan->isCacheable()) { - return $plan; - } - - $registry = $this->spaceRegistry ??= app(CacheSpaceRegistry::class); - $resolver = $this->spaceResolver ??= app(CacheSpaceResolver::class); - $space = $resolver->resolve($model::class, $builder->getSpace()); - - if ($registry->dependenciesAreOnlyModel($model::class, $plan->dependencies->models, $plan->dependencies->tables)) { - return $plan->withSpace($space); - } - - $validation = $registry->validateDependencies( - $space, - $plan->dependencies->models, - $plan->dependencies->tables, - includeDependenciesBySpace: $explain, - ); - - if ($validation->isValid) { - return $plan->withSpace($space); - } - - $offending = implode(', ', [...$validation->invalidModels, ...$validation->invalidTables]); - $reasons = ['cross-space dependencies for space [' . $space->name . ']: ' . $offending]; - - if (!$explain && config('app.debug', false)) { - $modelClass = $model::class; - Log::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 && config('normcache.spaces.cross_space_behavior', 'bypass') === 'throw') { - throw new \RuntimeException('NormCache: ' . $reasons[0]); - } + return $this->spaceValidator()->validate($plan, $builder, $model, $explain); + } - return CachePlan::bypass( - operation: $plan->operation, - dependencies: $plan->dependencies, - bypassReasons: ['dependency' => $reasons], - )->withSpace($space); + private function spaceValidator(): CachePlanSpaceValidator + { + return $this->spaceValidator ?? CachePlanSpaceValidator::standalone(); } private function globalBypass( diff --git a/tests/Integration/Infrastructure/ClusterModeTest.php b/tests/Integration/Infrastructure/ClusterModeTest.php index 4fb0999..bdb23c9 100644 --- a/tests/Integration/Infrastructure/ClusterModeTest.php +++ b/tests/Integration/Infrastructure/ClusterModeTest.php @@ -22,6 +22,54 @@ protected function setUp(): void $this->setClusterMode(true); } + 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_predis_cluster_delete_groups_mixed_space_keys_without_crossslot_errors(): void + { + $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); + } + + $store->delete($keys); + + foreach ($keys as $key) { + $this->assertNull($store->get($key)); + } + } + // dependsOn — multi-dependency normalized query stays normalized (no forced result cache) public function test_multi_dependency_normalized_query_stays_normalized_in_cluster_mode(): void @@ -337,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/TestCase.php b/tests/TestCase.php index a0271ee..1018935 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -10,21 +10,10 @@ use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; -use NormCache\Cache\ExecutionEngine; -use NormCache\Cache\ModelCacheRepository; -use NormCache\Cache\ModelHydrator; -use NormCache\Cache\NormalizedCacheRepository; -use NormCache\Cache\ResultCacheRepository; -use NormCache\Cache\ResultExecutor; -use NormCache\Cache\ThroughCacheRepository; -use NormCache\Cache\VersionTracker; use NormCache\CacheManager; +use NormCache\CacheManagerFactory; use NormCache\CacheServiceProvider; -use NormCache\Spaces\CacheSpaceRegistry; -use NormCache\Spaces\CacheSpaceResolver; use NormCache\Support\CacheKeyBuilder; -use NormCache\Support\RedisStore; -use NormCache\Values\CacheConfig; use Orchestra\Testbench\TestCase as OrchestraTestCase; use Predis\Client; @@ -184,42 +173,20 @@ protected function buildManager( int $stampedeWaitMs = 200, int $stampedeWakeTokens = 64, ): CacheManager { - $ttl ??= (int) config('normcache.ttl'); - $queryTtl ??= (int) config('normcache.query_ttl'); - - $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: $dispatchEvents, - 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: app(CacheSpaceResolver::class), - spaceRegistry: app(CacheSpaceRegistry::class), - ); + 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/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index ee2dbe7..3958f48 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -5,6 +5,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use NormCache\CacheManager; +use NormCache\CacheManagerFactory; use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; @@ -22,6 +23,34 @@ 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)); + } + // ------------------------------------------------------------------------- // RedisStore pass-through (basic I/O tests live in RedisStoreTest) // ------------------------------------------------------------------------- diff --git a/tests/Unit/CachePlanSpaceValidatorTest.php b/tests/Unit/CachePlanSpaceValidatorTest.php new file mode 100644 index 0000000..ee6b505 --- /dev/null +++ b/tests/Unit/CachePlanSpaceValidatorTest.php @@ -0,0 +1,54 @@ +validate($plan, $builder, $builder->getModel()); + + $this->assertSame(CacheStrategy::LiveQuery, $validated->strategy); + $this->assertSame('content', $validated->space?->name); + $this->assertStringContainsString(CatalogTag::class, $validated->bypassReasons['dependency'][0]); + } + + 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()); + } +} From 2d82632ea31265138699136b43a9429c7e016f6f Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:32:28 +1000 Subject: [PATCH 56/73] fix test with cross slot mget --- src/Support/RedisStore.php | 2 -- tests/Unit/CacheManagerTest.php | 6 +++--- tests/Unit/RedisStoreTest.php | 6 +++--- 3 files changed, 6 insertions(+), 8 deletions(-) diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 496794a..7a2c108 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -256,8 +256,6 @@ public function getMany(array $keys): array return $this->mgetValues($keys, unserialize: true); } - // MGET in input order, with null for missing keys. All keys share the same hash tag so - // a plain MGET is safe on Redis Cluster (same slot guaranteed). private function mgetValues(array $keys, bool $unserialize): array { if (empty($keys)) { diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 3958f48..ab65871 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -57,10 +57,10 @@ public function test_factory_builds_manager_with_explicit_overrides(): void public function test_store_get_many_returns_values_in_key_order(): void { - $this->manager->store()->set('a', 'alpha', 60); - $this->manager->store()->set('c', 'gamma', 60); + $this->manager->store()->set('{nc}:a', 'alpha', 60); + $this->manager->store()->set('{nc}:c', 'gamma', 60); - $result = $this->manager->store()->getMany(['a', 'b', 'c']); + $result = $this->manager->store()->getMany(['{nc}:a', '{nc}:b', '{nc}:c']); $this->assertSame(['alpha', null, 'gamma'], $result); } diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index 895488a..b130c18 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -72,10 +72,10 @@ public function test_release_building_pushes_configured_wake_tokens(): void 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); } From 5c4d8409add48477869a22ff0caf170a5cd92d32 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 13 Jul 2026 12:50:45 +1000 Subject: [PATCH 57/73] remove class static binding to use singleton --- src/CacheServiceProvider.php | 3 +++ src/CacheableBuilder.php | 15 ++------------- src/Support/CacheKeyBuilder.php | 2 -- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 29b8567..22afe95 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -8,6 +8,7 @@ use Illuminate\Queue\Events\Looping; use Illuminate\Support\Facades\Event; use Illuminate\Support\ServiceProvider; +use NormCache\Cache\ModelsExecutor; use NormCache\Console\FlushCommand; use NormCache\Debug\NormCacheCollector; use NormCache\Debug\NormCacheDebugBarCollector; @@ -53,6 +54,8 @@ public function register(): void return new CachePlanner(spaceValidator: $app->make(CachePlanSpaceValidator::class)); }); + $this->app->singleton(ModelsExecutor::class); + $this->app->singleton(CacheManagerFactory::class, function ($app) { return new CacheManagerFactory( $app->make(CacheSpaceRegistry::class), diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index 2b84155..ee1e086 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -37,10 +37,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; @@ -502,19 +498,12 @@ public function planPrepared( public function planner(): CachePlanner { - return self::$sharedPlanner ??= app(CachePlanner::class); + return app(CachePlanner::class); } private function modelsExecutor(): ModelsExecutor { - return self::$sharedModelsExecutor ??= new ModelsExecutor; - } - - // Drop memoized services on tenant/Octane resets; the planner caches app() lookups. - public static function resetSharedState(): void - { - self::$sharedPlanner = null; - self::$sharedModelsExecutor = null; + return app(ModelsExecutor::class); } private function rememberPaginationTotal(PreparedQuery $prepared, CachePlan $plan): int diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index d756692..9e69e0d 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -5,7 +5,6 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Support\Facades\DB; use NormCache\Cache\ModelHydrator; -use NormCache\CacheableBuilder; use NormCache\Values\CacheSpace; class CacheKeyBuilder @@ -142,7 +141,6 @@ public static function reset(): void self::$singleDepPairs = []; ModelHydrator::reset(); - CacheableBuilder::resetSharedState(); } public static function prototype(string $class): Model From e8ee3db5b67e06a4a5cead9d5551993e00a43c0e Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:05:41 +1000 Subject: [PATCH 58/73] remove belongsTo / morphTo relation override --- src/Relations/CacheableBelongsTo.php | 125 -------------- src/Relations/CacheableMorphTo.php | 156 ------------------ src/Relations/CachesRelationships.php | 15 -- .../Unit/CacheableBuilderSharedStateTest.php | 33 ---- 4 files changed, 329 deletions(-) delete mode 100644 src/Relations/CacheableBelongsTo.php delete mode 100644 src/Relations/CacheableMorphTo.php delete mode 100644 tests/Unit/CacheableBuilderSharedStateTest.php diff --git a/src/Relations/CacheableBelongsTo.php b/src/Relations/CacheableBelongsTo.php deleted file mode 100644 index 59d87c8..0000000 --- a/src/Relations/CacheableBelongsTo.php +++ /dev/null @@ -1,125 +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::withSpaceForModel( - $this->related::class, - $builder->getSpace(), - fn() => NormCache::hydrator()->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 $prepared->builder->collectFromPrepared($prepared); - } -} diff --git a/src/Relations/CacheableMorphTo.php b/src/Relations/CacheableMorphTo.php deleted file mode 100644 index b76c90d..0000000 --- a/src/Relations/CacheableMorphTo.php +++ /dev/null @@ -1,156 +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()); - - 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::withSpaceForModel( - $class, - null, - fn() => NormCache::hydrator()->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/CachesRelationships.php b/src/Relations/CachesRelationships.php index d01b21f..b6224ed 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); @@ -80,14 +75,4 @@ protected function newBelongsToMany( ); } - protected function newMorphTo( - Builder $query, - Model $parent, - $foreignKey, - $ownerKey, - $type, - $relation, - ) { - return new CacheableMorphTo($query, $parent, $foreignKey, $ownerKey, $type, $relation); - } } diff --git a/tests/Unit/CacheableBuilderSharedStateTest.php b/tests/Unit/CacheableBuilderSharedStateTest.php deleted file mode 100644 index 63541d0..0000000 --- a/tests/Unit/CacheableBuilderSharedStateTest.php +++ /dev/null @@ -1,33 +0,0 @@ -setValue(null, new CachePlanner); - $executor->setValue(null, new ModelsExecutor); - - CacheKeyBuilder::reset(); - - $this->assertNull($planner->getValue()); - $this->assertNull($executor->getValue()); - } - - public function test_builders_share_one_planner_instance(): void - { - $this->assertSame(Author::query()->planner(), Post::query()->planner()); - } -} From 5992257cac826f1702876dd83f0962abef0f2112 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 13 Jul 2026 13:23:59 +1000 Subject: [PATCH 59/73] detect active soft delete scope --- src/Planning/CachePlanner.php | 13 +++++++++++ src/Planning/QueryAnalyzer.php | 28 ++++++++++++++++++----- tests/Unit/CachePlannerTest.php | 30 +++++++++++++++++++++++++ tests/Unit/QueryAnalyzerTest.php | 38 ++++++++++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 5 deletions(-) diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 2465728..55c6444 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -3,6 +3,7 @@ namespace NormCache\Planning; use Illuminate\Database\Eloquent\Model; +use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Database\Query\Builder as QueryBuilder; use NormCache\CacheableBuilder; use NormCache\Enums\CacheOperation; @@ -200,6 +201,7 @@ private function planModels( $context->columns, [$model->getKeyName(), $model->getQualifiedKeyName()], includeTables: $explain, + softDeleteScopeColumn: $this->activeSoftDeleteScopeColumn($builder, $model), ); if ($this->qualifiesForDirectModels($explain, $insideTransaction, $hasExplicit, $context, $inferred, $inspection)) { @@ -380,6 +382,17 @@ private function inspect( ); } + 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, diff --git a/src/Planning/QueryAnalyzer.php b/src/Planning/QueryAnalyzer.php index 5d832e1..0f606ce 100644 --- a/src/Planning/QueryAnalyzer.php +++ b/src/Planning/QueryAnalyzer.php @@ -28,12 +28,13 @@ public function inspect( ?array $resolvedColumns, array $primaryKeyIdentifiers = [], bool $includeTables = false, + ?string $softDeleteScopeColumn = null, ): QueryInspection { return new QueryInspection( flags: $this->flags($base, $table, $resolvedColumns), primaryKeys: $primaryKeyIdentifiers === [] ? null - : self::extractPrimaryKeys($base, $primaryKeyIdentifiers), + : self::extractPrimaryKeys($base, $primaryKeyIdentifiers, $softDeleteScopeColumn), tables: $includeTables ? $this->extractTables($base, $table) : null, ); } @@ -159,8 +160,11 @@ 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 +173,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 +214,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; diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index d64edcb..ba8d6e9 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -48,6 +48,36 @@ public function test_successful_hot_plan_does_not_build_reason_strings(): void $this->assertSame([], $plan->bypassReasons); } + 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(), + ); + + $this->assertSame(CacheStrategy::DirectModels, $plan->strategy); + $this->assertSame([1, 2, 3], $plan->primaryKeys); + } + + public function test_removed_soft_delete_scope_does_not_ignore_a_manual_null_constraint(): void + { + $prepared = Post::withTrashed() + ->whereNull('posts.deleted_at') + ->whereKey([3, 1, 2]) + ->prepareCacheExecution(); + + $plan = (new CachePlanner)->plan( + $prepared->builder, + $prepared->base, + CachePlanContext::models(), + ); + + $this->assertSame(CacheStrategy::NormalizedQuery, $plan->strategy); + } + public function test_bypass_plan_still_contains_human_readable_reasons(): void { $prepared = Author::orderByRaw('CASE WHEN id = ? THEN 0 ELSE 1 END', [1]) diff --git a/tests/Unit/QueryAnalyzerTest.php b/tests/Unit/QueryAnalyzerTest.php index a7b89d8..6764271 100644 --- a/tests/Unit/QueryAnalyzerTest.php +++ b/tests/Unit/QueryAnalyzerTest.php @@ -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); From 264919bf4ebd7c3ef7aff5877d39c09d15096234 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 13 Jul 2026 14:01:50 +1000 Subject: [PATCH 60/73] use scoped lifecycle state / use built-in laravel functions --- src/Cache/ModelHydrator.php | 6 ++++-- src/CacheServiceProvider.php | 20 +++++--------------- src/CacheableBuilder.php | 12 ------------ src/Relations/CachesRelationships.php | 1 - tests/Unit/CacheManagerTest.php | 26 ++++++++++++++++++++++++++ 5 files changed, 35 insertions(+), 30 deletions(-) diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index 35b8ab4..f2149ff 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -393,7 +393,9 @@ private function prepareMissedQuery(string $modelClass, ?CacheableBuilder $misse if ($missedQuery === null || !$preserveQueryShape || !$this->canPreserveQueryShape($missedQuery->getQuery())) { $builder = $modelClass::query(); if ($builder instanceof CacheableBuilder) { - $missedQuery?->applyRemovedScopesTo($builder); + if ($missedQuery !== null) { + $builder->withoutGlobalScopes($missedQuery->removedScopes()); + } return $builder->withoutCache(); } @@ -410,7 +412,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/CacheServiceProvider.php b/src/CacheServiceProvider.php index 22afe95..77f36a0 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -63,7 +63,7 @@ public function register(): void ); }); - $this->app->singleton(CacheManager::class, fn($app) => $app->make(CacheManagerFactory::class)->make()); + $this->app->scoped(CacheManager::class, fn($app) => $app->make(CacheManagerFactory::class)->make()); $this->app->alias(CacheManager::class, 'normcache'); } @@ -82,24 +82,14 @@ public function boot(): void $this->app->make(CacheManager::class)->discardPending($event->connection->getName()); } }); + Event::listen(JobProcessed::class, CacheKeyBuilder::reset(...)); + Event::listen(Looping::class, CacheKeyBuilder::reset(...)); - // 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(); - $manager = $this->app->make(CacheManager::class); - $manager->discardAllPending(); - $manager->enable(); - }; - - Event::listen(JobProcessed::class, $resetManager); - Event::listen(Looping::class, $resetManager); - - // Re-enable (in case fallback disabled it) between Octane requests. + // Reset static metadata between Octane requests and tasks. foreach (['RequestReceived', 'TaskReceived'] as $event) { $octaneEvent = "Laravel\\Octane\\Events\\$event"; if (class_exists($octaneEvent)) { - Event::listen($octaneEvent, $resetManager); + Event::listen($octaneEvent, CacheKeyBuilder::reset(...)); } } diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index ee1e086..915debe 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -460,18 +460,6 @@ public function collectFromPrepared( : $collection; } - public function applyRemovedScopesTo(self $target): void - { - foreach ($this->removedScopes as $scope) { - $target->withoutGlobalScope($scope); - } - } - - public function hasRemovedScopes(): bool - { - return !empty($this->removedScopes); - } - // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- diff --git a/src/Relations/CachesRelationships.php b/src/Relations/CachesRelationships.php index b6224ed..7d7b1dc 100644 --- a/src/Relations/CachesRelationships.php +++ b/src/Relations/CachesRelationships.php @@ -74,5 +74,4 @@ protected function newBelongsToMany( $relatedPivotKey, $parentKey, $relatedKey, $relationName, ); } - } diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index ab65871..5960e1f 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -2,11 +2,13 @@ namespace NormCache\Tests\Unit; +use Illuminate\Queue\Events\Looping; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use NormCache\CacheManager; use NormCache\CacheManagerFactory; use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Support\CacheKeyBuilder; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\Fixtures\Models\SpacedPost; @@ -51,6 +53,30 @@ public function test_factory_builds_manager_with_explicit_overrides(): void $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_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) // ------------------------------------------------------------------------- From c6990cfe5d511b2d5c98d0558389a7a5a736bd96 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 13 Jul 2026 15:40:54 +1000 Subject: [PATCH 61/73] simplify cache lifecycle, flush scanning, and space validation --- config/normcache.php | 2 +- src/CacheManager.php | 5 -- src/Enums/CacheOperation.php | 2 - src/Planning/CachePlanSpaceValidator.php | 4 ++ src/Planning/CachePlanner.php | 23 +------ src/Spaces/CacheSpaceRegistry.php | 58 +++++----------- src/Support/RedisScanner.php | 67 ++++++++++++++++++ src/Support/RedisStore.php | 15 ++--- src/Values/CacheConfig.php | 4 +- src/Values/CachePlanContext.php | 10 --- tests/Unit/RedisStoreTest.php | 71 +++++++++++--------- tests/Unit/Spaces/CacheSpaceRegistryTest.php | 22 +++++- 12 files changed, 159 insertions(+), 124 deletions(-) diff --git a/config/normcache.php b/config/normcache.php index 3e916be..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). diff --git a/src/CacheManager.php b/src/CacheManager.php index 9fd3547..c0935fe 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -97,11 +97,6 @@ public function isEventsEnabled(): bool return $this->config->dispatchEvents; } - public function enable(): void - { - $this->config->enabled = true; - } - public function disable(): void { $this->config->enabled = false; 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/Planning/CachePlanSpaceValidator.php b/src/Planning/CachePlanSpaceValidator.php index c4819aa..1b0c776 100644 --- a/src/Planning/CachePlanSpaceValidator.php +++ b/src/Planning/CachePlanSpaceValidator.php @@ -54,6 +54,10 @@ public function validate( ); if ($validation->isValid) { + if (!$explain) { + $this->registry->registerTableDependencies($space, $plan->dependencies->tables); + } + return $plan->withSpace($space); } diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index 55c6444..a88f260 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -77,9 +77,7 @@ public function plan( CacheOperation::PaginationCount => $this->planScalarLike($builder, $model, $base, $context, $insideTransaction, $explain, self::SIMPLE_PAGINATION_FAST_PATH_BLOCKERS), CacheOperation::Pivot, CacheOperation::Through => $this->planRelationResult($builder, $base, $context, $insideTransaction, $explain), - CacheOperation::Models, - CacheOperation::BelongsToEagerLoad, - CacheOperation::MorphToEagerLoad => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), + CacheOperation::Models => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), }; return $this->spaceValidator()->validate($plan, $builder, $model, $explain); @@ -237,13 +235,6 @@ private function planModels( && !$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; - } if ($normalizable && $dependencies->safe) { return CachePlan::normalized( @@ -268,12 +259,7 @@ private function planModels( return $this->resultPlan($modelTable, $base, $context, $inspection, $dependencies); } - return $this->bypassPlan( - $context, - $inspection, - $dependencies, - requiresPrimaryKeys: $requiresPrimaryKeys, - ); + return $this->bypassPlan($context, $inspection, $dependencies); } private function planInspectedResult( @@ -485,15 +471,10 @@ 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'; - } - if ($relaxedRelationNormalization) { unset($bypassReasons['normalization']); } diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 3ae7a3d..f2ce56b 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -94,15 +94,11 @@ public function validateDependencies( bool $includeDependenciesBySpace = false, ): SpaceValidationResult { $invalidModels = []; - $invalidTables = []; $dependencySpaces = []; foreach ($models as $modelClass) { $spaces = $this->spacesForModel($modelClass); - - if ($includeDependenciesBySpace) { - $dependencySpaces[$modelClass] = $spaces; - } + $dependencySpaces[$modelClass] = $spaces; if (!$this->isAllowed($spaces, $space)) { $invalidModels[] = $modelClass; @@ -111,30 +107,23 @@ public function validateDependencies( foreach ($tables as $table) { $spaces = $this->spacesForTable($table); + $validatedSpaces = $this->isAllowed($spaces, $space) + ? $spaces + : [...$spaces, $space]; - if (!$this->isAllowed($spaces, $space)) { - $spaces = $this->rememberTableSpace($table, $space); - } - - if ($includeDependenciesBySpace) { - $dependencySpaces[$table] = $spaces; - } - - if (!$this->isAllowed($spaces, $space)) { - $invalidTables[] = $table; - } + $dependencySpaces[$table] = $validatedSpaces; } - $ok = $invalidModels === [] && $invalidTables === []; - $dependenciesBySpace = $includeDependenciesBySpace - ? $this->dependencySpaceNames($dependencySpaces) - : ($ok ? [] : $this->dependencySpaceNamesFor($models, $tables)); + $ok = $invalidModels === []; + $dependenciesBySpace = $ok && !$includeDependenciesBySpace + ? [] + : $this->dependencySpaceNames($dependencySpaces); return new SpaceValidationResult( isValid: $ok, space: $space, invalidModels: $invalidModels, - invalidTables: $invalidTables, + invalidTables: [], dependenciesBySpace: $dependenciesBySpace, ); } @@ -265,6 +254,13 @@ private function rememberSpace(string $name): void } } + public function registerTableDependencies(CacheSpace $space, array $tables): void + { + foreach ($tables as $table) { + $this->rememberTableSpace($table, $space); + } + } + /** @return list */ private function rememberTableSpace(string $table, CacheSpace $space): array { @@ -339,24 +335,4 @@ private function dependencySpaceNames(array $dependencies): array return $names; } - - /** - * @param list $models - * @param list $tables - * @return array> - */ - private function dependencySpaceNamesFor(array $models, array $tables): array - { - $names = []; - - foreach ($models as $modelClass) { - $names[$modelClass] = $this->spaceNames($this->spacesForModel($modelClass)); - } - - foreach ($tables as $table) { - $names[$table] = $this->spaceNames($this->spacesForTable($table)); - } - - return $names; - } } diff --git a/src/Support/RedisScanner.php b/src/Support/RedisScanner.php index 0615115..b0257e7 100644 --- a/src/Support/RedisScanner.php +++ b/src/Support/RedisScanner.php @@ -36,6 +36,40 @@ public function scanPattern(string $pattern): array ); } + 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 = []; @@ -134,6 +168,39 @@ function ($chunk) use (&$keys) { 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)) { diff --git a/src/Support/RedisStore.php b/src/Support/RedisStore.php index 7a2c108..0b2e8e7 100644 --- a/src/Support/RedisStore.php +++ b/src/Support/RedisStore.php @@ -379,18 +379,15 @@ private function groupByHashTag(array $keys): array public function flushByPatterns(array $patterns): int { - $total = 0; + $keys = ($this->scanner ??= new RedisScanner($this->connection))->scanPatterns($patterns); - foreach ($patterns as $pattern) { - $keys = $this->scanPattern($pattern); - - if (!empty($keys)) { - $total += count($keys); - $this->asyncDel($keys); - } + if ($keys === []) { + return 0; } - return $total; + $this->asyncDel($keys); + + return count($keys); } // Runs a Lua script via EVALSHA, falling back to EVAL on NOSCRIPT. All KEYS must diff --git a/src/Values/CacheConfig.php b/src/Values/CacheConfig.php index 71cfc52..2a6638d 100644 --- a/src/Values/CacheConfig.php +++ b/src/Values/CacheConfig.php @@ -3,8 +3,8 @@ namespace NormCache\Values; /** - * Runtime configuration for the cache manager. Mutable so tests and runtime - * toggles (fallback, cooldown experiments) can adjust knobs on the live instance. + * Runtime configuration for the cache manager. Mutable so tests, fallback, + * and cooldown experiments can adjust the live request-scoped instance. */ final class CacheConfig { diff --git a/src/Values/CachePlanContext.php b/src/Values/CachePlanContext.php index 730c08f..9cfe937 100644 --- a/src/Values/CachePlanContext.php +++ b/src/Values/CachePlanContext.php @@ -38,16 +38,6 @@ public static function paginationCount(?DependencySet $inferred = null): self return new self(CacheOperation::PaginationCount, null, $inferred); } - public static function belongsToEagerLoad(array $columns = []): self - { - return new self(CacheOperation::BelongsToEagerLoad, $columns); - } - - public static function morphToEagerLoad(): self - { - return new self(CacheOperation::MorphToEagerLoad); - } - public static function pivot(array $columns = [], ?DependencySet $inferred = null): self { return new self(CacheOperation::Pivot, $columns, $inferred); diff --git a/tests/Unit/RedisStoreTest.php b/tests/Unit/RedisStoreTest.php index b130c18..991e605 100644 --- a/tests/Unit/RedisStoreTest.php +++ b/tests/Unit/RedisStoreTest.php @@ -325,13 +325,43 @@ public function test_flush_by_patterns_works_with_connection_prefix(): void } } - 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) { @@ -340,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:{nc}:test:model:*', 'node-a', null] => ['laravel:{nc}:test:model:{testing:posts}:1'], - ['laravel:{nc}:test:query:*', 'node-b', null] => ['laravel:{nc}:test:query:{testing:posts}:v1:abc'], - default => [], - }; - } - }; + return $this->redisClient; } public function unlink($keys) @@ -381,9 +388,13 @@ public function unlink($keys) $this->assertSame(2, $deleted); $this->assertSame([ - ['{nc}:test:model:{testing:posts}:1'], - ['{nc}: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 diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index 9f2d3be..8454282 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -111,7 +111,7 @@ public function test_tables_start_in_the_default_space(): void $this->assertSame(['default'], array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags'))); } - public function test_table_dependency_is_valid_in_the_active_space(): void + public function test_validating_table_dependency_does_not_mutate_registry(): void { $registry = $this->registry(); $content = $registry->space('content'); @@ -121,7 +121,23 @@ public function test_table_dependency_is_valid_in_the_active_space(): void $this->assertTrue($result->isValid); $this->assertSame([], $result->invalidTables); $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); - $this->assertContains('content', array_map(fn($s) => $s->name, $registry->spacesForTable('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'); + + $registry->registerTableDependencies($content, ['mysql:legacy_flags']); + + $this->assertContains( + 'content', + array_map(fn($s) => $s->name, $registry->spacesForTable('mysql:legacy_flags')), + ); } public function test_single_base_model_dependencies_do_not_need_validation(): void @@ -161,7 +177,7 @@ public function test_validate_dependencies_reports_cross_space_members(): void $registry = $this->registry(); $content = $registry->space('content'); - // Author is default-only; raw table deps are registered into the active space. + // 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); From 50de2da5b57e9a2646f528e1727b75c535835cba Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Mon, 13 Jul 2026 16:58:16 +1000 Subject: [PATCH 62/73] fix distributed cache space invalidation --- src/CacheServiceProvider.php | 2 +- src/Planning/CachePlanSpaceValidator.php | 10 ++- src/Spaces/CacheSpaceRegistry.php | 78 +++++++++++++------- src/Values/SpaceValidationResult.php | 2 - tests/Unit/CacheManagerTest.php | 37 ++++++++-- tests/Unit/Spaces/CacheSpaceRegistryTest.php | 76 ++++++++++++++++++- 6 files changed, 165 insertions(+), 40 deletions(-) diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 77f36a0..88d3260 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -31,7 +31,7 @@ public function register(): void return new CacheSpaceRegistry( maxPerModel: (int) config('normcache.spaces.max_per_model', 16), placement: (array) config('normcache.spaces.placement', []), - metadataStore: $metadataStore->isCluster() ? $metadataStore : null, + metadataStore: $metadataStore, metadataKeyPrefix: (string) config('normcache.key_prefix', ''), ); }); diff --git a/src/Planning/CachePlanSpaceValidator.php b/src/Planning/CachePlanSpaceValidator.php index 1b0c776..ca7c3ba 100644 --- a/src/Planning/CachePlanSpaceValidator.php +++ b/src/Planning/CachePlanSpaceValidator.php @@ -54,14 +54,18 @@ public function validate( ); if ($validation->isValid) { - if (!$explain) { - $this->registry->registerTableDependencies($space, $plan->dependencies->tables); + if (!$explain && !$this->registry->registerTableDependencies($space, $plan->dependencies->tables)) { + return CachePlan::bypass( + operation: $plan->operation, + dependencies: $plan->dependencies, + bypassReasons: ['dependency' => ['failed to register table-space dependencies']], + )->withSpace($space); } return $plan->withSpace($space); } - $offending = implode(', ', [...$validation->invalidModels, ...$validation->invalidTables]); + $offending = implode(', ', $validation->invalidModels); $reason = 'cross-space dependencies for space [' . $space->name . ']: ' . $offending; if (!$explain && $this->debug) { diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index f2ce56b..e313e40 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -26,6 +26,9 @@ final class CacheSpaceRegistry /** @var list|null */ private ?array $metadataSpaceNames = null; + /** @var array hash tag => logical space name */ + private array $hashTagOwners = []; + /** * @param array $placement per-space hash-tag overrides */ @@ -68,7 +71,15 @@ public function spacesForModel(string $modelClass): array /** @return list */ public function spacesForTable(string $table): array { - return $this->tableSpaces[$table] ??= $this->resolveTableSpaces($table); + $spaces = $this->tableSpaces[$table] ?? [$this->defaultSpace()]; + + foreach ($this->resolveTableSpaces($table) as $space) { + if (!$this->isAllowed($spaces, $space)) { + $spaces[] = $space; + } + } + + return $this->tableSpaces[$table] = $spaces; } public function modelAllowedInSpace(string $modelClass, CacheSpace|string $space): bool @@ -123,7 +134,6 @@ public function validateDependencies( isValid: $ok, space: $space, invalidModels: $invalidModels, - invalidTables: [], dependenciesBySpace: $dependenciesBySpace, ); } @@ -162,7 +172,17 @@ private function resolveTableSpaces(string $table): array private function materializeSpace(string $name, bool $remember = true): CacheSpace { if (!isset($this->spaces[$name])) { - $this->spaces[$name] = new CacheSpace($name, $this->hashTagFor($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); @@ -184,9 +204,9 @@ private function hashTagFor(string $name): string $override = $this->placement[$name]['hash_tag'] ?? null; if ($override !== null) { - if ($override === '' || preg_match('/[{}]/', $override)) { + if ($override === '' || preg_match('/[{}*?\[\]\\\\]/', $override)) { throw new \InvalidArgumentException( - "Invalid hash_tag override [{$override}] for space [{$name}]: must be non-empty and contain no '{' or '}'." + "Invalid hash_tag override [{$override}] for space [{$name}]: must be non-empty and contain no Redis pattern characters." ); } @@ -200,7 +220,7 @@ private function hashTagFor(string $name): string private function validSpaceName(string $name): bool { - return $name !== '' && !preg_match('/[:{}\s]/', $name); + return $name !== '' && !preg_match('/[:{}\s*?\[\]\\\\]/', $name); } /** @return list */ @@ -231,14 +251,10 @@ private function metadataTableSpaces(string $table): array return []; } - try { - return array_values(array_filter( - $this->metadataStore->setMembers($this->metadataTableSpacesKey($table)), - fn(string $name) => $this->validSpaceName($name), - )); - } catch (\Throwable) { - return []; - } + return array_values(array_filter( + $this->metadataStore->setMembers($this->metadataTableSpacesKey($table)), + fn(string $name) => $this->validSpaceName($name), + )); } private function rememberSpace(string $name): void @@ -254,37 +270,49 @@ private function rememberSpace(string $name): void } } - public function registerTableDependencies(CacheSpace $space, array $tables): void + public function registerTableDependencies(CacheSpace $space, array $tables): bool { foreach ($tables as $table) { - $this->rememberTableSpace($table, $space); + if (!$this->rememberTableSpace($table, $space)) { + return false; + } } + + return true; } - /** @return list */ - private function rememberTableSpace(string $table, CacheSpace $space): array + private function rememberTableSpace(string $table, CacheSpace $space): bool { $spaces = $this->spacesForTable($table); - if (!$this->isAllowed($spaces, $space)) { - $spaces[] = $space; - $this->tableSpaces[$table] = $spaces; - $this->persistTableSpace($table, $space); + if ($this->isAllowed($spaces, $space)) { + return true; } - return $spaces; + if (!$this->persistTableSpace($table, $space)) { + return false; + } + + $spaces[] = $space; + $this->tableSpaces[$table] = $spaces; + + return true; } - private function persistTableSpace(string $table, CacheSpace $space): void + private function persistTableSpace(string $table, CacheSpace $space): bool { if ($this->metadataStore === null || $space->name === self::DEFAULT_SPACE) { - return; + return true; } try { $this->metadataStore->addToSet($this->metadataTableSpacesKey($table), [$space->name]); + + return true; } catch (\Throwable $e) { report($e); + + return false; } } diff --git a/src/Values/SpaceValidationResult.php b/src/Values/SpaceValidationResult.php index 3828398..651ce35 100644 --- a/src/Values/SpaceValidationResult.php +++ b/src/Values/SpaceValidationResult.php @@ -8,14 +8,12 @@ { /** * @param list $invalidModels - * @param list $invalidTables * @param array> $dependenciesBySpace */ public function __construct( public bool $isValid, public CacheSpace $space, public array $invalidModels = [], - public array $invalidTables = [], public array $dependenciesBySpace = [], ) {} } diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 5960e1f..a2f0497 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -249,17 +249,42 @@ public function test_flush_all_uses_wildcard_hash_tag_patterns_on_standalone_aft $this->assertEmpty($store->scanPattern('{nc:content}:test:*')); } - public function test_standalone_space_resolution_does_not_write_registry_metadata(): void + public function test_table_space_registration_survives_a_fresh_registry_instance(): void { - if (env('REDIS_CLUSTER') === 'true' || env('REDIS_CLUSTER') === true) { - $this->markTestSkipped('Redis Cluster mode intentionally writes space registry metadata for flush discovery.'); - } + $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->app->make(CacheSpaceRegistry::class)->spacesForModel(SpacedPost::class); + $this->assertContains( + 'content', + array_map(fn($space) => $space->name, $freshRegistry->spacesForTable($tableKey)), + ); + } + + public function test_table_space_lookup_refreshes_mappings_registered_by_another_registry(): void + { + $tableKey = 'testing:authors'; + $registry = $this->app->make(CacheSpaceRegistry::class); - $this->assertSame([], $this->manager->store()->scanPattern('{nc:meta}:test:spaces')); + $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->assertContains( + 'content', + array_map(fn($space) => $space->name, $registry->spacesForTable($tableKey)), + ); } public function test_flush_all_can_target_one_space(): void diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index 8454282..4eae112 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -3,11 +3,15 @@ namespace NormCache\Tests\Unit\Spaces; use Illuminate\Database\Eloquent\Model; +use Illuminate\Redis\Connections\PredisConnection; use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Support\RedisStore; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\UnitTestCase; use NormCache\Traits\Cacheable; +use ReflectionProperty; +use RuntimeException; class CacheSpaceRegistryTest extends UnitTestCase { @@ -16,6 +20,14 @@ private function registry(int $maxPerModel = 16): CacheSpaceRegistry return new CacheSpaceRegistry($maxPerModel); } + private function registryWithConnection(PredisConnection $connection): CacheSpaceRegistry + { + $store = new RedisStore('normcache-test'); + (new ReflectionProperty(RedisStore::class, 'connection'))->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(); @@ -68,6 +80,18 @@ public function test_invalid_space_name_throws(): void $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(); @@ -119,7 +143,6 @@ public function test_validating_table_dependency_does_not_mutate_registry(): voi $result = $registry->validateDependencies($content, [], ['mysql:legacy_flags'], includeDependenciesBySpace: true); $this->assertTrue($result->isValid); - $this->assertSame([], $result->invalidTables); $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); $this->assertSame( ['default'], @@ -132,7 +155,7 @@ public function test_table_dependencies_can_be_registered_after_plan_acceptance( $registry = $this->registry(); $content = $registry->space('content'); - $registry->registerTableDependencies($content, ['mysql:legacy_flags']); + $this->assertTrue($registry->registerTableDependencies($content, ['mysql:legacy_flags'])); $this->assertContains( 'content', @@ -140,6 +163,54 @@ public function test_table_dependencies_can_be_registered_after_plan_acceptance( ); } + 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_is_not_treated_as_default_only(): void + { + $connection = new class extends PredisConnection + { + public function __construct() {} + + public function command($method, array $parameters = []) + { + if (strtolower($method) === 'smembers') { + throw new RuntimeException('SMEMBERS denied'); + } + + return null; + } + }; + $registry = $this->registryWithConnection($connection); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('SMEMBERS denied'); + + $registry->spacesForTable('mysql:legacy_flags'); + } + public function test_single_base_model_dependencies_do_not_need_validation(): void { $registry = $this->registry(); @@ -182,7 +253,6 @@ public function test_validate_dependencies_reports_cross_space_members(): void $this->assertFalse($result->isValid); $this->assertSame([Author::class], $result->invalidModels); - $this->assertSame([], $result->invalidTables); $this->assertSame(['default'], $result->dependenciesBySpace[Author::class]); $this->assertSame(['content'], $result->dependenciesBySpace[SpacedPost::class]); $this->assertContains('content', $result->dependenciesBySpace['mysql:legacy_flags']); From 2b6edad7f64d94e9391106deaa129cd232638162 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Tue, 14 Jul 2026 11:56:37 +1000 Subject: [PATCH 63/73] restore cache recovery lifecycle and fail-open table space lookup --- src/CacheManager.php | 5 +++ src/CacheServiceProvider.php | 17 +++++++-- src/Spaces/CacheSpaceRegistry.php | 14 +++++-- .../Invalidation/ModelInvalidationTest.php | 17 +++++++++ tests/Unit/CacheManagerTest.php | 38 +++++++++++++++++++ tests/Unit/Spaces/CacheSpaceRegistryTest.php | 24 ++++++++---- 6 files changed, 100 insertions(+), 15 deletions(-) diff --git a/src/CacheManager.php b/src/CacheManager.php index c0935fe..9fd3547 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -97,6 +97,11 @@ public function isEventsEnabled(): bool return $this->config->dispatchEvents; } + public function enable(): void + { + $this->config->enabled = true; + } + public function disable(): void { $this->config->enabled = false; diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 88d3260..07608da 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -82,14 +82,23 @@ public function boot(): void $this->app->make(CacheManager::class)->discardPending($event->connection->getName()); } }); - Event::listen(JobProcessed::class, CacheKeyBuilder::reset(...)); - Event::listen(Looping::class, CacheKeyBuilder::reset(...)); - // Reset static metadata between Octane requests and tasks. + $resetManager = function () { + CacheKeyBuilder::reset(); + + $manager = $this->app->make(CacheManager::class); + $manager->discardAllPending(); + $manager->enable(); + }; + + Event::listen(JobProcessed::class, $resetManager); + Event::listen(Looping::class, $resetManager); + + // Reset request-scoped runtime state between Octane requests and tasks. foreach (['RequestReceived', 'TaskReceived'] as $event) { $octaneEvent = "Laravel\\Octane\\Events\\$event"; if (class_exists($octaneEvent)) { - Event::listen($octaneEvent, CacheKeyBuilder::reset(...)); + Event::listen($octaneEvent, $resetManager); } } diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index e313e40..41ecd82 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -251,10 +251,16 @@ private function metadataTableSpaces(string $table): array return []; } - return array_values(array_filter( - $this->metadataStore->setMembers($this->metadataTableSpacesKey($table)), - fn(string $name) => $this->validSpaceName($name), - )); + try { + return 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 diff --git a/tests/Integration/Invalidation/ModelInvalidationTest.php b/tests/Integration/Invalidation/ModelInvalidationTest.php index 207109b..2925cd1 100644 --- a/tests/Integration/Invalidation/ModelInvalidationTest.php +++ b/tests/Integration/Invalidation/ModelInvalidationTest.php @@ -2,7 +2,10 @@ namespace NormCache\Tests\Integration\Invalidation; +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; @@ -38,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']); diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index a2f0497..cc7ca66 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -2,12 +2,15 @@ namespace NormCache\Tests\Unit; +use Illuminate\Contracts\Queue\Job; +use Illuminate\Queue\Events\JobProcessed; use Illuminate\Queue\Events\Looping; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Redis; use NormCache\CacheManager; use NormCache\CacheManagerFactory; use NormCache\Spaces\CacheSpaceRegistry; +use NormCache\Support\CacheFallback; use NormCache\Support\CacheKeyBuilder; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; @@ -66,6 +69,41 @@ public function test_container_scopes_cache_manager_to_the_current_lifecycle(): $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); diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index 4eae112..34acfd6 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -188,27 +188,37 @@ public function command($method, array $parameters = []) ); } - public function test_failed_table_space_lookup_is_not_treated_as_default_only(): void + 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') { + if (strtolower($method) !== 'smembers') { + return null; + } + + if ($this->lookups++ === 0) { throw new RuntimeException('SMEMBERS denied'); } - return null; + return ['content']; } }; $registry = $this->registryWithConnection($connection); - $this->expectException(RuntimeException::class); - $this->expectExceptionMessage('SMEMBERS denied'); - - $registry->spacesForTable('mysql:legacy_flags'); + $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_single_base_model_dependencies_do_not_need_validation(): void From eeef2e29256dd6c42ccd18e88f02f7ee4769a092 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:49:21 +1000 Subject: [PATCH 64/73] make cache identities connection-aware --- src/Cache/ModelCacheRepository.php | 20 ++- src/Cache/ModelHydrator.php | 13 +- src/Cache/ModelsExecutor.php | 5 +- src/Cache/ResultCacheRepository.php | 19 ++- src/Cache/ResultExecutor.php | 8 +- src/Cache/VersionTracker.php | 9 +- src/Cache/VersionedCacheRepository.php | 33 +++- src/CacheManager.php | 4 +- src/Support/CacheKeyBuilder.php | 31 ++-- src/Traits/HandlesInvalidation.php | 26 +-- .../Cache/ConnectionAwareCachingTest.php | 152 ++++++++++++++++++ tests/Unit/CacheKeyBuilderTest.php | 9 ++ 12 files changed, 271 insertions(+), 58 deletions(-) create mode 100644 tests/Integration/Cache/ConnectionAwareCachingTest.php diff --git a/src/Cache/ModelCacheRepository.php b/src/Cache/ModelCacheRepository.php index 3d185fe..d763aff 100644 --- a/src/Cache/ModelCacheRepository.php +++ b/src/Cache/ModelCacheRepository.php @@ -17,12 +17,16 @@ public function __construct( private readonly CacheConfig $config, ) {} - public function store(string $modelClass, array $modelAttrs, ?CacheSpace $space = null): void - { + public function store( + string $modelClass, + array $modelAttrs, + ?CacheSpace $space = null, + ?string $connection = null, + ): void { $space ??= $this->keys->activeSpace(); - $modelVersion = $this->versions->currentVersion($modelClass, $space); + $modelVersion = $this->versions->currentVersion($modelClass, $space, $connection); - $this->storeForVersion($modelClass, $modelAttrs, $modelVersion, $space); + $this->storeForVersion($modelClass, $modelAttrs, $modelVersion, $space, $connection); } public function storeForBuild( @@ -30,15 +34,16 @@ public function storeForBuild( array $modelAttrs, BuildHandle $build, ?CacheSpace $space = null, + ?string $connection = null, ): void { - $classKey = $this->keys->classKey($modelClass); + $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); + $this->storeForVersion($modelClass, $modelAttrs, (int) $build->expectedVersions[$index], $space, $connection); } public function storeForVersion( @@ -46,12 +51,13 @@ public function storeForVersion( array $modelAttrs, int $expectedVersion, ?CacheSpace $space = null, + ?string $connection = null, ): void { if (empty($modelAttrs)) { return; } - $classKey = $this->keys->classKey($modelClass); + $classKey = $this->keys->classKey($modelClass, $connection); $attrsByKey = []; foreach ($modelAttrs as $id => $attrs) { diff --git a/src/Cache/ModelHydrator.php b/src/Cache/ModelHydrator.php index f2149ff..e680335 100644 --- a/src/Cache/ModelHydrator.php +++ b/src/Cache/ModelHydrator.php @@ -49,12 +49,14 @@ public function getModels( // 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; // 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()); + $modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace(), $connection); $raw = $this->store->getMany($this->modelKeysFor($classKey, $modelVersion, $ids)); } else { $modelVersion = 0; // deferred @@ -84,7 +86,7 @@ classKey: $classKey, } if ($versionDeferred) { - $context->modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace()); + $context->modelVersion = $this->versions->currentVersion($modelClass, $this->keys->activeSpace(), $connection); } if ($reporting) { @@ -391,7 +393,10 @@ private function overridesNewFromBuilder(Model $model): bool 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) { if ($missedQuery !== null) { $builder->withoutGlobalScopes($missedQuery->removedScopes()); diff --git a/src/Cache/ModelsExecutor.php b/src/Cache/ModelsExecutor.php index 7049108..9e09dac 100644 --- a/src/Cache/ModelsExecutor.php +++ b/src/Cache/ModelsExecutor.php @@ -49,10 +49,11 @@ 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::queries()->fetch($model, $hash, $cacheTag, $depClasses, $depTableKeys), - waitForBuild: fn() => NormCache::queries()->waitForBuild($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']); diff --git a/src/Cache/ResultCacheRepository.php b/src/Cache/ResultCacheRepository.php index bbc0f73..495a9ac 100644 --- a/src/Cache/ResultCacheRepository.php +++ b/src/Cache/ResultCacheRepository.php @@ -26,11 +26,17 @@ public function __construct( public function fetch( string $modelClass, array $depClasses, string $hash, ?string $tag, array $depTableKeys, - string $namespace = CacheKeyBuilder::K_RESULT + string $namespace = CacheKeyBuilder::K_RESULT, + ?string $connection = null, ): ResultCacheResult { - $classKey = $this->keys->classKey($modelClass); + $classKey = $this->keys->classKey($modelClass, $connection); $lockSuffix = $this->keys->resultBuildIdentityHash($namespace, $tag, $hash); - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); + [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs( + $classKey, + $depClasses, + $depTableKeys, + connection: $connection, + ); $wakeKey = $this->keys->wakeKey($classKey, $lockSuffix); $lockToken = $this->versions->buildLockToken(); @@ -236,13 +242,14 @@ public function storeEntry( public function waitForBuild( string $modelClass, array $depClasses, string $hash, ?string $tag, array $depTableKeys, - string $namespace = CacheKeyBuilder::K_RESULT + string $namespace = CacheKeyBuilder::K_RESULT, + ?string $connection = null, ): ?ResultCacheResult { - $classKey = $this->keys->classKey($modelClass); + $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); + $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 953bfee..c71de36 100644 --- a/src/Cache/ResultExecutor.php +++ b/src/Cache/ResultExecutor.php @@ -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(); @@ -43,8 +45,8 @@ public function execute( $execution = CacheFallback::rescue( $this->config, fn() => $this->engine->runScalar( - fetch: fn() => $this->results->fetch($modelClass, $depClasses, $hash, $tag, $depTableKeys, $namespace), - waitForBuild: fn() => $this->results->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]); diff --git a/src/Cache/VersionTracker.php b/src/Cache/VersionTracker.php index 2e83ec2..08d8574 100644 --- a/src/Cache/VersionTracker.php +++ b/src/Cache/VersionTracker.php @@ -13,10 +13,13 @@ public function __construct( private readonly CacheKeyBuilder $keys, ) {} - public function currentVersion(string $modelClass, ?CacheSpace $space = null): int - { + public function currentVersion( + string $modelClass, + ?CacheSpace $space = null, + ?string $connection = null, + ): int { return $this->normalizeVersion( - $this->fetchVersionWithCooldown($this->keys->classKey($modelClass), $space) + $this->fetchVersionWithCooldown($this->keys->classKey($modelClass, $connection), $space) ); } diff --git a/src/Cache/VersionedCacheRepository.php b/src/Cache/VersionedCacheRepository.php index ff0d1d8..b5d6b16 100644 --- a/src/Cache/VersionedCacheRepository.php +++ b/src/Cache/VersionedCacheRepository.php @@ -40,10 +40,21 @@ abstract protected function buildResult( ): QueryCacheResult|ThroughCacheResult; /** @return TResult */ - public function fetch(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult|ThroughCacheResult - { - $classKey = $this->keys->classKey($modelClass); - [$versionKeys, $scheduledKeys] = $this->keys->depKeyPairs($classKey, $depClasses, $depTableKeys); + 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(); @@ -89,12 +100,18 @@ public function fetch(string $modelClass, string $hash, ?string $tag, array $dep } /** @return TResult|null */ - public function waitForBuild(string $modelClass, string $hash, ?string $tag, array $depClasses, array $depTableKeys): QueryCacheResult|ThroughCacheResult|null - { - $classKey = $this->keys->classKey($modelClass); + 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); + $result = $this->fetch($modelClass, $hash, $tag, $depClasses, $depTableKeys, $connection); return $result->status === CacheStatus::Building ? null : $result; } diff --git a/src/CacheManager.php b/src/CacheManager.php index 9fd3547..8d40719 100644 --- a/src/CacheManager.php +++ b/src/CacheManager.php @@ -144,9 +144,9 @@ public function withSpaceForModel(string $modelClass, ?string $explicitSpace, ca return $this->withSpace($this->spaceFor($modelClass, $explicitSpace), $callback); } - public function currentVersion(string $modelClass): int + public function currentVersion(string $modelClass, ?string $connection = null): int { - return $this->versions->currentVersion($modelClass, $this->modelSpaces($modelClass)[0]); + return $this->versions->currentVersion($modelClass, $this->modelSpaces($modelClass)[0], $connection); } public function currentTableVersion(string $connectionName, string $table): int diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 9e69e0d..d208b48 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -127,9 +127,16 @@ public function wakePrefix(string $classKey, ?CacheSpace $space = null): string // 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. @@ -232,8 +239,13 @@ public function versionsFromSegment(string $seg): array /** * @return array{0: list, 1: list} [versionKeys, scheduledKeys] */ - public function depKeyPairs(string $classKey, array $depClasses, array $depTableKeys = [], ?CacheSpace $space = null): array - { + public function depKeyPairs( + string $classKey, + array $depClasses, + array $depTableKeys = [], + ?CacheSpace $space = null, + ?string $connection = null, + ): array { $space ??= $this->activeSpace; if ($depClasses === [] && $depTableKeys === []) { @@ -251,8 +263,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; @@ -311,10 +323,9 @@ private function singleDepPairCacheKey(string $classKey, ?CacheSpace $space): st return $this->keyPrefix . '|' . $this->tagPrefix($space) . '|' . $classKey; } - private function resolveClassKey(string $class): string + private function resolveClassKey(string $class, string $connection): string { $model = self::prototype($class); - $connection = $model->getConnectionName() ?? DB::getDefaultConnection(); if (str_contains($connection, ':')) { throw new \InvalidArgumentException( @@ -325,9 +336,9 @@ private function resolveClassKey(string $class): string 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/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index c63e348..7a96095 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -50,8 +50,8 @@ public function invalidateVersion(Model $model): void $this->queueOrRun( $conn, - fn() => $this->queueVersionFlush($conn, $this->keys->classKey($model::class), $this->modelSpaces($model::class)), - fn() => $this->doInvalidateVersion($model::class), + fn() => $this->queueVersionFlush($conn, $this->keys->classKey($model::class, $conn), $this->modelSpaces($model::class)), + fn() => $this->doInvalidateVersion($model::class, $conn), ); } @@ -69,7 +69,7 @@ public function flushModel(Model|string $model): void $this->queueOrRun( $conn, fn() => $this->queueModelFlush($conn, $modelClass), - fn() => $this->forceFlushModel($modelClass), + fn() => $this->forceFlushModel($modelClass, $conn), ); } @@ -121,9 +121,9 @@ function () use ($classKey, $spaces) { ); } - public function forceFlushModel(string $modelClass): void + public function forceFlushModel(string $modelClass, ?string $connectionName = null): void { - $classKey = $this->keys->classKey($modelClass); + $classKey = $this->keys->classKey($modelClass, $connectionName); foreach ($this->modelSpaces($modelClass) as $space) { $this->store->incrementAndExpire($this->keys->verKey($classKey, $space), $this->versionTtl()); // bypass cooldown @@ -212,12 +212,12 @@ public function invalidateMultipleVersions(array $modelClasses, ?string $connect $conn, function () use ($conn, $classes) { foreach ($classes as $modelClass) { - $this->queueVersionFlush($conn, $this->keys->classKey($modelClass), $this->modelSpaces($modelClass)); + $this->queueVersionFlush($conn, $this->keys->classKey($modelClass, $conn), $this->modelSpaces($modelClass)); } }, - function () use ($classes) { + function () use ($classes, $conn) { foreach ($classes as $modelClass) { - $this->doInvalidateVersion($modelClass); + $this->doInvalidateVersion($modelClass, $conn); } }, ); @@ -237,12 +237,12 @@ public function commitPending(string $connectionName): void CacheFallback::attempt( $this->config, - function () use ($flushes, $versions) { + function () use ($connectionName, $flushes, $versions) { foreach ($flushes as $modelClass) { - $this->forceFlushModel($modelClass); + $this->forceFlushModel($modelClass, $connectionName); } - $flushClassKeys = array_map(fn($class) => $this->keys->classKey($class), $flushes); + $flushClassKeys = array_map(fn($class) => $this->keys->classKey($class, $connectionName), $flushes); foreach ($versions as $classKey => $spaces) { if (!in_array($classKey, $flushClassKeys, true)) { @@ -278,9 +278,9 @@ private function queueOrRun(?string $connectionName, callable $queue, callable $ CacheFallback::attempt($this->config, $immediate); } - private function doInvalidateVersion(string $modelClass): void + private function doInvalidateVersion(string $modelClass, ?string $connectionName = null): void { - $classKey = $this->keys->classKey($modelClass); + $classKey = $this->keys->classKey($modelClass, $connectionName); foreach ($this->modelSpaces($modelClass) as $space) { $this->doInvalidateKey($classKey, $space); 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/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index d794d7a..2669a6e 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -4,6 +4,7 @@ use Illuminate\Database\Eloquent\Model; use NormCache\Support\CacheKeyBuilder; +use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\UnitTestCase; use NormCache\Values\CacheSpace; @@ -27,6 +28,14 @@ public function test_null_space_keeps_the_default_tag(): void $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_class_key_rejects_connection_name_containing_colon(): void { $model = new class extends Model From 5b38a2f59c1297ac62f337a0cd9e913c6b688599 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Tue, 14 Jul 2026 12:54:53 +1000 Subject: [PATCH 65/73] table space memo --- src/CacheServiceProvider.php | 1 + src/Spaces/CacheSpaceRegistry.php | 50 +++++++++++++++----- src/Traits/HandlesInvalidation.php | 37 ++++++++++++--- tests/Unit/CacheManagerTest.php | 38 ++++++++++++++- tests/Unit/Spaces/CacheSpaceRegistryTest.php | 32 +++++++++++++ 5 files changed, 139 insertions(+), 19 deletions(-) diff --git a/src/CacheServiceProvider.php b/src/CacheServiceProvider.php index 07608da..0117202 100644 --- a/src/CacheServiceProvider.php +++ b/src/CacheServiceProvider.php @@ -85,6 +85,7 @@ public function boot(): void $resetManager = function () { CacheKeyBuilder::reset(); + $this->app->make(CacheSpaceRegistry::class)->resetMetadataMemo(); $manager = $this->app->make(CacheManager::class); $manager->discardAllPending(); diff --git a/src/Spaces/CacheSpaceRegistry.php b/src/Spaces/CacheSpaceRegistry.php index 41ecd82..e706e69 100644 --- a/src/Spaces/CacheSpaceRegistry.php +++ b/src/Spaces/CacheSpaceRegistry.php @@ -26,6 +26,9 @@ final class CacheSpaceRegistry /** @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 = []; @@ -71,15 +74,19 @@ public function spacesForModel(string $modelClass): array /** @return list */ public function spacesForTable(string $table): array { - $spaces = $this->tableSpaces[$table] ?? [$this->defaultSpace()]; + return $this->mergeTableSpaces($table, $this->resolveTableSpaces($table)); + } - foreach ($this->resolveTableSpaces($table) as $space) { - if (!$this->isAllowed($spaces, $space)) { - $spaces[] = $space; - } - } + /** @return list */ + public function freshSpacesForTable(string $table): array + { + return $this->mergeTableSpaces($table, $this->resolveTableSpaces($table, fresh: true)); + } - return $this->tableSpaces[$table] = $spaces; + public function resetMetadataMemo(): void + { + $this->metadataSpaceNames = null; + $this->metadataTableSpaceNames = []; } public function modelAllowedInSpace(string $modelClass, CacheSpace|string $space): bool @@ -159,16 +166,33 @@ private function resolveModelSpaces(string $modelClass): array } /** @return list */ - private function resolveTableSpaces(string $table): array + private function resolveTableSpaces(string $table, bool $fresh = false): array { $names = array_values(array_unique([ self::DEFAULT_SPACE, - ...$this->metadataTableSpaces($table), + ...$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])) { @@ -245,14 +269,18 @@ private function metadataSpaces(): array } /** @return list */ - private function metadataTableSpaces(string $table): array + 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 array_values(array_filter( + return $this->metadataTableSpaceNames[$table] = array_values(array_filter( $this->metadataStore->setMembers($this->metadataTableSpacesKey($table)), fn(string $name) => $this->validSpaceName($name), )); diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 7a96095..6a03d81 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -20,6 +20,9 @@ trait HandlesInvalidation /** @var array>> conn => classKey => spaces */ private array $versionQueue = []; + /** @var array> conn => table classKey => true */ + private array $tableVersionQueue = []; + /** @return list the spaces a model's version key lives in */ private function modelSpaces(string $modelClass): array { @@ -29,7 +32,7 @@ private function modelSpaces(string $modelClass): array /** @return list the spaces a table's version key lives in */ private function tableInvalidationSpaces(string $tableKey): array { - return $this->spaceRegistry->spacesForTable($tableKey); + return $this->spaceRegistry->freshSpacesForTable($tableKey); } /** @return list */ @@ -89,7 +92,7 @@ public function invalidateTableVersion(string $connectionName, string $table): v $this->queueOrRun( $connectionName, - fn() => $this->queueVersionFlush($connectionName, $classKey, $this->tableInvalidationSpaces($classKey)), + fn() => $this->queueTableVersionFlush($connectionName, $classKey), fn() => $this->doInvalidateTable($classKey), ); } @@ -228,42 +231,57 @@ public function commitPending(string $connectionName): void { $flushes = array_keys($this->flushQueue[$connectionName] ?? []); $versions = $this->versionQueue[$connectionName] ?? []; + $tables = array_keys($this->tableVersionQueue[$connectionName] ?? []); - unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName]); + unset( + $this->flushQueue[$connectionName], + $this->versionQueue[$connectionName], + $this->tableVersionQueue[$connectionName], + ); - if ((empty($flushes) && empty($versions)) || !$this->isEnabled()) { + if ((empty($flushes) && empty($versions) && empty($tables)) || !$this->isEnabled()) { return; } CacheFallback::attempt( $this->config, - function () use ($connectionName, $flushes, $versions) { + function () use ($connectionName, $flushes, $versions, $tables) { foreach ($flushes as $modelClass) { $this->forceFlushModel($modelClass, $connectionName); } $flushClassKeys = array_map(fn($class) => $this->keys->classKey($class, $connectionName), $flushes); + $tableClassKeys = array_fill_keys($tables, true); foreach ($versions as $classKey => $spaces) { - if (!in_array($classKey, $flushClassKeys, true)) { + if (!in_array($classKey, $flushClassKeys, true) && !isset($tableClassKeys[$classKey])) { foreach ($spaces as $space) { $this->doInvalidateKey($classKey, $space); } } } + + foreach ($tables as $classKey) { + $this->doInvalidateTable($classKey); + } }, ); } public function discardPending(string $connectionName): void { - unset($this->flushQueue[$connectionName], $this->versionQueue[$connectionName]); + unset( + $this->flushQueue[$connectionName], + $this->versionQueue[$connectionName], + $this->tableVersionQueue[$connectionName], + ); } public function discardAllPending(): void { $this->flushQueue = []; $this->versionQueue = []; + $this->tableVersionQueue = []; } // Private — invalidation internals @@ -380,4 +398,9 @@ private function queueVersionFlush(string $connectionName, string $classKey, arr $this->versionQueue[$connectionName][$classKey] = array_values($queued); } + + private function queueTableVersionFlush(string $connectionName, string $classKey): void + { + $this->tableVersionQueue[$connectionName][$classKey] = true; + } } diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index cc7ca66..228f64d 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -303,7 +303,7 @@ public function test_table_space_registration_survives_a_fresh_registry_instance ); } - public function test_table_space_lookup_refreshes_mappings_registered_by_another_registry(): void + public function test_lifecycle_listener_refreshes_table_space_mappings_registered_by_another_registry(): void { $tableKey = 'testing:authors'; $registry = $this->app->make(CacheSpaceRegistry::class); @@ -319,12 +319,48 @@ public function test_table_space_lookup_refreshes_mappings_registered_by_another ); $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_flush_all_can_target_one_space(): void { $store = $this->manager->store(); diff --git a/tests/Unit/Spaces/CacheSpaceRegistryTest.php b/tests/Unit/Spaces/CacheSpaceRegistryTest.php index 34acfd6..bca05c3 100644 --- a/tests/Unit/Spaces/CacheSpaceRegistryTest.php +++ b/tests/Unit/Spaces/CacheSpaceRegistryTest.php @@ -221,6 +221,38 @@ public function command($method, array $parameters = []) ); } + 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(); From ffe2e9fe811cc7b5e9e44f7c9b8b3775ffbcea41 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:02:57 +1000 Subject: [PATCH 66/73] remove deadcode --- src/Cache/ResultExecutor.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Cache/ResultExecutor.php b/src/Cache/ResultExecutor.php index c71de36..e7cbfa7 100644 --- a/src/Cache/ResultExecutor.php +++ b/src/Cache/ResultExecutor.php @@ -54,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); From 195280bbca9a39130c2c93c38ae9a5eb1541f4ff Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:09:59 +1000 Subject: [PATCH 67/73] separate space resolution bypass reasons --- src/Events/QueryBypassed.php | 2 +- src/Planning/BypassReasons.php | 2 + src/Planning/CachePlanSpaceValidator.php | 4 +- .../Integration/Cache/SpaceResolutionTest.php | 3 +- .../Planning/SilentBypassLogTest.php | 15 +++++++ tests/Unit/CachePlanSpaceValidatorTest.php | 42 ++++++++++++++++++- 6 files changed, 63 insertions(+), 5 deletions(-) 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/Planning/BypassReasons.php b/src/Planning/BypassReasons.php index b2cc5cf..56e27d2 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> @@ -93,6 +94,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 index ca7c3ba..41d5548 100644 --- a/src/Planning/CachePlanSpaceValidator.php +++ b/src/Planning/CachePlanSpaceValidator.php @@ -58,7 +58,7 @@ public function validate( return CachePlan::bypass( operation: $plan->operation, dependencies: $plan->dependencies, - bypassReasons: ['dependency' => ['failed to register table-space dependencies']], + bypassReasons: ['space' => ['failed to register table-space dependencies']], )->withSpace($space); } @@ -83,7 +83,7 @@ public function validate( return CachePlan::bypass( operation: $plan->operation, dependencies: $plan->dependencies, - bypassReasons: ['dependency' => [$reason]], + bypassReasons: ['space' => [$reason]], )->withSpace($space); } } diff --git a/tests/Integration/Cache/SpaceResolutionTest.php b/tests/Integration/Cache/SpaceResolutionTest.php index 150a2f2..f1f77fd 100644 --- a/tests/Integration/Cache/SpaceResolutionTest.php +++ b/tests/Integration/Cache/SpaceResolutionTest.php @@ -37,7 +37,8 @@ public function test_cross_space_dependency_bypasses(): void $this->assertFalse($plan->isCacheable(), 'SpacedPost(content) depending on Author(default) must bypass'); $this->assertSame(CacheOperation::Models, $plan->operation); - $this->assertTrue($plan->hasBypassReason('dependency')); + $this->assertTrue($plan->hasBypassReason('space')); + $this->assertFalse($plan->hasBypassReason('dependency')); } public function test_same_space_dependency_still_caches(): void 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/Unit/CachePlanSpaceValidatorTest.php b/tests/Unit/CachePlanSpaceValidatorTest.php index ee6b505..0645b19 100644 --- a/tests/Unit/CachePlanSpaceValidatorTest.php +++ b/tests/Unit/CachePlanSpaceValidatorTest.php @@ -2,16 +2,20 @@ namespace NormCache\Tests\Unit; +use Illuminate\Redis\Connections\PredisConnection; use NormCache\Enums\CacheOperation; use NormCache\Enums\CacheStrategy; use NormCache\Planning\CachePlanSpaceValidator; use NormCache\Spaces\CacheSpaceRegistry; use NormCache\Spaces\CacheSpaceResolver; +use NormCache\Support\RedisStore; use NormCache\Tests\Fixtures\Models\CatalogTag; use NormCache\Tests\Fixtures\Models\SpacedPost; use NormCache\Tests\UnitTestCase; use NormCache\Values\CachePlan; use NormCache\Values\DependencySet; +use ReflectionProperty; +use RuntimeException; class CachePlanSpaceValidatorTest extends UnitTestCase { @@ -29,7 +33,43 @@ public function test_cross_space_dependencies_bypass_with_the_resolved_space(): $this->assertSame(CacheStrategy::LiveQuery, $validated->strategy); $this->assertSame('content', $validated->space?->name); - $this->assertStringContainsString(CatalogTag::class, $validated->bypassReasons['dependency'][0]); + $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 From baf010647d74324910b0de044b2a41716e23c650 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Tue, 14 Jul 2026 13:16:41 +1000 Subject: [PATCH 68/73] switch to xxh128 --- src/Cache/VersionTracker.php | 2 +- src/Support/CacheKeyBuilder.php | 2 +- src/Support/QueryHasher.php | 2 +- tests/Unit/Cache/VersionTrackerSpaceTest.php | 15 +++++++++++++++ tests/Unit/CacheKeyBuilderTest.php | 9 +++++++++ tests/Unit/QueryHasherTest.php | 4 ++-- 6 files changed, 29 insertions(+), 5 deletions(-) diff --git a/src/Cache/VersionTracker.php b/src/Cache/VersionTracker.php index 08d8574..bafba59 100644 --- a/src/Cache/VersionTracker.php +++ b/src/Cache/VersionTracker.php @@ -32,7 +32,7 @@ public function currentTableVersion(string $connectionName, string $table, ?Cach public function buildLockToken(): string { - return hash('xxh3', microtime(true) . mt_rand()); + return bin2hex(random_bytes(16)); } public function normalizeVersion(mixed $value = null): int diff --git a/src/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index d208b48..618cdfd 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -311,7 +311,7 @@ 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); } // ------------------------------------------------------------------------- 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/tests/Unit/Cache/VersionTrackerSpaceTest.php b/tests/Unit/Cache/VersionTrackerSpaceTest.php index 6e6d7b2..7f81a60 100644 --- a/tests/Unit/Cache/VersionTrackerSpaceTest.php +++ b/tests/Unit/Cache/VersionTrackerSpaceTest.php @@ -26,4 +26,19 @@ public function test_current_version_reads_the_space_scoped_version_key(): void // 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 index 2669a6e..e4f1a00 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -97,4 +97,13 @@ public function test_key_methods_emit_full_keys_with_hash_tag_and_prefix(): void $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/QueryHasherTest.php b/tests/Unit/QueryHasherTest.php index 4991359..b5b0f33 100644 --- a/tests/Unit/QueryHasherTest.php +++ b/tests/Unit/QueryHasherTest.php @@ -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 From 4f40f248987a70e63928547925d6df565cef0c7e Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Tue, 14 Jul 2026 14:18:05 +1000 Subject: [PATCH 69/73] simplify planner query lifecycle and relation tracking --- src/Cache/ModelsExecutor.php | 19 +++--- src/CacheableBuilder.php | 73 +++------------------- src/Planning/CachePlanner.php | 67 +++++--------------- src/Relations/CachesOneOrManyThrough.php | 4 +- src/Relations/CachesPivotRelation.php | 5 +- src/Relations/CachesRelationAggregates.php | 41 +++++------- src/Relations/CachesRelationExistence.php | 48 +++++--------- src/Traits/CachesScalarResults.php | 2 +- src/Values/PreparedQuery.php | 26 ++++++++ tests/Unit/CacheableBuilderPlanTest.php | 2 +- 10 files changed, 96 insertions(+), 191 deletions(-) diff --git a/src/Cache/ModelsExecutor.php b/src/Cache/ModelsExecutor.php index 9e09dac..1137c4f 100644 --- a/src/Cache/ModelsExecutor.php +++ b/src/Cache/ModelsExecutor.php @@ -23,7 +23,7 @@ public function runDirect( ): Collection { $executionBuilder = $prepared->builder; - return $executionBuilder->finalizeResult(NormCache::hydrator()->getModels( + return $prepared->finalizeModels(NormCache::hydrator()->getModels( $primaryKeys, $model, $selectedCols, @@ -31,7 +31,7 @@ public function runDirect( $executionBuilder, false, $prototype - ), $prepared); + )); } public function runNormalized( @@ -57,9 +57,8 @@ public function runNormalized( onBuild: function () use ($prepared, $executionBuilder, $base, $model, $selectedCols, $debugbarStart, $prototype) { CacheReporter::queryMiss($model, 'building:budget-exhausted', $debugbarStart, ['kind' => 'ids']); - return $executionBuilder->finalizeResult( - NormCache::hydrator()->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) { @@ -73,9 +72,8 @@ public function runNormalized( $result->build, ); - return $executionBuilder->finalizeResult( - NormCache::hydrator()->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) { @@ -85,9 +83,8 @@ public function runNormalized( 'contains_model' => $result->ids, ]); - return $executionBuilder->finalizeResult( - NormCache::hydrator()->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) ); }, ); diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index 915debe..33f3da2 100644 --- a/src/CacheableBuilder.php +++ b/src/CacheableBuilder.php @@ -232,7 +232,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(); @@ -252,12 +252,12 @@ public function get($columns = ['*']): Collection CacheStrategy::DirectModels => CacheFallback::rescue( NormCache::config(), fn() => $this->modelsExecutor()->runDirect($prepared, $plan->primaryKeys, $model, $plan->columns, $this->model), - fn() => $this->collectFromPrepared($prepared, $columns), + fn() => $prepared->collect($columns), ), CacheStrategy::NormalizedQuery => CacheFallback::rescue( NormCache::config(), fn() => $this->modelsExecutor()->runNormalized($prepared, $plan, $model, $plan->columns, $this->cacheTag, $this->queryTtl, $debugbarStart, $this->model), - fn() => $this->collectFromPrepared($prepared, $columns) + fn() => $prepared->collect($columns) ), CacheStrategy::VersionedResult => $this->executeResultQuery($prepared, $plan, $columns), CacheStrategy::LiveQuery => $this->bypassAndReturn($model, $plan->bypassReasons, $debugbarStart, $prepared, $columns), @@ -278,16 +278,14 @@ private function executeResultQuery( $columns, function () use ($prepared, $columns) { if ($this->hasAggregateColumns()) { - $rawModels = $this->collectFromPrepared($prepared, $columns, false); - - return $this->resultPayloadFromEloquentModels($rawModels); + return $this->resultPayloadFromEloquentModels($prepared->collect($columns, false)); } - return $this->buildResultPayloadFromQuery($prepared->baseWithColumns($columns)); + 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( @@ -299,7 +297,7 @@ private function bypassAndReturn( ): Collection { CacheReporter::queryBypassed($model, $bypassReasons, $debugbarStart); - return $this->collectFromPrepared($prepared, $columns); + return $prepared->collect($columns); } public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $page = null, $total = null): LengthAwarePaginator @@ -390,39 +388,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::hydrator()->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(); @@ -436,30 +401,6 @@ public function prepareScopedQuery(): PreparedQuery return new PreparedQuery($builder, $builder->getQuery()); } - public function collectFromPrepared( - PreparedQuery $prepared, - array $columns = ['*'], - bool $applyAfterCallbacks = true, - ?\Closure $beforeEagerLoad = null, - ): Collection { - $builder = $prepared->builder; - $models = $builder->getModels($columns); - - if ($beforeEagerLoad !== null) { - $beforeEagerLoad($models); - } - - if (count($models) > 0) { - $models = $builder->eagerLoadRelations($models); - } - - $collection = $builder->getModel()->newCollection($models); - - return $applyAfterCallbacks - ? $prepared->applyAfterCallbacks($collection) - : $collection; - } - // ------------------------------------------------------------------------- // Internal // ------------------------------------------------------------------------- diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index a88f260..eeafdbf 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -48,7 +48,7 @@ public function inferPreparedDependencies(CacheableBuilder $builder, QueryBuilde ? $this->analyzer->inferJoinDependencies($base, $builder->getModel()->getConnection()->getName()) : DependencySet::empty(); - return $builder->inferAggregateDependencies()->merge($joinDeps); + return $builder->inferRelationDependencies()->merge($joinDeps); } public function plan( @@ -73,10 +73,10 @@ public function plan( } $plan = match ($context->operation) { - CacheOperation::Scalar => $this->planScalarLike($builder, $model, $base, $context, $insideTransaction, $explain, self::SIMPLE_RESULT_FAST_PATH_BLOCKERS), - CacheOperation::PaginationCount => $this->planScalarLike($builder, $model, $base, $context, $insideTransaction, $explain, self::SIMPLE_PAGINATION_FAST_PATH_BLOCKERS), + CacheOperation::Scalar => $this->planInspectedResult($builder, $base, $context, $insideTransaction, $explain, self::SIMPLE_RESULT_FAST_PATH_BLOCKERS), + CacheOperation::PaginationCount => $this->planInspectedResult($builder, $base, $context, $insideTransaction, $explain, self::SIMPLE_PAGINATION_FAST_PATH_BLOCKERS), CacheOperation::Pivot, - CacheOperation::Through => $this->planRelationResult($builder, $base, $context, $insideTransaction, $explain), + CacheOperation::Through => $this->planInspectedResult($builder, $base, $context, $insideTransaction, $explain, strictRelation: true), CacheOperation::Models => $this->planModels($builder, $model, $base, $context, $insideTransaction, $explain), }; @@ -132,52 +132,6 @@ private function transactionBypass( ); } - private function planScalarLike( - CacheableBuilder $builder, - Model $model, - QueryBuilder $base, - CachePlanContext $context, - bool $insideTransaction, - bool $explain, - int $simpleBypassFlags, - ): CachePlan { - $hasExplicit = $builder->hasExplicitDependencies(); - - if (!$explain && !$insideTransaction && !$hasExplicit && $context->contextReasons === []) { - $plan = $this->trySimpleResultPlan($model, $base, $context, $simpleBypassFlags); - - if ($plan !== null) { - return $plan; - } - } - - return $this->planInspectedResult( - builder: $builder, - base: $base, - context: $context, - insideTransaction: $insideTransaction, - explain: $explain, - scalarLike: true, - ); - } - - private function planRelationResult( - CacheableBuilder $builder, - QueryBuilder $base, - CachePlanContext $context, - bool $insideTransaction, - bool $explain, - ): CachePlan { - return $this->planInspectedResult( - builder: $builder, - base: $base, - context: $context, - insideTransaction: $insideTransaction, - explain: $explain, - strictRelation: true, - ); - } - private function planModels( CacheableBuilder $builder, Model $model, @@ -268,7 +222,7 @@ private function planInspectedResult( CachePlanContext $context, bool $insideTransaction, bool $explain, - bool $scalarLike = false, + ?int $simpleBypassFlags = null, bool $strictRelation = false, ): CachePlan { $model = $builder->getModel(); @@ -279,6 +233,15 @@ private function planInspectedResult( $explicitTables = $builder->explicitTableDependencies(); $hasExplicit = $builder->hasExplicitDependencies(); + if ($simpleBypassFlags !== null + && !$explain + && !$insideTransaction + && !$hasExplicit + && $context->contextReasons === [] + && ($plan = $this->trySimpleResultPlan($model, $base, $context, $simpleBypassFlags)) !== null) { + return $plan; + } + $inspection = $this->inspect($model, $base, $context, collectTables: $explain); $dependencies = $this->dependencies->resolve( $modelClass, @@ -293,7 +256,7 @@ private function planInspectedResult( return $bypass; } - if ($scalarLike + if ($simpleBypassFlags !== null && !$hasExplicit && $inferred->hasNoDependencies() && $dependencies->safe diff --git a/src/Relations/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 66fd3c5..2f55be6 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -167,7 +167,7 @@ private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?Cach $plan = $builder->cachePlan($base, CachePlanContext::through( $projection ?? [], - $builder->inferAggregateDependencies(), + $builder->inferRelationDependencies(), DependencySet::singleModel($this->throughParent::class), )); @@ -236,6 +236,6 @@ private function hydrateFromIds( private function getFromPreparedBuilder(PreparedQuery $prepared, bool $applyAfterCallbacks = true): Collection { - return $prepared->builder->collectFromPrepared($prepared, applyAfterCallbacks: $applyAfterCallbacks); + return $prepared->collect(applyAfterCallbacks: $applyAfterCallbacks); } } diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index 10be966..e420091 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -184,7 +184,7 @@ private function shouldUsePivotCache( $plan = $builder->cachePlan($base, CachePlanContext::pivot( $resolvedColumns ?? [], - $builder->inferAggregateDependencies() + $builder->inferRelationDependencies() )); if (!$plan->usesResultCache()) { @@ -334,8 +334,7 @@ private function getFromPreparedPivotBuilder( PreparedQuery $prepared, bool $applyAfterCallbacks = true, ): Collection { - return $prepared->builder->collectFromPrepared( - $prepared, + return $prepared->collect( applyAfterCallbacks: $applyAfterCallbacks, beforeEagerLoad: fn(array $models) => $this->hydratePivotRelation($models), ); diff --git a/src/Relations/CachesRelationAggregates.php b/src/Relations/CachesRelationAggregates.php index ab7b4f2..cf43d4e 100644 --- a/src/Relations/CachesRelationAggregates.php +++ b/src/Relations/CachesRelationAggregates.php @@ -10,13 +10,9 @@ trait CachesRelationAggregates { - private bool $cacheAggregates = true; + private ?bool $aggregateCaching = true; - private bool $aggregateInferenceFailed = false; - - private array $aggregateDependencies = []; - - private array $aggregateTableDependencies = []; + private ?DependencySet $aggregateDependencies = null; private array $aggregateAliases = []; @@ -29,7 +25,7 @@ public function withoutAggregateCache(): static public function withAggregate($relations, $column, $function = null): static { - if (!$this->cacheAggregates) { + if ($this->aggregateCaching !== true) { return parent::withAggregate($relations, $column, $function); } @@ -54,7 +50,7 @@ public function withAggregate($relations, $column, $function = null): static if ($dependency === null) { $result = parent::withAggregate($relations, $column, $function); - $this->clearAggregateTracking(true); + $this->clearAggregateTracking(null); return $result; } @@ -73,8 +69,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])); + $resolved = new DependencySet( + models: array_values(array_unique($dependencies)), + tables: array_values(array_unique($tableDependencies)), + ); + $this->aggregateDependencies = ($this->aggregateDependencies ?? DependencySet::empty())->merge($resolved); $this->aggregateAliases = array_values(array_unique([...$this->aggregateAliases, ...$aliases])); return $result; @@ -97,12 +96,10 @@ 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 + private function clearAggregateTracking(?bool $state = false): void { - $this->cacheAggregates = false; - $this->aggregateInferenceFailed = $failed; - $this->aggregateDependencies = []; - $this->aggregateTableDependencies = []; + $this->aggregateCaching = $state; + $this->aggregateDependencies = null; $this->aggregateAliases = []; } @@ -117,16 +114,12 @@ private function classifyAggregate( return (new RelationDependencyClassifier)->classify($this->model->{$name}(), $constraint); } - public function inferAggregateDependencies(): DependencySet + public function inferRelationDependencies(): 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, - ), + $aggregate = match ($this->aggregateCaching) { + null => DependencySet::unsafe('Aggregate dependencies could not be inferred.'), + false => DependencySet::empty(), + true => $this->aggregateDependencies ?? DependencySet::empty(), }; return $aggregate->merge($this->inferExistenceDependencies()); diff --git a/src/Relations/CachesRelationExistence.php b/src/Relations/CachesRelationExistence.php index 654e94e..4ab0a8a 100644 --- a/src/Relations/CachesRelationExistence.php +++ b/src/Relations/CachesRelationExistence.php @@ -17,29 +17,24 @@ */ trait CachesRelationExistence { - private int $totalHasCalls = 0; + private ?DependencySet $existenceDependencies = null; - private int $simpleHasCalls = 0; - - private array $existenceDependencies = []; - - private array $existenceTableDependencies = []; + private bool $existenceInferenceFailed = false; 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, '.')) { - $dependency = $this->classifyExistenceRelation($relation, $callback); - - if ($dependency !== null) { - $this->simpleHasCalls++; - array_push($this->existenceDependencies, ...$dependency->modelDependencies()); - array_push($this->existenceTableDependencies, ...$dependency->tableDependencies()); - } + $dependency = is_string($relation) && !str_contains($relation, '.') + ? $this->classifyExistenceRelation($relation, $callback) + : null; + + if ($dependency === null) { + $this->existenceInferenceFailed = true; + } else { + $resolved = new DependencySet( + models: $dependency->modelDependencies(), + tables: $dependency->tableDependencies(), + ); + $this->existenceDependencies = ($this->existenceDependencies ?? DependencySet::empty())->merge($resolved); } return parent::has($relation, $operator, $count, $boolean, $callback); @@ -47,18 +42,9 @@ public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ? public function inferExistenceDependencies(): DependencySet { - if ($this->totalHasCalls === 0) { - return DependencySet::empty(); - } - - if ($this->totalHasCalls !== $this->simpleHasCalls) { - return DependencySet::unsafe('whereHas/has dependencies could not be fully inferred.'); - } - - return new DependencySet( - models: array_values(array_unique($this->existenceDependencies)), - tables: array_values(array_unique($this->existenceTableDependencies)), - ); + return $this->existenceInferenceFailed + ? DependencySet::unsafe('whereHas/has dependencies could not be fully inferred.') + : ($this->existenceDependencies ?? DependencySet::empty()); } private function classifyExistenceRelation(string $name, ?callable $constraint): ?RelationDependency diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index d12212d..e95b3b9 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -131,7 +131,7 @@ private function cacheScalar( return $fallback(); } - if (($kind === ResultKind::Pluck || $kind === ResultKind::Value) && $this->hasAfterQueryCallbacks()) { + if (($kind === ResultKind::Pluck || $kind === ResultKind::Value) && $this->afterQueryCallbacks !== []) { return $fallback(); } diff --git a/src/Values/PreparedQuery.php b/src/Values/PreparedQuery.php index da8f2df..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; @@ -36,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/tests/Unit/CacheableBuilderPlanTest.php b/tests/Unit/CacheableBuilderPlanTest.php index 8c7f7a9..fcbf750 100644 --- a/tests/Unit/CacheableBuilderPlanTest.php +++ b/tests/Unit/CacheableBuilderPlanTest.php @@ -24,7 +24,7 @@ public function test_plan_prepared_matches_direct_plan(): void $direct = $builder->cachePlan($prepared->base, CachePlanContext::models( ProjectionClassifier::resolve($prepared->base, ['*']), - $builder->inferAggregateDependencies(), + $builder->inferRelationDependencies(), selectAll: true, )); From 118c98cc8d18217c167e245fa40c19c1e5b9a5a8 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:32:24 +1000 Subject: [PATCH 70/73] refactor table-first cache planning and invalidation --- src/CacheableBuilder.php | 194 ++++++++++++++- src/Planning/BypassReasons.php | 8 +- src/Planning/CachePlanner.php | 125 +++++----- src/Planning/DependencyResolver.php | 106 ++------- src/Planning/QueryAnalyzer.php | 187 +++++++++++++-- src/Relations/CachesOneOrManyThrough.php | 1 - src/Relations/CachesPivotRelation.php | 5 +- src/Relations/CachesRelationAggregates.php | 81 ++----- src/Relations/CachesRelationExistence.php | 85 +++---- .../RelationDependencyClassifier.php | 112 --------- src/Support/CacheKeyBuilder.php | 7 +- src/Traits/CachesScalarResults.php | 6 +- src/Traits/HandlesInvalidation.php | 86 +++++-- src/Values/CachePlanContext.php | 28 +-- src/Values/QueryInspection.php | 22 +- src/Values/RelationDependency.php | 40 ---- .../Cache/CacheableBuilderTest.php | 47 +++- .../Cache/CrossTableDependencySafetyTest.php | 8 +- .../Integration/Cache/ThroughRelationTest.php | 1 + .../Integration/Cache/WhereHasCachingTest.php | 22 +- .../TransactionInvalidationTest.php | 13 + .../Planning/QueryDependencyPlanningTest.php | 171 +++++++++++++ tests/Integration/RelationOverrideTest.php | 4 +- tests/Unit/CacheKeyBuilderTest.php | 8 + tests/Unit/CacheManagerTest.php | 112 +++++++++ tests/Unit/CachePlanSpaceValidatorTest.php | 6 +- tests/Unit/CachePlannerTest.php | 225 +++--------------- tests/Unit/CacheableBuilderPlanTest.php | 42 ++-- tests/Unit/QueryAnalyzerTest.php | 75 +++--- .../RelationDependencyClassifierTest.php | 53 ----- 30 files changed, 1048 insertions(+), 832 deletions(-) delete mode 100644 src/Relations/RelationDependencyClassifier.php delete mode 100644 src/Values/RelationDependency.php create mode 100644 tests/Integration/Planning/QueryDependencyPlanningTest.php delete mode 100644 tests/Unit/Relations/RelationDependencyClassifierTest.php diff --git a/src/CacheableBuilder.php b/src/CacheableBuilder.php index 33f3da2..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; @@ -30,6 +32,7 @@ use NormCache\Values\CachePlanContext; use NormCache\Values\DependencySet; use NormCache\Values\PreparedQuery; +use NormCache\Values\QueryInspection; class CacheableBuilder extends Builder { @@ -49,6 +52,16 @@ class CacheableBuilder extends Builder private ?string $cacheSpace = null; + private ?DependencySet $capturedDependencies = null; + + private array $capturedContextReasons = []; + + private int $capturedOpaqueJoins = 0; + + private bool $capturedOpaqueFrom = false; + + private int $capturedOpaqueWhereSubqueries = 0; + // ------------------------------------------------------------------------- // Configuration // ------------------------------------------------------------------------- @@ -155,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; @@ -176,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; @@ -193,9 +371,8 @@ public function getCacheTag(): ?string public function explain(): string { $prepared = $this->prepareCacheExecution(); - $plan = $this->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::models( + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::models( ProjectionClassifier::resolve($prepared->base, ['*']), - $inferred, selectAll: true, ), PlanningMode::Explain); @@ -242,9 +419,8 @@ public function get($columns = ['*']): Collection $model = $this->model::class; $base = $prepared->base; - $plan = $this->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::models( + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::models( ProjectionClassifier::resolve($base, $columns), - $inferred, selectAll: $columns === ['*'], )); @@ -309,7 +485,7 @@ public function paginate($perPage = null, $columns = ['*'], $pageName = 'page', $debugbarStart = CacheReporter::beginMeasure(); $prepared = $this->prepareCacheExecution(); - $plan = $this->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::paginationCount($inferred)); + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::paginationCount()); if ($plan->strategy === CacheStrategy::LiveQuery) { CacheReporter::queryBypassed($this->model::class, $plan->bypassReasons, $debugbarStart); @@ -413,16 +589,16 @@ public function cachePlan( return $this->planner()->plan($this, $base, $context, $planningMode); } - /** @param \Closure(DependencySet): CachePlanContext $context */ + /** @param Closure(): CachePlanContext $context */ public function planPrepared( PreparedQuery $prepared, - \Closure $context, + Closure $context, PlanningMode $mode = PlanningMode::Hot, ): CachePlan { $builder = $prepared->builder; $base = $prepared->base; - return $builder->cachePlan($base, $context($builder->planner()->inferPreparedDependencies($builder, $base)), $mode); + return $builder->cachePlan($base, $context(), $mode); } public function planner(): CachePlanner diff --git a/src/Planning/BypassReasons.php b/src/Planning/BypassReasons.php index 56e27d2..9273cf7 100644 --- a/src/Planning/BypassReasons.php +++ b/src/Planning/BypassReasons.php @@ -41,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)'; } @@ -81,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 diff --git a/src/Planning/CachePlanner.php b/src/Planning/CachePlanner.php index eeafdbf..8ced440 100644 --- a/src/Planning/CachePlanner.php +++ b/src/Planning/CachePlanner.php @@ -42,13 +42,9 @@ public function __construct( private readonly ?CachePlanSpaceValidator $spaceValidator = null, ) {} - public function inferPreparedDependencies(CacheableBuilder $builder, QueryBuilder $base): DependencySet + public function analyzer(): QueryAnalyzer { - $joinDeps = !empty($base->joins) - ? $this->analyzer->inferJoinDependencies($base, $builder->getModel()->getConnection()->getName()) - : DependencySet::empty(); - - return $builder->inferRelationDependencies()->merge($joinDeps); + return $this->analyzer; } public function plan( @@ -72,13 +68,21 @@ public function plan( return $this->transactionBypass($builder, $model, $context); } - $plan = match ($context->operation) { - CacheOperation::Scalar => $this->planInspectedResult($builder, $base, $context, $insideTransaction, $explain, self::SIMPLE_RESULT_FAST_PATH_BLOCKERS), - CacheOperation::PaginationCount => $this->planInspectedResult($builder, $base, $context, $insideTransaction, $explain, self::SIMPLE_PAGINATION_FAST_PATH_BLOCKERS), - CacheOperation::Pivot, - CacheOperation::Through => $this->planInspectedResult($builder, $base, $context, $insideTransaction, $explain, strictRelation: true), - CacheOperation::Models => $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); } @@ -137,25 +141,17 @@ private function planModels( 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, - softDeleteScopeColumn: $this->activeSoftDeleteScopeColumn($builder, $model), - ); - if ($this->qualifiesForDirectModels($explain, $insideTransaction, $hasExplicit, $context, $inferred, $inspection)) { return CachePlan::direct( operation: $context->operation, @@ -166,7 +162,7 @@ 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) @@ -180,15 +176,15 @@ 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']); + && !isset($inspection->contextReasons['normalization']); if ($normalizable && $dependencies->safe) { return CachePlan::normalized( @@ -199,10 +195,8 @@ private function planModels( ); } - if ($dependencies->safe && $this->hasResultDependencies($context, $hasExplicit)) { + if ($dependencies->safe && $this->hasResultDependencies($inspection, $hasExplicit)) { if ($this->requiresExplicitSelectForJoinResult($builder, $base, $context)) { - $this->dependencies->warnUnderDeclared($modelTable, $base, $inspection, $dependencies); - return CachePlan::bypass( operation: $context->operation, dependencies: $dependencies, @@ -210,7 +204,7 @@ private function planModels( ); } - return $this->resultPlan($modelTable, $base, $context, $inspection, $dependencies); + return $this->resultPlan($context, $inspection, $dependencies); } return $this->bypassPlan($context, $inspection, $dependencies); @@ -220,6 +214,7 @@ private function planInspectedResult( CacheableBuilder $builder, QueryBuilder $base, CachePlanContext $context, + QueryInspection $inspection, bool $insideTransaction, bool $explain, ?int $simpleBypassFlags = null, @@ -228,7 +223,7 @@ private function planInspectedResult( $model = $builder->getModel(); $modelClass = $model::class; $modelTable = $model->getTable(); - $inferred = $context->inferredDependencies; + $inferred = $inspection->dependencies; $explicitModels = $builder->explicitDependencies(); $explicitTables = $builder->explicitTableDependencies(); $hasExplicit = $builder->hasExplicitDependencies(); @@ -237,12 +232,11 @@ private function planInspectedResult( && !$explain && !$insideTransaction && !$hasExplicit - && $context->contextReasons === [] - && ($plan = $this->trySimpleResultPlan($model, $base, $context, $simpleBypassFlags)) !== null) { + && $inspection->contextReasons === [] + && ($plan = $this->trySimpleResultPlan($model, $context, $inspection, $simpleBypassFlags)) !== null) { return $plan; } - $inspection = $this->inspect($model, $base, $context, collectTables: $explain); $dependencies = $this->dependencies->resolve( $modelClass, $context, @@ -269,7 +263,7 @@ private function planInspectedResult( } $normalizationFlags = $inspection->normalizationFlags(); - $hasContextNormalizationBypass = isset($context->contextReasons['normalization']); + $hasContextNormalizationBypass = isset($inspection->contextReasons['normalization']); if ($strictRelation && $normalizationFlags === QueryInspection::JOIN @@ -279,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( @@ -294,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; } @@ -315,19 +307,30 @@ 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(), ); } @@ -353,7 +356,7 @@ private function qualifiesForDirectModels( return !$explain && !$insideTransaction && !$hasExplicit - && $context->contextReasons === [] + && $inspection->contextReasons === [] && $inferred->safe && $inferred->hasNoDependencies() && $inspection->primaryKeys !== null @@ -381,12 +384,11 @@ private function safetyBypass( bool $insideTransaction, ): ?CachePlan { if (!$insideTransaction - && !$inspection->hasSafetyBypass() - && !isset($context->contextReasons['safety'])) { + && !$inspection->hasSafetyBypass()) { return null; } - $bypassReasons = $this->mergedBypassReasons($context, $inspection, $insideTransaction); + $bypassReasons = $this->mergedBypassReasons($inspection, $insideTransaction); return CachePlan::bypass( operation: $context->operation, @@ -407,21 +409,16 @@ 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, ): CachePlan { - $this->dependencies->warnUnderDeclared($modelTable, $base, $inspection, $dependencies); - return CachePlan::result( operation: $context->operation, dependencies: $dependencies, @@ -436,7 +433,7 @@ private function bypassPlan( DependencySet $dependencies, bool $relaxedRelationNormalization = false, ): CachePlan { - $bypassReasons = $this->mergedBypassReasons($context, $inspection); + $bypassReasons = $this->mergedBypassReasons($inspection); if ($relaxedRelationNormalization) { unset($bypassReasons['normalization']); @@ -457,17 +454,11 @@ private function bypassPlan( } 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 index de1e730..55f90bf 100644 --- a/src/Planning/DependencyResolver.php +++ b/src/Planning/DependencyResolver.php @@ -3,38 +3,27 @@ namespace NormCache\Planning; use Illuminate\Database\Eloquent\Model; -use Illuminate\Database\Query\Builder as QueryBuilder; -use Illuminate\Support\Facades\Log; use NormCache\CacheableBuilder; use NormCache\Values\CachePlanContext; use NormCache\Values\DependencySet; use NormCache\Values\QueryInspection; -/** - * Resolves the models/tables a cached query depends on, and (in debug) warns on - * tables the caller failed to declare. Split from CachePlanner for isolated - * testing and as the seam cache-spaces validates dependencies against. - */ final class DependencyResolver { - public function __construct( - private readonly QueryAnalyzer $analyzer = new QueryAnalyzer, - ) {} - public function resolve( string $modelClass, CachePlanContext $context, - ?QueryInspection $inspection, + QueryInspection $inspection, ?array $explicitModels, array $explicitTables, bool $hasExplicit, ): DependencySet { - $inferred = $context->inferredDependencies; + $inferred = $inspection->dependencies; $required = $context->requiredDependencies; if ($hasExplicit) { return new DependencySet( - models: array_keys(array_flip([ + models: array_values(array_unique([ $modelClass, ...$inferred->models, ...$required->models, @@ -50,21 +39,13 @@ public function resolve( ); } - $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']) + if ($inspection->hasDependencyBypass() + || isset($inspection->contextReasons['dependency']) || !$inferred->safe || !$required->safe) { return DependencySet::unsafe(array_values(array_unique([ - ...($inspection !== null ? BypassReasons::fromInspection($inspection)['dependency'] ?? [] : []), - ...($context->contextReasons['dependency'] ?? []), + ...(BypassReasons::fromInspection($inspection)['dependency'] ?? []), + ...($inspection->contextReasons['dependency'] ?? []), ...$inferred->reasons, ...$required->reasons, ]))); @@ -75,7 +56,7 @@ public function resolve( } return new DependencySet( - models: array_keys(array_flip([ + models: array_values(array_unique([ $modelClass, ...$inferred->models, ...$required->models, @@ -87,70 +68,25 @@ public function resolve( ); } - // Dependencies for plans made without query inspection (global/transaction bypasses). public function resolveBase( CacheableBuilder $builder, Model $model, CachePlanContext $context, ): DependencySet { - return $this->resolve( - $model::class, - $context, - null, - $builder->explicitDependencies(), - $builder->explicitTableDependencies(), - $builder->hasExplicitDependencies(), - ); - } - - public function warnUnderDeclared( - 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, - ); - } + $required = $context->requiredDependencies; - 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 + 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, ); - - // 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." - ); - } } } diff --git a/src/Planning/QueryAnalyzer.php b/src/Planning/QueryAnalyzer.php index 0f606ce..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,15 +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, $softDeleteScopeColumn), - tables: $includeTables ? $this->extractTables($base, $table) : null, + dependencies: $dependencies, + contextReasons: $contextReasons, ); } @@ -104,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); } } @@ -112,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'); + } + + if ($primaryTable !== null) { + unset($tables[$this->keys->tableKey($connection, $primaryTable)]); + } + + return new DependencySet(tables: array_keys($tables)); + } - foreach ($base->joins as $join) { - if (!is_string($join->table)) { - return DependencySet::empty(); + /** @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 (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++; + } + } + + if ($opaqueJoins > $capturedOpaqueJoins) { + return 'joined subquery dependency could not be inferred'; + } + + foreach ($base->wheres as $where) { + $query = $where['query'] ?? null; + + if ($query instanceof QueryBuilder + && ($reason = $this->collectQueryTables($query, $connection, $tables))) { + return $reason; } + } - if ($this->joinTableHasImplicitAlias($join->table)) { - return DependencySet::unsafe('join table alias could not be inferred'); + foreach ($base->unions ?? [] as $union) { + $query = $union['query'] ?? null; + + if (!$query instanceof QueryBuilder) { + return 'union dependency could not be inferred'; } - $tables[] = NormCache::keys()->tableKey($connection, self::stripAlias($join->table)); + if ($reason = $this->collectQueryTables($query, $connection, $tables)) { + return $reason; + } } - return new DependencySet(tables: array_values(array_unique($tables))); + 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 @@ -155,11 +295,6 @@ 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, @@ -242,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/CachesOneOrManyThrough.php b/src/Relations/CachesOneOrManyThrough.php index 2f55be6..1907b7d 100644 --- a/src/Relations/CachesOneOrManyThrough.php +++ b/src/Relations/CachesOneOrManyThrough.php @@ -167,7 +167,6 @@ private function shouldUseCache(CacheableBuilder $builder, Builder $base): ?Cach $plan = $builder->cachePlan($base, CachePlanContext::through( $projection ?? [], - $builder->inferRelationDependencies(), DependencySet::singleModel($this->throughParent::class), )); diff --git a/src/Relations/CachesPivotRelation.php b/src/Relations/CachesPivotRelation.php index e420091..7b90db9 100644 --- a/src/Relations/CachesPivotRelation.php +++ b/src/Relations/CachesPivotRelation.php @@ -182,10 +182,7 @@ private function shouldUsePivotCache( return false; } - $plan = $builder->cachePlan($base, CachePlanContext::pivot( - $resolvedColumns ?? [], - $builder->inferRelationDependencies() - )); + $plan = $builder->cachePlan($base, CachePlanContext::pivot($resolvedColumns ?? [])); if (!$plan->usesResultCache()) { return false; diff --git a/src/Relations/CachesRelationAggregates.php b/src/Relations/CachesRelationAggregates.php index cf43d4e..e35713c 100644 --- a/src/Relations/CachesRelationAggregates.php +++ b/src/Relations/CachesRelationAggregates.php @@ -5,38 +5,41 @@ use Illuminate\Database\Eloquent\Collection; use Illuminate\Support\Arr; use Illuminate\Support\Str; -use NormCache\Values\DependencySet; -use NormCache\Values\RelationDependency; trait CachesRelationAggregates { - private ?bool $aggregateCaching = true; + private bool $aggregateCaching = true; - private ?DependencySet $aggregateDependencies = null; + 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->aggregateCaching !== true) { + $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); @@ -46,22 +49,18 @@ public function withAggregate($relations, $column, $function = null): static $names[] = $name; - $dependency = $this->classifyAggregate($name, $constraint); - - if ($dependency === null) { - $result = parent::withAggregate($relations, $column, $function); - $this->clearAggregateTracking(null); - - return $result; + if (str_contains($name, '.')) { + $this->addCapturedContextReason('dependency', 'nested aggregate relation semantics could not be fully verified'); + } else { + $this->captureRelationSemantics($name); } + } - array_push($dependencies, ...$dependency->modelDependencies()); - array_push($tableDependencies, ...$dependency->tableDependencies()); + 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 = []; @@ -69,18 +68,11 @@ public function withAggregate($relations, $column, $function = null): static $aliases[] = $this->resolveAlias($col, $names[$i] ?? null, $function, $column); } - $resolved = new DependencySet( - models: array_values(array_unique($dependencies)), - tables: array_values(array_unique($tableDependencies)), - ); - $this->aggregateDependencies = ($this->aggregateDependencies ?? DependencySet::empty())->merge($resolved); $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(); @@ -96,35 +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 $state = false): void - { - $this->aggregateCaching = $state; - $this->aggregateDependencies = null; - $this->aggregateAliases = []; - } - - private function classifyAggregate( - string $name, - ?callable $constraint, - ): ?RelationDependency { - if (str_contains($name, '.')) { - return null; - } - - return (new RelationDependencyClassifier)->classify($this->model->{$name}(), $constraint); - } - - public function inferRelationDependencies(): DependencySet - { - $aggregate = match ($this->aggregateCaching) { - null => DependencySet::unsafe('Aggregate dependencies could not be inferred.'), - false => DependencySet::empty(), - true => $this->aggregateDependencies ?? DependencySet::empty(), - }; - - return $aggregate->merge($this->inferExistenceDependencies()); - } - public function hasAggregateColumns(): bool { return $this->aggregateAliases !== []; @@ -132,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 4ab0a8a..fc7df80 100644 --- a/src/Relations/CachesRelationExistence.php +++ b/src/Relations/CachesRelationExistence.php @@ -3,65 +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\Values\RelationDependency; +use NormCache\Traits\Cacheable; -/** - * @mixin CacheableBuilder - */ +/** @mixin CacheableBuilder */ trait CachesRelationExistence { - private ?DependencySet $existenceDependencies = null; - - private bool $existenceInferenceFailed = false; - public function has($relation, $operator = '>=', $count = 1, $boolean = 'and', ?Closure $callback = null): static { - $dependency = is_string($relation) && !str_contains($relation, '.') - ? $this->classifyExistenceRelation($relation, $callback) - : null; - - if ($dependency === null) { - $this->existenceInferenceFailed = true; + if (!is_string($relation) || str_contains($relation, '.')) { + $this->addCapturedContextReason('dependency', 'whereHas/has relation semantics could not be fully verified'); } else { - $resolved = new DependencySet( - models: $dependency->modelDependencies(), - tables: $dependency->tableDependencies(), - ); - $this->existenceDependencies = ($this->existenceDependencies ?? DependencySet::empty())->merge($resolved); + $this->captureRelationSemantics($relation); + } + + if (!($operator === '>=' && $count === 1) + && !($operator === '<' && $count === 1)) { + $this->addCapturedContextReason('dependency', 'has() count threshold requires explicit dependencies'); + } + + if ($callback !== null) { + $constraint = $callback; + $callback = function ($query) use ($constraint) { + $result = $constraint($query); + $this->captureConstrainedRelationQuery($query); + + return $result; + }; } return parent::has($relation, $operator, $count, $boolean, $callback); } - public function inferExistenceDependencies(): DependencySet + protected function captureRelationSemantics(string $name): void { - return $this->existenceInferenceFailed - ? DependencySet::unsafe('whereHas/has dependencies could not be fully inferred.') - : ($this->existenceDependencies ?? DependencySet::empty()); - } + try { + $relation = $this->getRelationWithoutConstraints($name); + $related = $relation->getRelated(); + $query = $relation->getQuery(); - private function classifyExistenceRelation(string $name, ?callable $constraint): ?RelationDependency - { - $relation = $this->getRelationWithoutConstraints($name); + 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'); + } - if ($relation instanceof MorphTo) { - return null; + if ($query->toBase()->lock !== null) { + $this->addCapturedContextReason('safety', 'relation query uses a lock'); + } + } catch (\Throwable) { + $this->addCapturedContextReason('dependency', 'relation semantics could not be inspected'); } + } - if (!($relation instanceof HasOneOrMany - || $relation instanceof BelongsTo - || $relation instanceof BelongsToMany - || $relation instanceof HasOneOrManyThrough)) { - return null; + private function captureConstrainedRelationQuery(mixed $query): void + { + if ($query instanceof CacheableBuilder) { + $this->mergeCapturedBuilderState($query); } - return (new RelationDependencyClassifier)->classify($relation, $constraint); + if (method_exists($query, 'toBase') && $query->toBase()->lock !== null) { + $this->addCapturedContextReason('safety', 'relation query uses a lock'); + } } } diff --git a/src/Relations/RelationDependencyClassifier.php b/src/Relations/RelationDependencyClassifier.php deleted file mode 100644 index 8d3b4f1..0000000 --- a/src/Relations/RelationDependencyClassifier.php +++ /dev/null @@ -1,112 +0,0 @@ - $relation - */ - public function classify(Relation $relation, ?callable $constraint): ?RelationDependency - { - $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::keys()->tableKey( - $relation->getParent()->getConnection()->getName(), - $relation->getTable(), - ); - } - - return new RelationDependency( - 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/Support/CacheKeyBuilder.php b/src/Support/CacheKeyBuilder.php index 618cdfd..09f944e 100644 --- a/src/Support/CacheKeyBuilder.php +++ b/src/Support/CacheKeyBuilder.php @@ -164,7 +164,12 @@ public static function deletedAtColumn(string $class): ?string public function tableKey(string $connectionName, string $table): string { - return "{$connectionName}:{$table}"; + return "{$connectionName}:" . self::stripTableAlias($table); + } + + public static function stripTableAlias(string $table): string + { + return preg_replace('/\s+as\s+\S+$/i', '', $table); } public function verKey(string $classKey, ?CacheSpace $space = null): string diff --git a/src/Traits/CachesScalarResults.php b/src/Traits/CachesScalarResults.php index e95b3b9..f71f147 100644 --- a/src/Traits/CachesScalarResults.php +++ b/src/Traits/CachesScalarResults.php @@ -12,7 +12,6 @@ use NormCache\Support\ProjectionClassifier; use NormCache\Support\ScalarTransformer; use NormCache\Values\CachePlanContext; -use NormCache\Values\DependencySet; /** * @mixin CacheableBuilder @@ -140,10 +139,7 @@ private function cacheScalar( $computeValue = $compute === null ? $fallback : fn() => $compute($base); - $plan = $this->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::scalar( - $columns, - $inferred, - )); + $plan = $this->planPrepared($prepared, fn() => CachePlanContext::scalar($columns)); if (!$plan->isCacheable()) { if (!$plan->hasBypassReason('opted_out')) { diff --git a/src/Traits/HandlesInvalidation.php b/src/Traits/HandlesInvalidation.php index 6a03d81..93e467c 100644 --- a/src/Traits/HandlesInvalidation.php +++ b/src/Traits/HandlesInvalidation.php @@ -20,13 +20,30 @@ trait HandlesInvalidation /** @var array>> conn => classKey => spaces */ private array $versionQueue = []; + /** @var array, true>> */ + private array $modelVersionQueue = []; + /** @var array> conn => table classKey => true */ private array $tableVersionQueue = []; - /** @return list the spaces a model's version key lives in */ - private function modelSpaces(string $modelClass): array - { - return $this->spaceRegistry->spacesForModel($modelClass); + /** @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 */ @@ -53,7 +70,7 @@ public function invalidateVersion(Model $model): void $this->queueOrRun( $conn, - fn() => $this->queueVersionFlush($conn, $this->keys->classKey($model::class, $conn), $this->modelSpaces($model::class)), + fn() => $this->modelVersionQueue[$conn][$model::class] = true, fn() => $this->doInvalidateVersion($model::class, $conn), ); } @@ -107,7 +124,7 @@ public function invalidatePivotTableVersion(string $connectionName, string $tabl $classKey = $this->keys->tableKey($connectionName, $table); $spaces = []; foreach ($modelClasses as $modelClass) { - foreach ($this->modelSpaces($modelClass) as $space) { + foreach ($this->modelSpaces($modelClass, freshTableSpaces: true) as $space) { $spaces[$space->name] = $space; } } @@ -128,7 +145,7 @@ public function forceFlushModel(string $modelClass, ?string $connectionName = nu { $classKey = $this->keys->classKey($modelClass, $connectionName); - foreach ($this->modelSpaces($modelClass) as $space) { + foreach ($this->modelSpaces($modelClass, $connectionName, freshTableSpaces: true) as $space) { $this->store->incrementAndExpire($this->keys->verKey($classKey, $space), $this->versionTtl()); // bypass cooldown } } @@ -174,7 +191,7 @@ public function flushTag(string $modelClass, string $tag): int CacheKeyBuilder::K_COUNT . ':' . $classKey . ':' . $tag . ':*', CacheKeyBuilder::K_SCALAR . ':' . $classKey . ':' . $tag . ':*', CacheKeyBuilder::K_THROUGH . ':' . $classKey . ':' . $tag . ':*', - ], $this->modelSpaces($modelClass))); + ], $this->modelSpaces($modelClass, freshTableSpaces: true))); } public function flushTagAcrossModels(string $tag): int @@ -215,7 +232,7 @@ public function invalidateMultipleVersions(array $modelClasses, ?string $connect $conn, function () use ($conn, $classes) { foreach ($classes as $modelClass) { - $this->queueVersionFlush($conn, $this->keys->classKey($modelClass, $conn), $this->modelSpaces($modelClass)); + $this->modelVersionQueue[$conn][$modelClass] = true; } }, function () use ($classes, $conn) { @@ -230,39 +247,66 @@ function () use ($classes, $conn) { public function commitPending(string $connectionName): void { $flushes = array_keys($this->flushQueue[$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($tables)) || !$this->isEnabled()) { + if ((empty($flushes) && empty($models) && empty($versions) && empty($tables)) || !$this->isEnabled()) { return; } CacheFallback::attempt( $this->config, - function () use ($connectionName, $flushes, $versions, $tables) { + 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, $connectionName), $flushes); - $tableClassKeys = array_fill_keys($tables, true); + foreach ($models as $modelClass) { + $queue( + $this->keys->classKey($modelClass, $connectionName), + $this->modelSpaces($modelClass, $connectionName, freshTableSpaces: true), + ); + } foreach ($versions as $classKey => $spaces) { - if (!in_array($classKey, $flushClassKeys, true) && !isset($tableClassKeys[$classKey])) { - foreach ($spaces as $space) { - $this->doInvalidateKey($classKey, $space); - } - } + $queue($classKey, $spaces); } foreach ($tables as $classKey) { - $this->doInvalidateTable($classKey); + $queue($classKey, $this->tableInvalidationSpaces($classKey)); + } + + foreach ($pending as $classKey => $spaces) { + foreach ($spaces as $space) { + $this->doInvalidateKey($classKey, $space); + } } }, ); @@ -272,6 +316,7 @@ public function discardPending(string $connectionName): void { unset( $this->flushQueue[$connectionName], + $this->modelVersionQueue[$connectionName], $this->versionQueue[$connectionName], $this->tableVersionQueue[$connectionName], ); @@ -280,6 +325,7 @@ public function discardPending(string $connectionName): void public function discardAllPending(): void { $this->flushQueue = []; + $this->modelVersionQueue = []; $this->versionQueue = []; $this->tableVersionQueue = []; } @@ -300,7 +346,7 @@ private function doInvalidateVersion(string $modelClass, ?string $connectionName { $classKey = $this->keys->classKey($modelClass, $connectionName); - foreach ($this->modelSpaces($modelClass) as $space) { + foreach ($this->modelSpaces($modelClass, $connectionName) as $space) { $this->doInvalidateKey($classKey, $space); } } diff --git a/src/Values/CachePlanContext.php b/src/Values/CachePlanContext.php index 9cfe937..73fde21 100644 --- a/src/Values/CachePlanContext.php +++ b/src/Values/CachePlanContext.php @@ -6,52 +6,44 @@ 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 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(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); + 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); + return new self(CacheOperation::PaginationCount); } - public static function pivot(array $columns = [], ?DependencySet $inferred = null): self + public static function pivot(array $columns = []): self { - return new self(CacheOperation::Pivot, $columns, $inferred); + return new self(CacheOperation::Pivot, $columns); } - public static function through( - array $columns = [], - ?DependencySet $inferred = null, - ?DependencySet $required = null, - ): self { + public static function through(array $columns = [], ?DependencySet $required = null): self + { return new self( CacheOperation::Through, $columns, - $inferred, requiredDependencies: $required, ); } diff --git a/src/Values/QueryInspection.php b/src/Values/QueryInspection.php index 3fbc34f..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,12 +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 normalizationFlags(): int { return $this->flags & self::NORMALIZATION_BYPASS; @@ -73,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/RelationDependency.php b/src/Values/RelationDependency.php deleted file mode 100644 index 331785f..0000000 --- a/src/Values/RelationDependency.php +++ /dev/null @@ -1,40 +0,0 @@ - $constraintModels - * @param list $constraintTables - */ - public function __construct( - public string $relatedClass, - public ?string $throughClass = null, - public ?string $tableKey = null, - public array $constraintModels = [], - public array $constraintTables = [], - ) {} - - /** @return list */ - public function modelDependencies(): array - { - return [ - $this->relatedClass, - ...($this->throughClass !== null ? [$this->throughClass] : []), - ...$this->constraintModels, - ]; - } - - /** @return list */ - public function tableDependencies(): array - { - return [ - ...($this->tableKey !== null ? [$this->tableKey] : []), - ...$this->constraintTables, - ]; - } -} diff --git a/tests/Integration/Cache/CacheableBuilderTest.php b/tests/Integration/Cache/CacheableBuilderTest.php index 1a2a7d0..3de41f2 100644 --- a/tests/Integration/Cache/CacheableBuilderTest.php +++ b/tests/Integration/Cache/CacheableBuilderTest.php @@ -179,6 +179,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']); @@ -517,13 +537,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 +583,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')); }); } @@ -830,6 +848,23 @@ 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 { diff --git a/tests/Integration/Cache/CrossTableDependencySafetyTest.php b/tests/Integration/Cache/CrossTableDependencySafetyTest.php index cc00556..b184f94 100644 --- a/tests/Integration/Cache/CrossTableDependencySafetyTest.php +++ b/tests/Integration/Cache/CrossTableDependencySafetyTest.php @@ -100,7 +100,7 @@ public function test_count_with_group_by_single_table_still_caches(): void $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('result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_simple_aggregate_constraint_still_infers_dependencies(): void @@ -122,7 +122,7 @@ public function test_simple_aggregate_constraint_still_infers_dependencies(): vo $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('result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_aliased_from_query_bypasses_normalized_cache(): void diff --git a/tests/Integration/Cache/ThroughRelationTest.php b/tests/Integration/Cache/ThroughRelationTest.php index c75e942..1f17777 100644 --- a/tests/Integration/Cache/ThroughRelationTest.php +++ b/tests/Integration/Cache/ThroughRelationTest.php @@ -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) { diff --git a/tests/Integration/Cache/WhereHasCachingTest.php b/tests/Integration/Cache/WhereHasCachingTest.php index f5dd0f2..51267c5 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,15 +18,13 @@ 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 @@ -104,10 +103,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); } diff --git a/tests/Integration/Invalidation/TransactionInvalidationTest.php b/tests/Integration/Invalidation/TransactionInvalidationTest.php index 0fdb32d..360fe51 100644 --- a/tests/Integration/Invalidation/TransactionInvalidationTest.php +++ b/tests/Integration/Invalidation/TransactionInvalidationTest.php @@ -291,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/RelationOverrideTest.php b/tests/Integration/RelationOverrideTest.php index c0861b8..0f5da08 100644 --- a/tests/Integration/RelationOverrideTest.php +++ b/tests/Integration/RelationOverrideTest.php @@ -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('result:*')); + $this->assertNotEmpty($this->redisKeys('result:*')); } public function test_eager_morph_many_relation_is_served_from_cache_and_invalidated(): void diff --git a/tests/Unit/CacheKeyBuilderTest.php b/tests/Unit/CacheKeyBuilderTest.php index e4f1a00..8541918 100644 --- a/tests/Unit/CacheKeyBuilderTest.php +++ b/tests/Unit/CacheKeyBuilderTest.php @@ -36,6 +36,14 @@ public function test_class_key_can_be_scoped_to_an_effective_connection(): void $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 diff --git a/tests/Unit/CacheManagerTest.php b/tests/Unit/CacheManagerTest.php index 228f64d..52f4642 100644 --- a/tests/Unit/CacheManagerTest.php +++ b/tests/Unit/CacheManagerTest.php @@ -5,18 +5,22 @@ 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 { @@ -303,6 +307,85 @@ public function test_table_space_registration_survives_a_fresh_registry_instance ); } + 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'; @@ -361,6 +444,35 @@ public function test_transaction_commit_resolves_table_spaces_registered_after_q $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(); diff --git a/tests/Unit/CachePlanSpaceValidatorTest.php b/tests/Unit/CachePlanSpaceValidatorTest.php index 0645b19..37ef497 100644 --- a/tests/Unit/CachePlanSpaceValidatorTest.php +++ b/tests/Unit/CachePlanSpaceValidatorTest.php @@ -14,10 +14,8 @@ use NormCache\Tests\UnitTestCase; use NormCache\Values\CachePlan; use NormCache\Values\DependencySet; -use ReflectionProperty; -use RuntimeException; -class CachePlanSpaceValidatorTest extends UnitTestCase +final class CachePlanSpaceValidatorTest extends UnitTestCase { public function test_cross_space_dependencies_bypass_with_the_resolved_space(): void { @@ -53,7 +51,7 @@ public function command($method, array $parameters = []) } }; $store = new RedisStore('normcache-test'); - (new ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); + (new \ReflectionProperty(RedisStore::class, 'connection'))->setValue($store, $connection); $registry = new CacheSpaceRegistry(metadataStore: $store); $validator = new CachePlanSpaceValidator($registry, new CacheSpaceResolver($registry)); diff --git a/tests/Unit/CachePlannerTest.php b/tests/Unit/CachePlannerTest.php index ba8d6e9..16712cb 100644 --- a/tests/Unit/CachePlannerTest.php +++ b/tests/Unit/CachePlannerTest.php @@ -2,61 +2,28 @@ namespace NormCache\Tests\Unit; -use Illuminate\Support\Facades\Log; use NormCache\Enums\CacheStrategy; use NormCache\Planning\CachePlanner; -use NormCache\Planning\DependencyResolver; use NormCache\Tests\Fixtures\Models\Author; use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\UnitTestCase; use NormCache\Values\CachePlanContext; -use NormCache\Values\DependencySet; class CachePlannerTest extends UnitTestCase { - public function test_planner_logs_warning_for_under_declared_dependencies_in_debug_mode(): void - { - config(['app.debug' => true]); - - Log::shouldReceive('warning') - ->once() - ->withArgs(function ($message) { - return str_contains($message, 'under-declared dependency') && str_contains($message, 'authors'); - }); - - $resolver = new DependencyResolver; - - $dependencies = new DependencySet([], ['posts']); - - $reflection = new \ReflectionMethod($resolver, 'checkDependencyCompleteness'); - $reflection->setAccessible(true); - $reflection->invoke($resolver, ['posts', 'authors'], $dependencies, 'posts'); - } - public function test_successful_hot_plan_does_not_build_reason_strings(): void { $prepared = Author::where('name', 'Alice')->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->flatReasons()); $this->assertSame([], $plan->bypassReasons); } 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(), - ); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); $this->assertSame(CacheStrategy::DirectModels, $plan->strategy); $this->assertSame([1, 2, 3], $plan->primaryKeys); @@ -64,219 +31,99 @@ public function test_active_soft_delete_scope_allows_a_direct_primary_key_plan() public function test_removed_soft_delete_scope_does_not_ignore_a_manual_null_constraint(): void { - $prepared = Post::withTrashed() - ->whereNull('posts.deleted_at') - ->whereKey([3, 1, 2]) - ->prepareCacheExecution(); - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::models(), - ); + $prepared = Post::withTrashed()->whereNull('posts.deleted_at')->whereKey([3, 1, 2])->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::models()); $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(['*']), - ); - - $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(['*']), - ); + $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(['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(['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( - ['*'], - 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(); - - $plan = (new CachePlanner)->plan( - $prepared->builder, - $prepared->base, - CachePlanContext::scalar(['*']), - ); + $prepared = Author::join('posts', 'posts.author_id', '=', 'authors.id')->prepareCacheExecution(); + $plan = (new CachePlanner)->plan($prepared->builder, $prepared->base, CachePlanContext::scalar(['*'])); - $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(['*']), - ); + $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 index fcbf750..5377284 100644 --- a/tests/Unit/CacheableBuilderPlanTest.php +++ b/tests/Unit/CacheableBuilderPlanTest.php @@ -7,7 +7,6 @@ use NormCache\Tests\Fixtures\Models\Post; use NormCache\Tests\UnitTestCase; use NormCache\Values\CachePlanContext; -use NormCache\Values\DependencySet; class CacheableBuilderPlanTest extends UnitTestCase { @@ -15,39 +14,32 @@ public function test_plan_prepared_matches_direct_plan(): void { $builder = Author::query()->where('name', 'A'); $prepared = $builder->prepareCacheExecution(); - - $viaSeam = $builder->planPrepared($prepared, fn(DependencySet $inferred) => CachePlanContext::models( + $context = fn() => CachePlanContext::models( ProjectionClassifier::resolve($prepared->base, ['*']), - $inferred, selectAll: true, - )); + ); - $direct = $builder->cachePlan($prepared->base, CachePlanContext::models( - ProjectionClassifier::resolve($prepared->base, ['*']), - $builder->inferRelationDependencies(), - selectAll: true, - )); - - $this->assertEquals($direct, $viaSeam); + $this->assertEquals( + $builder->cachePlan($prepared->base, $context()), + $builder->planPrepared($prepared, $context), + ); } - public function test_plan_prepared_merges_join_dependencies(): void + public function test_plan_prepared_infers_join_table_dependency(): void { - $builder = Post::query()->join('authors', 'authors.id', '=', 'posts.author_id'); + $builder = Post::query() + ->join('authors', 'authors.id', '=', 'posts.author_id') + ->select('posts.*'); $prepared = $builder->prepareCacheExecution(); - $captured = null; - $builder->planPrepared($prepared, function (DependencySet $inferred) use ($prepared, &$captured) { - $captured = $inferred; - - return CachePlanContext::models( + $plan = $builder->planPrepared( + $prepared, + fn() => CachePlanContext::models( ProjectionClassifier::resolve($prepared->base, ['*']), - $inferred, - selectAll: true, - ); - }); + selectAll: false, + ), + ); - $this->assertNotNull($captured); - $this->assertNotEquals(DependencySet::empty(), $captured); + $this->assertContains('testing:authors', $plan->dependencies->tables); } } diff --git a/tests/Unit/QueryAnalyzerTest.php b/tests/Unit/QueryAnalyzerTest.php index 6764271..075839e 100644 --- a/tests/Unit/QueryAnalyzerTest.php +++ b/tests/Unit/QueryAnalyzerTest.php @@ -22,7 +22,7 @@ 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 @@ -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 @@ -175,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 @@ -251,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/Relations/RelationDependencyClassifierTest.php b/tests/Unit/Relations/RelationDependencyClassifierTest.php deleted file mode 100644 index e4f07c5..0000000 --- a/tests/Unit/Relations/RelationDependencyClassifierTest.php +++ /dev/null @@ -1,53 +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); - $this->assertSame([Post::class], $entry->modelDependencies()); - $this->assertSame([], $entry->tableDependencies()); - } - - public function test_belongs_to_many_relation_includes_pivot_table_key(): void - { - $entry = (new RelationDependencyClassifier)->classify((new Author)->tags(), null); - - $this->assertNotNull($entry->tableKey); - $this->assertContains($entry->tableKey, $entry->tableDependencies()); - } - - 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); - } -} From 807191349c45081395ae9f232e6ef82f4ae70477 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:48:00 +1000 Subject: [PATCH 71/73] update tests --- .../Cache/CacheableBuilderTest.php | 57 ------- .../Integration/Cache/JoinResultCacheTest.php | 13 -- ...php => ModelHydratorColdHydrationTest.php} | 152 +----------------- tests/Integration/Cache/OptimizationsTest.php | 9 -- .../Integration/Cache/WhereHasCachingTest.php | 51 +----- .../Contract/CustomBehaviorContractTest.php | 10 -- .../Contract/ModelHydrationContractTest.php | 46 ++++++ .../Contract/ResultCacheContractTest.php | 59 ++----- .../Contract/WhereHasContractTest.php | 63 ++++++++ .../Infrastructure/ClusterSpacesTest.php | 22 ++- 10 files changed, 139 insertions(+), 343 deletions(-) rename tests/Integration/Cache/{ModelHydratorClosureColdHydrationTest.php => ModelHydratorColdHydrationTest.php} (65%) create mode 100644 tests/Integration/Contract/ModelHydrationContractTest.php create mode 100644 tests/Integration/Contract/WhereHasContractTest.php diff --git a/tests/Integration/Cache/CacheableBuilderTest.php b/tests/Integration/Cache/CacheableBuilderTest.php index 3de41f2..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,14 +22,6 @@ */ class CacheableBuilderTest extends TestCase { - public function test_get_writes_query_cache_key(): void - { - Author::create(['name' => 'Alice']); - Author::all(); - - $this->assertNotEmpty($this->redisKeys('query:*')); - } - public function test_without_cache_writes_no_query_keys(): void { Author::create(['name' => 'Alice']); @@ -39,16 +30,6 @@ public function test_without_cache_writes_no_query_keys(): void $this->assertEmpty($this->redisKeys('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())); - } - public function test_ttl_uses_custom_ttl(): void { Author::create(['name' => 'Alice']); @@ -60,16 +41,6 @@ public function test_ttl_uses_custom_ttl(): void $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('query:*')); - } - public function test_query_with_group_by_bypasses_cache(): void { Author::create(['name' => 'Alice']); @@ -95,23 +66,6 @@ public function test_query_with_raw_select_expression_bypasses_cache(): void $this->assertEmpty($this->redisKeys('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); - } - public function test_bulk_update_invalidates_version(): void { Author::create(['name' => 'Alice']); @@ -288,17 +242,6 @@ public function test_primary_key_query_with_limit_uses_model_cache_without_query $this->assertEmpty($this->redisKeys('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('query:*')); - } - public function test_primary_key_query_with_zero_limit_returns_empty_without_query_cache(): void { $author = Author::create(['name' => 'Alice']); diff --git a/tests/Integration/Cache/JoinResultCacheTest.php b/tests/Integration/Cache/JoinResultCacheTest.php index 9baf36b..1436a0e 100644 --- a/tests/Integration/Cache/JoinResultCacheTest.php +++ b/tests/Integration/Cache/JoinResultCacheTest.php @@ -30,19 +30,6 @@ public function test_join_with_depends_on_and_no_explicit_select_bypasses_cache( $this->assertEmpty($this->redisKeys('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); - } - public function test_join_with_depends_on_and_explicit_root_select_caches_as_result(): void { $author = Author::create(['name' => 'Alice']); diff --git a/tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php b/tests/Integration/Cache/ModelHydratorColdHydrationTest.php similarity index 65% rename from tests/Integration/Cache/ModelHydratorClosureColdHydrationTest.php rename to tests/Integration/Cache/ModelHydratorColdHydrationTest.php index af6bcce..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 { @@ -34,83 +30,6 @@ private function makeHydrator(): ModelHydrator 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']); @@ -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']); @@ -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/OptimizationsTest.php b/tests/Integration/Cache/OptimizationsTest.php index 0b459d8..22d0d93 100644 --- a/tests/Integration/Cache/OptimizationsTest.php +++ b/tests/Integration/Cache/OptimizationsTest.php @@ -121,15 +121,6 @@ public function test_corrupt_query_cache_payload_degrades_to_miss_and_repairs(): $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); diff --git a/tests/Integration/Cache/WhereHasCachingTest.php b/tests/Integration/Cache/WhereHasCachingTest.php index 51267c5..16ef0e9 100644 --- a/tests/Integration/Cache/WhereHasCachingTest.php +++ b/tests/Integration/Cache/WhereHasCachingTest.php @@ -27,18 +27,14 @@ public function test_simple_wherehas_infers_related_table_dependency(): void $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('result:*')); } @@ -59,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('result:*')); - } - public function test_wherehas_with_safe_constraint_invalidates_on_dependency_change(): void { $author = Author::create(['name' => 'Alice']); @@ -134,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(); @@ -190,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('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/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/ClusterSpacesTest.php b/tests/Integration/Infrastructure/ClusterSpacesTest.php index 1015c31..1bcf875 100644 --- a/tests/Integration/Infrastructure/ClusterSpacesTest.php +++ b/tests/Integration/Infrastructure/ClusterSpacesTest.php @@ -248,21 +248,33 @@ public function test_flush_all_clears_default_and_known_spaces_on_cluster(): voi $this->assertNoKeysForHashTag('nc:reporting', 'test:*'); } - public function test_scheduled_invalidation_keys_are_space_scoped(): void + public function test_scheduled_invalidation_keys_are_scoped_to_each_affected_space(): void { - $this->cacheManager()->config()->cooldown = 5; - $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->assertNoKeysForHashTag('nc', 'test:scheduled:*'); + $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) { @@ -270,7 +282,7 @@ public function test_scheduled_invalidation_keys_are_space_scoped(): void $this->assertSame($contentSlot, $this->redisClusterSlot($m[1] ?? '')); } - // fetch_version_with_cooldown passes ver + scheduled as Lua KEYS[]; mismatched slots throw CROSSSLOT. + // The read Lua passes each version key with its scheduled key; mismatched slots throw CROSSSLOT. $this->assertNoCrossSlot(fn() => SpacedPost::query()->get()); } From 4b50662ed23a7684b37612bee7e738837f8ddefe Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:03:23 +1000 Subject: [PATCH 72/73] update readme / changelog --- CHANGELOG.md | 32 ++++++++++---------------------- README.md | 22 ++++++++++------------ 2 files changed, 20 insertions(+), 34 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d190bfe..9f8ec06 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,38 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 --- -## [Unreleased] - -No unreleased changes. - ---- - ## [3.0.0] — 2026-07-09 ### Added -- **Cache spaces for Redis Cluster sharding:** models can declare `$normCacheSpaces`, queries can select `->space()`, and each space maps to its own Redis hash tag. -- **Space-targeted flushing:** `NormCache::flushAll('space')` and `php artisan normcache:flush --space=...` now flush one cache space. +- **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:** replaced the old slotting/sharding configuration with model-declared cache spaces. -- Model payloads are now stored under versioned keys (`model:{classKey}:v{version}:{id}`), so invalidation bumps versions instead of scanning model member sets. -- `dependsOnTables()` now works in named cache spaces; raw table dependencies are registered in the active space and invalidated with that space's table version. +- **BREAKING:** model-declared cache spaces replace the `cluster` and `slotting` configuration. +- `dependsOnTables()` supports named cache spaces. ### Fixed -- Fixed cache-space consistency across query, result, model, relation, pivot, through, build-lock, wake, and invalidation keys. -- Fixed Redis Cluster cross-slot errors in space-aware cache paths and broad flush scans. -- Fixed relation and pivot invalidation edge cases, including guarded relation writes, empty parent relation loads, and repeated builder use. -- Fixed pre-save invalidation timing so Eloquent observers see a cache miss after writes. -- Fixed connection/table key safety by rejecting connection names containing `:`. -- Tightened versioned writes so concurrent invalidation cannot leave stale query, result, pivot, through, or model payloads behind. - -### Removed - -- Removed the old slotting/sharding configuration and internals. -- Removed leftover stale-serving internals after the `2.4.0` `stale_version_depth` removal. +- 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 c0ae4b8..d33cc5f 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ 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. -**Requirements:** PHP 8.2+, Laravel 12/13, Redis 4.0+ +**Requirements:** PHP 8.2+, Laravel 12/13, Redis 6.0+ ## Table of Contents @@ -43,13 +43,11 @@ class Post extends Model ## What's new in 3.0 -Normcache 3.0 is a Redis Cluster-oriented release. +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 replace the old slotting/sharding configuration. -- Space-targeted flushes are available through `flushAll('space')` and `normcache:flush --space=...`. -- Raw table dependencies from `dependsOnTables()` work in named spaces. -- Model payload invalidation now relies on versioned keys instead of members-set scans. -- `stale_version_depth` remains removed; rebuild coordination uses build locks and wake tokens. +- **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()`. ## Usage @@ -98,7 +96,7 @@ Author::join('legacy_stats', 'legacy_stats.author_id', '=', 'authors.id') ### Aggregates and relationships -`count`, `exists`, `sum`, `avg`, `min`, `max`, pagination totals, and `withCount` / `withSum` / `withAvg` / `withMin` / `withMax` / `withExists` are cached when their dependencies are safe. +`count`, `exists`, `value`, `pluck`, `sum`, `avg`, `min`, `max`, pagination totals, and `withCount` / `withSum` / `withAvg` / `withMin` / `withMax` / `withExists` are cached when their dependencies are safe. Eager-loaded `BelongsTo`, `BelongsToMany`, `MorphTo`, `MorphToMany`, `MorphedByMany`, `HasManyThrough`, and `HasOneThrough` relations are cached. `attach`, `detach`, `sync`, and `updateExistingPivot` invalidate the relevant pivot cache. @@ -112,7 +110,9 @@ use NormCache\Facades\NormCache; NormCache::flushModel(Post::class); NormCache::flushAll(); NormCache::flushAll('content'); +``` +```bash php artisan normcache:flush --model="App\Models\Post" php artisan normcache:flush php artisan normcache:flush --space=content @@ -139,7 +139,7 @@ NormCache::flushTagAcrossModels('homepage'); ## Cache spaces -Cache spaces are Normcache's Redis Cluster sharding boundary. Each space maps to one Redis hash tag, and every cached plan runs its Lua operations inside the active space. +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. Models without a declaration use the default space (`{nc}`). Declare named spaces with `$normCacheSpaces`: @@ -179,7 +179,7 @@ Post::query() ->get(); ``` -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()` are registered in the active space and invalidated with that space's table version. +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()`. Configure placement when you need to control Redis Cluster hash tags: @@ -193,8 +193,6 @@ Configure placement when you need to control Redis Cluster hash tags: ], ``` -In Redis Cluster mode, broad flushes use the recorded space registry to scan each known space. Standalone Redis uses wildcard hash-tag scans. - ## Configuration Publish `config/normcache.php` if you need to customize runtime behavior: From 5982889cdc32856807c63c9fc035257192705754 Mon Sep 17 00:00:00 2001 From: Kai H <32325424+kai-init@users.noreply.github.com> Date: Wed, 15 Jul 2026 12:15:54 +1000 Subject: [PATCH 73/73] update docs --- CHANGELOG.md | 2 +- README.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9f8ec06..f24d4c4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- **BREAKING:** model-declared cache spaces replace the `cluster` and `slotting` configuration. +- **BREAKING:** model-declared cache spaces replace the `cluster` and `slotting` configuration. Run `php artisan normcache:flush` before deploying. - `dependsOnTables()` supports named cache spaces. ### Fixed diff --git a/README.md b/README.md index d33cc5f..50c9c4e 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,7 @@ Redis Cluster sharding is now fully atomic within each cache space. Normcache ke - **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