diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..82c9c5f60 --- /dev/null +++ b/.env.example @@ -0,0 +1,13 @@ +# Redis Integration Tests +# Copy this file to .env and configure to run integration tests locally. +# These tests are skipped by default. Set RUN_REDIS_INTEGRATION_TESTS=true to enable. + +# Enable/disable Redis integration tests +RUN_REDIS_INTEGRATION_TESTS=false + +# Redis connection settings +# Defaults work for standard local Redis (localhost:6379, no auth, DB 8) +REDIS_HOST=127.0.0.1 +REDIS_PORT=6379 +REDIS_AUTH= +REDIS_DB=8 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index aab39105b..b0ad47f3c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -29,6 +29,13 @@ jobs: - name: Checkout code uses: actions/checkout@v4 + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-${{ matrix.php }}-${{ hashFiles('composer.lock') }} + restore-keys: composer-${{ matrix.php }}- + - name: Install dependencies run: | COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o @@ -36,4 +43,57 @@ jobs: - name: Execute tests run: | PHP_CS_FIXER_IGNORE_ENV=1 vendor/bin/php-cs-fixer fix --dry-run --diff - vendor/bin/phpunit -c phpunit.xml.dist + vendor/bin/phpunit -c phpunit.xml.dist --exclude-group redis-integration + + redis_integration_tests: + runs-on: ubuntu-latest + if: "!contains(github.event.head_commit.message, '[skip ci]') && !contains(github.event.head_commit.message, '[ci skip]')" + + strategy: + fail-fast: false + matrix: + include: + - redis: "redis:8" + name: "Redis 8" + - redis: "valkey/valkey:9" + name: "Valkey 9" + + name: Integration (${{ matrix.name }}) + + services: + redis: + image: ${{ matrix.redis }} + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 6379:6379 + + container: + image: phpswoole/swoole:6.1.4-php8.4 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Cache Composer dependencies + uses: actions/cache@v4 + with: + path: /root/.composer/cache + key: composer-8.4-${{ hashFiles('composer.lock') }} + restore-keys: composer-8.4- + + - name: Install dependencies + run: | + COMPOSER_MEMORY_LIMIT=-1 composer install --prefer-dist -n -o + + - name: Execute Redis integration tests + env: + RUN_REDIS_INTEGRATION_TESTS: true + REDIS_HOST: redis + REDIS_PORT: 6379 + REDIS_DB: 8 + run: | + vendor/bin/phpunit -c phpunit.xml.dist --group redis-integration diff --git a/.gitignore b/.gitignore index 80c45d9f8..68ec01a1b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ .idea +/.env /.phpunit.cache +/.tmp /vendor composer.lock /phpunit.xml diff --git a/phpunit.xml.dist b/phpunit.xml.dist index cc75f1b1b..e327f01ea 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -1,5 +1,6 @@ [ 'driver' => 'redis', 'connection' => 'default', + 'tag_mode' => env('REDIS_CACHE_TAG_MODE', 'all'), // Redis 8.0+ and PhpRedis 6.3.0+ required for 'any' 'lock_connection' => 'default', ], diff --git a/src/cache/src/CacheManager.php b/src/cache/src/CacheManager.php index 2cd9b3995..9998fe0a1 100644 --- a/src/cache/src/CacheManager.php +++ b/src/cache/src/CacheManager.php @@ -237,6 +237,7 @@ protected function createRedisDriver(array $config): Repository $connection = $config['connection'] ?? 'default'; $store = new RedisStore($redis, $this->getPrefix($config), $connection); + $store->setTagMode($config['tag_mode'] ?? 'all'); return $this->repository( $store->setLockConnection($config['lock_connection'] ?? $connection), diff --git a/src/cache/src/ConfigProvider.php b/src/cache/src/ConfigProvider.php index 36669872e..7907b02d3 100644 --- a/src/cache/src/ConfigProvider.php +++ b/src/cache/src/ConfigProvider.php @@ -10,6 +10,9 @@ use Hypervel\Cache\Contracts\Store; use Hypervel\Cache\Listeners\CreateSwooleTable; use Hypervel\Cache\Listeners\CreateTimer; +use Hypervel\Cache\Redis\Console\BenchmarkCommand; +use Hypervel\Cache\Redis\Console\DoctorCommand; +use Hypervel\Cache\Redis\Console\PruneStaleTagsCommand; class ConfigProvider { @@ -25,8 +28,11 @@ public function __invoke(): array CreateTimer::class, ], 'commands' => [ + BenchmarkCommand::class, ClearCommand::class, + DoctorCommand::class, PruneDbExpiredCommand::class, + PruneStaleTagsCommand::class, ], 'publish' => [ [ diff --git a/src/cache/src/LuaScripts.php b/src/cache/src/LuaScripts.php deleted file mode 100644 index fd936af75..000000000 --- a/src/cache/src/LuaScripts.php +++ /dev/null @@ -1,27 +0,0 @@ -store->allTagOps()->addEntry()->execute($key, $ttl, $this->tagIds(), $updateWhen); + } + + /** + * Get all of the cache entry keys for the tag set. + */ + public function entries(): LazyCollection + { + return $this->store->allTagOps()->getEntries()->execute($this->tagIds()); + } + + /** + * Flush the tag from the cache. + */ + public function flushTag(string $name): string + { + return $this->resetTag($name); + } + + /** + * Reset the tag and return the new tag identifier. + */ + public function resetTag(string $name): string + { + $this->store->forget($this->tagKey($name)); + + return $this->tagId($name); + } + + /** + * Get the unique tag identifier for a given tag. + * + * Delegates to StoreContext which delegates to TagMode (single source of truth). + * Format: "_all:tag:{name}:entries" + */ + public function tagId(string $name): string + { + return $this->store->getContext()->tagId($name); + } + + /** + * Get the tag identifier key for a given tag. + * + * Same as tagId() - the identifier without cache prefix. + * Used with store->forget() which adds the prefix. + */ + public function tagKey(string $name): string + { + return $this->store->getContext()->tagId($name); + } +} diff --git a/src/cache/src/Redis/AllTaggedCache.php b/src/cache/src/Redis/AllTaggedCache.php new file mode 100644 index 000000000..74fbd5ef9 --- /dev/null +++ b/src/cache/src/Redis/AllTaggedCache.php @@ -0,0 +1,297 @@ +getSeconds($ttl); + + if ($seconds <= 0) { + return false; + } + + return $this->store->allTagOps()->add()->execute( + $this->itemKey($key), + $value, + $seconds, + $this->tags->tagIds() + ); + } + + // Null TTL: non-atomic get + forever (matches Repository::add behavior) + if (is_null($this->get($key))) { + $result = $this->store->allTagOps()->forever()->execute( + $this->itemKey($key), + $value, + $this->tags->tagIds() + ); + + if ($result) { + $this->event(new KeyWritten(null, $key, $value)); + } + + return $result; + } + + return false; + } + + /** + * Store an item in the cache. + */ + public function put(array|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + if (is_array($key)) { + return $this->putMany($key, $value); + } + + if ($ttl === null) { + return $this->forever($key, $value); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return $this->forget($key); + } + + $result = $this->store->allTagOps()->put()->execute( + $this->itemKey($key), + $value, + $seconds, + $this->tags->tagIds() + ); + + if ($result) { + $this->event(new KeyWritten(null, $key, $value, $seconds)); + } + + return $result; + } + + /** + * Store multiple items in the cache for a given number of seconds. + */ + public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + if ($ttl === null) { + return $this->putManyForever($values); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return false; + } + + $result = $this->store->allTagOps()->putMany()->execute( + $values, + $seconds, + $this->tags->tagIds(), + sha1($this->tags->getNamespace()) . ':' + ); + + if ($result) { + foreach ($values as $key => $value) { + $this->event(new KeyWritten(null, $key, $value, $seconds)); + } + } + + return $result; + } + + /** + * Increment the value of an item in the cache. + */ + public function increment(string $key, int $value = 1): bool|int + { + return $this->store->allTagOps()->increment()->execute( + $this->itemKey($key), + $value, + $this->tags->tagIds() + ); + } + + /** + * Decrement the value of an item in the cache. + */ + public function decrement(string $key, int $value = 1): bool|int + { + return $this->store->allTagOps()->decrement()->execute( + $this->itemKey($key), + $value, + $this->tags->tagIds() + ); + } + + /** + * Store an item in the cache indefinitely. + */ + public function forever(string $key, mixed $value): bool + { + $result = $this->store->allTagOps()->forever()->execute( + $this->itemKey($key), + $value, + $this->tags->tagIds() + ); + + if ($result) { + $this->event(new KeyWritten(null, $key, $value)); + } + + return $result; + } + + /** + * Remove all items from the cache. + */ + public function flush(): bool + { + $this->event(new CacheFlushing(null)); + + $this->store->allTagOps()->flush()->execute($this->tags->tagIds(), $this->tags->getNames()); + + $this->event(new CacheFlushed(null)); + + return true; + } + + /** + * Remove all stale reference entries from the tag set. + */ + public function flushStale(): bool + { + $this->store->allTagOps()->flushStale()->execute($this->tags->tagIds()); + + return true; + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * Optimized to use a single connection for both GET and PUT operations, + * avoiding double pool overhead for cache misses. Also ensures tag tracking + * entries are properly created (which the parent implementation bypasses). + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function remember(string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed + { + if ($ttl === null) { + return $this->rememberForever($key, $callback); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + // Invalid TTL, just execute callback without caching + return $callback(); + } + + [$value, $wasHit] = $this->store->allTagOps()->remember()->execute( + $this->itemKey($key), + $seconds, + $callback, + $this->tags->tagIds() + ); + + if ($wasHit) { + $this->event(new CacheHit(null, $key, $value)); + } else { + $this->event(new CacheMissed(null, $key)); + $this->event(new KeyWritten(null, $key, $value, $seconds)); + } + + return $value; + } + + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * Optimized to use a single connection for both GET and SET operations, + * avoiding double pool overhead for cache misses. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function rememberForever(string $key, Closure $callback): mixed + { + [$value, $wasHit] = $this->store->allTagOps()->rememberForever()->execute( + $this->itemKey($key), + $callback, + $this->tags->tagIds() + ); + + if ($wasHit) { + $this->event(new CacheHit(null, $key, $value)); + } else { + $this->event(new CacheMissed(null, $key)); + $this->event(new KeyWritten(null, $key, $value)); + } + + return $value; + } + + /** + * Get the tag set instance (covariant return type). + */ + public function getTags(): AllTagSet + { + return $this->tags; + } + + /** + * Store multiple items in the cache indefinitely. + */ + protected function putManyForever(array $values): bool + { + $result = true; + + foreach ($values as $key => $value) { + if (! $this->forever($key, $value)) { + $result = false; + } + } + + return $result; + } +} diff --git a/src/cache/src/Redis/AnyTagSet.php b/src/cache/src/Redis/AnyTagSet.php new file mode 100644 index 000000000..a1554cf72 --- /dev/null +++ b/src/cache/src/Redis/AnyTagSet.php @@ -0,0 +1,165 @@ +names; + } + + /** + * Get the hash key for a tag. + * + * Delegates to StoreContext which delegates to TagMode (single source of truth). + * Format: "{prefix}_any:tag:{tag}:entries" + */ + public function tagHashKey(string $name): string + { + return $this->getRedisStore()->getContext()->tagHashKey($name); + } + + /** + * Get all cache keys for this tag set (union of all tags). + * + * This is a generator that yields unique keys across all tags. + * Used for listing tagged items or bulk operations. + */ + public function entries(): Generator + { + $seen = []; + + foreach ($this->names as $name) { + foreach ($this->getRedisStore()->anyTagOps()->getTaggedKeys()->execute($name) as $key) { + if (! isset($seen[$key])) { + $seen[$key] = true; + yield $key; + } + } + } + } + + /** + * Reset the tag set. + * + * In any mode, this actually deletes the cached items, + * unlike all mode which just changes the tag version. + */ + public function reset(): void + { + $this->flush(); + } + + /** + * Flush all tags in this set. + * + * Deletes all cache items that have ANY of the specified tags + * (union semantics), along with their reverse indexes and tag hashes. + */ + public function flush(): void + { + $this->getRedisStore()->anyTagOps()->flush()->execute($this->names); + } + + /** + * Flush a single tag. + */ + public function flushTag(string $name): string + { + $this->getRedisStore()->anyTagOps()->flush()->execute([$name]); + + return $this->tagKey($name); + } + + /** + * Get a unique namespace that changes when any of the tags are flushed. + * + * Not used in any mode since we don't namespace keys by tags. + * Returns empty string for compatibility with TaggedCache. + */ + public function getNamespace(): string + { + return ''; + } + + /** + * Reset the tag and return the new tag identifier. + * + * In any mode, this flushes the tag and returns the tag name. + * The tag name never changes (unlike all mode's UUIDs). + */ + public function resetTag(string $name): string + { + $this->flushTag($name); + + return $name; + } + + /** + * Get the tag key for a given tag name. + * + * Returns the hash key for the tag (same as tagHashKey). + */ + public function tagKey(string $name): string + { + return $this->tagHashKey($name); + } + + /** + * Get the store as a RedisStore instance. + */ + protected function getRedisStore(): RedisStore + { + return $this->store; + } +} diff --git a/src/cache/src/Redis/AnyTaggedCache.php b/src/cache/src/Redis/AnyTaggedCache.php new file mode 100644 index 000000000..21ad5ed97 --- /dev/null +++ b/src/cache/src/Redis/AnyTaggedCache.php @@ -0,0 +1,355 @@ +putMany($key, $value); + } + + if ($ttl === null) { + return $this->forever($key, $value); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + // Can't forget via tags, just return false + return false; + } + + $result = $this->store->anyTagOps()->put()->execute($key, $value, $seconds, $this->tags->getNames()); + + if ($result) { + $this->event(new KeyWritten(null, $key, $value, $seconds)); + } + + return $result; + } + + /** + * Store multiple items in the cache for a given number of seconds. + */ + public function putMany(array $values, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + if ($ttl === null) { + return $this->putManyForever($values); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return false; + } + + $result = $this->store->anyTagOps()->putMany()->execute($values, $seconds, $this->tags->getNames()); + + if ($result) { + foreach ($values as $key => $value) { + $this->event(new KeyWritten(null, $key, $value, $seconds)); + } + } + + return $result; + } + + /** + * Store an item in the cache if the key does not exist. + */ + public function add(string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool + { + if ($ttl === null) { + // Default to 1 year for "null" TTL on add + $seconds = 31536000; + } else { + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + return false; + } + } + + return $this->store->anyTagOps()->add()->execute($key, $value, $seconds, $this->tags->getNames()); + } + + /** + * Store an item in the cache indefinitely. + */ + public function forever(string $key, mixed $value): bool + { + $result = $this->store->anyTagOps()->forever()->execute($key, $value, $this->tags->getNames()); + + if ($result) { + $this->event(new KeyWritten(null, $key, $value)); + } + + return $result; + } + + /** + * Increment the value of an item in the cache. + */ + public function increment(string $key, int $value = 1): bool|int + { + return $this->store->anyTagOps()->increment()->execute($key, $value, $this->tags->getNames()); + } + + /** + * Decrement the value of an item in the cache. + */ + public function decrement(string $key, int $value = 1): bool|int + { + return $this->store->anyTagOps()->decrement()->execute($key, $value, $this->tags->getNames()); + } + + /** + * Remove all items from the cache that have any of the specified tags. + */ + public function flush(): bool + { + $this->event(new CacheFlushing(null)); + + $this->tags->flush(); + + $this->event(new CacheFlushed(null)); + + return true; + } + + /** + * Get all items (keys and values) tagged with the current tags. + * + * This is useful for debugging or bulk operations on tagged items. + * + * @return Generator + */ + public function items(): Generator + { + return $this->store->anyTagOps()->getTagItems()->execute($this->tags->getNames()); + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * Optimized to use a single connection for both GET and PUT operations, + * avoiding double pool overhead for cache misses. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function remember(string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed + { + if ($ttl === null) { + return $this->rememberForever($key, $callback); + } + + $seconds = $this->getSeconds($ttl); + + if ($seconds <= 0) { + // Invalid TTL, just execute callback without caching + return $callback(); + } + + [$value, $wasHit] = $this->store->anyTagOps()->remember()->execute( + $key, + $seconds, + $callback, + $this->tags->getNames() + ); + + if ($wasHit) { + $this->event(new CacheHit(null, $key, $value)); + } else { + $this->event(new CacheMissed(null, $key)); + $this->event(new KeyWritten(null, $key, $value, $seconds)); + } + + return $value; + } + + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * Optimized to use a single connection for both GET and SET operations, + * avoiding double pool overhead for cache misses. + * + * @template TCacheValue + * + * @param Closure(): TCacheValue $callback + * @return TCacheValue + */ + public function rememberForever(string $key, Closure $callback): mixed + { + [$value, $wasHit] = $this->store->anyTagOps()->rememberForever()->execute( + $key, + $callback, + $this->tags->getNames() + ); + + if ($wasHit) { + $this->event(new CacheHit(null, $key, $value)); + } else { + $this->event(new CacheMissed(null, $key)); + $this->event(new KeyWritten(null, $key, $value)); + } + + return $value; + } + + /** + * Get the tag set instance (covariant return type). + */ + public function getTags(): AnyTagSet + { + return $this->tags; + } + + /** + * Format the key for a cache item. + * + * In any mode, keys are NOT namespaced by tags. + * Tags are only for invalidation, not for scoping reads. + */ + protected function itemKey(string $key): string + { + return $key; + } + + /** + * Store multiple items in the cache indefinitely. + */ + protected function putManyForever(array $values): bool + { + $result = true; + + foreach ($values as $key => $value) { + if (! $this->forever($key, $value)) { + $result = false; + } + } + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/BenchmarkContext.php b/src/cache/src/Redis/Console/Benchmark/BenchmarkContext.php new file mode 100644 index 000000000..4f895284e --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/BenchmarkContext.php @@ -0,0 +1,310 @@ +cacheManager->store($this->storeName); + } + + /** + * Get the underlying store instance. + */ + public function getStoreInstance(): RedisStore + { + if ($this->storeInstance !== null) { + return $this->storeInstance; + } + + $store = $this->getStore()->getStore(); + + if (! $store instanceof RedisStore) { + throw new RuntimeException( + 'Benchmark requires a Redis store, but got: ' . $store::class + ); + } + + return $this->storeInstance = $store; + } + + /** + * Get the cache prefix. + */ + public function getCachePrefix(): string + { + return $this->cachePrefix ??= $this->getStoreInstance()->getPrefix(); + } + + /** + * Get the current tag mode. + */ + public function getTagMode(): TagMode + { + return $this->getStoreInstance()->getTagMode(); + } + + /** + * Check if the store is configured for 'any' tag mode. + */ + public function isAnyMode(): bool + { + return $this->getTagMode() === TagMode::Any; + } + + /** + * Check if the store is configured for 'all' tag mode. + */ + public function isAllMode(): bool + { + return $this->getTagMode() === TagMode::All; + } + + /** + * Get patterns to match all tag storage structures with a given tag name prefix. + * + * Returns patterns for BOTH tag modes to ensure complete cleanup + * regardless of current mode (important for --compare-tag-modes): + * - Any mode: {cachePrefix}_any:tag:{tagNamePrefix}* + * - All mode: {cachePrefix}_all:tag:{tagNamePrefix}* + * + * @param string $tagNamePrefix The prefix to match tag names against + * @return array Patterns to use with SCAN/KEYS commands + */ + public function getTagStoragePatterns(string $tagNamePrefix): array + { + $prefix = $this->getCachePrefix(); + + return [ + // Any mode tag storage: {cachePrefix}_any:tag:{tagNamePrefix}* + $prefix . TagMode::Any->tagSegment() . $tagNamePrefix . '*', + // All mode tag storage: {cachePrefix}_all:tag:{tagNamePrefix}* + $prefix . TagMode::All->tagSegment() . $tagNamePrefix . '*', + ]; + } + + /** + * Get patterns to match all cache value keys with a given key prefix. + * + * Returns patterns for BOTH tag modes to ensure complete cleanup + * regardless of current mode (important for --compare-tag-modes): + * - Untagged keys: {cachePrefix}{keyPrefix}* (same in both modes) + * - Tagged keys in all mode: {cachePrefix}{sha1}:{keyPrefix}* (namespaced) + * + * @param string $keyPrefix The prefix to match cache keys against + * @return array Patterns to use with SCAN/KEYS commands + */ + public function getCacheValuePatterns(string $keyPrefix): array + { + $prefix = $this->getCachePrefix(); + + return [ + // Untagged cache values (both modes) and any-mode tagged values + $prefix . $keyPrefix . '*', + // All-mode tagged values at {cachePrefix}{sha1}:{keyName} + $prefix . '*:' . $keyPrefix . '*', + ]; + } + + /** + * Get a value prefixed with the benchmark prefix. + * + * Used for both cache keys and tag names to ensure complete isolation + * from production data and safe cleanup. + */ + public function prefixed(string $value): string + { + return self::KEY_PREFIX . $value; + } + + /** + * Create a progress bar using the command's output style. + */ + public function createProgressBar(int $max): ProgressBar + { + return $this->command->getOutput()->createProgressBar($max); + } + + /** + * Write a line to output. + */ + public function line(string $message): void + { + $this->command->line($message); + } + + /** + * Write a blank line to output. + */ + public function newLine(int $count = 1): void + { + $this->command->newLine($count); + } + + /** + * Call another command (with output). + */ + public function call(string $command, array $arguments = []): int + { + return $this->command->call($command, $arguments); + } + + /** + * Check memory usage and throw exception if approaching limit. + * + * @throws BenchmarkMemoryException + */ + public function checkMemoryUsage(): void + { + $currentUsage = memory_get_usage(true); + $memoryLimit = (new SystemInfo())->getMemoryLimitBytes(); + + if ($memoryLimit === -1) { + return; + } + + $usagePercent = (int) (($currentUsage / $memoryLimit) * 100); + + if ($usagePercent >= $this->memoryThreshold) { + throw new BenchmarkMemoryException($currentUsage, $memoryLimit, $usagePercent); + } + } + + /** + * Perform cleanup of benchmark data. + * + * This method uses mode-aware patterns to ensure complete cleanup: + * 1. Flush all tagged items via $store->tags()->flush() + * 2. Clean non-tagged benchmark keys + * 3. Clean any remaining tag storage structures (matching _bench: prefix) + * 4. Run prune command to clean up orphans + */ + public function cleanup(): void + { + $store = $this->getStore(); + $storeInstance = $this->getStoreInstance(); + + // Build list of all benchmark tags (all prefixed with _bench:) + $tags = [ + $this->prefixed('deep:tag'), + $this->prefixed('read:tag'), + $this->prefixed('bulk:tag'), + $this->prefixed('cleanup:main'), + $this->prefixed('cleanup:shared:1'), + $this->prefixed('cleanup:shared:2'), + $this->prefixed('cleanup:shared:3'), + ]; + + // Standard tags (max 10) + for ($i = 0; $i < 10; ++$i) { + $tags[] = $this->prefixed("tag:{$i}"); + } + + // Heavy tags (max 60 to cover extreme scale) + for ($i = 0; $i < 60; ++$i) { + $tags[] = $this->prefixed("heavy:tag:{$i}"); + } + + // 1. Flush tagged items - this handles cache values, tag hashes/zsets, and registry + $store->tags($tags)->flush(); + + // 2. Clean up non-tagged benchmark keys using mode-aware patterns + // In all mode, tagged keys are at {prefix}{sha1}:{key}, so we need multiple patterns + foreach ($this->getCacheValuePatterns(self::KEY_PREFIX) as $pattern) { + $this->flushKeysByPattern($storeInstance, $pattern); + } + + // 3. Clean up any remaining tag storage structures matching benchmark prefix + // Uses patterns for BOTH modes to ensure complete cleanup after --compare-tag-modes + foreach ($this->getTagStoragePatterns(self::KEY_PREFIX) as $pattern) { + $this->flushKeysByPattern($storeInstance, $pattern); + } + + // 4. Any mode: clean up benchmark entries from the tag registry + if ($this->isAnyMode()) { + $context = $storeInstance->getContext(); + $context->withConnection(function ($conn) use ($context) { + $registryKey = $context->registryKey(); + $members = $conn->zRange($registryKey, 0, -1); + $benchMembers = array_filter( + $members, + fn ($m) => str_starts_with($m, self::KEY_PREFIX) + ); + if (! empty($benchMembers)) { + $conn->zRem($registryKey, ...$benchMembers); + } + }); + } + } + + /** + * Flush keys by pattern using RedisConnection::flushByPattern(). + * + * @param RedisStore $store The Redis store instance + * @param string $pattern The pattern to match (should include cache prefix, NOT OPT_PREFIX) + */ + private function flushKeysByPattern(RedisStore $store, string $pattern): void + { + $store->getContext()->withConnection( + fn (RedisConnection $conn) => $conn->flushByPattern($pattern) + ); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/ResultsFormatter.php b/src/cache/src/Redis/Console/Benchmark/ResultsFormatter.php new file mode 100644 index 000000000..5d692a2b9 --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/ResultsFormatter.php @@ -0,0 +1,209 @@ +> + */ + private array $metricGroups = [ + 'Non-Tagged Operations' => [ + 'put_rate' => ['label' => 'put()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'nontagged'], + 'get_rate' => ['label' => 'get()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'nontagged'], + 'forget_rate' => ['label' => 'forget()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'nontagged'], + 'add_rate_nontagged' => ['label' => 'add()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'nontagged', 'key' => 'add_rate'], + 'remember_rate' => ['label' => 'remember()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'nontagged'], + 'putmany_rate_nontagged' => ['label' => 'putMany()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'nontagged', 'key' => 'putmany_rate'], + ], + 'Tagged Operations' => [ + 'write_rate' => ['label' => 'put()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'standard'], + 'read_rate' => ['label' => 'get()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'read'], + 'add_rate_tagged' => ['label' => 'add()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'standard', 'key' => 'add_rate'], + 'remember_rate_tagged' => ['label' => 'remember()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'standard', 'key' => 'remember_rate'], + 'putmany_rate_tagged' => ['label' => 'putMany()', 'unit' => 'Items/sec', 'format' => 'rate', 'better' => 'higher', 'scenario' => 'standard', 'key' => 'putmany_rate'], + 'flush_time' => ['label' => 'flush() 1 tag', 'unit' => 'Seconds', 'format' => 'time', 'better' => 'lower', 'scenario' => 'standard'], + ], + 'Maintenance' => [ + 'cleanup_time' => ['label' => 'Prune stale tags', 'unit' => 'Seconds', 'format' => 'time', 'better' => 'lower', 'scenario' => 'cleanup'], + ], + ]; + + /** + * Create a new results formatter instance. + */ + public function __construct(Command $command) + { + $this->command = $command; + } + + /** + * Display results table for a single mode. + * + * @param array $results + */ + public function displayResultsTable(array $results, string $tagMode): void + { + $this->command->newLine(); + $this->command->info('═══════════════════════════════════════════════════════════════'); + $this->command->info(" Results ({$tagMode} mode)"); + $this->command->info('═══════════════════════════════════════════════════════════════'); + $this->command->newLine(); + + $tableData = []; + + foreach ($this->metricGroups as $groupName => $metrics) { + $groupHasData = false; + + foreach ($metrics as $metricId => $config) { + $scenario = $config['scenario']; + $metricKey = $config['key'] ?? $metricId; + + if (! isset($results[$scenario])) { + continue; + } + + $value = $results[$scenario]->get($metricKey); + + if ($value === null) { + continue; + } + + if (! $groupHasData) { + // Add group header as a separator row + $tableData[] = ["{$groupName}", '']; + $groupHasData = true; + } + + $tableData[] = [ + ' ' . $config['label'] . ' (' . $config['unit'] . ')', + $this->formatValue($value, $config['format']), + ]; + } + } + + $this->command->table( + ['Metric', 'Result'], + $tableData + ); + } + + /** + * Display comparison table between two tag modes. + * + * @param array $allModeResults + * @param array $anyModeResults + */ + public function displayComparisonTable(array $allModeResults, array $anyModeResults): void + { + $this->command->newLine(); + $this->command->info('═══════════════════════════════════════════════════════════════'); + $this->command->info(' Tag Mode Comparison: All vs Any'); + $this->command->info('═══════════════════════════════════════════════════════════════'); + $this->command->newLine(); + + $tableData = []; + + foreach ($this->metricGroups as $groupName => $metrics) { + $groupHasData = false; + + foreach ($metrics as $metricId => $config) { + $scenario = $config['scenario']; + $metricKey = $config['key'] ?? $metricId; + + $allValue = isset($allModeResults[$scenario]) ? $allModeResults[$scenario]->get($metricKey) : null; + $anyValue = isset($anyModeResults[$scenario]) ? $anyModeResults[$scenario]->get($metricKey) : null; + + if ($allValue === null && $anyValue === null) { + continue; + } + + if (! $groupHasData) { + // Add group header as a separator row + $tableData[] = ["{$groupName}", '', '', '']; + $groupHasData = true; + } + + $diff = $this->calculateDiff($allValue, $anyValue, $config['better']); + + $tableData[] = [ + ' ' . $config['label'] . ' (' . $config['unit'] . ')', + $allValue !== null ? $this->formatValue($allValue, $config['format']) : 'N/A', + $anyValue !== null ? $this->formatValue($anyValue, $config['format']) : 'N/A', + $diff, + ]; + } + } + + $this->command->table( + ['Metric', 'All Mode', 'Any Mode', 'Diff'], + $tableData + ); + + $this->displayLegend(); + } + + /** + * Display the legend explaining color coding. + */ + private function displayLegend(): void + { + $this->command->newLine(); + $this->command->line(' Legend: Diff shows Any Mode relative to All Mode'); + $this->command->line(' Green (+%) = Any Mode is better'); + $this->command->line(' Red (-%) = Any Mode is worse'); + $this->command->line(' For times, lower is better. For rates, higher is better.'); + } + + /** + * Format a value based on its type. + */ + private function formatValue(float $value, string $format): string + { + return match ($format) { + 'rate' => Number::format($value, precision: 0), + 'time' => Number::format($value, precision: 4) . 's', + default => (string) $value, + }; + } + + /** + * Calculate the percentage difference and format with color. + */ + private function calculateDiff(?float $allValue, ?float $anyValue, string $better): string + { + if ($allValue === null || $anyValue === null || $allValue == 0) { + return '-'; + } + + // Calculate percentage difference: (any - all) / all * 100 + $percentDiff = (($anyValue - $allValue) / $allValue) * 100; + + // Determine if "any" mode is better + // For rates (higher is better): positive diff = any is better + // For times (lower is better): negative diff = any is better + $anyIsBetter = ($better === 'higher' && $percentDiff > 0) + || ($better === 'lower' && $percentDiff < 0); + + $color = $anyIsBetter ? 'green' : 'red'; + $sign = $percentDiff >= 0 ? '+' : ''; + + return sprintf('%s%.1f%%', $color, $sign, $percentDiff); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/ScenarioResult.php b/src/cache/src/Redis/Console/Benchmark/ScenarioResult.php new file mode 100644 index 000000000..36562d79a --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/ScenarioResult.php @@ -0,0 +1,37 @@ + $metrics Metric name => value (e.g., ['write_rate' => 1234.5, 'flush_time' => 0.05]) + */ + public function __construct( + public readonly array $metrics, + ) { + } + + /** + * Get a specific metric value. + */ + public function get(string $key): ?float + { + return $this->metrics[$key] ?? null; + } + + /** + * Convert to array for compatibility. + */ + public function toArray(): array + { + return $this->metrics; + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/Scenarios/BulkWriteScenario.php b/src/cache/src/Redis/Console/Benchmark/Scenarios/BulkWriteScenario.php new file mode 100644 index 000000000..f4212092c --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/Scenarios/BulkWriteScenario.php @@ -0,0 +1,67 @@ +items; + $ctx->newLine(); + $ctx->line(" Running Bulk Write Scenario (putMany, {$items} items)..."); + $ctx->cleanup(); + + $store = $ctx->getStore(); + $chunkSize = 100; + + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + $tag = $ctx->prefixed('bulk:tag'); + $buffer = []; + + for ($i = 0; $i < $items; ++$i) { + $buffer[$ctx->prefixed("bulk:{$i}")] = 'value'; + + if (count($buffer) >= $chunkSize) { + $store->tags([$tag])->putMany($buffer, 3600); + $buffer = []; + $bar->advance($chunkSize); + } + } + + if (! empty($buffer)) { + $store->tags([$tag])->putMany($buffer, 3600); + $bar->advance(count($buffer)); + } + + $bar->finish(); + $ctx->line(''); + + $writeTime = (hrtime(true) - $start) / 1e9; + $writeRate = $items / $writeTime; + + return new ScenarioResult([ + 'write_rate' => $writeRate, + ]); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/Scenarios/CleanupScenario.php b/src/cache/src/Redis/Console/Benchmark/Scenarios/CleanupScenario.php new file mode 100644 index 000000000..07231931f --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/Scenarios/CleanupScenario.php @@ -0,0 +1,75 @@ +items / 2)); + + $ctx->newLine(); + $ctx->line(" Running Cleanup Scenario ({$adjustedItems} items, shared tags)..."); + $ctx->cleanup(); + + $mainTag = $ctx->prefixed('cleanup:main'); + $sharedTags = [ + $ctx->prefixed('cleanup:shared:1'), + $ctx->prefixed('cleanup:shared:2'), + $ctx->prefixed('cleanup:shared:3'), + ]; + $allTags = array_merge([$mainTag], $sharedTags); + + // 1. Write items with shared tags + $bar = $ctx->createProgressBar($adjustedItems); + $store = $ctx->getStore(); + + for ($i = 0; $i < $adjustedItems; ++$i) { + $store->tags($allTags)->put($ctx->prefixed("cleanup:{$i}"), 'value', 3600); + + if ($i % 100 === 0) { + $bar->advance(100); + } + } + + $bar->finish(); + $ctx->line(''); + + // 2. Flush main tag (creates orphans in shared tags in any mode) + $ctx->line(' Flushing main tag...'); + $store->tags([$mainTag])->flush(); + + // 3. Run Cleanup + $ctx->line(' Running cleanup command...'); + $ctx->newLine(); + $start = hrtime(true); + + $ctx->call('cache:prune-redis-stale-tags', ['store' => $ctx->storeName]); + + $cleanupTime = (hrtime(true) - $start) / 1e9; + + return new ScenarioResult([ + 'cleanup_time' => $cleanupTime, + ]); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/Scenarios/DeepTaggingScenario.php b/src/cache/src/Redis/Console/Benchmark/Scenarios/DeepTaggingScenario.php new file mode 100644 index 000000000..c2407e7db --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/Scenarios/DeepTaggingScenario.php @@ -0,0 +1,64 @@ +items; + $ctx->newLine(); + $ctx->line(" Running Deep Tagging Scenario (1 tag, {$items} items)..."); + $ctx->cleanup(); + + $tag = $ctx->prefixed('deep:tag'); + + // 1. Write + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + $store = $ctx->getStore(); + + $chunkSize = 100; + + for ($i = 0; $i < $items; ++$i) { + $store->tags([$tag])->put($ctx->prefixed("deep:{$i}"), 'value', 3600); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + // 2. Flush + $ctx->line(' Flushing deep tag...'); + $start = hrtime(true); + $store->tags([$tag])->flush(); + $flushTime = (hrtime(true) - $start) / 1e9; + + return new ScenarioResult([ + 'flush_time' => $flushTime, + ]); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/Scenarios/HeavyTaggingScenario.php b/src/cache/src/Redis/Console/Benchmark/Scenarios/HeavyTaggingScenario.php new file mode 100644 index 000000000..351209f75 --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/Scenarios/HeavyTaggingScenario.php @@ -0,0 +1,77 @@ +heavyTags; + + // Reduce items for heavy tagging to keep benchmark time reasonable + $adjustedItems = max(100, (int) ($ctx->items / 5)); + + $ctx->newLine(); + $ctx->line(" Running Heavy Tagging Scenario ({$adjustedItems} items, {$tagsPerItem} tags/item)..."); + $ctx->cleanup(); + + // Build tags array + $tags = []; + + for ($i = 0; $i < $tagsPerItem; ++$i) { + $tags[] = $ctx->prefixed("heavy:tag:{$i}"); + } + + // 1. Write + $start = hrtime(true); + $bar = $ctx->createProgressBar($adjustedItems); + $store = $ctx->getStore(); + + $chunkSize = 10; + + for ($i = 0; $i < $adjustedItems; ++$i) { + $store->tags($tags)->put($ctx->prefixed("heavy:{$i}"), 'value', 3600); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $writeTime = (hrtime(true) - $start) / 1e9; + $writeRate = $adjustedItems / $writeTime; + + // 2. Flush (Flush one tag) + $ctx->line(' Flushing heavy items by single tag...'); + $start = hrtime(true); + $store->tags([$tags[0]])->flush(); + $flushTime = (hrtime(true) - $start) / 1e9; + + return new ScenarioResult([ + 'write_rate' => $writeRate, + 'flush_time' => $flushTime, + ]); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/Scenarios/NonTaggedScenario.php b/src/cache/src/Redis/Console/Benchmark/Scenarios/NonTaggedScenario.php new file mode 100644 index 000000000..21a6a4d8c --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/Scenarios/NonTaggedScenario.php @@ -0,0 +1,179 @@ +items; + $ctx->newLine(); + $ctx->line(" Running Non-Tagged Operations Scenario ({$items} items)..."); + $ctx->cleanup(); + + $store = $ctx->getStore(); + $chunkSize = 100; + + // 1. Write Performance (put) + $ctx->line(' Testing put()...'); + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + for ($i = 0; $i < $items; ++$i) { + $store->put($ctx->prefixed("nontagged:put:{$i}"), 'value', 3600); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $putTime = (hrtime(true) - $start) / 1e9; + $putRate = $items / $putTime; + + // 2. Read Performance (get) + $ctx->line(' Testing get()...'); + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + for ($i = 0; $i < $items; ++$i) { + $store->get($ctx->prefixed("nontagged:put:{$i}")); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $getTime = (hrtime(true) - $start) / 1e9; + $getRate = $items / $getTime; + + // 3. Delete Performance (forget) + $ctx->line(' Testing forget()...'); + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + for ($i = 0; $i < $items; ++$i) { + $store->forget($ctx->prefixed("nontagged:put:{$i}")); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $forgetTime = (hrtime(true) - $start) / 1e9; + $forgetRate = $items / $forgetTime; + + // 4. Remember Performance (cache miss + store) + $ctx->line(' Testing remember()...'); + $rememberItems = min(1000, (int) ($items / 10)); + $start = hrtime(true); + $bar = $ctx->createProgressBar($rememberItems); + $rememberChunk = 10; + + for ($i = 0; $i < $rememberItems; ++$i) { + $store->remember($ctx->prefixed("nontagged:remember:{$i}"), 3600, function (): string { + return 'computed_value'; + }); + + if ($i % $rememberChunk === 0) { + $bar->advance($rememberChunk); + } + } + + $bar->finish(); + $ctx->line(''); + + $rememberTime = (hrtime(true) - $start) / 1e9; + $rememberRate = $rememberItems / $rememberTime; + + // 5. Bulk Write Performance (putMany) + $ctx->line(' Testing putMany()...'); + $ctx->cleanup(); + $bulkChunkSize = 100; + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + $buffer = []; + + for ($i = 0; $i < $items; ++$i) { + $buffer[$ctx->prefixed("nontagged:bulk:{$i}")] = 'value'; + + if (count($buffer) >= $bulkChunkSize) { + $store->putMany($buffer, 3600); + $buffer = []; + $bar->advance($bulkChunkSize); + } + } + + if (! empty($buffer)) { + $store->putMany($buffer, 3600); + $bar->advance(count($buffer)); + } + + $bar->finish(); + $ctx->line(''); + + $putManyTime = (hrtime(true) - $start) / 1e9; + $putManyRate = $items / $putManyTime; + + // 6. Add Performance (add) + $ctx->line(' Testing add()...'); + $ctx->cleanup(); + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + for ($i = 0; $i < $items; ++$i) { + $store->add($ctx->prefixed("nontagged:add:{$i}"), 'value', 3600); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $addTime = (hrtime(true) - $start) / 1e9; + $addRate = $items / $addTime; + + return new ScenarioResult([ + 'put_rate' => $putRate, + 'get_rate' => $getRate, + 'forget_rate' => $forgetRate, + 'remember_rate' => $rememberRate, + 'putmany_rate' => $putManyRate, + 'add_rate' => $addRate, + ]); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/Scenarios/ReadPerformanceScenario.php b/src/cache/src/Redis/Console/Benchmark/Scenarios/ReadPerformanceScenario.php new file mode 100644 index 000000000..8f5abc056 --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/Scenarios/ReadPerformanceScenario.php @@ -0,0 +1,84 @@ +items; + $ctx->newLine(); + $ctx->line(' Running Read Performance Scenario...'); + $ctx->cleanup(); + + $store = $ctx->getStore(); + $chunkSize = 100; + + // Seed data + $bar = $ctx->createProgressBar($items); + + $tag = $ctx->prefixed('read:tag'); + + for ($i = 0; $i < $items; ++$i) { + $store->tags([$tag])->put($ctx->prefixed("read:{$i}"), 'value', 3600); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + // Read performance + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + // In 'any' mode, items can be read directly without specifying tags + // In 'all' mode, items must be read with the same tags used when storing + $isAnyMode = $ctx->getStoreInstance()->getTagMode()->isAnyMode(); + + for ($i = 0; $i < $items; ++$i) { + if ($isAnyMode) { + $store->get($ctx->prefixed("read:{$i}")); + } else { + $store->tags([$tag])->get($ctx->prefixed("read:{$i}")); + } + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $readTime = (hrtime(true) - $start) / 1e9; + $readRate = $items / $readTime; + + return new ScenarioResult([ + 'read_rate' => $readRate, + ]); + } +} diff --git a/src/cache/src/Redis/Console/Benchmark/Scenarios/ScenarioInterface.php b/src/cache/src/Redis/Console/Benchmark/Scenarios/ScenarioInterface.php new file mode 100644 index 000000000..97c1deab7 --- /dev/null +++ b/src/cache/src/Redis/Console/Benchmark/Scenarios/ScenarioInterface.php @@ -0,0 +1,24 @@ +items; + $tagsPerItem = $ctx->tagsPerItem; + + $ctx->newLine(); + $ctx->line(" Running Standard Tagging Scenario ({$items} items, {$tagsPerItem} tags/item)..."); + $ctx->cleanup(); + + // Build tags array + $tags = []; + + for ($i = 0; $i < $tagsPerItem; ++$i) { + $tags[] = $ctx->prefixed("tag:{$i}"); + } + + // 1. Write + $ctx->line(' Testing put() with tags...'); + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + $store = $ctx->getStore(); + $chunkSize = 100; + + for ($i = 0; $i < $items; ++$i) { + $store->tags($tags)->put($ctx->prefixed("item:{$i}"), 'value', 3600); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $writeTime = (hrtime(true) - $start) / 1e9; + $writeRate = $items / $writeTime; + + // 2. Flush (Flush one tag, which removes all $items items since all share this tag) + $ctx->line(" Flushing {$items} items via 1 tag..."); + $start = hrtime(true); + $store->tags([$tags[0]])->flush(); + $flushTime = (hrtime(true) - $start) / 1e9; + + // 3. Add Performance (add) + $ctx->cleanup(); + $ctx->line(' Testing add() with tags...'); + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + for ($i = 0; $i < $items; ++$i) { + $store->tags($tags)->add($ctx->prefixed("item:add:{$i}"), 'value', 3600); + + if ($i % $chunkSize === 0) { + $bar->advance($chunkSize); + $ctx->checkMemoryUsage(); + } + } + + $bar->finish(); + $ctx->line(''); + + $addTime = (hrtime(true) - $start) / 1e9; + $addRate = $items / $addTime; + + // 4. Remember Performance (cache miss + store with tags) + $ctx->cleanup(); + $ctx->line(' Testing remember() with tags...'); + $rememberItems = min(1000, (int) ($items / 10)); + $start = hrtime(true); + $bar = $ctx->createProgressBar($rememberItems); + $rememberChunk = 10; + + for ($i = 0; $i < $rememberItems; ++$i) { + $store->tags($tags)->remember($ctx->prefixed("item:remember:{$i}"), 3600, function (): string { + return 'computed_value'; + }); + + if ($i % $rememberChunk === 0) { + $bar->advance($rememberChunk); + } + } + + $bar->finish(); + $ctx->line(''); + + $rememberTime = (hrtime(true) - $start) / 1e9; + $rememberRate = $rememberItems / $rememberTime; + + // 5. Bulk Write Performance (putMany) + $ctx->cleanup(); + $ctx->line(' Testing putMany() with tags...'); + $bulkChunkSize = 100; + $start = hrtime(true); + $bar = $ctx->createProgressBar($items); + + $buffer = []; + + for ($i = 0; $i < $items; ++$i) { + $buffer[$ctx->prefixed("item:bulk:{$i}")] = 'value'; + + if (count($buffer) >= $bulkChunkSize) { + $store->tags($tags)->putMany($buffer, 3600); + $buffer = []; + $bar->advance($bulkChunkSize); + } + } + + if (! empty($buffer)) { + $store->tags($tags)->putMany($buffer, 3600); + $bar->advance(count($buffer)); + } + + $bar->finish(); + $ctx->line(''); + + $putManyTime = (hrtime(true) - $start) / 1e9; + $putManyRate = $items / $putManyTime; + + return new ScenarioResult([ + 'write_time' => $writeTime, + 'write_rate' => $writeRate, + 'flush_time' => $flushTime, + 'add_rate' => $addRate, + 'remember_rate' => $rememberRate, + 'putmany_rate' => $putManyRate, + ]); + } +} diff --git a/src/cache/src/Redis/Console/BenchmarkCommand.php b/src/cache/src/Redis/Console/BenchmarkCommand.php new file mode 100644 index 000000000..8fca0c462 --- /dev/null +++ b/src/cache/src/Redis/Console/BenchmarkCommand.php @@ -0,0 +1,580 @@ + + */ + protected array $scales = [ + 'small' => ['items' => 1000, 'tags_per_item' => 3, 'heavy_tags' => 10], + 'medium' => ['items' => 10000, 'tags_per_item' => 5, 'heavy_tags' => 20], + 'large' => ['items' => 100000, 'tags_per_item' => 5, 'heavy_tags' => 50], + 'extreme' => ['items' => 1000000, 'tags_per_item' => 5, 'heavy_tags' => 50], + ]; + + /** + * Recommended memory limits in MB per scale. + * + * @var array + */ + protected array $recommendedMemory = [ + 'small' => 256, + 'medium' => 512, + 'large' => 1024, + 'extreme' => 2048, + ]; + + protected string $storeName; + + private ResultsFormatter $formatter; + + /** + * Execute the console command. + */ + public function handle(): int + { + $this->displayHeader(); + $this->formatter = new ResultsFormatter($this); + + // 1. Validate options early + $scale = $this->option('scale'); + + if (! isset($this->scales[$scale])) { + $this->error("Invalid scale: {$scale}. Available: " . implode(', ', array_keys($this->scales))); + + return self::FAILURE; + } + + $runs = (int) $this->option('runs'); + + if ($runs < 1 || $runs > 10) { + $this->error("Invalid runs: {$runs}. Must be between 1 and 10."); + + return self::FAILURE; + } + + // Validate tag mode if provided + $tagModeOption = $this->option('tag-mode'); + + if ($tagModeOption !== null && ! in_array($tagModeOption, ['all', 'any'], true)) { + $this->error("Invalid tag mode: {$tagModeOption}. Available: all, any"); + + return self::FAILURE; + } + + // 2. Setup & Validation + if (! $this->setup()) { + return self::FAILURE; + } + + // 3. Check for monitoring tools + if (! $this->checkMonitoringTools()) { + return self::FAILURE; + } + + // 4. Display System Information + $this->displaySystemInfo(); + + // 5. Check memory requirements + $this->checkMemoryRequirements($scale); + + // 6. Safety Check + if (! $this->confirmSafeToRun()) { + $this->info('Benchmark cancelled.'); + + return self::SUCCESS; + } + + $config = $this->scales[$scale]; + $runsText = $runs > 1 ? " averaging {$runs} runs" : ''; + $this->info("Running benchmark at {$scale} scale ({$config['items']} items){$runsText}."); + $this->newLine(); + + $cacheManager = $this->app->get(CacheContract::class); + $ctx = $this->createContext($config, $cacheManager); + + try { + // Run Benchmark(s) + if ($this->option('compare-tag-modes')) { + $this->runComparison($ctx, $runs); + } else { + // Use provided tag mode or current config + $store = $ctx->getStoreInstance(); + $tagMode = $tagModeOption ?? $store->getTagMode()->value; + $this->runSuiteWithRuns($tagMode, $ctx, $runs); + } + } catch (BenchmarkMemoryException $e) { + $this->displayMemoryError($e); + + return self::FAILURE; + } + + $this->newLine(); + $this->info('Cleaning up benchmark data...'); + $ctx->cleanup(); + + return self::SUCCESS; + } + + /** + * Get the list of scenarios to run. + * + * @return array + */ + protected function getScenarios(): array + { + return [ + new NonTaggedScenario(), + new StandardTaggingScenario(), + new HeavyTaggingScenario(), + new DeepTaggingScenario(), + new CleanupScenario(), + new BulkWriteScenario(), + new ReadPerformanceScenario(), + ]; + } + + /** + * Create a benchmark context with the given configuration. + */ + protected function createContext(array $config, CacheContract $cacheManager): BenchmarkContext + { + return new BenchmarkContext( + storeName: $this->storeName, + items: $config['items'], + tagsPerItem: $config['tags_per_item'], + heavyTags: $config['heavy_tags'], + command: $this, + cacheManager: $cacheManager, + ); + } + + /** + * Validate options and detect the redis store. + */ + protected function setup(): bool + { + $this->storeName = $this->option('store') ?? $this->detectRedisStore(); + + if (! $this->storeName) { + $this->error('Could not detect a cache store using the "redis" driver.'); + + return false; + } + + $cacheManager = $this->app->get(CacheContract::class); + + try { + $storeInstance = $cacheManager->store($this->storeName)->getStore(); + + if (! $storeInstance instanceof RedisStore) { + $this->error("The cache store '{$this->storeName}' is not using the 'redis' driver."); + $this->error('Found: ' . $storeInstance::class); + + return false; + } + + // Test connection + $cacheManager->store($this->storeName)->get('test'); + } catch (Exception $e) { + $this->error("Could not connect to Redis store '{$this->storeName}': " . $e->getMessage()); + + return false; + } + + return true; + } + + /** + * Check for active monitoring tools that could skew results. + */ + protected function checkMonitoringTools(): bool + { + $config = $this->app->get(ConfigInterface::class); + $monitoringTools = (new MonitoringDetector($config))->detect(); + + if (! empty($monitoringTools) && ! $this->option('force')) { + $this->newLine(); + $this->error('Monitoring/profiling tools detected that will skew benchmark results:'); + $this->newLine(); + + foreach ($monitoringTools as $tool => $howToDisable) { + $this->line(" • {$tool} - set {$howToDisable}"); + } + + $this->newLine(); + $this->line('These tools intercept every cache operation, adding overhead that does not'); + $this->line('exist in production. They also consume significant memory.'); + $this->newLine(); + $this->line('Either disable these tools, or run with --force to benchmark anyway.'); + + return false; + } + + if (! empty($monitoringTools) && $this->option('force')) { + $this->newLine(); + $this->warn('Running with --force despite monitoring tools being active.'); + $this->warn(' Results will be slower than production performance.'); + $this->newLine(); + } + + return true; + } + + /** + * Prompt user to confirm running the benchmark. + */ + protected function confirmSafeToRun(): bool + { + if ($this->option('force')) { + return true; + } + + $config = $this->app->get(ConfigInterface::class); + $env = $config->get('app.env', 'production'); + $scale = $this->option('scale'); + + $this->warn('WARNING: This benchmark will put EXTREME load on your Redis instance'); + $this->newLine(); + + $this->line('This command will:'); + $this->line(' - Create thousands/millions of cache keys'); + $this->line(' - Perform intensive flush operations'); + $this->line(' - Use significant CPU and memory'); + $this->line(' - Potentially impact other applications using the same Redis instance'); + $this->newLine(); + + if ($env === 'production') { + $this->error('PRODUCTION ENVIRONMENT DETECTED!'); + $this->error('Running this benchmark on production is STRONGLY DISCOURAGED.'); + $this->newLine(); + } + + $this->line("Scale: {$scale}"); + $this->newLine(); + + $this->line('Recommendations:'); + $this->line(' - Run on development/staging environment only'); + $this->line(' - Use a dedicated Redis instance for benchmarking'); + $this->newLine(); + + return $this->confirm('Do you want to proceed with the benchmark?', false); + } + + /** + * Run benchmark comparison between all and any tag modes. + */ + protected function runComparison(BenchmarkContext $ctx, int $runs): void + { + $this->info('Running comparison between All and Any tag modes...'); + $this->newLine(); + + $this->info('--- Phase 1: All Mode (Intersection) ---'); + $allResults = $this->runSuiteWithRuns('all', $ctx, $runs, returnResults: true); + + $this->newLine(); + $this->info('--- Phase 2: Any Mode (Union) ---'); + $anyResults = $this->runSuiteWithRuns('any', $ctx, $runs, returnResults: true); + + $this->formatter->displayComparisonTable($allResults, $anyResults); + } + + /** + * Run benchmark suite multiple times and average results. + * + * @return array + */ + protected function runSuiteWithRuns(string $tagMode, BenchmarkContext $ctx, int $runs, bool $returnResults = false): array + { + /** @var array> $allRunResults */ + $allRunResults = []; + + for ($run = 1; $run <= $runs; ++$run) { + if ($runs > 1) { + $this->line("Run {$run}/{$runs}"); + } + + $results = $this->runSuite($tagMode, $ctx); + $allRunResults[] = $results; + + if ($run < $runs) { + $this->newLine(); + $this->line('Pausing 1 second before next run...'); + $this->newLine(); + sleep(1); + } + } + + $averagedResults = $this->averageResults($allRunResults); + + if (! $returnResults) { + $this->formatter->displayResultsTable($averagedResults, $tagMode); + } + + return $averagedResults; + } + + /** + * Run all benchmark scenarios once with specified tag mode. + * + * @return array + */ + protected function runSuite(string $tagMode, BenchmarkContext $ctx): array + { + // Set the tag mode on the store + $store = $ctx->getStoreInstance(); + $store->setTagMode(TagMode::fromConfig($tagMode)); + + $this->line("Tag Mode: {$tagMode}"); + + $results = []; + + foreach ($this->getScenarios() as $scenario) { + $key = $this->scenarioKey($scenario); + $result = $scenario->run($ctx); + $results[$key] = $result; + } + + return $results; + } + + /** + * Average results from multiple runs. + * + * @param array> $allRunResults + * @return array + */ + protected function averageResults(array $allRunResults): array + { + if (count($allRunResults) === 1) { + return $allRunResults[0]; + } + + $averaged = []; + + // Get all scenario keys from first run + foreach (array_keys($allRunResults[0]) as $scenarioKey) { + $metrics = []; + + // Collect metrics from all runs for this scenario + foreach ($allRunResults as $runResult) { + if (isset($runResult[$scenarioKey])) { + foreach ($runResult[$scenarioKey]->toArray() as $metricKey => $value) { + $metrics[$metricKey][] = $value; + } + } + } + + // Average each metric + $averagedMetrics = []; + + foreach ($metrics as $metricKey => $values) { + $averagedMetrics[$metricKey] = array_sum($values) / count($values); + } + + $averaged[$scenarioKey] = new ScenarioResult($averagedMetrics); + } + + return $averaged; + } + + /** + * Get the result key for a scenario. + */ + private function scenarioKey(ScenarioInterface $scenario): string + { + return match ($scenario::class) { + NonTaggedScenario::class => 'nontagged', + StandardTaggingScenario::class => 'standard', + HeavyTaggingScenario::class => 'heavy', + DeepTaggingScenario::class => 'deep', + CleanupScenario::class => 'cleanup', + BulkWriteScenario::class => 'bulk', + ReadPerformanceScenario::class => 'read', + default => strtolower(basename(str_replace('\\', '/', $scenario::class))), + }; + } + + /** + * Display the command header banner. + */ + protected function displayHeader(): void + { + $this->newLine(); + $this->info('╔═══════════════════════════════════════════════════════════════╗'); + $this->info('║ Hypervel Redis Cache - Performance Benchmark ║'); + $this->info('╚═══════════════════════════════════════════════════════════════╝'); + $this->newLine(); + } + + /** + * Display system and environment information. + */ + protected function displaySystemInfo(): void + { + $systemInfo = new SystemInfo(); + + $this->info('System Information'); + $this->line(str_repeat('─', 63)); + + $os = PHP_OS_FAMILY; + $osVersion = php_uname('r'); + $this->line(" OS: {$os} {$osVersion}"); + + $arch = php_uname('m'); + $this->line(" Architecture: {$arch}"); + + $phpVersion = PHP_VERSION; + $this->line(" PHP: {$phpVersion}"); + + $cpuCores = $systemInfo->getCpuCores(); + + if ($cpuCores) { + $this->line(" CPU Cores: {$cpuCores}"); + } + + $totalMemory = $systemInfo->getTotalMemory(); + + if ($totalMemory) { + $this->line(" Total Memory: {$totalMemory}"); + } + + $memoryLimit = $systemInfo->getMemoryLimitFormatted(); + $this->line(" PHP Memory Limit: {$memoryLimit}"); + + $vmType = $systemInfo->detectVirtualization(); + + if ($vmType) { + $this->line(" Virtualization: {$vmType}"); + } + + // Display Redis/Valkey info + $cacheManager = $this->app->get(CacheContract::class); + + try { + $store = $cacheManager->store($this->storeName)->getStore(); + + if ($store instanceof RedisStore) { + $context = $store->getContext(); + $info = $context->withConnection( + fn (RedisConnection $conn) => $conn->info('server') + ); + + if (isset($info['valkey_version'])) { + $this->line(" Cache Service: Valkey {$info['valkey_version']}"); + } elseif (isset($info['redis_version'])) { + $this->line(" Cache Service: Redis {$info['redis_version']}"); + } + + $this->line(' Tag Mode: ' . $store->getTagMode()->value . ''); + } + } catch (Exception) { + // Silently skip if Redis connection fails + } + + $this->newLine(); + } + + /** + * Warn if memory limit is below recommended for the scale. + */ + protected function checkMemoryRequirements(string $scale): void + { + $recommended = $this->recommendedMemory[$scale] ?? 256; + $currentLimitBytes = (new SystemInfo())->getMemoryLimitBytes(); + + if ($currentLimitBytes === -1) { + return; + } + + $currentLimitMB = (int) ($currentLimitBytes / 1024 / 1024); + + if ($currentLimitMB < $recommended) { + $this->warn("Memory limit ({$currentLimitMB}MB) is below recommended ({$recommended}MB) for '{$scale}' scale."); + $this->line(' Consider: php -d memory_limit=' . $recommended . 'M bin/hyperf.php cache:redis-benchmark'); + $this->newLine(); + } + } + + /** + * Display memory exhaustion error with recovery guidance. + */ + protected function displayMemoryError(BenchmarkMemoryException $e): void + { + $config = $this->app->get(ConfigInterface::class); + + $this->newLine(); + $this->error('Benchmark aborted due to memory constraints.'); + $this->newLine(); + $this->line($e->getMessage()); + $this->newLine(); + $this->warn('Cleanup skipped to avoid further memory exhaustion.'); + $this->line(' After fixing memory issues, clean up leftover benchmark keys:'); + $this->newLine(); + $this->line(' Option 1 - Clear all cache (simple):'); + $this->line(' php bin/hyperf.php cache:clear --store=' . $this->storeName . ''); + $this->newLine(); + $this->line(' Option 2 - Clear only benchmark keys (preserves other cache):'); + $cachePrefix = $config->get("cache.stores.{$this->storeName}.prefix", $config->get('cache.prefix', '')); + $this->line(' redis-cli KEYS "' . $cachePrefix . BenchmarkContext::KEY_PREFIX . '*" | xargs redis-cli DEL'); + } + + /** + * Get the console command options. + */ + protected function getOptions(): array + { + return [ + ['scale', null, InputOption::VALUE_OPTIONAL, 'Scale of the benchmark (small, medium, large, extreme)', 'medium'], + ['tag-mode', null, InputOption::VALUE_OPTIONAL, 'Tag mode to test (all, any). Defaults to current config.'], + ['compare-tag-modes', null, InputOption::VALUE_NONE, 'Run benchmark in both tag modes and compare results'], + ['store', null, InputOption::VALUE_OPTIONAL, 'The cache store to use (defaults to detecting redis driver)'], + ['runs', null, InputOption::VALUE_OPTIONAL, 'Number of runs to average (default: 3)', '3'], + ['force', null, InputOption::VALUE_NONE, 'Skip confirmation prompt'], + ]; + } +} diff --git a/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php new file mode 100644 index 000000000..7035764c6 --- /dev/null +++ b/src/cache/src/Redis/Console/Concerns/DetectsRedisStore.php @@ -0,0 +1,30 @@ +app->get(ConfigInterface::class); + $stores = $config->get('cache.stores', []); + + foreach ($stores as $name => $storeConfig) { + if (($storeConfig['driver'] ?? null) === 'redis') { + return $name; + } + } + + return null; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/CheckResult.php b/src/cache/src/Redis/Console/Doctor/CheckResult.php new file mode 100644 index 000000000..8c676bf44 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/CheckResult.php @@ -0,0 +1,71 @@ + + */ + public array $assertions = []; + + /** + * Record an assertion result. + */ + public function assert(bool $condition, string $description): void + { + $this->assertions[] = [ + 'passed' => $condition, + 'description' => $description, + ]; + } + + /** + * Get the number of passed assertions. + */ + public function passCount(): int + { + return count(array_filter($this->assertions, fn (array $a): bool => $a['passed'])); + } + + /** + * Get the number of failed assertions. + */ + public function failCount(): int + { + return count(array_filter($this->assertions, fn (array $a): bool => ! $a['passed'])); + } + + /** + * Check if all assertions passed. + */ + public function passed(): bool + { + if (empty($this->assertions)) { + return true; + } + + return $this->failCount() === 0; + } + + /** + * Get all failed assertion descriptions. + * + * @return array + */ + public function failures(): array + { + return array_map( + fn (array $a): string => $a['description'], + array_filter($this->assertions, fn (array $a): bool => ! $a['passed']) + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/AddOperationsCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/AddOperationsCheck.php new file mode 100644 index 000000000..df1263708 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/AddOperationsCheck.php @@ -0,0 +1,93 @@ +cache->add($ctx->prefixed('add:new'), 'first', 60); + $result->assert( + $addResult === true && $ctx->cache->get($ctx->prefixed('add:new')) === 'first', + 'add() succeeds for non-existent key' + ); + + // Try to add existing key + $addResult = $ctx->cache->add($ctx->prefixed('add:new'), 'second', 60); + $result->assert( + $addResult === false && $ctx->cache->get($ctx->prefixed('add:new')) === 'first', + 'add() fails for existing key (value unchanged)' + ); + + // Add with tags + $addTag = $ctx->prefixed('unique'); + $addKey = $ctx->prefixed('add:tagged'); + $addResult = $ctx->cache->tags([$addTag])->add($addKey, 'value', 60); + $result->assert( + $addResult === true, + 'add() with tags succeeds for non-existent key' + ); + + // Verify the value was actually stored and is retrievable + if ($ctx->isAnyMode()) { + $storedValue = $ctx->cache->get($addKey); + $result->assert( + $storedValue === 'value', + 'add() with tags: value retrievable via direct get (any mode)' + ); + } else { + $storedValue = $ctx->cache->tags([$addTag])->get($addKey); + $result->assert( + $storedValue === 'value', + 'add() with tags: value retrievable via tagged get (all mode)' + ); + + // Verify ZSET entry exists + $tagSetKey = $ctx->tagHashKey($addTag); + $entryCount = $ctx->redis->zCard($tagSetKey); + $result->assert( + $entryCount > 0, + 'add() with tags: ZSET entry created (all mode)' + ); + } + + // Try to add existing key with tags + $addResult = $ctx->cache->tags([$addTag])->add($addKey, 'new value', 60); + $result->assert( + $addResult === false, + 'add() with tags fails for existing key' + ); + + // Verify value unchanged after failed add + if ($ctx->isAnyMode()) { + $unchangedValue = $ctx->cache->get($addKey); + } else { + $unchangedValue = $ctx->cache->tags([$addTag])->get($addKey); + } + $result->assert( + $unchangedValue === 'value', + 'add() with tags: value unchanged after failed add' + ); + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/BasicOperationsCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/BasicOperationsCheck.php new file mode 100644 index 000000000..b30e33e06 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/BasicOperationsCheck.php @@ -0,0 +1,76 @@ +cache->put($ctx->prefixed('basic:key1'), 'value1', 60); + $result->assert( + $ctx->cache->get($ctx->prefixed('basic:key1')) === 'value1', + 'put() and get() string value' + ); + + // Has + $result->assert( + $ctx->cache->has($ctx->prefixed('basic:key1')) === true, + 'has() returns true for existing key' + ); + + // Missing + $result->assert( + $ctx->cache->missing($ctx->prefixed('basic:nonexistent')) === true, + 'missing() returns true for non-existent key' + ); + + // Forget + $ctx->cache->forget($ctx->prefixed('basic:key1')); + $result->assert( + $ctx->cache->get($ctx->prefixed('basic:key1')) === null, + 'forget() removes key' + ); + + // Pull + $ctx->cache->put($ctx->prefixed('basic:pull'), 'pulled', 60); + $value = $ctx->cache->pull($ctx->prefixed('basic:pull')); + $result->assert( + $value === 'pulled' && $ctx->cache->get($ctx->prefixed('basic:pull')) === null, + 'pull() retrieves and removes key' + ); + + // Remember + $value = $ctx->cache->remember($ctx->prefixed('basic:remember'), 60, fn (): string => 'remembered'); + $result->assert( + $value === 'remembered' && $ctx->cache->get($ctx->prefixed('basic:remember')) === 'remembered', + 'remember() stores and returns closure result' + ); + + // RememberForever + $value = $ctx->cache->rememberForever($ctx->prefixed('basic:forever'), fn (): string => 'permanent'); + $result->assert( + $value === 'permanent', + 'rememberForever() stores without expiration' + ); + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/BulkOperationsCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/BulkOperationsCheck.php new file mode 100644 index 000000000..317525216 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/BulkOperationsCheck.php @@ -0,0 +1,97 @@ +cache->putMany([ + $ctx->prefixed('bulk:1') => 'value1', + $ctx->prefixed('bulk:2') => 'value2', + $ctx->prefixed('bulk:3') => 'value3', + ], 60); + + $result->assert( + $ctx->cache->get($ctx->prefixed('bulk:1')) === 'value1' + && $ctx->cache->get($ctx->prefixed('bulk:2')) === 'value2' + && $ctx->cache->get($ctx->prefixed('bulk:3')) === 'value3', + 'putMany() stores multiple items' + ); + + // many() + $values = $ctx->cache->many([ + $ctx->prefixed('bulk:1'), + $ctx->prefixed('bulk:2'), + $ctx->prefixed('bulk:nonexistent'), + ]); + $result->assert( + $values[$ctx->prefixed('bulk:1')] === 'value1' + && $values[$ctx->prefixed('bulk:2')] === 'value2' + && $values[$ctx->prefixed('bulk:nonexistent')] === null, + 'many() retrieves multiple items (null for missing)' + ); + + // putMany with tags + $bulkTag = $ctx->prefixed('bulk'); + $taggedKey1 = $ctx->prefixed('bulk:tagged1'); + $taggedKey2 = $ctx->prefixed('bulk:tagged2'); + + $ctx->cache->tags([$bulkTag])->putMany([ + $taggedKey1 => 'tagged1', + $taggedKey2 => 'tagged2', + ], 60); + + if ($ctx->isAnyMode()) { + $result->assert( + $ctx->redis->hExists($ctx->tagHashKey($bulkTag), $taggedKey1) === true + && $ctx->redis->hExists($ctx->tagHashKey($bulkTag), $taggedKey2) === true, + 'putMany() with tags adds all items to tag hash (any mode)' + ); + } else { + // Verify all mode sorted set contains entries + $tagSetKey = $ctx->tagHashKey($bulkTag); + $entryCount = $ctx->redis->zCard($tagSetKey); + $result->assert( + $entryCount >= 2, + 'putMany() with tags adds entries to tag ZSET (all mode)' + ); + } + + // Flush putMany tags + $ctx->cache->tags([$bulkTag])->flush(); + + if ($ctx->isAnyMode()) { + $result->assert( + $ctx->cache->get($taggedKey1) === null && $ctx->cache->get($taggedKey2) === null, + 'flush() removes items added via putMany()' + ); + } else { + $result->assert( + $ctx->cache->tags([$bulkTag])->get($taggedKey1) === null + && $ctx->cache->tags([$bulkTag])->get($taggedKey2) === null, + 'flush() removes items added via putMany()' + ); + } + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/CacheStoreCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/CacheStoreCheck.php new file mode 100644 index 000000000..a90cda2fb --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/CacheStoreCheck.php @@ -0,0 +1,60 @@ +driver === 'redis'; + + $result->assert( + $isRedisDriver, + $isRedisDriver + ? "Cache store '{$this->storeName}' uses redis driver" + : "Cache store '{$this->storeName}' uses redis driver (current: {$this->driver})" + ); + + if ($isRedisDriver) { + $result->assert( + true, + "Tagging mode: {$this->taggingMode}" + ); + } + + return $result; + } + + public function getFixInstructions(): ?string + { + if ($this->driver !== 'redis') { + return "Update the driver for '{$this->storeName}' store to 'redis' in config/cache.php"; + } + + return null; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/CheckInterface.php b/src/cache/src/Redis/Console/Doctor/Checks/CheckInterface.php new file mode 100644 index 000000000..8f1220bfa --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/CheckInterface.php @@ -0,0 +1,27 @@ +getTestPrefix(); + $remainingKeys = $this->findTestKeys($ctx, $testPrefix); + + $result->assert( + empty($remainingKeys), + empty($remainingKeys) + ? 'All test data cleaned up successfully' + : 'Cleanup incomplete - ' . count($remainingKeys) . ' test key(s) remain: ' . implode(', ', array_slice($remainingKeys, 0, 5)) + ); + + // Any mode: verify tag registry has no test entries + if ($ctx->isAnyMode()) { + $registryOrphans = $this->findRegistryOrphans($ctx, $testPrefix); + $result->assert( + empty($registryOrphans), + empty($registryOrphans) + ? 'Tag registry has no test entries' + : 'Tag registry has orphaned test entries: ' . implode(', ', array_slice($registryOrphans, 0, 5)) + ); + } + + return $result; + } + + /** + * Find any remaining test keys in Redis. + * + * @return array + */ + private function findTestKeys(DoctorContext $ctx, string $testPrefix): array + { + $remainingKeys = []; + + // Get patterns to check (includes both mode patterns for comprehensive verification) + $patterns = array_merge( + $ctx->getCacheValuePatterns($testPrefix), + $ctx->getTagStoragePatterns($testPrefix), + ); + + // Get OPT_PREFIX for SCAN pattern + $optPrefix = (string) $ctx->redis->getOption(Redis::OPT_PREFIX); + + foreach ($patterns as $pattern) { + // SCAN requires the full pattern including OPT_PREFIX + $scanPattern = $optPrefix . $pattern; + $iterator = null; + + while (($keys = $ctx->redis->scan($iterator, $scanPattern, 100)) !== false) { + foreach ($keys as $key) { + // Strip OPT_PREFIX from returned keys for display + $remainingKeys[] = $optPrefix ? substr($key, strlen($optPrefix)) : $key; + } + + if ($iterator === 0) { + break; + } + } + } + + return array_unique($remainingKeys); + } + + /** + * Find any test entries remaining in the tag registry. + * + * @return array + */ + private function findRegistryOrphans(DoctorContext $ctx, string $testPrefix): array + { + $registryKey = $ctx->store->getContext()->registryKey(); + $members = $ctx->redis->zRange($registryKey, 0, -1); + + if (! is_array($members)) { + return []; + } + + return array_filter( + $members, + fn ($m) => str_starts_with($m, $testPrefix) + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/ConcurrencyCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/ConcurrencyCheck.php new file mode 100644 index 000000000..34c6f1f99 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/ConcurrencyCheck.php @@ -0,0 +1,120 @@ +output = $output; + } + + public function name(): string + { + return 'Real Concurrency (Coroutines)'; + } + + public function run(DoctorContext $ctx): CheckResult + { + $result = new CheckResult(); + + // Check if we're in a coroutine context + if (! Coroutine::inCoroutine()) { + $result->assert( + true, + 'Concurrency tests skipped (not in coroutine context)' + ); + + return $result; + } + + $this->testAtomicAdd($ctx, $result); + $this->testConcurrentFlush($ctx, $result); + + return $result; + } + + private function testAtomicAdd(DoctorContext $ctx, CheckResult $result): void + { + $key = $ctx->prefixed('real-concurrent:add-' . Str::random(8)); + $tag = $ctx->prefixed('concurrent-test'); + $ctx->cache->forget($key); + + try { + $results = parallel([ + fn () => $ctx->cache->tags([$tag])->add($key, 'process-1', 60), + fn () => $ctx->cache->tags([$tag])->add($key, 'process-2', 60), + fn () => $ctx->cache->tags([$tag])->add($key, 'process-3', 60), + fn () => $ctx->cache->tags([$tag])->add($key, 'process-4', 60), + fn () => $ctx->cache->tags([$tag])->add($key, 'process-5', 60), + ]); + + $successCount = count(array_filter($results, fn ($r): bool => $r === true)); + $result->assert( + $successCount === 1, + 'Atomic add() - exactly 1 of 5 coroutines succeeded' + ); + } catch (Throwable $e) { + $this->output?->writeln(" ⊘ Atomic add() test skipped ({$e->getMessage()})"); + } + } + + private function testConcurrentFlush(DoctorContext $ctx, CheckResult $result): void + { + $tag1 = $ctx->prefixed('concurrent-flush-a-' . Str::random(8)); + $tag2 = $ctx->prefixed('concurrent-flush-b-' . Str::random(8)); + + // Create 5 items with both tags + for ($i = 0; $i < 5; ++$i) { + $ctx->cache->tags([$tag1, $tag2])->put($ctx->prefixed("flush-item-{$i}"), "value-{$i}", 60); + } + + try { + // Flush both tags concurrently + parallel([ + fn () => $ctx->cache->tags([$tag1])->flush(), + fn () => $ctx->cache->tags([$tag2])->flush(), + ]); + + if ($ctx->isAnyMode()) { + // Verify no orphans in either tag hash + $tag1Key = $ctx->tagHashKey($tag1); + $tag2Key = $ctx->tagHashKey($tag2); + + $result->assert( + $ctx->redis->exists($tag1Key) === 0 && $ctx->redis->exists($tag2Key) === 0, + 'Concurrent flush - no orphaned tag hashes' + ); + } else { + // All mode: verify both tag ZSETs are deleted + $tag1SetKey = $ctx->tagHashKey($tag1); + $tag2SetKey = $ctx->tagHashKey($tag2); + + $result->assert( + $ctx->redis->exists($tag1SetKey) === 0 && $ctx->redis->exists($tag2SetKey) === 0, + 'Concurrent flush - both tag ZSETs deleted (all mode)' + ); + } + } catch (Throwable $e) { + $this->output?->writeln(" ⊘ Concurrent flush test skipped ({$e->getMessage()})"); + } + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/EdgeCasesCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/EdgeCasesCheck.php new file mode 100644 index 000000000..3d425ddb9 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/EdgeCasesCheck.php @@ -0,0 +1,97 @@ +cache->put($ctx->prefixed('edge:null'), null, 60); + $result->assert( + $ctx->cache->has($ctx->prefixed('edge:null')) === false, + 'null values are not stored (Laravel behavior)' + ); + + // Zero values + $ctx->cache->put($ctx->prefixed('edge:zero'), 0, 60); + $result->assert( + (int) $ctx->cache->get($ctx->prefixed('edge:zero')) === 0, + 'Zero values are stored and retrieved' + ); + + // Empty string + $ctx->cache->put($ctx->prefixed('edge:empty'), '', 60); + $result->assert( + $ctx->cache->get($ctx->prefixed('edge:empty')) === '', + 'Empty strings are stored' + ); + + // Numeric tags + $numericTags = [$ctx->prefixed('123'), $ctx->prefixed('string-tag')]; + $numericTagKey = $ctx->prefixed('edge:numeric-tags'); + $ctx->cache->tags($numericTags)->put($numericTagKey, 'value', 60); + + if ($ctx->isAnyMode()) { + $result->assert( + $ctx->redis->hExists($ctx->tagHashKey($ctx->prefixed('123')), $numericTagKey) === true, + 'Numeric tags are handled (cast to strings, any mode)' + ); + } else { + // For all mode, verify the key was stored using tagged get + $result->assert( + $ctx->cache->tags($numericTags)->get($numericTagKey) === 'value', + 'Numeric tags are handled (cast to strings, all mode)' + ); + } + + // Special characters in keys + $ctx->cache->put($ctx->prefixed('edge:special!@#$%'), 'special', 60); + $result->assert( + $ctx->cache->get($ctx->prefixed('edge:special!@#$%')) === 'special', + 'Special characters in keys are handled' + ); + + // Complex data structures + $complex = [ + 'nested' => [ + 'array' => [1, 2, 3], + 'object' => (object) ['key' => 'value'], + ], + 'boolean' => true, + 'float' => 3.14159, + ]; + $complexTag = $ctx->prefixed('complex'); + $complexKey = $ctx->prefixed('edge:complex'); + $ctx->cache->tags([$complexTag])->put($complexKey, $complex, 60); + + if ($ctx->isAnyMode()) { + $retrieved = $ctx->cache->get($complexKey); + } else { + $retrieved = $ctx->cache->tags([$complexTag])->get($complexKey); + } + $result->assert( + is_array($retrieved) && $retrieved['nested']['array'][0] === 1, + 'Complex data structures are serialized and deserialized' + ); + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/EnvironmentCheckInterface.php b/src/cache/src/Redis/Console/Doctor/Checks/EnvironmentCheckInterface.php new file mode 100644 index 000000000..123c6eaf4 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/EnvironmentCheckInterface.php @@ -0,0 +1,33 @@ +output = $output; + } + + public function name(): string + { + return 'Expiration Tests'; + } + + public function run(DoctorContext $ctx): CheckResult + { + $result = new CheckResult(); + + $tag = $ctx->prefixed('expire-' . Str::random(8)); + $key = $ctx->prefixed('expire:' . Str::random(8)); + + // Put with 1 second TTL + $ctx->cache->tags([$tag])->put($key, 'val', 1); + + $this->output?->writeln(' Waiting 2 seconds for expiration...'); + sleep(2); + + if ($ctx->isAnyMode()) { + // Any mode: direct get works + $result->assert( + $ctx->cache->get($key) === null, + 'Item expired after TTL' + ); + $this->testAnyModeExpiration($ctx, $result, $tag, $key); + } else { + // All mode: must use tagged get + $result->assert( + $ctx->cache->tags([$tag])->get($key) === null, + 'Item expired after TTL' + ); + $this->testAllModeExpiration($ctx, $result, $tag, $key); + } + + return $result; + } + + private function testAnyModeExpiration( + DoctorContext $ctx, + CheckResult $result, + string $tag, + string $key, + ): void { + // Check hash field cleanup + $connection = $ctx->store->connection(); + $tagKey = $ctx->tagHashKey($tag); + + $result->assert( + ! $connection->hExists($tagKey, $key), + 'Tag hash field expired (HEXPIRE cleanup)' + ); + } + + private function testAllModeExpiration( + DoctorContext $ctx, + CheckResult $result, + string $tag, + string $key, + ): void { + // In all mode, the ZSET entry remains until flushStale() is called + // The cache key has expired (Redis TTL), but the ZSET entry is stale + $tagSetKey = $ctx->tagHashKey($tag); + + // Compute the namespaced key using central source of truth + $namespacedKey = $ctx->namespacedKey([$tag], $key); + + // Check ZSET entry exists (stale but present) + $score = $ctx->redis->zScore($tagSetKey, $namespacedKey); + $staleEntryExists = $score !== false; + + $result->assert( + $staleEntryExists, + 'Stale ZSET entry exists after cache key expired (before cleanup)' + ); + + // Run cleanup to remove stale entries + /** @var \Hypervel\Cache\Redis\AllTaggedCache $taggedCache */ + $taggedCache = $ctx->cache->tags([$tag]); + $taggedCache->flushStale(); + + // Now the ZSET entry should be gone + $scoreAfterCleanup = $ctx->redis->zScore($tagSetKey, $namespacedKey); + $result->assert( + $scoreAfterCleanup === false, + 'ZSET entry removed after flushStale() cleanup' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/FlushBehaviorCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/FlushBehaviorCheck.php new file mode 100644 index 000000000..116aa61ed --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/FlushBehaviorCheck.php @@ -0,0 +1,111 @@ +isAnyMode()) { + $this->testAnyMode($ctx, $result); + } else { + $this->testAllMode($ctx, $result); + } + + return $result; + } + + private function testAnyMode(DoctorContext $ctx, CheckResult $result): void + { + // Setup items with different tag combinations + $ctx->cache->tags([$ctx->prefixed('color:red'), $ctx->prefixed('color:blue')])->put($ctx->prefixed('flush:purple'), 'purple', 60); + $ctx->cache->tags([$ctx->prefixed('color:red'), $ctx->prefixed('color:yellow')])->put($ctx->prefixed('flush:orange'), 'orange', 60); + $ctx->cache->tags([$ctx->prefixed('color:blue'), $ctx->prefixed('color:yellow')])->put($ctx->prefixed('flush:green'), 'green', 60); + $ctx->cache->tags([$ctx->prefixed('color:red')])->put($ctx->prefixed('flush:red'), 'red only', 60); + $ctx->cache->tags([$ctx->prefixed('color:blue')])->put($ctx->prefixed('flush:blue'), 'blue only', 60); + + // Flush one tag + $ctx->cache->tags([$ctx->prefixed('color:red')])->flush(); + + $result->assert( + $ctx->cache->get($ctx->prefixed('flush:purple')) === null + && $ctx->cache->get($ctx->prefixed('flush:orange')) === null + && $ctx->cache->get($ctx->prefixed('flush:red')) === null + && $ctx->cache->get($ctx->prefixed('flush:green')) === 'green' + && $ctx->cache->get($ctx->prefixed('flush:blue')) === 'blue only', + 'Flushing one tag removes all items with that tag (any/OR behavior)' + ); + + // Flush multiple tags + $ctx->cache->tags([$ctx->prefixed('color:blue'), $ctx->prefixed('color:yellow')])->flush(); + + $result->assert( + $ctx->cache->get($ctx->prefixed('flush:green')) === null + && $ctx->cache->get($ctx->prefixed('flush:blue')) === null, + 'Flushing multiple tags removes items with ANY of those tags' + ); + } + + private function testAllMode(DoctorContext $ctx, CheckResult $result): void + { + // Setup items with different tag combinations + $redTag = $ctx->prefixed('color:red'); + $blueTag = $ctx->prefixed('color:blue'); + $yellowTag = $ctx->prefixed('color:yellow'); + + $purpleTags = [$redTag, $blueTag]; + $orangeTags = [$redTag, $yellowTag]; + $greenTags = [$blueTag, $yellowTag]; + + $ctx->cache->tags($purpleTags)->put($ctx->prefixed('flush:purple'), 'purple', 60); + $ctx->cache->tags($orangeTags)->put($ctx->prefixed('flush:orange'), 'orange', 60); + $ctx->cache->tags($greenTags)->put($ctx->prefixed('flush:green'), 'green', 60); + $ctx->cache->tags([$redTag])->put($ctx->prefixed('flush:red'), 'red only', 60); + $ctx->cache->tags([$blueTag])->put($ctx->prefixed('flush:blue'), 'blue only', 60); + + // Flush one tag - removes all items tracked in that tag's ZSET + $ctx->cache->tags([$redTag])->flush(); + + // Items with red tag should be gone (purple, orange, red) + // Items without red tag should remain (green, blue) + $purpleGone = $ctx->cache->tags($purpleTags)->get($ctx->prefixed('flush:purple')) === null; + $orangeGone = $ctx->cache->tags($orangeTags)->get($ctx->prefixed('flush:orange')) === null; + $redGone = $ctx->cache->tags([$redTag])->get($ctx->prefixed('flush:red')) === null; + $greenExists = $ctx->cache->tags($greenTags)->get($ctx->prefixed('flush:green')) === 'green'; + $blueExists = $ctx->cache->tags([$blueTag])->get($ctx->prefixed('flush:blue')) === 'blue only'; + + $result->assert( + $purpleGone && $orangeGone && $redGone && $greenExists && $blueExists, + 'Flushing one tag removes all items tracked in that tag ZSET' + ); + + // Flush multiple tags - removes items tracked in ANY of those ZSETs + $ctx->cache->tags([$blueTag, $yellowTag])->flush(); + + $greenGone = $ctx->cache->tags($greenTags)->get($ctx->prefixed('flush:green')) === null; + $blueGone = $ctx->cache->tags([$blueTag])->get($ctx->prefixed('flush:blue')) === null; + + $result->assert( + $greenGone && $blueGone, + 'Flushing multiple tags removes items tracked in ANY of those ZSETs' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/ForeverStorageCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/ForeverStorageCheck.php new file mode 100644 index 000000000..d93181564 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/ForeverStorageCheck.php @@ -0,0 +1,88 @@ +cache->forever($ctx->prefixed('forever:key1'), 'permanent'); + $ttl = $ctx->redis->ttl($ctx->cachePrefix . $ctx->prefixed('forever:key1')); + $result->assert( + $ttl === -1, + 'forever() stores without expiration' + ); + + // Forever with tags + $foreverTag = $ctx->prefixed('permanent'); + $foreverKey = $ctx->prefixed('forever:tagged'); + $ctx->cache->tags([$foreverTag])->forever($foreverKey, 'also permanent'); + + if ($ctx->isAnyMode()) { + // Any mode: key is stored without namespace modification + $keyTtl = $ctx->redis->ttl($ctx->cachePrefix . $foreverKey); + $result->assert( + $keyTtl === -1, + 'forever() with tags: key has no expiration' + ); + $this->testAnyModeHashTtl($ctx, $result, $foreverTag, $foreverKey); + } else { + // All mode: key is namespaced with sha1 of tag IDs + $namespacedKey = $ctx->namespacedKey([$foreverTag], $foreverKey); + $keyTtl = $ctx->redis->ttl($ctx->cachePrefix . $namespacedKey); + $result->assert( + $keyTtl === -1, + 'forever() with tags: key has no expiration' + ); + $this->testAllMode($ctx, $result, $foreverTag, $foreverKey, $namespacedKey); + } + + return $result; + } + + private function testAnyModeHashTtl(DoctorContext $ctx, CheckResult $result, string $tag, string $key): void + { + // Verify hash field also has no expiration + $fieldTtl = $ctx->redis->httl($ctx->tagHashKey($tag), [$key]); + $result->assert( + $fieldTtl[0] === -1, + 'forever() with tags: hash field has no expiration (any mode)' + ); + } + + private function testAllMode( + DoctorContext $ctx, + CheckResult $result, + string $tag, + string $key, + string $namespacedKey, + ): void { + // Verify sorted set score is -1 for forever items + $tagSetKey = $ctx->tagHashKey($tag); + $score = $ctx->redis->zScore($tagSetKey, $namespacedKey); + + $result->assert( + $score === -1.0, + 'forever() with tags: ZSET entry has score -1 (all mode)' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/HashStructuresCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/HashStructuresCheck.php new file mode 100644 index 000000000..15f9642f9 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/HashStructuresCheck.php @@ -0,0 +1,81 @@ +isAllMode()) { + $result->assert( + true, + 'Hash structures check skipped (all mode uses sorted sets)' + ); + + return $result; + } + + // Create tagged item + $ctx->cache->tags([$ctx->prefixed('verify')])->put($ctx->prefixed('hash:item'), 'value', 120); + + $tagKey = $ctx->tagHashKey($ctx->prefixed('verify')); + + // Verify hash exists + $result->assert( + $ctx->redis->exists($tagKey) === 1, + 'Tag hash is created' + ); + + // Verify field exists + $result->assert( + $ctx->redis->hExists($tagKey, $ctx->prefixed('hash:item')) === true, + 'Cache key is added as hash field' + ); + + // Verify field value + $value = $ctx->redis->hGet($tagKey, $ctx->prefixed('hash:item')); + $result->assert( + $value === '1', + 'Hash field value is "1" (minimal metadata)' + ); + + // Verify field has expiration + $ttl = $ctx->redis->httl($tagKey, [$ctx->prefixed('hash:item')]); + $result->assert( + $ttl[0] > 0 && $ttl[0] <= 120, + 'Hash field has expiration matching cache TTL' + ); + + // Verify cache key itself exists + $result->assert( + $ctx->redis->exists($ctx->cachePrefix . $ctx->prefixed('hash:item')) === 1, + 'Cache key exists in Redis' + ); + + // Verify cache key TTL + $keyTtl = $ctx->redis->ttl($ctx->cachePrefix . $ctx->prefixed('hash:item')); + $result->assert( + $keyTtl > 0 && $keyTtl <= 120, + 'Cache key has correct TTL' + ); + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/HexpireCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/HexpireCheck.php new file mode 100644 index 000000000..e300a9518 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/HexpireCheck.php @@ -0,0 +1,75 @@ +taggingMode === 'all') { + $result->assert(true, 'HEXPIRE check skipped (not required for all mode)'); + + return $result; + } + + try { + // Try to use HEXPIRE on a test key + $testKey = 'erc:doctor:hexpire-test:' . bin2hex(random_bytes(4)); + + $this->redis->hSet($testKey, 'field', '1'); + $this->redis->hexpire($testKey, 60, ['field']); + $this->redis->del($testKey); + + $this->available = true; + $result->assert(true, 'HEXPIRE command is available'); + } catch (Throwable) { + $this->available = false; + $result->assert(false, 'HEXPIRE command is available'); + } + + return $result; + } + + public function getFixInstructions(): ?string + { + if ($this->taggingMode === 'all') { + return null; + } + + if (! $this->available) { + return 'HEXPIRE requires Redis 8.0+ or Valkey 9.0+. Upgrade your Redis/Valkey server, or switch to all tagging mode.'; + } + + return null; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/IncrementDecrementCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/IncrementDecrementCheck.php new file mode 100644 index 000000000..8944d7c9a --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/IncrementDecrementCheck.php @@ -0,0 +1,108 @@ +cache->put($ctx->prefixed('incr:counter1'), 0, 60); + $incrementResult = $ctx->cache->increment($ctx->prefixed('incr:counter1'), 5); + $result->assert( + $incrementResult === 5 && $ctx->cache->get($ctx->prefixed('incr:counter1')) === '5', + 'increment() increases value (returns string)' + ); + + // Decrement without tags + $decrementResult = $ctx->cache->decrement($ctx->prefixed('incr:counter1'), 3); + $result->assert( + $decrementResult === 2 && $ctx->cache->get($ctx->prefixed('incr:counter1')) === '2', + 'decrement() decreases value (returns string)' + ); + + // Increment with tags + $counterTag = $ctx->prefixed('counters'); + $taggedKey = $ctx->prefixed('incr:tagged'); + $ctx->cache->tags([$counterTag])->put($taggedKey, 10, 60); + $taggedResult = $ctx->cache->tags([$counterTag])->increment($taggedKey, 15); + + if ($ctx->isAnyMode()) { + // Any mode: direct get works + $result->assert( + $taggedResult === 25 && $ctx->cache->get($taggedKey) === '25', + 'increment() works with tags' + ); + } else { + // All mode: must use tagged get + $result->assert( + $taggedResult === 25 && $ctx->cache->tags([$counterTag])->get($taggedKey) === '25', + 'increment() works with tags' + ); + } + + // Test increment on non-existent key (creates it) + $ctx->cache->forget($ctx->prefixed('incr:new')); + $newResult = $ctx->cache->tags([$ctx->prefixed('counters')])->increment($ctx->prefixed('incr:new'), 1); + $result->assert( + $newResult === 1, + 'increment() creates non-existent key' + ); + + if ($ctx->isAnyMode()) { + $this->testAnyModeHashTtl($ctx, $result); + } else { + $this->testAllMode($ctx, $result); + } + + return $result; + } + + private function testAnyModeHashTtl(DoctorContext $ctx, CheckResult $result): void + { + // Verify hash field has no expiration for non-TTL key + $ttl = $ctx->redis->httl($ctx->tagHashKey($ctx->prefixed('counters')), [$ctx->prefixed('incr:new')]); + $result->assert( + $ttl[0] === -1, + 'Tag entry for non-TTL key has no expiration (any mode)' + ); + } + + private function testAllMode(DoctorContext $ctx, CheckResult $result): void + { + // Verify ZSET entry exists for incremented key + $counterTag = $ctx->prefixed('counters'); + $incrKey = $ctx->prefixed('incr:new'); + + $tagSetKey = $ctx->tagHashKey($counterTag); + + // Compute namespaced key using central source of truth + $namespacedKey = $ctx->namespacedKey([$counterTag], $incrKey); + + // Verify ZSET entry exists + // Note: increment on non-existent key creates with no TTL, so score should be -1 + $score = $ctx->redis->zScore($tagSetKey, $namespacedKey); + $result->assert( + $score !== false, + 'ZSET entry exists for incremented key (all mode)' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/LargeDatasetCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/LargeDatasetCheck.php new file mode 100644 index 000000000..bfab17481 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/LargeDatasetCheck.php @@ -0,0 +1,75 @@ +prefixed('large-set'); + + // Bulk insert + $startTime = microtime(true); + + for ($i = 0; $i < $count; ++$i) { + $ctx->cache->tags([$tag])->put($ctx->prefixed("large:item{$i}"), "value{$i}", 60); + } + + $insertTime = microtime(true) - $startTime; + + $firstKey = $ctx->prefixed('large:item0'); + $lastKey = $ctx->prefixed('large:item' . ($count - 1)); + + if ($ctx->isAnyMode()) { + $firstValue = $ctx->cache->get($firstKey); + $lastValue = $ctx->cache->get($lastKey); + } else { + $firstValue = $ctx->cache->tags([$tag])->get($firstKey); + $lastValue = $ctx->cache->tags([$tag])->get($lastKey); + } + + $result->assert( + $firstValue === 'value0' && $lastValue === 'value' . ($count - 1), + "Inserted {$count} items (took " . number_format($insertTime, 2) . 's)' + ); + + // Bulk flush + $startTime = microtime(true); + $ctx->cache->tags([$tag])->flush(); + $flushTime = microtime(true) - $startTime; + + if ($ctx->isAnyMode()) { + $firstAfterFlush = $ctx->cache->get($firstKey); + $lastAfterFlush = $ctx->cache->get($lastKey); + } else { + $firstAfterFlush = $ctx->cache->tags([$tag])->get($firstKey); + $lastAfterFlush = $ctx->cache->tags([$tag])->get($lastKey); + } + + $result->assert( + $firstAfterFlush === null && $lastAfterFlush === null, + "Flushed {$count} items (took " . number_format($flushTime, 2) . 's)' + ); + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/MemoryLeakPreventionCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/MemoryLeakPreventionCheck.php new file mode 100644 index 000000000..d358f5e60 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/MemoryLeakPreventionCheck.php @@ -0,0 +1,117 @@ +isAnyMode()) { + $this->testAnyMode($ctx, $result); + } else { + $this->testAllMode($ctx, $result); + } + + return $result; + } + + private function testAnyMode(DoctorContext $ctx, CheckResult $result): void + { + // Create item with short TTL + $ctx->cache->tags([$ctx->prefixed('leak-test')])->put($ctx->prefixed('leak:short'), 'value', 3); + + $tagKey = $ctx->tagHashKey($ctx->prefixed('leak-test')); + + // Verify field has expiration + $ttl = $ctx->redis->httl($tagKey, [$ctx->prefixed('leak:short')]); + $result->assert( + $ttl[0] > 0 && $ttl[0] <= 3, + 'Hash field has TTL set (will auto-expire)' + ); + + // Test lazy cleanup after flush + $ctx->cache->tags([$ctx->prefixed('alpha'), $ctx->prefixed('beta')])->put($ctx->prefixed('leak:shared'), 'value', 60); + + // Flush one tag + $ctx->cache->tags([$ctx->prefixed('alpha')])->flush(); + + // Alpha hash should be deleted + $result->assert( + $ctx->redis->exists($ctx->tagHashKey($ctx->prefixed('alpha'))) === 0, + 'Flushed tag hash is deleted' + ); + + // Hypervel uses lazy cleanup mode - orphans remain until prune command runs + $result->assert( + $ctx->redis->hExists($ctx->tagHashKey($ctx->prefixed('beta')), $ctx->prefixed('leak:shared')), + 'Orphaned field exists in shared tag hash (lazy cleanup - will be cleaned by prune command)' + ); + } + + private function testAllMode(DoctorContext $ctx, CheckResult $result): void + { + // Create item with future TTL + $leakTag = $ctx->prefixed('leak-test'); + $leakKey = $ctx->prefixed('leak:short'); + $ctx->cache->tags([$leakTag])->put($leakKey, 'value', 60); + + $tagSetKey = $ctx->tagHashKey($leakTag); + + // Compute the namespaced key using central source of truth + $namespacedKey = $ctx->namespacedKey([$leakTag], $leakKey); + + // Verify ZSET entry exists with future timestamp score + $score = $ctx->redis->zScore($tagSetKey, $namespacedKey); + $result->assert( + $score !== false && $score > time(), + 'ZSET entry has future timestamp score (will be cleaned when expired)' + ); + + // Test lazy cleanup after flush + $alphaTag = $ctx->prefixed('alpha'); + $betaTag = $ctx->prefixed('beta'); + $sharedKey = $ctx->prefixed('leak:shared'); + $ctx->cache->tags([$alphaTag, $betaTag])->put($sharedKey, 'value', 60); + + // Compute namespaced key for shared item using central source of truth + $sharedNamespacedKey = $ctx->namespacedKey([$alphaTag, $betaTag], $sharedKey); + + // Flush one tag + $ctx->cache->tags([$alphaTag])->flush(); + + // Alpha ZSET should be deleted + $alphaSetKey = $ctx->tagHashKey($alphaTag); + $result->assert( + $ctx->redis->exists($alphaSetKey) === 0, + 'Flushed tag ZSET is deleted' + ); + + // All mode uses lazy cleanup - orphaned entry remains in beta ZSET until prune command runs + $betaSetKey = $ctx->tagHashKey($betaTag); + $orphanScore = $ctx->redis->zScore($betaSetKey, $sharedNamespacedKey); + $result->assert( + $orphanScore !== false, + 'Orphaned entry exists in shared tag ZSET (lazy cleanup - will be cleaned by prune command)' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/MultipleTagsCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/MultipleTagsCheck.php new file mode 100644 index 000000000..bfe8c561d --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/MultipleTagsCheck.php @@ -0,0 +1,127 @@ +prefixed('posts'), + $ctx->prefixed('featured'), + $ctx->prefixed('user:123'), + ]; + $key = $ctx->prefixed('multi:post1'); + + // Store with multiple tags + $ctx->cache->tags($tags)->put($key, 'Featured Post', 60); + + // Verify item was stored + if ($ctx->isAnyMode()) { + // Any mode: direct get works + $result->assert( + $ctx->cache->get($key) === 'Featured Post', + 'Item with multiple tags is stored' + ); + $this->testAnyMode($ctx, $result, $tags, $key); + } else { + // All mode: must use tagged get + $result->assert( + $ctx->cache->tags($tags)->get($key) === 'Featured Post', + 'Item with multiple tags is stored' + ); + $this->testAllMode($ctx, $result, $tags, $key); + } + + return $result; + } + + /** + * @param array $tags + */ + private function testAnyMode(DoctorContext $ctx, CheckResult $result, array $tags, string $key): void + { + // Verify in all tag hashes + $result->assert( + $ctx->redis->hExists($ctx->tagHashKey($tags[0]), $key) === true + && $ctx->redis->hExists($ctx->tagHashKey($tags[1]), $key) === true + && $ctx->redis->hExists($ctx->tagHashKey($tags[2]), $key) === true, + 'Item appears in all tag hashes (any mode)' + ); + + // Flush by one tag (any behavior - removes item) + $ctx->cache->tags([$tags[1]])->flush(); + + $result->assert( + $ctx->cache->get($key) === null, + 'Flushing ANY tag removes the item (any behavior)' + ); + + $result->assert( + $ctx->redis->exists($ctx->tagHashKey($tags[1])) === 0, + 'Flushed tag hash is deleted (any mode)' + ); + } + + /** + * @param array $tags + */ + private function testAllMode(DoctorContext $ctx, CheckResult $result, array $tags, string $key): void + { + // Verify all tag ZSETs contain an entry + $postsTagKey = $ctx->tagHashKey($tags[0]); + $featuredTagKey = $ctx->tagHashKey($tags[1]); + $userTagKey = $ctx->tagHashKey($tags[2]); + + $postsCount = $ctx->redis->zCard($postsTagKey); + $featuredCount = $ctx->redis->zCard($featuredTagKey); + $userCount = $ctx->redis->zCard($userTagKey); + + $result->assert( + $postsCount > 0 && $featuredCount > 0 && $userCount > 0, + 'Item appears in all tag ZSETs (all mode)' + ); + + // Flush by one tag - in all mode, this removes items tracked in that tag's ZSET + $ctx->cache->tags([$tags[1]])->flush(); + + $result->assert( + $ctx->cache->tags($tags)->get($key) === null, + 'Flushing tag removes items with that tag (all mode)' + ); + + // Test tag order matters in all mode + $orderKey = $ctx->prefixed('multi:order-test'); + $ctx->cache->tags([$ctx->prefixed('alpha'), $ctx->prefixed('beta')])->put($orderKey, 'ordered', 60); + + // Same order should retrieve + $sameOrder = $ctx->cache->tags([$ctx->prefixed('alpha'), $ctx->prefixed('beta')])->get($orderKey); + + // Different order creates different namespace - should NOT retrieve + $diffOrder = $ctx->cache->tags([$ctx->prefixed('beta'), $ctx->prefixed('alpha')])->get($orderKey); + + $result->assert( + $sameOrder === 'ordered' && $diffOrder === null, + 'Tag order matters - different order creates different namespace' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php new file mode 100644 index 000000000..a71c65296 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/PhpRedisCheck.php @@ -0,0 +1,60 @@ +assert(false, 'PHPRedis extension is installed'); + + return $result; + } + + $this->installedVersion = phpversion('redis') ?: 'unknown'; + + $result->assert(true, "PHPRedis extension is installed (v{$this->installedVersion})"); + + $versionOk = version_compare($this->installedVersion, self::REQUIRED_VERSION, '>='); + $result->assert( + $versionOk, + 'PHPRedis version >= ' . self::REQUIRED_VERSION + ); + + return $result; + } + + public function getFixInstructions(): ?string + { + if (! extension_loaded('redis')) { + return 'Install PHPRedis: pecl install redis'; + } + + if ($this->installedVersion !== null && version_compare($this->installedVersion, self::REQUIRED_VERSION, '<')) { + return "Upgrade PHPRedis: pecl upgrade redis (current: {$this->installedVersion}, required: " . self::REQUIRED_VERSION . '+)'; + } + + return null; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php new file mode 100644 index 000000000..5a3a0047b --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/RedisVersionCheck.php @@ -0,0 +1,108 @@ +redis->info('server'); + + if (isset($info['valkey_version'])) { + $this->serviceName = 'Valkey'; + $this->serviceVersion = $info['valkey_version']; + $requiredVersion = self::VALKEY_REQUIRED_VERSION; + } elseif (isset($info['redis_version'])) { + $this->serviceName = 'Redis'; + $this->serviceVersion = $info['redis_version']; + $requiredVersion = self::REDIS_REQUIRED_VERSION; + } else { + $result->assert(false, 'Could not determine Redis/Valkey version'); + + return $result; + } + + $result->assert(true, "{$this->serviceName} server is reachable (v{$this->serviceVersion})"); + + // Version requirement only applies to any mode + if ($this->taggingMode === 'any') { + $versionOk = version_compare($this->serviceVersion, $requiredVersion, '>='); + $result->assert( + $versionOk, + "{$this->serviceName} version >= {$requiredVersion} (required for any tagging mode)" + ); + } else { + $result->assert( + true, + "{$this->serviceName} version check skipped (all mode has no version requirement)" + ); + } + } catch (Throwable $e) { + $this->connectionFailed = true; + $result->assert(false, 'Redis/Valkey server is reachable: ' . $e->getMessage()); + } + + return $result; + } + + public function getFixInstructions(): ?string + { + if ($this->connectionFailed) { + return 'Ensure Redis/Valkey server is running and accessible'; + } + + if ($this->taggingMode !== 'any') { + return null; + } + + if ($this->serviceName === 'Redis' && $this->serviceVersion !== null) { + if (version_compare($this->serviceVersion, self::REDIS_REQUIRED_VERSION, '<')) { + return 'Upgrade to Redis ' . self::REDIS_REQUIRED_VERSION . '+ or Valkey ' . self::VALKEY_REQUIRED_VERSION . '+ for any tagging mode'; + } + } + + if ($this->serviceName === 'Valkey' && $this->serviceVersion !== null) { + if (version_compare($this->serviceVersion, self::VALKEY_REQUIRED_VERSION, '<')) { + return 'Upgrade to Valkey ' . self::VALKEY_REQUIRED_VERSION . '+ for any tagging mode'; + } + } + + return null; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/SequentialOperationsCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/SequentialOperationsCheck.php new file mode 100644 index 000000000..22c74087f --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/SequentialOperationsCheck.php @@ -0,0 +1,86 @@ +prefixed('rapid'); + $rapidKey = $ctx->prefixed('concurrent:key'); + for ($i = 0; $i < 10; ++$i) { + $ctx->cache->tags([$rapidTag])->put($rapidKey, "value{$i}", 60); + } + + if ($ctx->isAnyMode()) { + $rapidValue = $ctx->cache->get($rapidKey); + } else { + $rapidValue = $ctx->cache->tags([$rapidTag])->get($rapidKey); + } + $result->assert( + $rapidValue === 'value9', + 'Last write wins in rapid succession' + ); + + // Multiple increments + $ctx->cache->put($ctx->prefixed('concurrent:counter'), 0, 60); + + for ($i = 0; $i < 50; ++$i) { + $ctx->cache->increment($ctx->prefixed('concurrent:counter')); + } + + $result->assert( + $ctx->cache->get($ctx->prefixed('concurrent:counter')) === '50', + 'Multiple increments all applied correctly' + ); + + // Race condition: add operations + $ctx->cache->forget($ctx->prefixed('concurrent:add')); + $results = []; + + for ($i = 0; $i < 5; ++$i) { + $results[] = $ctx->cache->add($ctx->prefixed('concurrent:add'), "value{$i}", 60); + } + + $result->assert( + $results[0] === true && array_sum($results) === 1, + 'add() is atomic (only first succeeds)' + ); + + // Overlapping tag operations + $overlapTags = [$ctx->prefixed('overlap1'), $ctx->prefixed('overlap2')]; + $overlapKey = $ctx->prefixed('concurrent:overlap'); + $ctx->cache->tags($overlapTags)->put($overlapKey, 'value', 60); + $ctx->cache->tags([$ctx->prefixed('overlap1')])->flush(); + + if ($ctx->isAnyMode()) { + $overlapValue = $ctx->cache->get($overlapKey); + } else { + $overlapValue = $ctx->cache->tags($overlapTags)->get($overlapKey); + } + $result->assert( + $overlapValue === null, + 'Partial flush removes item correctly' + ); + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/SharedTagFlushCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/SharedTagFlushCheck.php new file mode 100644 index 000000000..b5b91566a --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/SharedTagFlushCheck.php @@ -0,0 +1,129 @@ +prefixed('tagA-' . bin2hex(random_bytes(4))); + $tagB = $ctx->prefixed('tagB-' . bin2hex(random_bytes(4))); + $key = $ctx->prefixed('shared:' . bin2hex(random_bytes(4))); + $value = 'value-' . bin2hex(random_bytes(4)); + + $tags = [$tagA, $tagB]; + + // Store item with both tags + $ctx->cache->tags($tags)->put($key, $value, 60); + + // Verify item was stored + if ($ctx->isAnyMode()) { + // Any mode: direct get works + $result->assert( + $ctx->cache->get($key) === $value, + 'Item with shared tags is stored' + ); + $this->testAnyMode($ctx, $result, $tagA, $tagB, $key); + } else { + // All mode: must use tagged get + $result->assert( + $ctx->cache->tags($tags)->get($key) === $value, + 'Item with shared tags is stored' + ); + $this->testAllMode($ctx, $result, $tagA, $tagB, $key, $tags); + } + + return $result; + } + + private function testAnyMode( + DoctorContext $ctx, + CheckResult $result, + string $tagA, + string $tagB, + string $key, + ): void { + // Verify in both tag hashes + $tagAKey = $ctx->tagHashKey($tagA); + $tagBKey = $ctx->tagHashKey($tagB); + + $result->assert( + $ctx->redis->hExists($tagAKey, $key) && $ctx->redis->hExists($tagBKey, $key), + 'Key exists in both tag hashes (any mode)' + ); + + // Flush Tag A + $ctx->cache->tags([$tagA])->flush(); + + $result->assert( + $ctx->cache->get($key) === null, + 'Shared tag flush removes item (any mode)' + ); + + // In lazy mode (Hypervel default), orphans remain in Tag B hash + // They will be cleaned by the scheduled prune command + $result->assert( + $ctx->redis->hExists($tagBKey, $key), + 'Orphaned field exists in shared tag (lazy cleanup - will be cleaned by prune command)' + ); + } + + /** + * @param array $tags + */ + private function testAllMode( + DoctorContext $ctx, + CheckResult $result, + string $tagA, + string $tagB, + string $key, + array $tags, + ): void { + // Verify both tag ZSETs contain entries before flush + $tagASetKey = $ctx->tagHashKey($tagA); + $tagBSetKey = $ctx->tagHashKey($tagB); + + $tagACount = $ctx->redis->zCard($tagASetKey); + $tagBCount = $ctx->redis->zCard($tagBSetKey); + + $result->assert( + $tagACount > 0 && $tagBCount > 0, + 'Key exists in both tag ZSETs before flush (all mode)' + ); + + // Flush Tag A + $ctx->cache->tags([$tagA])->flush(); + + $result->assert( + $ctx->cache->tags($tags)->get($key) === null, + 'Shared tag flush removes item (all mode)' + ); + + // In all mode, the cache key is deleted when any tag is flushed + // Orphaned entries remain in Tag B's ZSET until prune is run + $tagBCountAfter = $ctx->redis->zCard($tagBSetKey); + + $result->assert( + $tagBCountAfter > 0, + 'Orphaned entry exists in shared tag ZSET (cleaned by prune command)' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/TaggedOperationsCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/TaggedOperationsCheck.php new file mode 100644 index 000000000..b35189201 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/TaggedOperationsCheck.php @@ -0,0 +1,111 @@ +prefixed('products'); + $key = $ctx->prefixed('tag:product1'); + $ctx->cache->tags([$tag])->put($key, 'Product 1', 60); + + if ($ctx->isAnyMode()) { + // Any mode: key is stored without namespace modification + // Can be retrieved directly without tags + $result->assert( + $ctx->cache->get($key) === 'Product 1', + 'Tagged item can be retrieved without tags (direct get)' + ); + $this->testAnyMode($ctx, $result, $tag, $key); + } else { + // All mode: key is namespaced with sha1 of tags + // Direct get without tags will NOT find the item + $result->assert( + $ctx->cache->get($key) === null, + 'Tagged item NOT retrievable without tags (namespace differs)' + ); + $this->testAllMode($ctx, $result, $tag, $key); + } + + // Tag flush (common to both modes) + $ctx->cache->tags([$tag])->flush(); + + if ($ctx->isAnyMode()) { + $result->assert( + $ctx->cache->get($key) === null, + 'flush() removes tagged items' + ); + } else { + // In all mode, use tagged get to verify flush worked + $result->assert( + $ctx->cache->tags([$tag])->get($key) === null, + 'flush() removes tagged items' + ); + } + + return $result; + } + + private function testAnyMode(DoctorContext $ctx, CheckResult $result, string $tag, string $key): void + { + // Verify hash structure exists + $tagKey = $ctx->tagHashKey($tag); + $result->assert( + $ctx->redis->hExists($tagKey, $key) === true, + 'Tag hash contains the cache key (any mode)' + ); + + // Verify get() on tagged cache throws + $threw = false; + try { + $ctx->cache->tags([$tag])->get($key); + } catch (BadMethodCallException) { + $threw = true; + } + $result->assert( + $threw, + 'Tagged get() throws BadMethodCallException (any mode)' + ); + } + + private function testAllMode(DoctorContext $ctx, CheckResult $result, string $tag, string $key): void + { + // In all mode, get() on tagged cache works + $value = $ctx->cache->tags([$tag])->get($key); + $result->assert( + $value === 'Product 1', + 'Tagged get() returns value (all mode)' + ); + + // Verify tag sorted set structure exists + // Tag key format: {prefix}tag:{tagName}:entries + $tagSetKey = $ctx->tagHashKey($tag); + $members = $ctx->redis->zRange($tagSetKey, 0, -1); + $result->assert( + is_array($members) && count($members) > 0, + 'Tag ZSET contains entries (all mode)' + ); + } +} diff --git a/src/cache/src/Redis/Console/Doctor/Checks/TaggedRememberCheck.php b/src/cache/src/Redis/Console/Doctor/Checks/TaggedRememberCheck.php new file mode 100644 index 000000000..326866b80 --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/Checks/TaggedRememberCheck.php @@ -0,0 +1,73 @@ +prefixed('remember'); + $rememberKey = $ctx->prefixed('tag:remember'); + $foreverKey = $ctx->prefixed('tag:forever'); + + // Remember with tags + $value = $ctx->cache->tags([$tag])->remember( + $rememberKey, + 60, + fn (): string => 'remembered-value' + ); + + if ($ctx->isAnyMode()) { + // Any mode: direct get works + $result->assert( + $value === 'remembered-value' && $ctx->cache->get($rememberKey) === 'remembered-value', + 'remember() with tags stores and returns value' + ); + } else { + // All mode: must use tagged get + $result->assert( + $value === 'remembered-value' && $ctx->cache->tags([$tag])->get($rememberKey) === 'remembered-value', + 'remember() with tags stores and returns value' + ); + } + + // RememberForever with tags + $value = $ctx->cache->tags([$tag])->rememberForever( + $foreverKey, + fn (): string => 'forever-value' + ); + + if ($ctx->isAnyMode()) { + // Any mode: direct get works + $result->assert( + $value === 'forever-value' && $ctx->cache->get($foreverKey) === 'forever-value', + 'rememberForever() with tags stores and returns value' + ); + } else { + // All mode: must use tagged get + $result->assert( + $value === 'forever-value' && $ctx->cache->tags([$tag])->get($foreverKey) === 'forever-value', + 'rememberForever() with tags stores and returns value' + ); + } + + return $result; + } +} diff --git a/src/cache/src/Redis/Console/Doctor/DoctorContext.php b/src/cache/src/Redis/Console/Doctor/DoctorContext.php new file mode 100644 index 000000000..5ac5f67bd --- /dev/null +++ b/src/cache/src/Redis/Console/Doctor/DoctorContext.php @@ -0,0 +1,172 @@ +store->getContext()->tagHashKey($tag); + } + + /** + * Get the tag identifier (without cache prefix). + * + * Format: "_any:tag:{tagName}:entries" or "_all:tag:{tagName}:entries" + * Used for namespace computation in all mode. + */ + public function tagId(string $tag): string + { + return $this->store->getContext()->tagId($tag); + } + + /** + * Compute the namespaced key for a tagged cache item in all mode. + * + * In all mode, cache keys are prefixed with sha1 of sorted tag IDs. + * Format: "{sha1}:{key}" + * + * @param array $tags The tag names + * @param string $key The cache key + * @return string The namespaced key + */ + public function namespacedKey(array $tags, string $key): string + { + $tagIds = array_map(fn (string $tag) => $this->tagId($tag), $tags); + sort($tagIds); + $namespace = sha1(implode('|', $tagIds)); + + return $namespace . ':' . $key; + } + + /** + * Get the test prefix constant for cleanup operations. + */ + public function getTestPrefix(): string + { + return self::TEST_PREFIX; + } + + /** + * Check if the store is configured for 'any' tag mode. + * In this mode, flushing ANY matching tag removes the item. + */ + public function isAnyMode(): bool + { + return $this->store->getTagMode() === TagMode::Any; + } + + /** + * Check if the store is configured for 'all' tag mode. + * In this mode, items must match ALL specified tags. + */ + public function isAllMode(): bool + { + return $this->store->getTagMode() === TagMode::All; + } + + /** + * Get the current tag mode. + */ + public function getTagMode(): TagMode + { + return $this->store->getTagMode(); + } + + /** + * Get the current tag mode as a string value. + */ + public function getTagModeValue(): string + { + return $this->store->getTagMode()->value; + } + + /** + * Get patterns to match all tag storage structures with a given tag name prefix. + * + * Used for cleanup operations to delete dynamically-created test tags. + * Returns patterns for BOTH tag modes to ensure complete cleanup + * regardless of current mode (e.g., if config changed between runs): + * - Any mode: {cachePrefix}_any:tag:{tagNamePrefix}* + * - All mode: {cachePrefix}_all:tag:{tagNamePrefix}* + * + * @param string $tagNamePrefix The prefix to match tag names against + * @return array Patterns to use with SCAN/KEYS commands + */ + public function getTagStoragePatterns(string $tagNamePrefix): array + { + return [ + // Any mode tag storage: {cachePrefix}_any:tag:{tagNamePrefix}* + $this->cachePrefix . TagMode::Any->tagSegment() . $tagNamePrefix . '*', + // All mode tag storage: {cachePrefix}_all:tag:{tagNamePrefix}* + $this->cachePrefix . TagMode::All->tagSegment() . $tagNamePrefix . '*', + ]; + } + + /** + * Get patterns to match all cache value keys with a given key prefix. + * + * Used for cleanup operations to delete test cache values. + * Returns patterns for BOTH tag modes to ensure complete cleanup + * regardless of current mode (e.g., if config changed between runs): + * - Untagged keys: {cachePrefix}{keyPrefix}* (same in both modes) + * - Tagged keys in all mode: {cachePrefix}{sha1}:{keyPrefix}* (namespaced) + * + * @param string $keyPrefix The prefix to match cache keys against + * @return array Patterns to use with SCAN/KEYS commands + */ + public function getCacheValuePatterns(string $keyPrefix): array + { + return [ + // Untagged cache values (both modes) and any-mode tagged values + $this->cachePrefix . $keyPrefix . '*', + // All-mode tagged values at {cachePrefix}{sha1}:{keyName} + $this->cachePrefix . '*:' . $keyPrefix . '*', + ]; + } +} diff --git a/src/cache/src/Redis/Console/DoctorCommand.php b/src/cache/src/Redis/Console/DoctorCommand.php new file mode 100644 index 000000000..d794244e1 --- /dev/null +++ b/src/cache/src/Redis/Console/DoctorCommand.php @@ -0,0 +1,473 @@ + */ + private array $failures = []; + + /** + * Unique prefix to prevent collision with production data. + * Mode-agnostic - just identifies doctor test data. + */ + private const TEST_PREFIX = '_doctor:test:'; + + /** + * Execute the console command. + */ + public function handle(): int + { + $this->displayHeader(); + $this->displaySystemInformation(); + + // Detect or validate store + $storeName = $this->option('store') ?: $this->detectRedisStore(); + + if (! $storeName) { + $this->error('Could not detect a cache store using the "redis" driver.'); + $this->info('Please configure a store in config/cache.php or provide one via --store.'); + + return self::FAILURE; + } + + // Validate that the store is using redis driver + $repository = $this->app->get(CacheContract::class)->store($storeName); + $store = $repository->getStore(); + + if (! $store instanceof RedisStore) { + $this->error("The cache store '{$storeName}' is not using the 'redis' driver."); + $this->error('Please update the store driver to "redis" in config/cache.php.'); + + return self::FAILURE; + } + + $tagMode = $store->getTagMode()->value; + + // Run environment checks (fail fast if requirements not met) + $this->info('Checking System Requirements...'); + $this->newLine(); + + if (! $this->runEnvironmentChecks($storeName, $store, $tagMode)) { + return self::FAILURE; + } + + $this->info('✓ All requirements met!'); + $this->newLine(2); + + $this->info("Testing cache store: {$storeName} ({$tagMode} mode)"); + $this->newLine(); + + // Create context for functional checks + $config = $this->app->get(ConfigInterface::class); + $connectionName = $config->get("cache.stores.{$storeName}.connection", 'default'); + + // Get the Redis connection from the store's context + $context = $store->getContext(); + $redis = $context->withConnection(fn (RedisConnection $conn) => $conn); + + $doctorContext = new DoctorContext( + cache: $repository, + store: $store, + redis: $redis, + cachePrefix: $store->getPrefix(), + storeName: $storeName, + ); + + // Run functional checks with cleanup + try { + $this->cleanup($doctorContext, silent: true); + $this->runFunctionalChecks($doctorContext); + } finally { + $this->cleanup($doctorContext); + } + + // Run cleanup verification after cleanup + $this->runCleanupVerification($doctorContext); + + $this->displaySummary(); + + return $this->testsFailed === 0 ? self::SUCCESS : self::FAILURE; + } + + /** + * Get environment check classes. + * + * @return list + */ + protected function getEnvironmentChecks(string $storeName, RedisStore $store, string $tagMode): array + { + // Get connection for version checks + $context = $store->getContext(); + $redis = $context->withConnection(fn (RedisConnection $conn) => $conn); + + return [ + new PhpRedisCheck(), + new RedisVersionCheck($redis, $tagMode), + new HexpireCheck($redis, $tagMode), + new CacheStoreCheck($storeName, 'redis', $tagMode), + ]; + } + + /** + * Get functional check classes. + * + * @return list + */ + protected function getFunctionalChecks(): array + { + return [ + new BasicOperationsCheck(), + new TaggedOperationsCheck(), + new TaggedRememberCheck(), + new MultipleTagsCheck(), + new SharedTagFlushCheck(), + new IncrementDecrementCheck(), + new AddOperationsCheck(), + new ForeverStorageCheck(), + new BulkOperationsCheck(), + new FlushBehaviorCheck(), + new EdgeCasesCheck(), + new HashStructuresCheck(), + new ExpirationCheck(), + new MemoryLeakPreventionCheck(), + new LargeDatasetCheck(), + new SequentialOperationsCheck(), + new ConcurrencyCheck(), + ]; + } + + /** + * Run environment checks. Returns false if any check fails. + */ + protected function runEnvironmentChecks(string $storeName, RedisStore $store, string $taggingMode): bool + { + $allPassed = true; + + foreach ($this->getEnvironmentChecks($storeName, $store, $taggingMode) as $check) { + $result = $check->run(); + + foreach ($result->assertions as $assertion) { + if ($assertion['passed']) { + $this->line(" ✓ {$assertion['description']}"); + } else { + $this->line(" ✗ {$assertion['description']}"); + $allPassed = false; + } + } + + // If this check failed, show fix instructions and stop + if (! $result->passed()) { + $this->newLine(); + $fixInstructions = $check->getFixInstructions(); + + if ($fixInstructions) { + $this->error('Fix: ' . $fixInstructions); + } + + return false; + } + } + + return $allPassed; + } + + /** + * Run all functional checks. + */ + protected function runFunctionalChecks(DoctorContext $context): void + { + $this->info('Running Integration Tests...'); + $this->newLine(); + + foreach ($this->getFunctionalChecks() as $check) { + // Inject output for checks that need it + if (method_exists($check, 'setOutput')) { + $check->setOutput($this->output); + } + + $this->section($check->name()); + $result = $check->run($context); + $this->displayCheckResult($result); + } + } + + /** + * Display results from a check. + */ + protected function displayCheckResult(CheckResult $result): void + { + foreach ($result->assertions as $assertion) { + if ($assertion['passed']) { + ++$this->testsPassed; + $this->line(" ✓ {$assertion['description']}"); + } else { + ++$this->testsFailed; + $this->failures[] = $assertion['description']; + $this->line(" ✗ {$assertion['description']}"); + } + } + } + + /** + * Run cleanup verification check after cleanup completes. + */ + protected function runCleanupVerification(DoctorContext $context): void + { + $check = new CleanupVerificationCheck(); + $this->section($check->name()); + $result = $check->run($context); + $this->displayCheckResult($result); + } + + /** + * Display the command header banner. + */ + protected function displayHeader(): void + { + $this->info('╔═══════════════════════════════════════════════════════════════╗'); + $this->info('║ Hypervel Cache - System Doctor ║'); + $this->info('╚═══════════════════════════════════════════════════════════════╝'); + $this->newLine(); + } + + /** + * Display system and environment information. + */ + protected function displaySystemInformation(): void + { + $this->info('System Information'); + $this->info('──────────────────────────────────────────────────────────────'); + + // PHP Version + $this->line(' PHP Version: ' . PHP_VERSION . ''); + + // PHPRedis Extension Version + if (extension_loaded('redis')) { + $this->line(' PHPRedis Version: ' . phpversion('redis') . ''); + } else { + $this->line(' PHPRedis Version: Not installed'); + } + + // Framework Version + $this->line(' Framework: Hypervel'); + + // Cache Store + $config = $this->app->get(ConfigInterface::class); + $defaultStore = $config->get('cache.default', 'file'); + $this->line(" Default Cache Store: {$defaultStore}"); + + // Redis/Valkey Service + try { + $storeName = $this->option('store') ?: $this->detectRedisStore(); + + if ($storeName) { + $connectionName = $config->get("cache.stores.{$storeName}.connection", 'default'); + $repository = $this->app->get(CacheContract::class)->store($storeName); + $store = $repository->getStore(); + + if ($store instanceof RedisStore) { + $context = $store->getContext(); + $info = $context->withConnection( + fn (RedisConnection $conn) => $conn->info('server') + ); + + if (isset($info['valkey_version'])) { + $this->line(' Service: Valkey'); + $this->line(" Service Version: {$info['valkey_version']}"); + } elseif (isset($info['redis_version'])) { + $this->line(' Service: Redis'); + $this->line(" Service Version: {$info['redis_version']}"); + } + + $this->line(' Tag Mode: ' . $store->getTagMode()->value . ''); + } + } + } catch (Exception) { + $this->line(' Service: Connection failed'); + } + + $this->newLine(2); + } + + /** + * Clean up test data created during doctor checks. + */ + protected function cleanup(DoctorContext $context, bool $silent = false): void + { + if (! $silent) { + $this->newLine(); + $this->info('Cleaning up test data...'); + } + + // Flush all test tags (this handles most tagged items) + $testTags = [ + 'products', 'posts', 'featured', 'user:123', 'counters', 'unique', + 'permanent', 'bulk', 'color:red', 'color:blue', 'color:yellow', + 'complex', 'verify', 'leak-test', 'alpha', 'beta', 'cleanup', + 'large-set', 'rapid', 'overlap1', 'overlap2', '123', 'string-tag', + 'remember', 'concurrent-test', + ]; + + foreach ($testTags as $tag) { + try { + $context->cache->tags([$context->prefixed($tag)])->flush(); + } catch (Exception) { + // Ignore cleanup errors + } + } + + // Delete individual test cache values by pattern (mode-aware) + foreach ($context->getCacheValuePatterns(self::TEST_PREFIX) as $pattern) { + try { + $this->flushKeysByPattern($context->store, $pattern); + } catch (Exception) { + // Ignore cleanup errors + } + } + + // Delete tag storage structures for dynamically-created test tags + // Uses patterns for BOTH modes to ensure complete cleanup regardless of current mode + // e.g., tagA-{random}, tagB-{random} from SharedTagFlushCheck + foreach ($context->getTagStoragePatterns(self::TEST_PREFIX) as $pattern) { + try { + $this->flushKeysByPattern($context->store, $pattern); + } catch (Exception) { + // Ignore cleanup errors + } + } + + // Any mode: clean up test entries from the tag registry + if ($context->isAnyMode()) { + try { + $registryKey = $context->store->getContext()->registryKey(); + // Get all members matching the test prefix and remove them + $members = $context->redis->zRange($registryKey, 0, -1); + $testMembers = array_filter( + $members, + fn ($m) => str_starts_with($m, self::TEST_PREFIX) + ); + if (! empty($testMembers)) { + $context->redis->zRem($registryKey, ...$testMembers); + } + // If registry is now empty, delete it + if ($context->redis->zCard($registryKey) === 0) { + $context->redis->del($registryKey); + } + } catch (Exception) { + // Ignore cleanup errors + } + } + + if (! $silent) { + $this->info('Cleanup complete.'); + } + } + + /** + * Display a section header for a check group. + */ + protected function section(string $title): void + { + $this->newLine(); + $this->info("┌─ {$title}"); + } + + /** + * Display the final test summary with pass/fail counts. + */ + protected function displaySummary(): void + { + $this->newLine(2); + $this->info('═══════════════════════════════════════════════════════════════'); + + if ($this->testsFailed === 0) { + $this->info("✓ ALL TESTS PASSED ({$this->testsPassed} tests)"); + } else { + $this->error("✗ {$this->testsFailed} TEST(S) FAILED (out of " . ($this->testsPassed + $this->testsFailed) . ' total)'); + $this->newLine(); + $this->error('Failed tests:'); + + foreach ($this->failures as $failure) { + $this->error(" - {$failure}"); + } + } + + $this->info('═══════════════════════════════════════════════════════════════'); + } + + /** + * Get the console command options. + */ + protected function getOptions(): array + { + return [ + ['store', null, InputOption::VALUE_OPTIONAL, 'The cache store to test (defaults to detecting redis driver)'], + ]; + } + + /** + * Flush keys matching a pattern. + */ + private function flushKeysByPattern(RedisStore $store, string $pattern): void + { + $store->getContext()->withConnection( + fn (RedisConnection $conn) => $conn->flushByPattern($pattern) + ); + } +} diff --git a/src/cache/src/Redis/Console/PruneStaleTagsCommand.php b/src/cache/src/Redis/Console/PruneStaleTagsCommand.php new file mode 100644 index 000000000..c7ec674e8 --- /dev/null +++ b/src/cache/src/Redis/Console/PruneStaleTagsCommand.php @@ -0,0 +1,109 @@ +argument('store') ?? 'redis'; + + $repository = $this->app->get(CacheContract::class)->store($storeName); + $store = $repository->getStore(); + + if (! $store instanceof RedisStore) { + $this->error("The cache store '{$storeName}' is not using the Redis driver."); + $this->error('This command only works with Redis cache stores.'); + + return 1; + } + + $tagMode = $store->getTagMode(); + $this->info("Pruning stale tags from '{$storeName}' store ({$tagMode->value} mode)..."); + $this->newLine(); + + if ($tagMode->isAnyMode()) { + $stats = $store->anyTagOps()->prune()->execute(); + $this->displayAnyModeStats($stats); + } else { + $stats = $store->allTagOps()->prune()->execute(); + $this->displayAllModeStats($stats); + } + + $this->newLine(); + $this->info('Stale cache tags pruned successfully.'); + + return 0; + } + + /** + * Display stats for all mode pruning. + * + * @param array{tags_scanned: int, stale_entries_removed: int, entries_checked: int, orphans_removed: int, empty_sets_deleted: int} $stats + */ + protected function displayAllModeStats(array $stats): void + { + $this->table( + ['Metric', 'Value'], + [ + ['Tags scanned', number_format($stats['tags_scanned'])], + ['Stale entries removed (TTL expired)', number_format($stats['stale_entries_removed'])], + ['Entries checked for orphans', number_format($stats['entries_checked'])], + ['Orphaned entries removed', number_format($stats['orphans_removed'])], + ['Empty tag sets deleted', number_format($stats['empty_sets_deleted'])], + ] + ); + } + + /** + * Display stats for any mode pruning. + * + * @param array{hashes_scanned: int, fields_checked: int, orphans_removed: int, empty_hashes_deleted: int, expired_tags_removed: int} $stats + */ + protected function displayAnyModeStats(array $stats): void + { + $this->table( + ['Metric', 'Value'], + [ + ['Tag hashes scanned', number_format($stats['hashes_scanned'])], + ['Fields checked', number_format($stats['fields_checked'])], + ['Orphaned fields removed', number_format($stats['orphans_removed'])], + ['Empty hashes deleted', number_format($stats['empty_hashes_deleted'])], + ['Expired tags removed from registry', number_format($stats['expired_tags_removed'])], + ] + ); + } + + /** + * Get the console command arguments. + */ + protected function getArguments(): array + { + return [ + ['store', InputArgument::OPTIONAL, 'The name of the store you would like to prune tags from', 'redis'], + ]; + } +} diff --git a/src/cache/src/Redis/Exceptions/BenchmarkMemoryException.php b/src/cache/src/Redis/Exceptions/BenchmarkMemoryException.php new file mode 100644 index 000000000..3ad0c2879 --- /dev/null +++ b/src/cache/src/Redis/Exceptions/BenchmarkMemoryException.php @@ -0,0 +1,56 @@ + 0) + * @return bool True if item was added, false if it already exists or on failure + */ + public function execute(string $key, mixed $value, int $seconds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds) { + // SET key value EX seconds NX + // - EX: Set expiration in seconds + // - NX: Only set if key does Not eXist + // Returns OK if set, null/false if key already exists + $result = $conn->client()->set( + $this->context->prefix() . $key, + $this->serialization->serialize($conn, $value), + ['EX' => max(1, $seconds), 'NX'] + ); + + return (bool) $result; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Add.php b/src/cache/src/Redis/Operations/AllTag/Add.php new file mode 100644 index 000000000..c632abf35 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Add.php @@ -0,0 +1,111 @@ + $tagIds Array of tag identifiers + * @return bool True if the key was added (didn't exist), false if it already existed + */ + public function execute(string $key, mixed $value, int $seconds, array $tagIds): bool + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $seconds, $tagIds); + } + + return $this->executePipeline($key, $value, $seconds, $tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + * + * Pipelines ZADD commands for all tags, then uses SET NX EX for atomic add. + */ + private function executePipeline(string $key, mixed $value, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $score = now()->addSeconds($seconds)->getTimestamp(); + + // Pipeline the ZADD operations for tag tracking + if (! empty($tagIds)) { + $pipeline = $client->pipeline(); + + foreach ($tagIds as $tagId) { + $pipeline->zadd($prefix . $tagId, $score, $key); + } + + $pipeline->exec(); + } + + // SET key value EX seconds NX - atomic "add if not exists" + $result = $client->set( + $prefix . $key, + $this->serialization->serialize($conn, $value), + ['EX' => max(1, $seconds), 'NX'] + ); + + return (bool) $result; + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + * + * Sequential ZADD commands since tags may be in different slots, + * then SET NX EX for atomic add. + */ + private function executeCluster(string $key, mixed $value, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $score = now()->addSeconds($seconds)->getTimestamp(); + + // ZADD to each tag's sorted set (sequential - cross-slot) + foreach ($tagIds as $tagId) { + $client->zadd($prefix . $tagId, $score, $key); + } + + // SET key value EX seconds NX - atomic "add if not exists" + $result = $client->set( + $prefix . $key, + $this->serialization->serialize($conn, $value), + ['EX' => max(1, $seconds), 'NX'] + ); + + return (bool) $result; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/AddEntry.php b/src/cache/src/Redis/Operations/AllTag/AddEntry.php new file mode 100644 index 000000000..227198022 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/AddEntry.php @@ -0,0 +1,112 @@ + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + * @param null|string $updateWhen Optional ZADD flag: 'NX' (only add new), 'XX' (only update existing), 'GT'/'LT' + */ + public function execute(string $key, int $ttl, array $tagIds, ?string $updateWhen = null): void + { + if (empty($tagIds)) { + return; + } + + // Convert TTL to timestamp score: + // - If TTL > 0: timestamp when this entry expires + // - If TTL <= 0: -1 to indicate "forever" (won't be cleaned by ZREMRANGEBYSCORE) + $score = $ttl > 0 ? now()->addSeconds($ttl)->getTimestamp() : -1; + + // Cluster mode: RedisCluster doesn't support pipeline, and tags + // may be in different slots requiring sequential commands + if ($this->context->isCluster()) { + $this->executeCluster($key, $score, $tagIds, $updateWhen); + return; + } + + $this->executePipeline($key, $score, $tagIds, $updateWhen); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + */ + private function executePipeline(string $key, int $score, array $tagIds, ?string $updateWhen): void + { + $this->context->withConnection(function (RedisConnection $conn) use ($key, $score, $tagIds, $updateWhen) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $pipeline = $client->pipeline(); + + foreach ($tagIds as $tagId) { + $prefixedTagKey = $prefix . $tagId; + + if ($updateWhen) { + // ZADD with flag (NX, XX, GT, LT) - options must be array + $pipeline->zadd($prefixedTagKey, [$updateWhen], $score, $key); + } else { + // Standard ZADD + $pipeline->zadd($prefixedTagKey, $score, $key); + } + } + + $pipeline->exec(); + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + * + * Each tag sorted set may be in a different slot, so we must + * execute ZADD commands sequentially rather than in a pipeline. + */ + private function executeCluster(string $key, int $score, array $tagIds, ?string $updateWhen): void + { + $this->context->withConnection(function (RedisConnection $conn) use ($key, $score, $tagIds, $updateWhen) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + foreach ($tagIds as $tagId) { + $prefixedTagKey = $prefix . $tagId; + + if ($updateWhen) { + // ZADD with flag (NX, XX, GT, LT) + // RedisCluster requires options as array, not string + $client->zadd($prefixedTagKey, [$updateWhen], $score, $key); + } else { + // Standard ZADD + $client->zadd($prefixedTagKey, $score, $key); + } + } + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Decrement.php b/src/cache/src/Redis/Operations/AllTag/Decrement.php new file mode 100644 index 000000000..3db9f45d0 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Decrement.php @@ -0,0 +1,96 @@ + $tagIds Array of tag identifiers + * @return false|int The new value after decrementing, or false on failure + */ + public function execute(string $key, int $value, array $tagIds): int|false + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $tagIds); + } + + return $this->executePipeline($key, $value, $tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + */ + private function executePipeline(string $key, int $value, array $tagIds): int|false + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + $pipeline = $client->pipeline(); + + // ZADD NX to each tag's sorted set (only add if not exists) + foreach ($tagIds as $tagId) { + $pipeline->zadd($prefix . $tagId, ['NX'], self::FOREVER_SCORE, $key); + } + + // DECRBY for the value + $pipeline->decrBy($prefix . $key, $value); + + $results = $pipeline->exec(); + + if ($results === false) { + return false; + } + + // Last result is the DECRBY result + return end($results); + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + */ + private function executeCluster(string $key, int $value, array $tagIds): int|false + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + // ZADD NX to each tag's sorted set (sequential - cross-slot) + foreach ($tagIds as $tagId) { + $client->zadd($prefix . $tagId, ['NX'], self::FOREVER_SCORE, $key); + } + + // DECRBY for the value + return $client->decrBy($prefix . $key, $value); + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Flush.php b/src/cache/src/Redis/Operations/AllTag/Flush.php new file mode 100644 index 000000000..d19e28b0e --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Flush.php @@ -0,0 +1,121 @@ + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + * @param array $tagNames Array of tag names (e.g., ["users", "posts"]) + */ + public function execute(array $tagIds, array $tagNames): void + { + $this->flushValues($tagIds); + $this->flushTags($tagNames); + } + + /** + * Flush the individual cache entries for the tags. + * + * Uses a single connection for all chunk deletions to avoid pool + * checkout/release overhead per chunk. In standard mode, uses pipeline + * for batching. In cluster mode, uses sequential commands. + * + * @param array $tagIds Array of tag identifiers + */ + private function flushValues(array $tagIds): void + { + $prefix = $this->context->prefix(); + + // Collect all entries and prepare chunks + // (materialize the LazyCollection to get prefixed keys) + $entries = $this->getEntries->execute($tagIds) + ->map(fn (string $key) => $prefix . $key); + + // Use a single connection for all chunk deletions + $this->context->withConnection(function (RedisConnection $conn) use ($entries) { + $client = $conn->client(); + $isCluster = $client instanceof RedisCluster; + + foreach ($entries->chunk(self::CHUNK_SIZE) as $chunk) { + $keys = $chunk->all(); + + if (empty($keys)) { + continue; + } + + if ($isCluster) { + // Cluster mode: sequential DEL (keys may be in different slots) + $client->del(...$keys); + } else { + // Standard mode: pipeline for batching + $this->deleteChunkPipelined($client, $keys); + } + } + }); + } + + /** + * Delete a chunk of keys using pipeline. + * + * @param object|Redis $client The Redis client (or mock in tests) + * @param array $keys Keys to delete + */ + private function deleteChunkPipelined(mixed $client, array $keys): void + { + $pipeline = $client->pipeline(); + $pipeline->del(...$keys); + $pipeline->exec(); + } + + /** + * Delete the tag sorted sets. + * + * Uses variadic del() to delete all tag keys in a single Redis call. + * + * @param array $tagNames Array of tag names + */ + private function flushTags(array $tagNames): void + { + if (empty($tagNames)) { + return; + } + + $this->context->withConnection(function (RedisConnection $conn) use ($tagNames) { + $tagKeys = array_map( + fn (string $name) => $this->context->tagHashKey($name), + $tagNames + ); + + $conn->del(...$tagKeys); + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/FlushStale.php b/src/cache/src/Redis/Operations/AllTag/FlushStale.php new file mode 100644 index 000000000..a4557cac0 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/FlushStale.php @@ -0,0 +1,106 @@ +flushStale() or globally via the prune command. + * + * Entries with score -1 (forever items) are never flushed. + */ +class FlushStale +{ + public function __construct( + private readonly StoreContext $context, + ) { + } + + /** + * Flush stale entries from the given tag sorted sets. + * + * Removes entries with TTL scores between 0 and current timestamp. + * Entries with score -1 (forever items) are not affected. + * + * In cluster mode, uses sequential commands since RedisCluster + * doesn't support pipeline mode and tags may be in different slots. + * + * @param array $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + */ + public function execute(array $tagIds): void + { + if (empty($tagIds)) { + return; + } + + // Cluster mode: RedisCluster doesn't support pipeline, and tags + // may be in different slots requiring sequential commands + if ($this->context->isCluster()) { + $this->executeCluster($tagIds); + return; + } + + $this->executePipeline($tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + */ + private function executePipeline(array $tagIds): void + { + $this->context->withConnection(function (RedisConnection $conn) use ($tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $timestamp = (string) now()->getTimestamp(); + + $pipeline = $client->pipeline(); + + foreach ($tagIds as $tagId) { + $pipeline->zRemRangeByScore( + $prefix . $tagId, + '0', + $timestamp + ); + } + + $pipeline->exec(); + }); + } + + /** + * Execute using multi() for Redis Cluster. + * + * RedisCluster doesn't support pipeline(), but multi() works across slots: + * - Tracks which nodes receive commands + * - Sends MULTI to each node lazily (on first key for that node) + * - Executes EXEC on all involved nodes + * - Aggregates results into a single array + */ + private function executeCluster(array $tagIds): void + { + $this->context->withConnection(function (RedisConnection $conn) use ($tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $timestamp = (string) now()->getTimestamp(); + + $multi = $client->multi(); + + foreach ($tagIds as $tagId) { + $multi->zRemRangeByScore( + $prefix . $tagId, + '0', + $timestamp + ); + } + + $multi->exec(); + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Forever.php b/src/cache/src/Redis/Operations/AllTag/Forever.php new file mode 100644 index 000000000..a3a5ef1fb --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Forever.php @@ -0,0 +1,93 @@ + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + * @return bool True if successful + */ + public function execute(string $key, mixed $value, array $tagIds): bool + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $tagIds); + } + + return $this->executePipeline($key, $value, $tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + */ + private function executePipeline(string $key, mixed $value, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $serialized = $this->serialization->serialize($conn, $value); + + $pipeline = $client->pipeline(); + + // ZADD to each tag's sorted set with score -1 (forever) + foreach ($tagIds as $tagId) { + $pipeline->zadd($prefix . $tagId, self::FOREVER_SCORE, $key); + } + + // SET for the cache value (no expiration) + $pipeline->set($prefix . $key, $serialized); + + $results = $pipeline->exec(); + + // Last result is the SET - check it succeeded + return $results !== false && end($results) !== false; + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + */ + private function executeCluster(string $key, mixed $value, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $serialized = $this->serialization->serialize($conn, $value); + + // ZADD to each tag's sorted set (sequential - cross-slot) + foreach ($tagIds as $tagId) { + $client->zadd($prefix . $tagId, self::FOREVER_SCORE, $key); + } + + // SET for the cache value (no expiration) + return (bool) $client->set($prefix . $key, $serialized); + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/GetEntries.php b/src/cache/src/Redis/Operations/AllTag/GetEntries.php new file mode 100644 index 000000000..ae07427eb --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/GetEntries.php @@ -0,0 +1,73 @@ + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + * @return LazyCollection Lazy collection yielding cache keys (without prefix) + */ + public function execute(array $tagIds): LazyCollection + { + $context = $this->context; + $prefix = $this->context->prefix(); + + // phpredis 6.1.0+ uses null as initial cursor value, older versions use '0' + $defaultCursorValue = match (true) { + version_compare(phpversion('redis'), '6.1.0', '>=') => null, + default => '0', + }; + + return new LazyCollection(function () use ($context, $prefix, $tagIds, $defaultCursorValue) { + foreach ($tagIds as $tagId) { + // Collect all entries for this tag within one connection hold + $tagEntries = $context->withConnection(function (RedisConnection $conn) use ($prefix, $tagId, $defaultCursorValue) { + $cursor = $defaultCursorValue; + $allEntries = []; + + do { + $entries = $conn->zScan( + $prefix . $tagId, + $cursor, + '*', + 1000 + ); + + if (! is_array($entries)) { + break; + } + + $allEntries = array_merge($allEntries, array_keys($entries)); + } while (((string) $cursor) !== $defaultCursorValue); + + return array_unique($allEntries); + }); + + foreach ($tagEntries as $entry) { + yield $entry; + } + } + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Increment.php b/src/cache/src/Redis/Operations/AllTag/Increment.php new file mode 100644 index 000000000..d3c0396d3 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Increment.php @@ -0,0 +1,96 @@ + $tagIds Array of tag identifiers + * @return false|int The new value after incrementing, or false on failure + */ + public function execute(string $key, int $value, array $tagIds): int|false + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $tagIds); + } + + return $this->executePipeline($key, $value, $tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + */ + private function executePipeline(string $key, int $value, array $tagIds): int|false + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + $pipeline = $client->pipeline(); + + // ZADD NX to each tag's sorted set (only add if not exists) + foreach ($tagIds as $tagId) { + $pipeline->zadd($prefix . $tagId, ['NX'], self::FOREVER_SCORE, $key); + } + + // INCRBY for the value + $pipeline->incrBy($prefix . $key, $value); + + $results = $pipeline->exec(); + + if ($results === false) { + return false; + } + + // Last result is the INCRBY result + return end($results); + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + */ + private function executeCluster(string $key, int $value, array $tagIds): int|false + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + // ZADD NX to each tag's sorted set (sequential - cross-slot) + foreach ($tagIds as $tagId) { + $client->zadd($prefix . $tagId, ['NX'], self::FOREVER_SCORE, $key); + } + + // INCRBY for the value + return $client->incrBy($prefix . $key, $value); + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Prune.php b/src/cache/src/Redis/Operations/AllTag/Prune.php new file mode 100644 index 000000000..b3de71875 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Prune.php @@ -0,0 +1,166 @@ +context->isCluster(); + + return $this->context->withConnection(function (RedisConnection $conn) use ($scanCount, $isCluster) { + $client = $conn->client(); + $pattern = $this->context->tagScanPattern(); + $optPrefix = $this->context->optPrefix(); + $prefix = $this->context->prefix(); + $now = time(); + + $stats = [ + 'tags_scanned' => 0, + 'stale_entries_removed' => 0, + 'entries_checked' => 0, + 'orphans_removed' => 0, + 'empty_sets_deleted' => 0, + ]; + + // Use SafeScan to handle OPT_PREFIX correctly + $safeScan = new SafeScan($client, $optPrefix); + + foreach ($safeScan->execute($pattern, $scanCount) as $tagKey) { + ++$stats['tags_scanned']; + + // Step 1: Remove TTL-expired entries (stale by time) + $staleRemoved = $client->zRemRangeByScore($tagKey, '0', (string) $now); + $stats['stale_entries_removed'] += is_int($staleRemoved) ? $staleRemoved : 0; + + // Step 2: Remove orphaned entries (cache key doesn't exist) + $orphanResult = $this->removeOrphanedEntries($client, $tagKey, $prefix, $scanCount, $isCluster); + $stats['entries_checked'] += $orphanResult['checked']; + $stats['orphans_removed'] += $orphanResult['removed']; + + // Step 3: Delete if empty + if ($client->zCard($tagKey) === 0) { + $client->del($tagKey); + ++$stats['empty_sets_deleted']; + } + + // Throttle between tags to let Redis breathe + usleep(5000); // 5ms + } + + return $stats; + }); + } + + /** + * Remove orphaned entries from a sorted set where the cache key no longer exists. + * + * @param string $tagKey The tag sorted set key (without OPT_PREFIX, phpredis auto-adds it) + * @param string $prefix The cache prefix (e.g., "cache:") + * @param int $scanCount Number of members per ZSCAN iteration + * @param bool $isCluster Whether we're connected to a Redis Cluster + * @return array{checked: int, removed: int} + */ + private function removeOrphanedEntries( + Redis|RedisCluster $client, + string $tagKey, + string $prefix, + int $scanCount, + bool $isCluster, + ): array { + $checked = 0; + $removed = 0; + + // phpredis 6.1.0+ uses null as initial cursor, older versions use 0 + $iterator = match (true) { + version_compare(phpversion('redis') ?: '0', '6.1.0', '>=') => null, + default => 0, + }; + + do { + // ZSCAN returns [member => score, ...] array + $members = $client->zScan($tagKey, $iterator, '*', $scanCount); + + if ($members === false || ! is_array($members) || empty($members)) { + break; + } + + $memberKeys = array_keys($members); + $checked += count($memberKeys); + + // Check which keys exist: + // - Standard Redis: pipeline() batches commands with less overhead + // - Cluster: multi() handles cross-slot commands (pipeline not supported) + $batch = $isCluster ? $client->multi() : $client->pipeline(); + + foreach ($memberKeys as $key) { + $batch->exists($prefix . $key); + } + + $existsResults = $batch->exec(); + + // Collect orphaned members (cache key doesn't exist) + $orphanedMembers = []; + + foreach ($memberKeys as $index => $key) { + // EXISTS returns int (0 or 1) + if (empty($existsResults[$index])) { + $orphanedMembers[] = $key; + } + } + + // Remove orphaned members from the sorted set + if (! empty($orphanedMembers)) { + $client->zRem($tagKey, ...$orphanedMembers); + $removed += count($orphanedMembers); + } + } while ($iterator > 0); + + return [ + 'checked' => $checked, + 'removed' => $removed, + ]; + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Put.php b/src/cache/src/Redis/Operations/AllTag/Put.php new file mode 100644 index 000000000..6218f5d24 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Put.php @@ -0,0 +1,100 @@ + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + * @return bool True if successful + */ + public function execute(string $key, mixed $value, int $seconds, array $tagIds): bool + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $seconds, $tagIds); + } + + return $this->executePipeline($key, $value, $seconds, $tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + * + * Pipelines ZADD commands for all tags + SETEX in a single round trip. + */ + private function executePipeline(string $key, mixed $value, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $score = now()->addSeconds($seconds)->getTimestamp(); + $serialized = $this->serialization->serialize($conn, $value); + + $pipeline = $client->pipeline(); + + // ZADD to each tag's sorted set + foreach ($tagIds as $tagId) { + $pipeline->zadd($prefix . $tagId, $score, $key); + } + + // SETEX for the cache value + $pipeline->setex($prefix . $key, max(1, $seconds), $serialized); + + $results = $pipeline->exec(); + + // Last result is the SETEX - check it succeeded + return $results !== false && end($results) !== false; + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + * + * Each tag sorted set may be in a different slot, so we must + * execute commands sequentially rather than in a pipeline. + */ + private function executeCluster(string $key, mixed $value, int $seconds, array $tagIds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $score = now()->addSeconds($seconds)->getTimestamp(); + $serialized = $this->serialization->serialize($conn, $value); + + // ZADD to each tag's sorted set (sequential - cross-slot) + foreach ($tagIds as $tagId) { + $client->zadd($prefix . $tagId, $score, $key); + } + + // SETEX for the cache value + return (bool) $client->setex($prefix . $key, max(1, $seconds), $serialized); + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/PutMany.php b/src/cache/src/Redis/Operations/AllTag/PutMany.php new file mode 100644 index 000000000..5029a45ba --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/PutMany.php @@ -0,0 +1,140 @@ + $values Key-value pairs (keys already namespaced) + * @param int $seconds TTL in seconds + * @param array $tagIds Array of tag identifiers + * @param string $namespace The namespace prefix for keys (for building namespaced keys) + * @return bool True if all operations successful + */ + public function execute(array $values, int $seconds, array $tagIds, string $namespace): bool + { + if (empty($values)) { + return true; + } + + if ($this->context->isCluster()) { + return $this->executeCluster($values, $seconds, $tagIds, $namespace); + } + + return $this->executePipeline($values, $seconds, $tagIds, $namespace); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + * + * Uses variadic ZADD to batch all cache keys into a single command per tag, + * reducing the total number of Redis commands from O(keys × tags) to O(tags + keys). + */ + private function executePipeline(array $values, int $seconds, array $tagIds, string $namespace): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($values, $seconds, $tagIds, $namespace) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $score = now()->addSeconds($seconds)->getTimestamp(); + $ttl = max(1, $seconds); + + // Prepare all data upfront + $preparedEntries = []; + foreach ($values as $key => $value) { + $namespacedKey = $namespace . $key; + $preparedEntries[$namespacedKey] = $this->serialization->serialize($conn, $value); + } + + $namespacedKeys = array_keys($preparedEntries); + + $pipeline = $client->pipeline(); + + // Batch ZADD: one command per tag with all cache keys as members + // ZADD format: key, score1, member1, score2, member2, ... + foreach ($tagIds as $tagId) { + $zaddArgs = []; + foreach ($namespacedKeys as $key) { + $zaddArgs[] = $score; + $zaddArgs[] = $key; + } + $pipeline->zadd($prefix . $tagId, ...$zaddArgs); + } + + // Then all SETEXs + foreach ($preparedEntries as $namespacedKey => $serialized) { + $pipeline->setex($prefix . $namespacedKey, $ttl, $serialized); + } + + $results = $pipeline->exec(); + + return $results !== false && ! in_array(false, $results, true); + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + * + * Uses variadic ZADD to batch all cache keys into a single command per tag. + * This is safe in cluster mode because variadic ZADD targets ONE sorted set key, + * which resides in a single slot. + */ + private function executeCluster(array $values, int $seconds, array $tagIds, string $namespace): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($values, $seconds, $tagIds, $namespace) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $score = now()->addSeconds($seconds)->getTimestamp(); + $ttl = max(1, $seconds); + + // Prepare all data upfront + $preparedEntries = []; + foreach ($values as $key => $value) { + $namespacedKey = $namespace . $key; + $preparedEntries[$namespacedKey] = $this->serialization->serialize($conn, $value); + } + + $namespacedKeys = array_keys($preparedEntries); + + // Batch ZADD: one command per tag with all cache keys as members + // Each tag's sorted set is in ONE slot, so variadic ZADD works in cluster + foreach ($tagIds as $tagId) { + $zaddArgs = []; + foreach ($namespacedKeys as $key) { + $zaddArgs[] = $score; + $zaddArgs[] = $key; + } + $client->zadd($prefix . $tagId, ...$zaddArgs); + } + + // Then all SETEXs + $allSucceeded = true; + foreach ($preparedEntries as $namespacedKey => $serialized) { + if (! $client->setex($prefix . $namespacedKey, $ttl, $serialized)) { + $allSucceeded = false; + } + } + + return $allSucceeded; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/Remember.php b/src/cache/src/Redis/Operations/AllTag/Remember.php new file mode 100644 index 000000000..029dd3571 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/Remember.php @@ -0,0 +1,136 @@ + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + public function execute(string $key, int $seconds, Closure $callback, array $tagIds): array + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $seconds, $callback, $tagIds); + } + + return $this->executePipeline($key, $seconds, $callback, $tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + * + * GET first, then on miss: pipelines ZADD commands for all tags + SETEX in a single round trip. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executePipeline(string $key, int $seconds, Closure $callback, array $tagIds): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $seconds, $callback, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Now store with tag tracking using pipeline + $score = now()->addSeconds($seconds)->getTimestamp(); + $serialized = $this->serialization->serialize($conn, $value); + + $pipeline = $client->pipeline(); + + // ZADD to each tag's sorted set + foreach ($tagIds as $tagId) { + $pipeline->zadd($prefix . $tagId, $score, $key); + } + + // SETEX for the cache value + $pipeline->setex($prefixedKey, max(1, $seconds), $serialized); + + $pipeline->exec(); + + return [$value, false]; + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + * + * Each tag sorted set may be in a different slot, so we must + * execute commands sequentially rather than in a pipeline. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executeCluster(string $key, int $seconds, Closure $callback, array $tagIds): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $seconds, $callback, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Now store with tag tracking using sequential commands + $score = now()->addSeconds($seconds)->getTimestamp(); + $serialized = $this->serialization->serialize($conn, $value); + + // ZADD to each tag's sorted set (sequential - cross-slot) + foreach ($tagIds as $tagId) { + $client->zadd($prefix . $tagId, $score, $key); + } + + // SETEX for the cache value + $client->setex($prefixedKey, max(1, $seconds), $serialized); + + return [$value, false]; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTag/RememberForever.php b/src/cache/src/Redis/Operations/AllTag/RememberForever.php new file mode 100644 index 000000000..bcfd149a5 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTag/RememberForever.php @@ -0,0 +1,135 @@ + $tagIds Array of tag identifiers (e.g., "_all:tag:users:entries") + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + public function execute(string $key, Closure $callback, array $tagIds): array + { + if ($this->context->isCluster()) { + return $this->executeCluster($key, $callback, $tagIds); + } + + return $this->executePipeline($key, $callback, $tagIds); + } + + /** + * Execute using pipeline for standard Redis (non-cluster). + * + * GET first, then on miss: pipelines ZADD commands for all tags + SET in a single round trip. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executePipeline(string $key, Closure $callback, array $tagIds): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $callback, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Now store with tag tracking using pipeline + $serialized = $this->serialization->serialize($conn, $value); + + $pipeline = $client->pipeline(); + + // ZADD to each tag's sorted set with score -1 (forever) + foreach ($tagIds as $tagId) { + $pipeline->zadd($prefix . $tagId, self::FOREVER_SCORE, $key); + } + + // SET for the cache value (no expiration) + $pipeline->set($prefixedKey, $serialized); + + $pipeline->exec(); + + return [$value, false]; + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + * + * Each tag sorted set may be in a different slot, so we must + * execute commands sequentially rather than in a pipeline. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executeCluster(string $key, Closure $callback, array $tagIds): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $callback, $tagIds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Now store with tag tracking using sequential commands + $serialized = $this->serialization->serialize($conn, $value); + + // ZADD to each tag's sorted set (sequential - cross-slot) + foreach ($tagIds as $tagId) { + $client->zadd($prefix . $tagId, self::FOREVER_SCORE, $key); + } + + // SET for the cache value (no expiration) + $client->set($prefixedKey, $serialized); + + return [$value, false]; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AllTagOperations.php b/src/cache/src/Redis/Operations/AllTagOperations.php new file mode 100644 index 000000000..c5e54a788 --- /dev/null +++ b/src/cache/src/Redis/Operations/AllTagOperations.php @@ -0,0 +1,204 @@ +put ??= new Put($this->context, $this->serialization); + } + + /** + * Get the PutMany operation for storing multiple items with tag tracking. + */ + public function putMany(): PutMany + { + return $this->putMany ??= new PutMany($this->context, $this->serialization); + } + + /** + * Get the Add operation for storing items if they don't exist. + */ + public function add(): Add + { + return $this->add ??= new Add($this->context, $this->serialization); + } + + /** + * Get the Forever operation for storing items indefinitely with tag tracking. + */ + public function forever(): Forever + { + return $this->forever ??= new Forever($this->context, $this->serialization); + } + + /** + * Get the Increment operation for incrementing values with tag tracking. + */ + public function increment(): Increment + { + return $this->increment ??= new Increment($this->context); + } + + /** + * Get the Decrement operation for decrementing values with tag tracking. + */ + public function decrement(): Decrement + { + return $this->decrement ??= new Decrement($this->context); + } + + /** + * Get the AddEntry operation for adding cache key references to tag sorted sets. + * + * @deprecated Use put(), forever(), increment(), decrement() instead for combined operations + */ + public function addEntry(): AddEntry + { + return $this->addEntry ??= new AddEntry($this->context); + } + + /** + * Get the GetEntries operation for retrieving cache keys from tag sorted sets. + */ + public function getEntries(): GetEntries + { + return $this->getEntries ??= new GetEntries($this->context); + } + + /** + * Get the FlushStale operation for removing expired entries from tag sorted sets. + */ + public function flushStale(): FlushStale + { + return $this->flushStale ??= new FlushStale($this->context); + } + + /** + * Get the Flush operation for removing all items with specified tags. + */ + public function flush(): Flush + { + return $this->flush ??= new Flush($this->context, $this->getEntries()); + } + + /** + * Get the Prune operation for removing stale entries from all tag sorted sets. + * + * This discovers all tag:*:entries keys via SCAN and removes entries + * with expired TTL scores, then deletes empty sorted sets. + */ + public function prune(): Prune + { + return $this->prune ??= new Prune($this->context); + } + + /** + * Get the Remember operation for cache-through with tag tracking. + * + * This operation is optimized to use a single connection for both + * GET and PUT operations, avoiding double pool overhead on cache misses. + */ + public function remember(): Remember + { + return $this->remember ??= new Remember($this->context, $this->serialization); + } + + /** + * Get the RememberForever operation for cache-through with tag tracking (no TTL). + * + * This operation is optimized to use a single connection for both + * GET and SET operations, avoiding double pool overhead on cache misses. + * Uses ZADD with score -1 for tag entries (prevents cleanup by ZREMRANGEBYSCORE). + */ + public function rememberForever(): RememberForever + { + return $this->rememberForever ??= new RememberForever($this->context, $this->serialization); + } + + /** + * Clear all cached operation instances. + * + * Called when the store's connection or prefix changes. + */ + public function clear(): void + { + $this->put = null; + $this->putMany = null; + $this->add = null; + $this->forever = null; + $this->increment = null; + $this->decrement = null; + $this->addEntry = null; + $this->getEntries = null; + $this->flushStale = null; + $this->flush = null; + $this->prune = null; + $this->remember = null; + $this->rememberForever = null; + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Add.php b/src/cache/src/Redis/Operations/AnyTag/Add.php new file mode 100644 index 000000000..e050515cc --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Add.php @@ -0,0 +1,189 @@ + 0) + * @param array $tags Array of tag names (will be cast to strings) + * @return bool True if item was added, false if it already exists + */ + public function execute(string $key, mixed $value, int $seconds, array $tags): bool + { + // 1. Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $seconds, $tags); + } + + // 2. Standard Mode: Use Lua for atomicity and performance + return $this->executeUsingLua($key, $value, $seconds, $tags); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, mixed $value, int $seconds, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + // First try to add the key with NX flag + $added = $client->set( + $prefix . $key, + $this->serialization->serialize($conn, $value), + ['EX' => max(1, $seconds), 'NX'] + ); + + if (! $added) { + return false; + } + + // If successfully added, add to tags + // Note: RedisCluster does not support pipeline(), so we execute sequentially. + // This means we lose atomicity for the tag updates, but that's the trade-off for clusters. + + // Store reverse index of tags for this key + $tagsKey = $this->context->reverseIndexKey($key); + + if (! empty($tags)) { + // Use multi() for reverse index updates (same slot) + $multi = $client->multi(); + $multi->sadd($tagsKey, ...$tags); + $multi->expire($tagsKey, max(1, $seconds)); + $multi->exec(); + } + + // Add to tags with field expiration (using HSETEX for atomic operation) + // And update the Tag Registry + $registryKey = $this->context->registryKey(); + $expiry = time() + $seconds; + + // 1. Update Tag Hashes (Cross-slot, must be sequential) + foreach ($tags as $tag) { + $tag = (string) $tag; + $client->hsetex($this->context->tagHashKey($tag), [$key => StoreContext::TAG_FIELD_VALUE], ['EX' => $seconds]); + } + + // 2. Update Registry (Same slot, single command optimization) + if (! empty($tags)) { + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + // Update Registry: ZADD with GT (Greater Than) to only extend expiry + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + return true; + }); + } + + /** + * Execute using Lua script for better performance. + */ + private function executeUsingLua(string $key, mixed $value, int $seconds, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + $script = <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local val = ARGV[1] + local ttl = ARGV[2] + local tagPrefix = ARGV[3] + local registryKey = ARGV[4] + local now = ARGV[5] + local rawKey = ARGV[6] + local tagHashSuffix = ARGV[7] + local expiry = now + ttl + + -- 1. Try to add key (SET NX) + -- redis.call returns a table/object for OK, or false/nil + local added = redis.call('SET', key, val, 'EX', ttl, 'NX') + + if not added then + return false + end + + -- 2. Add to Tags Reverse Index + local newTagsList = {} + for i = 8, #ARGV do + table.insert(newTagsList, ARGV[i]) + end + + if #newTagsList > 0 then + redis.call('SADD', tagsKey, unpack(newTagsList)) + redis.call('EXPIRE', tagsKey, ttl) + end + + -- 3. Add to Tag Hashes & Registry + for _, tag in ipairs(newTagsList) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + -- Use HSET + HEXPIRE instead of HSETEX to avoid potential Lua argument issues + redis.call('HSET', tagHash, rawKey, '1') + redis.call('HEXPIRE', tagHash, ttl, 'FIELDS', 1, rawKey) + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return true +LUA; + + $args = [ + $prefix . $key, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + $this->serialization->serializeForLua($conn, $value), // ARGV[1] + max(1, $seconds), // ARGV[2] + $this->context->fullTagPrefix(), // ARGV[3] + $this->context->fullRegistryKey(), // ARGV[4] + time(), // ARGV[5] + $key, // ARGV[6] (Raw key for hash field) + $this->context->tagHashSuffix(), // ARGV[7] + ...$tags, // ARGV[8...] + ]; + + $scriptHash = sha1($script); + $result = $client->evalSha($scriptHash, $args, 2); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + $result = $client->eval($script, $args, 2); + } + + return (bool) $result; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Decrement.php b/src/cache/src/Redis/Operations/AnyTag/Decrement.php new file mode 100644 index 000000000..bbd64642a --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Decrement.php @@ -0,0 +1,213 @@ + $tags Array of tag names (will be cast to strings) + * @return false|int The new value after decrementing, or false on failure + */ + public function execute(string $key, int $value, array $tags): int|bool + { + // 1. Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $tags); + } + + // 2. Standard Mode: Use Lua for atomicity and performance + return $this->executeUsingLua($key, $value, $tags); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, int $value, array $tags): int|bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + // 1. Decrement and Get TTL (Same slot, so we can use multi) + $multi = $client->multi(); + $multi->decrBy($prefix . $key, $value); + $multi->ttl($prefix . $key); + [$newValue, $ttl] = $multi->exec(); + + $tagsKey = $this->context->reverseIndexKey($key); + $oldTags = $client->smembers($tagsKey); + + // Add to tags with expiration if the key has TTL + if (! empty($tags)) { + // 2. Update Reverse Index (Same slot, so we can use multi) + $multi = $client->multi(); + $multi->del($tagsKey); + $multi->sadd($tagsKey, ...$tags); + + if ($ttl > 0) { + $multi->expire($tagsKey, $ttl); + } + + $multi->exec(); + + // Remove item from tags it no longer belongs to + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $tag = (string) $tag; + $client->hdel($this->context->tagHashKey($tag), $key); + } + + // Calculate expiry for Registry + $expiry = ($ttl > 0) ? (time() + $ttl) : StoreContext::MAX_EXPIRY; + $registryKey = $this->context->registryKey(); + + // 3. Update Tag Hashes (Cross-slot, must be sequential) + foreach ($tags as $tag) { + $tag = (string) $tag; + $tagHashKey = $this->context->tagHashKey($tag); + + if ($ttl > 0) { + // Use HSETEX for atomic operation + $client->hsetex($tagHashKey, [$key => StoreContext::TAG_FIELD_VALUE], ['EX' => $ttl]); + } else { + $client->hSet($tagHashKey, $key, StoreContext::TAG_FIELD_VALUE); + } + } + + // 4. Update Registry (Same slot, single command optimization) + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + return $newValue; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, int $value, array $tags): int|bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + $script = <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local val = tonumber(ARGV[1]) + local tagPrefix = ARGV[2] + local registryKey = ARGV[3] + local now = ARGV[4] + local rawKey = ARGV[5] + local tagHashSuffix = ARGV[6] + + -- 1. Decrement + local newValue = redis.call('DECRBY', key, val) + + -- 2. Get TTL + local ttl = redis.call('TTL', key) + local expiry = 253402300799 -- Default forever + if ttl > 0 then + expiry = now + ttl + end + + -- 3. Get Old Tags + local oldTags = redis.call('SMEMBERS', tagsKey) + local newTagsMap = {} + local newTagsList = {} + + for i = 7, #ARGV do + local tag = ARGV[i] + newTagsMap[tag] = true + table.insert(newTagsList, tag) + end + + -- 4. Remove from Old Tags + for _, tag in ipairs(oldTags) do + if not newTagsMap[tag] then + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + end + + -- 5. Update Reverse Index + redis.call('DEL', tagsKey) + if #newTagsList > 0 then + redis.call('SADD', tagsKey, unpack(newTagsList)) + if ttl > 0 then + redis.call('EXPIRE', tagsKey, ttl) + end + end + + -- 6. Update Tag Hashes + for _, tag in ipairs(newTagsList) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + if ttl > 0 then + redis.call('HSETEX', tagHash, 'EX', ttl, 'FIELDS', 1, rawKey, '1') + else + redis.call('HSET', tagHash, rawKey, '1') + end + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return newValue +LUA; + + $args = [ + $prefix . $key, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + $value, // ARGV[1] + $this->context->fullTagPrefix(), // ARGV[2] + $this->context->fullRegistryKey(), // ARGV[3] + time(), // ARGV[4] + $key, // ARGV[5] + $this->context->tagHashSuffix(), // ARGV[6] + ...$tags, // ARGV[7...] + ]; + + $scriptHash = sha1($script); + $result = $client->evalSha($scriptHash, $args, 2); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + return $client->eval($script, $args, 2); + } + + return $result; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Flush.php b/src/cache/src/Redis/Operations/AnyTag/Flush.php new file mode 100644 index 000000000..3282952f2 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Flush.php @@ -0,0 +1,224 @@ + $tags Array of tag names to flush + * @return bool True if successful, false on failure + */ + public function execute(array $tags): bool + { + // 1. Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($tags); + } + + // 2. Standard Mode: Use Pipeline + return $this->executeUsingPipeline($tags); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($tags) { + $client = $conn->client(); + + // Collect all keys from all tags + $keyGenerator = function () use ($tags) { + foreach ($tags as $tag) { + $keys = $this->getTaggedKeys->execute((string) $tag); + + foreach ($keys as $key) { + yield $key; + } + } + }; + + $buffer = []; + $bufferSize = 0; + + foreach ($keyGenerator() as $key) { + $buffer[$key] = true; + ++$bufferSize; + + if ($bufferSize >= self::CHUNK_SIZE) { + $this->processChunkCluster($client, array_keys($buffer)); + $buffer = []; + $bufferSize = 0; + } + } + + if ($bufferSize > 0) { + $this->processChunkCluster($client, array_keys($buffer)); + } + + // Delete the tag hashes themselves and remove from registry + $registryKey = $this->context->registryKey(); + + foreach ($tags as $tag) { + $tag = (string) $tag; + $client->del($this->context->tagHashKey($tag)); + $client->zrem($registryKey, $tag); + } + + return true; + }); + } + + /** + * Execute using Pipeline. + */ + private function executeUsingPipeline(array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($tags) { + $client = $conn->client(); + + // Collect all keys from all tags + $keyGenerator = function () use ($tags) { + foreach ($tags as $tag) { + $keys = $this->getTaggedKeys->execute((string) $tag); + + foreach ($keys as $key) { + yield $key; + } + } + }; + + $buffer = []; + $bufferSize = 0; + + foreach ($keyGenerator() as $key) { + $buffer[$key] = true; + ++$bufferSize; + + if ($bufferSize >= self::CHUNK_SIZE) { + $this->processChunkPipeline($client, array_keys($buffer)); + $buffer = []; + $bufferSize = 0; + } + } + + if ($bufferSize > 0) { + $this->processChunkPipeline($client, array_keys($buffer)); + } + + // Delete the tag hashes themselves and remove from registry + $registryKey = $this->context->registryKey(); + $pipeline = $client->pipeline(); + + foreach ($tags as $tag) { + $tag = (string) $tag; + $pipeline->del($this->context->tagHashKey($tag)); + $pipeline->zrem($registryKey, $tag); + } + + $pipeline->exec(); + + return true; + }); + } + + /** + * Process a chunk of keys for lazy flush (Cluster Mode). + * + * @param Redis|RedisCluster $client + * @param array $keys Array of cache keys (without prefix) + */ + private function processChunkCluster(mixed $client, array $keys): void + { + $prefix = $this->context->prefix(); + + // Delete reverse indexes for this chunk + $reverseIndexKeys = array_map( + fn (string $key): string => $this->context->reverseIndexKey($key), + $keys + ); + + // Convert to prefixed keys for this chunk + $prefixedChunk = array_map( + fn (string $key): string => $prefix . $key, + $keys + ); + + if (! empty($reverseIndexKeys)) { + $client->del(...$reverseIndexKeys); + } + + if (! empty($prefixedChunk)) { + $client->unlink(...$prefixedChunk); + } + } + + /** + * Process a chunk of keys for lazy flush (Pipeline Mode). + * + * @param Redis|RedisCluster $client + * @param array $keys Array of cache keys (without prefix) + */ + private function processChunkPipeline(mixed $client, array $keys): void + { + $prefix = $this->context->prefix(); + + // Delete reverse indexes for this chunk + $reverseIndexKeys = array_map( + fn (string $key): string => $this->context->reverseIndexKey($key), + $keys + ); + + // Convert to prefixed keys for this chunk + $prefixedChunk = array_map( + fn (string $key): string => $prefix . $key, + $keys + ); + + $pipeline = $client->pipeline(); + + if (! empty($reverseIndexKeys)) { + $pipeline->del(...$reverseIndexKeys); + } + + if (! empty($prefixedChunk)) { + $pipeline->unlink(...$prefixedChunk); + } + + $pipeline->exec(); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Forever.php b/src/cache/src/Redis/Operations/AnyTag/Forever.php new file mode 100644 index 000000000..f543b3fb2 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Forever.php @@ -0,0 +1,195 @@ + $tags Array of tag names (will be cast to strings) + * @return bool True if successful, false on failure + */ + public function execute(string $key, mixed $value, array $tags): bool + { + // 1. Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $tags); + } + + // 2. Standard Mode: Use Lua for atomicity and performance + return $this->executeUsingLua($key, $value, $tags); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, mixed $value, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + // Get old tags to handle replacement correctly (remove from old, add to new) + $tagsKey = $this->context->reverseIndexKey($key); + $oldTags = $client->smembers($tagsKey); + + // Store the actual cache value without expiration + $client->set( + $prefix . $key, + $this->serialization->serialize($conn, $value) + ); + + // Store reverse index of tags for this key + // Use multi() as these keys are in the same slot + $multi = $client->multi(); + $multi->del($tagsKey); + + if (! empty($tags)) { + $multi->sadd($tagsKey, ...$tags); + } + + $multi->exec(); + + // Remove item from tags it no longer belongs to + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $tag = (string) $tag; + $client->hdel($this->context->tagHashKey($tag), $key); + } + + // Calculate expiry for Registry (Year 9999) + $expiry = StoreContext::MAX_EXPIRY; + $registryKey = $this->context->registryKey(); + + // 1. Add to each tag's hash without expiration (Cross-slot, sequential) + foreach ($tags as $tag) { + $tag = (string) $tag; + $client->hSet($this->context->tagHashKey($tag), $key, StoreContext::TAG_FIELD_VALUE); + // No HEXPIRE for forever items + } + + // 2. Update Registry (Same slot, single command optimization) + if (! empty($tags)) { + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + // Update Registry: ZADD with GT (Greater Than) to only extend expiry + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + return true; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, mixed $value, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + $script = <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local val = ARGV[1] + local tagPrefix = ARGV[2] + local registryKey = ARGV[3] + local rawKey = ARGV[4] + local tagHashSuffix = ARGV[5] + + -- 1. Set Value + redis.call('SET', key, val) + + -- 2. Get Old Tags + local oldTags = redis.call('SMEMBERS', tagsKey) + local newTagsMap = {} + local newTagsList = {} + + for i = 6, #ARGV do + local tag = ARGV[i] + newTagsMap[tag] = true + table.insert(newTagsList, tag) + end + + -- 3. Remove from Old Tags + for _, tag in ipairs(oldTags) do + if not newTagsMap[tag] then + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + end + + -- 4. Update Reverse Index + redis.call('DEL', tagsKey) + if #newTagsList > 0 then + redis.call('SADD', tagsKey, unpack(newTagsList)) + end + + -- 5. Add to New Tags + local expiry = 253402300799 + for _, tag in ipairs(newTagsList) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HSET', tagHash, rawKey, '1') + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return true +LUA; + + $args = [ + $prefix . $key, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + $this->serialization->serializeForLua($conn, $value), // ARGV[1] + $this->context->fullTagPrefix(), // ARGV[2] + $this->context->fullRegistryKey(), // ARGV[3] + $key, // ARGV[4] + $this->context->tagHashSuffix(), // ARGV[5] + ...$tags, // ARGV[6...] + ]; + + $scriptHash = sha1($script); + $result = $client->evalSha($scriptHash, $args, 2); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + $client->eval($script, $args, 2); + } + + return true; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/GetTagItems.php b/src/cache/src/Redis/Operations/AnyTag/GetTagItems.php new file mode 100644 index 000000000..b41a46f5e --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/GetTagItems.php @@ -0,0 +1,98 @@ + $tags Array of tag names + * @return Generator Yields key => value pairs + */ + public function execute(array $tags): Generator + { + $seenKeys = []; + + foreach ($tags as $tag) { + $keys = $this->getTaggedKeys->execute((string) $tag); + $keyBuffer = []; + + foreach ($keys as $key) { + if (isset($seenKeys[$key])) { + continue; + } + + $seenKeys[$key] = true; + $keyBuffer[] = $key; + + if (count($keyBuffer) >= self::CHUNK_SIZE) { + yield from $this->fetchValues($keyBuffer); + $keyBuffer = []; + } + } + + if (! empty($keyBuffer)) { + yield from $this->fetchValues($keyBuffer); + } + } + } + + /** + * Fetch values for a list of keys. + * + * @param array $keys Array of cache keys (without prefix) + * @return Generator Yields key => value pairs + */ + private function fetchValues(array $keys): Generator + { + if (empty($keys)) { + return; + } + + $prefix = $this->context->prefix(); + $prefixedKeys = array_map(fn ($key): string => $prefix . $key, $keys); + + $results = $this->context->withConnection( + function (RedisConnection $conn) use ($prefixedKeys, $keys) { + $values = $conn->client()->mget($prefixedKeys); + $items = []; + + foreach ($values as $index => $value) { + if ($value !== false && $value !== null) { + $items[$keys[$index]] = $this->serialization->unserialize($conn, $value); + } + } + + return $items; + } + ); + + yield from $results; + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php new file mode 100644 index 000000000..6ac9df0a8 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/GetTaggedKeys.php @@ -0,0 +1,109 @@ + Generator yielding cache keys (without prefix) + */ + public function execute(string $tag, int $count = 1000): Generator + { + $tagKey = $this->context->tagHashKey($tag); + + // Check size with a quick connection checkout + $size = $this->context->withConnection( + fn (RedisConnection $conn) => $conn->client()->hlen($tagKey) + ); + + if ($size <= $this->scanThreshold) { + // For small hashes, fetch all at once (safe - data fully fetched before connection release) + $fields = $this->context->withConnection( + fn (RedisConnection $conn) => $conn->client()->hkeys($tagKey) + ); + + return $this->arrayToGenerator($fields ?: []); + } + + // For large hashes, use HSCAN with per-batch connections + return $this->hscanGenerator($tagKey, $count); + } + + /** + * Convert an array to a generator. + * + * @param array $items + * @return Generator + */ + private function arrayToGenerator(array $items): Generator + { + foreach ($items as $item) { + yield $item; + } + } + + /** + * Create a generator using HSCAN for memory-efficient iteration. + * + * Acquires a connection per-batch to avoid race conditions in Swoole coroutine + * environments. The connection is released between HSCAN iterations, ensuring + * it won't be used by another coroutine while the generator is paused. + * + * @return Generator + */ + private function hscanGenerator(string $tagKey, int $count): Generator + { + $iterator = null; + + do { + // Acquire connection just for this HSCAN batch + $fields = $this->context->withConnection( + function (RedisConnection $conn) use ($tagKey, &$iterator, $count) { + return $conn->client()->hscan($tagKey, $iterator, null, $count); + } + ); + + if ($fields !== false && ! empty($fields)) { + // HSCAN returns key-value pairs, we only need keys + foreach (array_keys($fields) as $key) { + yield $key; + } + } + } while ($iterator > 0); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Increment.php b/src/cache/src/Redis/Operations/AnyTag/Increment.php new file mode 100644 index 000000000..ae130966f --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Increment.php @@ -0,0 +1,214 @@ + $tags Array of tag names (will be cast to strings) + * @return false|int The new value after incrementing, or false on failure + */ + public function execute(string $key, int $value, array $tags): int|bool + { + // 1. Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $tags); + } + + // 2. Standard Mode: Use Lua for atomicity and performance + return $this->executeUsingLua($key, $value, $tags); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, int $value, array $tags): int|bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + // 1. Increment and Get TTL (Same slot, so we can use multi) + $multi = $client->multi(); + $multi->incrBy($prefix . $key, $value); + $multi->ttl($prefix . $key); + [$newValue, $ttl] = $multi->exec(); + + $tagsKey = $this->context->reverseIndexKey($key); + $oldTags = $client->smembers($tagsKey); + + // Add to tags with expiration if the key has TTL + if (! empty($tags)) { + // 2. Update Reverse Index (Same slot, so we can use multi) + $multi = $client->multi(); + $multi->del($tagsKey); + $multi->sadd($tagsKey, ...$tags); + + if ($ttl > 0) { + $multi->expire($tagsKey, $ttl); + } + + $multi->exec(); + + // Remove item from tags it no longer belongs to + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $tag = (string) $tag; + $client->hdel($this->context->tagHashKey($tag), $key); + } + + // Calculate expiry for Registry + $expiry = ($ttl > 0) ? (time() + $ttl) : StoreContext::MAX_EXPIRY; + $registryKey = $this->context->registryKey(); + + // 3. Update Tag Hashes (Cross-slot, must be sequential) + foreach ($tags as $tag) { + $tag = (string) $tag; + $tagHashKey = $this->context->tagHashKey($tag); + + if ($ttl > 0) { + // Use HSETEX for atomic operation + $client->hsetex($tagHashKey, [$key => StoreContext::TAG_FIELD_VALUE], ['EX' => $ttl]); + } else { + $client->hSet($tagHashKey, $key, StoreContext::TAG_FIELD_VALUE); + } + } + + // 4. Update Registry (Same slot, single command optimization) + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + return $newValue; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, int $value, array $tags): int|bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + $script = <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local val = tonumber(ARGV[1]) + local tagPrefix = ARGV[2] + local registryKey = ARGV[3] + local now = ARGV[4] + local rawKey = ARGV[5] + local tagHashSuffix = ARGV[6] + + -- 1. Increment + local newValue = redis.call('INCRBY', key, val) + + -- 2. Get TTL + local ttl = redis.call('TTL', key) + local expiry = 253402300799 -- Default forever + if ttl > 0 then + expiry = now + ttl + end + + -- 3. Get Old Tags + local oldTags = redis.call('SMEMBERS', tagsKey) + local newTagsMap = {} + local newTagsList = {} + + for i = 7, #ARGV do + local tag = ARGV[i] + newTagsMap[tag] = true + table.insert(newTagsList, tag) + end + + -- 4. Remove from Old Tags + for _, tag in ipairs(oldTags) do + if not newTagsMap[tag] then + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + end + + -- 5. Update Reverse Index + redis.call('DEL', tagsKey) + if #newTagsList > 0 then + redis.call('SADD', tagsKey, unpack(newTagsList)) + if ttl > 0 then + redis.call('EXPIRE', tagsKey, ttl) + end + end + + -- 6. Add to New Tags + for _, tag in ipairs(newTagsList) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + if ttl > 0 then + -- Use HSETEX for atomic field creation and expiration + redis.call('HSETEX', tagHash, 'EX', ttl, 'FIELDS', 1, rawKey, '1') + else + redis.call('HSET', tagHash, rawKey, '1') + end + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return newValue +LUA; + + $args = [ + $prefix . $key, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + $value, // ARGV[1] + $this->context->fullTagPrefix(), // ARGV[2] + $this->context->fullRegistryKey(), // ARGV[3] + time(), // ARGV[4] + $key, // ARGV[5] + $this->context->tagHashSuffix(), // ARGV[6] + ...$tags, // ARGV[7...] + ]; + + $scriptHash = sha1($script); + $result = $client->evalSha($scriptHash, $args, 2); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + return $client->eval($script, $args, 2); + } + + return $result; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Prune.php b/src/cache/src/Redis/Operations/AnyTag/Prune.php new file mode 100644 index 000000000..3e707a9a8 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Prune.php @@ -0,0 +1,286 @@ +context->isCluster()) { + return $this->executeCluster($scanCount); + } + + return $this->executePipeline($scanCount); + } + + /** + * Execute using pipeline for standard Redis. + * + * @return array{hashes_scanned: int, fields_checked: int, orphans_removed: int, empty_hashes_deleted: int, expired_tags_removed: int} + */ + private function executePipeline(int $scanCount): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($scanCount) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $registryKey = $this->context->registryKey(); + $now = time(); + + $stats = [ + 'hashes_scanned' => 0, + 'fields_checked' => 0, + 'orphans_removed' => 0, + 'empty_hashes_deleted' => 0, + 'expired_tags_removed' => 0, + ]; + + // Step 1: Remove expired tags from registry + $expiredCount = $client->zRemRangeByScore($registryKey, '-inf', (string) $now); + $stats['expired_tags_removed'] = is_int($expiredCount) ? $expiredCount : 0; + + // Step 2: Get active tags from registry + $tags = $client->zRange($registryKey, 0, -1); + + if (empty($tags) || ! is_array($tags)) { + return $stats; + } + + // Step 3: Process each tag hash + foreach ($tags as $tag) { + $tagHash = $this->context->tagHashKey($tag); + $result = $this->cleanupTagHashPipeline($client, $tagHash, $prefix, $scanCount); + + ++$stats['hashes_scanned']; + $stats['fields_checked'] += $result['checked']; + $stats['orphans_removed'] += $result['removed']; + + if ($result['deleted']) { + ++$stats['empty_hashes_deleted']; + } + + // Small sleep to let Redis breathe between tag hashes + usleep(5000); // 5ms + } + + return $stats; + }); + } + + /** + * Execute using sequential commands for Redis Cluster. + * + * @return array{hashes_scanned: int, fields_checked: int, orphans_removed: int, empty_hashes_deleted: int, expired_tags_removed: int} + */ + private function executeCluster(int $scanCount): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($scanCount) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $registryKey = $this->context->registryKey(); + $now = time(); + + $stats = [ + 'hashes_scanned' => 0, + 'fields_checked' => 0, + 'orphans_removed' => 0, + 'empty_hashes_deleted' => 0, + 'expired_tags_removed' => 0, + ]; + + // Step 1: Remove expired tags from registry + $expiredCount = $client->zRemRangeByScore($registryKey, '-inf', (string) $now); + $stats['expired_tags_removed'] = is_int($expiredCount) ? $expiredCount : 0; + + // Step 2: Get active tags from registry + $tags = $client->zRange($registryKey, 0, -1); + + if (empty($tags) || ! is_array($tags)) { + return $stats; + } + + // Step 3: Process each tag hash + foreach ($tags as $tag) { + $tagHash = $this->context->tagHashKey($tag); + $result = $this->cleanupTagHashCluster($client, $tagHash, $prefix, $scanCount); + + ++$stats['hashes_scanned']; + $stats['fields_checked'] += $result['checked']; + $stats['orphans_removed'] += $result['removed']; + + if ($result['deleted']) { + ++$stats['empty_hashes_deleted']; + } + + // Small sleep to let Redis breathe between tag hashes + usleep(5000); // 5ms + } + + return $stats; + }); + } + + /** + * Clean up orphaned fields from a single tag hash using pipeline. + * + * @param Redis|RedisCluster $client + * @return array{checked: int, removed: int, deleted: bool} + */ + private function cleanupTagHashPipeline(mixed $client, string $tagHash, string $prefix, int $scanCount): array + { + $checked = 0; + $removed = 0; + + // phpredis 6.1.0+ uses null as initial cursor, older versions use 0 + $iterator = match (true) { + version_compare(phpversion('redis') ?: '0', '6.1.0', '>=') => null, + default => 0, + }; + + do { + // HSCAN returns [field => value, ...] array + $fields = $client->hScan($tagHash, $iterator, '*', $scanCount); + + if ($fields === false || ! is_array($fields) || empty($fields)) { + break; + } + + $fieldKeys = array_keys($fields); + $checked += count($fieldKeys); + + // Use pipeline to check existence of all cache keys + $pipeline = $client->pipeline(); + foreach ($fieldKeys as $key) { + $pipeline->exists($prefix . $key); + } + $existsResults = $pipeline->exec(); + + // Collect orphaned fields (cache key doesn't exist) + $orphanedFields = []; + foreach ($fieldKeys as $index => $key) { + if (! $existsResults[$index]) { + $orphanedFields[] = $key; + } + } + + // Remove orphaned fields + if (! empty($orphanedFields)) { + $client->hDel($tagHash, ...$orphanedFields); + $removed += count($orphanedFields); + } + } while ($iterator > 0); + + // Check if hash is now empty and delete it + $deleted = false; + $hashLen = $client->hLen($tagHash); + if ($hashLen === 0) { + $client->del($tagHash); + $deleted = true; + } + + return [ + 'checked' => $checked, + 'removed' => $removed, + 'deleted' => $deleted, + ]; + } + + /** + * Clean up orphaned fields from a single tag hash using sequential commands (cluster mode). + * + * @param Redis|RedisCluster $client + * @return array{checked: int, removed: int, deleted: bool} + */ + private function cleanupTagHashCluster(mixed $client, string $tagHash, string $prefix, int $scanCount): array + { + $checked = 0; + $removed = 0; + + // phpredis 6.1.0+ uses null as initial cursor, older versions use 0 + $iterator = match (true) { + version_compare(phpversion('redis') ?: '0', '6.1.0', '>=') => null, + default => 0, + }; + + do { + // HSCAN returns [field => value, ...] array + $fields = $client->hScan($tagHash, $iterator, '*', $scanCount); + + if ($fields === false || ! is_array($fields) || empty($fields)) { + break; + } + + $fieldKeys = array_keys($fields); + $checked += count($fieldKeys); + + // Check existence sequentially in cluster mode + $orphanedFields = []; + foreach ($fieldKeys as $key) { + if (! $client->exists($prefix . $key)) { + $orphanedFields[] = $key; + } + } + + // Remove orphaned fields + if (! empty($orphanedFields)) { + $client->hDel($tagHash, ...$orphanedFields); + $removed += count($orphanedFields); + } + } while ($iterator > 0); + + // Check if hash is now empty and delete it + $deleted = false; + $hashLen = $client->hLen($tagHash); + if ($hashLen === 0) { + $client->del($tagHash); + $deleted = true; + } + + return [ + 'checked' => $checked, + 'removed' => $removed, + 'deleted' => $deleted, + ]; + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Put.php b/src/cache/src/Redis/Operations/AnyTag/Put.php new file mode 100644 index 000000000..0cd577287 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Put.php @@ -0,0 +1,219 @@ + 0) + * @param array $tags Array of tag names (will be cast to strings) + * @return bool True if successful, false on failure + */ + public function execute(string $key, mixed $value, int $seconds, array $tags): bool + { + // 1. Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($key, $value, $seconds, $tags); + } + + // 2. Standard Mode: Use Lua for atomicity and performance + return $this->executeUsingLua($key, $value, $seconds, $tags); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(string $key, mixed $value, int $seconds, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + // Get old tags to handle replacement correctly (remove from old, add to new) + $tagsKey = $this->context->reverseIndexKey($key); + $oldTags = $client->smembers($tagsKey); + + // Store the actual cache value + $client->setex( + $prefix . $key, + max(1, $seconds), + $this->serialization->serialize($conn, $value) + ); + + // Store reverse index of tags for this key + // Use multi() as these keys are in the same slot + $multi = $client->multi(); + $multi->del($tagsKey); // Clear old tags + + if (! empty($tags)) { + $multi->sadd($tagsKey, ...$tags); + $multi->expire($tagsKey, max(1, $seconds)); + } + + $multi->exec(); + + // Remove item from tags it no longer belongs to + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $tag = (string) $tag; + $client->hdel($this->context->tagHashKey($tag), $key); + } + + // Add to each tag's hash with expiration (using HSETEX for atomic operation) + // And update the Tag Registry + $registryKey = $this->context->registryKey(); + $expiry = time() + $seconds; + + // 1. Update Tag Hashes (Cross-slot, must be sequential) + foreach ($tags as $tag) { + $tag = (string) $tag; + + // Use HSETEX to set field and expiration atomically in one command + $client->hsetex( + $this->context->tagHashKey($tag), + [$key => StoreContext::TAG_FIELD_VALUE], + ['EX' => $seconds] + ); + } + + // 2. Update Registry (Same slot, single command optimization) + if (! empty($tags)) { + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + // Update Registry: ZADD with GT (Greater Than) to only extend expiry + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + return true; + }); + } + + /** + * Execute using Lua script for performance. + */ + private function executeUsingLua(string $key, mixed $value, int $seconds, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + + $script = <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local val = ARGV[1] + local ttl = ARGV[2] + local tagPrefix = ARGV[3] + local registryKey = ARGV[4] + local now = ARGV[5] + local rawKey = ARGV[6] + local tagHashSuffix = ARGV[7] + local expiry = now + ttl + + -- 1. Set Cache + redis.call('SETEX', key, ttl, val) + + -- 2. Get Old Tags + local oldTags = redis.call('SMEMBERS', tagsKey) + local newTagsMap = {} + local newTagsList = {} + + -- Parse new tags + for i = 8, #ARGV do + local tag = ARGV[i] + newTagsMap[tag] = true + table.insert(newTagsList, tag) + end + + -- 3. Remove from Old Tags + for _, tag in ipairs(oldTags) do + if not newTagsMap[tag] then + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + end + + -- 4. Update Tags Key + redis.call('DEL', tagsKey) + if #newTagsList > 0 then + redis.call('SADD', tagsKey, unpack(newTagsList)) + redis.call('EXPIRE', tagsKey, ttl) + end + + -- 5. Add to New Tags & Registry + for _, tag in ipairs(newTagsList) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + -- Use HSETEX for atomic field creation and expiration (Redis 8.0+) + redis.call('HSETEX', tagHash, 'EX', ttl, 'FIELDS', 1, rawKey, '1') + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return true +LUA; + + $args = [ + $prefix . $key, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + $this->serialization->serializeForLua($conn, $value), // ARGV[1] + max(1, $seconds), // ARGV[2] + $this->context->fullTagPrefix(), // ARGV[3] + $this->context->fullRegistryKey(), // ARGV[4] + time(), // ARGV[5] + $key, // ARGV[6] (Raw key for hash field) + $this->context->tagHashSuffix(), // ARGV[7] + ...$tags, // ARGV[8...] + ]; + + $scriptHash = sha1($script); + $result = $client->evalSha($scriptHash, $args, 2); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + $client->eval($script, $args, 2); + } + + return true; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/PutMany.php b/src/cache/src/Redis/Operations/AnyTag/PutMany.php new file mode 100644 index 000000000..12c05f5ba --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/PutMany.php @@ -0,0 +1,253 @@ + $values Array of key => value pairs + * @param int $seconds TTL in seconds + * @param array $tags Array of tag names + * @return bool True if successful, false on failure + */ + public function execute(array $values, int $seconds, array $tags): bool + { + if (empty($values)) { + return true; + } + + // 1. Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($values, $seconds, $tags); + } + + // 2. Standard Mode: Use Pipeline + return $this->executeUsingPipeline($values, $seconds, $tags); + } + + /** + * Execute for cluster using sequential commands. + */ + private function executeCluster(array $values, int $seconds, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($values, $seconds, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $registryKey = $this->context->registryKey(); + $expiry = time() + $seconds; + $ttl = max(1, $seconds); + + foreach (array_chunk($values, self::CHUNK_SIZE, true) as $chunk) { + // Step 1: Retrieve old tags for all keys in the chunk + $oldTagsResults = []; + + foreach ($chunk as $key => $value) { + $oldTagsResults[] = $client->smembers($this->context->reverseIndexKey($key)); + } + + // Step 2: Prepare updates + $keysByNewTag = []; + $keysToRemoveByTag = []; + + $i = 0; + + foreach ($chunk as $key => $value) { + $oldTags = $oldTagsResults[$i] ?? []; + ++$i; + + // Calculate tags to remove (Old Tags - New Tags) + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $keysToRemoveByTag[$tag][] = $key; + } + + // 1. Store the actual cache value + $client->setex( + $prefix . $key, + $ttl, + $this->serialization->serialize($conn, $value) + ); + + // 2. Store reverse index of tags for this key + $tagsKey = $this->context->reverseIndexKey($key); + + // Use multi() for reverse index updates (same slot) + $multi = $client->multi(); + $multi->del($tagsKey); // Clear old tags + + if (! empty($tags)) { + $multi->sadd($tagsKey, ...$tags); + $multi->expire($tagsKey, $ttl); + } + + $multi->exec(); + + // Collect keys for batch tag update (New Tags) + foreach ($tags as $tag) { + $keysByNewTag[$tag][] = $key; + } + } + + // 3. Batch remove from old tags + foreach ($keysToRemoveByTag as $tag => $keys) { + $tag = (string) $tag; + $client->hdel($this->context->tagHashKey($tag), ...$keys); + } + + // 4. Batch update new tag hashes + foreach ($keysByNewTag as $tag => $keys) { + $tag = (string) $tag; + $tagHashKey = $this->context->tagHashKey($tag); + + // Prepare HSET arguments: [key1 => 1, key2 => 1, ...] + $hsetArgs = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); + + // Use multi() for tag hash updates (same slot) + $multi = $client->multi(); + $multi->hSet($tagHashKey, $hsetArgs); + $multi->hexpire($tagHashKey, $ttl, $keys); + $multi->exec(); + } + + // 5. Batch update Registry (Same slot, single command optimization) + if (! empty($keysByNewTag)) { + $zaddArgs = []; + + foreach ($keysByNewTag as $tag => $keys) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + } + + return true; + }); + } + + /** + * Execute using Pipeline. + */ + private function executeUsingPipeline(array $values, int $seconds, array $tags): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($values, $seconds, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $registryKey = $this->context->registryKey(); + $expiry = time() + $seconds; + $ttl = max(1, $seconds); + + foreach (array_chunk($values, self::CHUNK_SIZE, true) as $chunk) { + // Step 1: Retrieve old tags for all keys in the chunk + $pipeline = $client->pipeline(); + + foreach ($chunk as $key => $value) { + $pipeline->smembers($this->context->reverseIndexKey($key)); + } + + $oldTagsResults = $pipeline->exec(); + + // Step 2: Prepare updates + $keysByNewTag = []; + $keysToRemoveByTag = []; + + $pipeline = $client->pipeline(); + $i = 0; + + foreach ($chunk as $key => $value) { + $oldTags = $oldTagsResults[$i] ?? []; + ++$i; + + // Calculate tags to remove (Old Tags - New Tags) + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $keysToRemoveByTag[$tag][] = $key; + } + + // 1. Store the actual cache value + $pipeline->setex( + $prefix . $key, + $ttl, + $this->serialization->serialize($conn, $value) + ); + + // 2. Store reverse index of tags for this key + $tagsKey = $this->context->reverseIndexKey($key); + $pipeline->del($tagsKey); // Clear old tags + + if (! empty($tags)) { + $pipeline->sadd($tagsKey, ...$tags); + $pipeline->expire($tagsKey, $ttl); + } + + // Collect keys for batch tag update (New Tags) + foreach ($tags as $tag) { + $keysByNewTag[$tag][] = $key; + } + } + + // 3. Batch remove from old tags + foreach ($keysToRemoveByTag as $tag => $keys) { + $tag = (string) $tag; + $pipeline->hdel($this->context->tagHashKey($tag), ...$keys); + } + + // 4. Batch update new tag hashes + foreach ($keysByNewTag as $tag => $keys) { + $tag = (string) $tag; + $tagHashKey = $this->context->tagHashKey($tag); + + // Prepare HSET arguments: [key1 => 1, key2 => 1, ...] + $hsetArgs = array_fill_keys($keys, StoreContext::TAG_FIELD_VALUE); + + $pipeline->hSet($tagHashKey, $hsetArgs); + $pipeline->hexpire($tagHashKey, $ttl, $keys); + } + + // Update Registry in batch + if (! empty($keysByNewTag)) { + $zaddArgs = []; + + foreach ($keysByNewTag as $tag => $keys) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + $pipeline->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + $pipeline->exec(); + } + + return true; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/Remember.php b/src/cache/src/Redis/Operations/AnyTag/Remember.php new file mode 100644 index 000000000..d430a5290 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/Remember.php @@ -0,0 +1,243 @@ + 0) + * @param Closure $callback The callback to execute on cache miss + * @param array $tags Array of tag names (will be cast to strings) + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + public function execute(string $key, int $seconds, Closure $callback, array $tags): array + { + // Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($key, $seconds, $callback, $tags); + } + + // Standard Mode: Use Lua for atomicity and performance + return $this->executeUsingLua($key, $seconds, $callback, $tags); + } + + /** + * Execute for cluster using sequential commands. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executeCluster(string $key, int $seconds, Closure $callback, array $tags): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $seconds, $callback, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Get old tags to handle replacement correctly (remove from old, add to new) + $tagsKey = $this->context->reverseIndexKey($key); + $oldTags = $client->smembers($tagsKey); + + // Store the actual cache value + $client->setex( + $prefixedKey, + max(1, $seconds), + $this->serialization->serialize($conn, $value) + ); + + // Store reverse index of tags for this key + // Use multi() as these keys are in the same slot + $multi = $client->multi(); + $multi->del($tagsKey); // Clear old tags + + if (! empty($tags)) { + $multi->sadd($tagsKey, ...$tags); + $multi->expire($tagsKey, max(1, $seconds)); + } + + $multi->exec(); + + // Remove item from tags it no longer belongs to + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $tag = (string) $tag; + $client->hdel($this->context->tagHashKey($tag), $key); + } + + // Add to each tag's hash with expiration (using HSETEX for atomic operation) + // And update the Tag Registry + $registryKey = $this->context->registryKey(); + $expiry = time() + $seconds; + + // 1. Update Tag Hashes (Cross-slot, must be sequential) + foreach ($tags as $tag) { + $tag = (string) $tag; + + // Use HSETEX to set field and expiration atomically in one command + $client->hsetex( + $this->context->tagHashKey($tag), + [$key => StoreContext::TAG_FIELD_VALUE], + ['EX' => $seconds] + ); + } + + // 2. Update Registry (Same slot, single command optimization) + if (! empty($tags)) { + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + // Update Registry: ZADD with GT (Greater Than) to only extend expiry + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + return [$value, false]; + }); + } + + /** + * Execute using Lua script for performance. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executeUsingLua(string $key, int $seconds, Closure $callback, array $tags): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $seconds, $callback, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value first + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Now use Lua script to atomically store with tags + $script = <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local val = ARGV[1] + local ttl = ARGV[2] + local tagPrefix = ARGV[3] + local registryKey = ARGV[4] + local now = ARGV[5] + local rawKey = ARGV[6] + local tagHashSuffix = ARGV[7] + local expiry = now + ttl + + -- 1. Set Cache + redis.call('SETEX', key, ttl, val) + + -- 2. Get Old Tags + local oldTags = redis.call('SMEMBERS', tagsKey) + local newTagsMap = {} + local newTagsList = {} + + -- Parse new tags + for i = 8, #ARGV do + local tag = ARGV[i] + newTagsMap[tag] = true + table.insert(newTagsList, tag) + end + + -- 3. Remove from Old Tags + for _, tag in ipairs(oldTags) do + if not newTagsMap[tag] then + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + end + + -- 4. Update Tags Key + redis.call('DEL', tagsKey) + if #newTagsList > 0 then + redis.call('SADD', tagsKey, unpack(newTagsList)) + redis.call('EXPIRE', tagsKey, ttl) + end + + -- 5. Add to New Tags & Registry + for _, tag in ipairs(newTagsList) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + -- Use HSETEX for atomic field creation and expiration (Redis 8.0+) + redis.call('HSETEX', tagHash, 'EX', ttl, 'FIELDS', 1, rawKey, '1') + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return true +LUA; + + $args = [ + $prefixedKey, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + $this->serialization->serializeForLua($conn, $value), // ARGV[1] + max(1, $seconds), // ARGV[2] + $this->context->fullTagPrefix(), // ARGV[3] + $this->context->fullRegistryKey(), // ARGV[4] + time(), // ARGV[5] + $key, // ARGV[6] (Raw key for hash field) + $this->context->tagHashSuffix(), // ARGV[7] + ...$tags, // ARGV[8...] + ]; + + $scriptHash = sha1($script); + $result = $client->evalSha($scriptHash, $args, 2); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + $client->eval($script, $args, 2); + } + + return [$value, false]; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTag/RememberForever.php b/src/cache/src/Redis/Operations/AnyTag/RememberForever.php new file mode 100644 index 000000000..b20aa9b92 --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTag/RememberForever.php @@ -0,0 +1,224 @@ + $tags Array of tag names (will be cast to strings) + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + public function execute(string $key, Closure $callback, array $tags): array + { + // Cluster Mode: Must use sequential commands + if ($this->context->isCluster()) { + return $this->executeCluster($key, $callback, $tags); + } + + // Standard Mode: Use Lua for atomicity and performance + return $this->executeUsingLua($key, $callback, $tags); + } + + /** + * Execute for cluster using sequential commands. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executeCluster(string $key, Closure $callback, array $tags): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $callback, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Get old tags to handle replacement correctly (remove from old, add to new) + $tagsKey = $this->context->reverseIndexKey($key); + $oldTags = $client->smembers($tagsKey); + + // Store the actual cache value without expiration + $client->set( + $prefixedKey, + $this->serialization->serialize($conn, $value) + ); + + // Store reverse index of tags for this key (no expiration for forever) + // Use multi() as these keys are in the same slot + $multi = $client->multi(); + $multi->del($tagsKey); + + if (! empty($tags)) { + $multi->sadd($tagsKey, ...$tags); + } + + $multi->exec(); + + // Remove item from tags it no longer belongs to + $tagsToRemove = array_diff($oldTags, $tags); + + foreach ($tagsToRemove as $tag) { + $tag = (string) $tag; + $client->hdel($this->context->tagHashKey($tag), $key); + } + + // Calculate expiry for Registry (Year 9999) + $expiry = StoreContext::MAX_EXPIRY; + $registryKey = $this->context->registryKey(); + + // 1. Add to each tag's hash without expiration (Cross-slot, sequential) + foreach ($tags as $tag) { + $tag = (string) $tag; + $client->hSet($this->context->tagHashKey($tag), $key, StoreContext::TAG_FIELD_VALUE); + // No HEXPIRE for forever items + } + + // 2. Update Registry (Same slot, single command optimization) + if (! empty($tags)) { + $zaddArgs = []; + + foreach ($tags as $tag) { + $zaddArgs[] = $expiry; + $zaddArgs[] = (string) $tag; + } + + // Update Registry: ZADD with GT (Greater Than) to only extend expiry + $client->zadd($registryKey, ['GT'], ...$zaddArgs); + } + + return [$value, false]; + }); + } + + /** + * Execute using Lua script for performance. + * + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + private function executeUsingLua(string $key, Closure $callback, array $tags): array + { + return $this->context->withConnection(function (RedisConnection $conn) use ($key, $callback, $tags) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $prefixedKey = $prefix . $key; + + // Try to get the cached value first + $value = $client->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback + $value = $callback(); + + // Now use Lua script to atomically store with tags (forever semantics) + $script = <<<'LUA' + local key = KEYS[1] + local tagsKey = KEYS[2] + local val = ARGV[1] + local tagPrefix = ARGV[2] + local registryKey = ARGV[3] + local rawKey = ARGV[4] + local tagHashSuffix = ARGV[5] + + -- 1. Set Value (no expiration) + redis.call('SET', key, val) + + -- 2. Get Old Tags + local oldTags = redis.call('SMEMBERS', tagsKey) + local newTagsMap = {} + local newTagsList = {} + + for i = 6, #ARGV do + local tag = ARGV[i] + newTagsMap[tag] = true + table.insert(newTagsList, tag) + end + + -- 3. Remove from Old Tags + for _, tag in ipairs(oldTags) do + if not newTagsMap[tag] then + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HDEL', tagHash, rawKey) + end + end + + -- 4. Update Reverse Index (no expiration for forever) + redis.call('DEL', tagsKey) + if #newTagsList > 0 then + redis.call('SADD', tagsKey, unpack(newTagsList)) + end + + -- 5. Add to New Tags (HSET without HEXPIRE, registry with MAX_EXPIRY) + local expiry = 253402300799 + for _, tag in ipairs(newTagsList) do + local tagHash = tagPrefix .. tag .. tagHashSuffix + redis.call('HSET', tagHash, rawKey, '1') + redis.call('ZADD', registryKey, 'GT', expiry, tag) + end + + return true +LUA; + + $args = [ + $prefixedKey, // KEYS[1] + $this->context->reverseIndexKey($key), // KEYS[2] + $this->serialization->serializeForLua($conn, $value), // ARGV[1] + $this->context->fullTagPrefix(), // ARGV[2] + $this->context->fullRegistryKey(), // ARGV[3] + $key, // ARGV[4] + $this->context->tagHashSuffix(), // ARGV[5] + ...$tags, // ARGV[6...] + ]; + + $scriptHash = sha1($script); + $result = $client->evalSha($scriptHash, $args, 2); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + $client->eval($script, $args, 2); + } + + return [$value, false]; + }); + } +} diff --git a/src/cache/src/Redis/Operations/AnyTagOperations.php b/src/cache/src/Redis/Operations/AnyTagOperations.php new file mode 100644 index 000000000..ab105058a --- /dev/null +++ b/src/cache/src/Redis/Operations/AnyTagOperations.php @@ -0,0 +1,191 @@ +put ??= new Put($this->context, $this->serialization); + } + + /** + * Get the PutMany operation for storing multiple items with tags. + */ + public function putMany(): PutMany + { + return $this->putMany ??= new PutMany($this->context, $this->serialization); + } + + /** + * Get the Add operation for storing items if they don't exist. + */ + public function add(): Add + { + return $this->add ??= new Add($this->context, $this->serialization); + } + + /** + * Get the Forever operation for storing items indefinitely with tags. + */ + public function forever(): Forever + { + return $this->forever ??= new Forever($this->context, $this->serialization); + } + + /** + * Get the Increment operation for incrementing values with tags. + */ + public function increment(): Increment + { + return $this->increment ??= new Increment($this->context); + } + + /** + * Get the Decrement operation for decrementing values with tags. + */ + public function decrement(): Decrement + { + return $this->decrement ??= new Decrement($this->context); + } + + /** + * Get the GetTaggedKeys operation for retrieving keys associated with a tag. + */ + public function getTaggedKeys(): GetTaggedKeys + { + return $this->getTaggedKeys ??= new GetTaggedKeys($this->context); + } + + /** + * Get the GetTagItems operation for retrieving key-value pairs for tags. + */ + public function getTagItems(): GetTagItems + { + return $this->getTagItems ??= new GetTagItems( + $this->context, + $this->serialization, + $this->getTaggedKeys() + ); + } + + /** + * Get the Flush operation for removing all items with specified tags. + */ + public function flush(): Flush + { + return $this->flush ??= new Flush($this->context, $this->getTaggedKeys()); + } + + /** + * Get the Prune operation for removing orphaned fields from tag hashes. + * + * This removes expired tags from the registry, scans active tag hashes + * for fields referencing deleted cache keys, and deletes empty hashes. + */ + public function prune(): Prune + { + return $this->prune ??= new Prune($this->context); + } + + /** + * Get the Remember operation for cache-through with tags. + * + * This operation is optimized to use a single connection for both + * GET and PUT operations, avoiding double pool overhead on cache misses. + */ + public function remember(): Remember + { + return $this->remember ??= new Remember($this->context, $this->serialization); + } + + /** + * Get the RememberForever operation for cache-through with tags (no TTL). + * + * This operation is optimized to use a single connection for both + * GET and SET operations, avoiding double pool overhead on cache misses. + */ + public function rememberForever(): RememberForever + { + return $this->rememberForever ??= new RememberForever($this->context, $this->serialization); + } + + /** + * Clear all cached operation instances. + * + * Called when the store's connection or prefix changes. + */ + public function clear(): void + { + $this->put = null; + $this->putMany = null; + $this->add = null; + $this->forever = null; + $this->increment = null; + $this->decrement = null; + $this->getTaggedKeys = null; + $this->getTagItems = null; + $this->flush = null; + $this->prune = null; + $this->remember = null; + $this->rememberForever = null; + } +} diff --git a/src/cache/src/Redis/Operations/Decrement.php b/src/cache/src/Redis/Operations/Decrement.php new file mode 100644 index 000000000..b4a52eb64 --- /dev/null +++ b/src/cache/src/Redis/Operations/Decrement.php @@ -0,0 +1,32 @@ +context->withConnection(function (RedisConnection $conn) use ($key, $value) { + return $conn->decrBy($this->context->prefix() . $key, $value); + }); + } +} diff --git a/src/cache/src/Redis/Operations/Flush.php b/src/cache/src/Redis/Operations/Flush.php new file mode 100644 index 000000000..0c3930448 --- /dev/null +++ b/src/cache/src/Redis/Operations/Flush.php @@ -0,0 +1,36 @@ +context->withConnection(function (RedisConnection $conn) { + $conn->flushdb(); + + return true; + }); + } +} diff --git a/src/cache/src/Redis/Operations/Forever.php b/src/cache/src/Redis/Operations/Forever.php new file mode 100644 index 000000000..ba2848c51 --- /dev/null +++ b/src/cache/src/Redis/Operations/Forever.php @@ -0,0 +1,37 @@ +context->withConnection(function (RedisConnection $conn) use ($key, $value) { + return (bool) $conn->set( + $this->context->prefix() . $key, + $this->serialization->serialize($conn, $value) + ); + }); + } +} diff --git a/src/cache/src/Redis/Operations/Forget.php b/src/cache/src/Redis/Operations/Forget.php new file mode 100644 index 000000000..5422da354 --- /dev/null +++ b/src/cache/src/Redis/Operations/Forget.php @@ -0,0 +1,32 @@ +context->withConnection(function (RedisConnection $conn) use ($key) { + return (bool) $conn->del($this->context->prefix() . $key); + }); + } +} diff --git a/src/cache/src/Redis/Operations/Get.php b/src/cache/src/Redis/Operations/Get.php new file mode 100644 index 000000000..8b3ea71a9 --- /dev/null +++ b/src/cache/src/Redis/Operations/Get.php @@ -0,0 +1,39 @@ +context->withConnection(function (RedisConnection $conn) use ($key) { + $value = $conn->get($this->context->prefix() . $key); + + return $this->serialization->unserialize($conn, $value); + }); + } +} diff --git a/src/cache/src/Redis/Operations/Increment.php b/src/cache/src/Redis/Operations/Increment.php new file mode 100644 index 000000000..ba950cd33 --- /dev/null +++ b/src/cache/src/Redis/Operations/Increment.php @@ -0,0 +1,32 @@ +context->withConnection(function (RedisConnection $conn) use ($key, $value) { + return $conn->incrBy($this->context->prefix() . $key, $value); + }); + } +} diff --git a/src/cache/src/Redis/Operations/Many.php b/src/cache/src/Redis/Operations/Many.php new file mode 100644 index 000000000..9bc784cbd --- /dev/null +++ b/src/cache/src/Redis/Operations/Many.php @@ -0,0 +1,55 @@ + $keys The cache keys to retrieve + * @return array Key-value pairs, with null for missing keys + */ + public function execute(array $keys): array + { + if (empty($keys)) { + return []; + } + + return $this->context->withConnection(function (RedisConnection $conn) use ($keys) { + $prefix = $this->context->prefix(); + + $prefixedKeys = array_map( + fn (string $key): string => $prefix . $key, + $keys + ); + + $values = $conn->mget($prefixedKeys); + $results = []; + + foreach ($values as $index => $value) { + $results[$keys[$index]] = $this->serialization->unserialize($conn, $value); + } + + return $results; + }); + } +} diff --git a/src/cache/src/Redis/Operations/Put.php b/src/cache/src/Redis/Operations/Put.php new file mode 100644 index 000000000..45b79fc51 --- /dev/null +++ b/src/cache/src/Redis/Operations/Put.php @@ -0,0 +1,38 @@ +context->withConnection(function (RedisConnection $conn) use ($key, $value, $seconds) { + return (bool) $conn->setex( + $this->context->prefix() . $key, + max(1, $seconds), + $this->serialization->serialize($conn, $value) + ); + }); + } +} diff --git a/src/cache/src/Redis/Operations/PutMany.php b/src/cache/src/Redis/Operations/PutMany.php new file mode 100644 index 000000000..a832b94a8 --- /dev/null +++ b/src/cache/src/Redis/Operations/PutMany.php @@ -0,0 +1,159 @@ + $values Array of key => value pairs + * @param int $seconds TTL in seconds + * @return bool True if successful, false on failure + */ + public function execute(array $values, int $seconds): bool + { + if (empty($values)) { + return true; + } + + // Cluster mode: Keys may hash to different slots, use MULTI + individual SETEX + if ($this->context->isCluster()) { + return $this->executeCluster($values, $seconds); + } + + // Standard mode: Use Lua script for efficiency + return $this->executeUsingLua($values, $seconds); + } + + /** + * Execute for cluster using MULTI/EXEC. + * + * In cluster mode, keys may hash to different slots. Unlike standalone Redis, + * RedisCluster does NOT currently support pipelining - commands are sent sequentially + * to each node as they are encountered. MULTI/EXEC still provides value by: + * + * 1. Grouping commands into transactions per-node (atomicity per slot) + * 2. Aggregating results from all nodes into a single array on exec() + * 3. Matching Laravel's default RedisStore behavior for consistency + * + * Note: For true cross-slot batching, phpredis would need pipeline() support + * which is currently intentionally not implemented due to MOVED/ASK error complexity. + * + * @see https://github.com/phpredis/phpredis/blob/develop/cluster.md + * @see https://github.com/phpredis/phpredis/issues/1910 + */ + private function executeCluster(array $values, int $seconds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($values, $seconds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $seconds = max(1, $seconds); + + // MULTI/EXEC groups commands by node but does NOT pipeline them. + // Commands are sent sequentially; exec() aggregates results from all nodes. + $multi = $client->multi(); + + foreach ($values as $key => $value) { + // Use serialization helper to respect client configuration + $serializedValue = $this->serialization->serialize($conn, $value); + + $multi->setex( + $prefix . $key, + $seconds, + $serializedValue + ); + } + + $results = $multi->exec(); + + // Check all results succeeded + if (! is_array($results)) { + return false; + } + + foreach ($results as $result) { + if ($result === false) { + return false; + } + } + + return true; + }); + } + + /** + * Execute using Lua script for better performance. + * + * The Lua script loops through all key-value pairs and executes SETEX + * for each, reducing Redis command parsing overhead compared to + * sending N individual SETEX commands. + */ + private function executeUsingLua(array $values, int $seconds): bool + { + return $this->context->withConnection(function (RedisConnection $conn) use ($values, $seconds) { + $client = $conn->client(); + $prefix = $this->context->prefix(); + $seconds = max(1, $seconds); + + // Build keys and values arrays + // KEYS: All the cache keys + // ARGV[1]: TTL in seconds + // ARGV[2..N+1]: Serialized values (matching order of KEYS) + $keys = []; + $args = [$seconds]; // First arg is TTL + + foreach ($values as $key => $value) { + $keys[] = $prefix . $key; + // Use serialization helper for Lua arguments + $args[] = $this->serialization->serializeForLua($conn, $value); + } + + // Combine keys and args for eval/evalSha + // Format: [key1, key2, ..., ttl, val1, val2, ...] + $evalArgs = array_merge($keys, $args); + $numKeys = count($keys); + + $scriptHash = sha1(self::LUA_SCRIPT); + $result = $client->evalSha($scriptHash, $evalArgs, $numKeys); + + // evalSha returns false if script not loaded (NOSCRIPT), fall back to eval + if ($result === false) { + $result = $client->eval(self::LUA_SCRIPT, $evalArgs, $numKeys); + } + + return (bool) $result; + }); + } +} diff --git a/src/cache/src/Redis/Operations/Remember.php b/src/cache/src/Redis/Operations/Remember.php new file mode 100644 index 000000000..0d0f4ce0f --- /dev/null +++ b/src/cache/src/Redis/Operations/Remember.php @@ -0,0 +1,62 @@ +context->withConnection(function (RedisConnection $conn) use ($key, $seconds, $callback) { + $prefixedKey = $this->context->prefix() . $key; + + // Try to get the cached value + $value = $conn->get($prefixedKey); + + if ($value !== false && $value !== null) { + return $this->serialization->unserialize($conn, $value); + } + + // Cache miss - execute callback and store result + $value = $callback(); + + $conn->setex( + $prefixedKey, + max(1, $seconds), + $this->serialization->serialize($conn, $value) + ); + + return $value; + }); + } +} diff --git a/src/cache/src/Redis/Operations/RememberForever.php b/src/cache/src/Redis/Operations/RememberForever.php new file mode 100644 index 000000000..fc795f93f --- /dev/null +++ b/src/cache/src/Redis/Operations/RememberForever.php @@ -0,0 +1,62 @@ +context->withConnection(function (RedisConnection $conn) use ($key, $callback) { + $prefixedKey = $this->context->prefix() . $key; + + // Try to get the cached value + $value = $conn->get($prefixedKey); + + if ($value !== false && $value !== null) { + return [$this->serialization->unserialize($conn, $value), true]; + } + + // Cache miss - execute callback and store result forever (no TTL) + $value = $callback(); + + $conn->set( + $prefixedKey, + $this->serialization->serialize($conn, $value) + ); + + return [$value, false]; + }); + } +} diff --git a/src/cache/src/Redis/Support/MonitoringDetector.php b/src/cache/src/Redis/Support/MonitoringDetector.php new file mode 100644 index 000000000..478d8e2c2 --- /dev/null +++ b/src/cache/src/Redis/Support/MonitoringDetector.php @@ -0,0 +1,53 @@ + Tool name => how to disable + */ + public function detect(): array + { + $detected = []; + + // Hypervel Telescope + if (class_exists(Telescope::class) && $this->config->get('telescope.enabled')) { + $detected['Hypervel Telescope'] = 'TELESCOPE_ENABLED=false'; + } + + // Xdebug (when not in 'off' mode) + if (extension_loaded('xdebug')) { + $mode = ini_get('xdebug.mode') ?: 'off'; + + if ($mode !== 'off') { + $detected['Xdebug (mode: ' . $mode . ')'] = 'xdebug.mode=off or disable extension'; + } + } + + // Blackfire + if (extension_loaded('blackfire')) { + $detected['Blackfire'] = 'disable blackfire extension'; + } + + return $detected; + } +} diff --git a/src/cache/src/Redis/Support/Serialization.php b/src/cache/src/Redis/Support/Serialization.php new file mode 100644 index 000000000..441430388 --- /dev/null +++ b/src/cache/src/Redis/Support/Serialization.php @@ -0,0 +1,128 @@ +serialized()) { + return $value; + } + + return $this->phpSerialize($value); + } + + /** + * Serialize a value for use in Lua script ARGV. + * + * Unlike regular serialization (which returns raw values when a serializer + * is configured, expecting phpredis to auto-serialize), Lua scripts require + * pre-serialized string values in ARGV because phpredis does NOT auto-serialize + * Lua ARGV parameters. + * + * This method handles three scenarios: + * 1. Serializer configured (igbinary/json/php): Use pack() which calls _serialize() + * 2. No serializer, but compression enabled: PHP serialize, then compress + * 3. No serializer, no compression: Just PHP serialize + * + * @param RedisConnection $conn The connection to use for serialization + * @param mixed $value The value to serialize + * @return string The serialized value suitable for Lua ARGV + */ + public function serializeForLua(RedisConnection $conn, mixed $value): string + { + // Case 1: Serializer configured (e.g. igbinary/json) + // pack() calls _serialize() which handles serialization AND compression + if ($conn->serialized()) { + return $conn->pack([$value])[0]; + } + + // No serializer - must PHP-serialize first + $serialized = $this->phpSerialize($value); + + // Case 2: Check if compression is enabled (even without serializer) + $client = $conn->client(); + + if ($client->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE) { + // _serialize() applies compression even with SERIALIZER_NONE + // Cast to string in case serialize() returned a numeric value + return $client->_serialize(is_numeric($serialized) ? (string) $serialized : $serialized); + } + + // Case 3: No serializer, no compression + // Cast to string in case serialize() returned a numeric value + return is_numeric($serialized) ? (string) $serialized : $serialized; + } + + /** + * Unserialize a value retrieved from Redis. + * + * When a serializer is configured on the connection, returns the value as-is + * (phpredis already unserialized it). Otherwise, uses PHP unserialization. + * + * @param RedisConnection $conn The connection to use for unserialization checks + * @param mixed $value The value to unserialize + * @return mixed The unserialized value, or null if input was null/false + */ + public function unserialize(RedisConnection $conn, mixed $value): mixed + { + if ($value === null || $value === false) { + return null; + } + + if ($conn->serialized()) { + return $value; + } + + return $this->phpUnserialize($value); + } + + /** + * PHP serialize a value (Laravel's default logic). + * + * Returns raw numeric values for performance optimization. + */ + private function phpSerialize(mixed $value): mixed + { + // is_nan() only works on floats, so check is_float first + return is_numeric($value) && ! in_array($value, [INF, -INF]) && ! (is_float($value) && is_nan($value)) + ? $value + : serialize($value); + } + + /** + * PHP unserialize a value. + */ + private function phpUnserialize(mixed $value): mixed + { + return is_numeric($value) ? $value : unserialize((string) $value); + } +} diff --git a/src/cache/src/Redis/Support/StoreContext.php b/src/cache/src/Redis/Support/StoreContext.php new file mode 100644 index 000000000..a47572c67 --- /dev/null +++ b/src/cache/src/Redis/Support/StoreContext.php @@ -0,0 +1,195 @@ +prefix; + } + + /** + * Get the connection name. + */ + public function connectionName(): string + { + return $this->connectionName; + } + + /** + * Get the tag mode. + */ + public function tagMode(): TagMode + { + return $this->tagMode; + } + + /** + * Get the tag identifier (without cache prefix). + * + * Used by All mode for namespace computation (sha1 of sorted tag IDs). + * Format: "_any:tag:{tagName}:entries" or "_all:tag:{tagName}:entries" + */ + public function tagId(string $tag): string + { + return $this->tagMode->tagId($tag); + } + + /** + * Get the full tag hash key for a given tag. + * + * Format: "{prefix}_any:tag:{tagName}:entries" or "{prefix}_all:tag:{tagName}:entries" + */ + public function tagHashKey(string $tag): string + { + return $this->tagMode->tagKey($this->prefix, $tag); + } + + /** + * Get the tag hash suffix (for Lua scripts that build keys dynamically). + */ + public function tagHashSuffix(): string + { + return ':entries'; + } + + /** + * Get the SCAN pattern for finding all tag sorted sets. + * + * Format: "{prefix}_any:tag:*:entries" or "{prefix}_all:tag:*:entries" + */ + public function tagScanPattern(): string + { + return $this->prefix . $this->tagMode->tagSegment() . '*:entries'; + } + + /** + * Get the full reverse index key for a cache key. + * + * Format: "{prefix}{cacheKey}:_any:tags" or "{prefix}{cacheKey}:_all:tags" + */ + public function reverseIndexKey(string $key): string + { + return $this->tagMode->reverseIndexKey($this->prefix, $key); + } + + /** + * Get the tag registry key (without OPT_PREFIX). + * + * Format: "{prefix}_any:tag:registry" or "{prefix}_all:tag:registry" + */ + public function registryKey(): string + { + return $this->tagMode->registryKey($this->prefix); + } + + /** + * Execute callback with a held connection from the pool. + * + * Use this for operations requiring multiple commands on the same + * connection (cluster mode, complex transactions). The connection + * is automatically returned to the pool after the callback completes. + * + * @template T + * @param callable(RedisConnection): T $callback + * @return T + */ + public function withConnection(callable $callback): mixed + { + $pool = $this->poolFactory->getPool($this->connectionName); + /** @var RedisConnection $connection */ + $connection = $pool->get(); + + try { + return $callback($connection); + } finally { + $connection->release(); + } + } + + /** + * Check if the connection is a Redis Cluster. + */ + public function isCluster(): bool + { + return $this->withConnection( + fn (RedisConnection $conn) => $conn->client() instanceof RedisCluster + ); + } + + /** + * Get the OPT_PREFIX value from the Redis client. + */ + public function optPrefix(): string + { + return $this->withConnection( + fn (RedisConnection $conn) => (string) $conn->client()->getOption(Redis::OPT_PREFIX) + ); + } + + /** + * Get the full tag prefix including OPT_PREFIX (for Lua scripts). + * + * Format: "{optPrefix}{prefix}_any:tag:" or "{optPrefix}{prefix}_all:tag:" + */ + public function fullTagPrefix(): string + { + return $this->optPrefix() . $this->prefix . $this->tagMode->tagSegment(); + } + + /** + * Get the full reverse index key including OPT_PREFIX (for Lua scripts). + */ + public function fullReverseIndexKey(string $key): string + { + return $this->optPrefix() . $this->tagMode->reverseIndexKey($this->prefix, $key); + } + + /** + * Get the full registry key including OPT_PREFIX (for Lua scripts). + */ + public function fullRegistryKey(): string + { + return $this->optPrefix() . $this->tagMode->registryKey($this->prefix); + } +} diff --git a/src/cache/src/Redis/TagMode.php b/src/cache/src/Redis/TagMode.php new file mode 100644 index 000000000..a84c5c588 --- /dev/null +++ b/src/cache/src/Redis/TagMode.php @@ -0,0 +1,128 @@ +value}:tag:"; + } + + /** + * Tag identifier (without cache prefix): "_any:tag:{tagName}:entries". + * + * Used by All mode for namespace computation (sha1 of sorted tag IDs). + */ + public function tagId(string $tagName): string + { + return $this->tagSegment() . $tagName . ':entries'; + } + + /** + * Full tag key (with cache prefix): "{prefix}_any:tag:{tagName}:entries". + */ + public function tagKey(string $prefix, string $tagName): string + { + return $prefix . $this->tagId($tagName); + } + + /** + * Reverse index suffix: ":_any:tags". + */ + public function reverseIndexSuffix(): string + { + return ":_{$this->value}:tags"; + } + + /** + * Full reverse index key: "{prefix}{cacheKey}:_any:tags". + * + * Tracks which tags a cache key belongs to (Any mode only). + */ + public function reverseIndexKey(string $prefix, string $cacheKey): string + { + return $prefix . $cacheKey . $this->reverseIndexSuffix(); + } + + /** + * Registry key: "{prefix}_any:tag:registry". + * + * Sorted set tracking all active tags (Any mode only). + */ + public function registryKey(string $prefix): string + { + return $prefix . $this->tagSegment() . 'registry'; + } + + /** + * Check if this is Any mode. + */ + public function isAnyMode(): bool + { + return $this === self::Any; + } + + /** + * Check if this is All mode. + */ + public function isAllMode(): bool + { + return $this === self::All; + } + + /** + * Any mode: items retrievable without specifying tags. + * All mode: must specify same tags used when storing. + */ + public function supportsDirectGet(): bool + { + return $this->isAnyMode(); + } + + /** + * All mode: keys are namespaced with sha1 of tag names. + */ + public function usesNamespacedKeys(): bool + { + return $this->isAllMode(); + } + + /** + * Any mode has reverse index tracking which tags a key belongs to. + */ + public function hasReverseIndex(): bool + { + return $this->isAnyMode(); + } + + /** + * Any mode has registry tracking all active tags. + */ + public function hasRegistry(): bool + { + return $this->isAnyMode(); + } +} diff --git a/src/cache/src/RedisLock.php b/src/cache/src/RedisLock.php index ba286fd29..30d0595f5 100644 --- a/src/cache/src/RedisLock.php +++ b/src/cache/src/RedisLock.php @@ -31,15 +31,28 @@ public function acquire(): bool if ($this->seconds > 0) { return $this->redis->set($this->name, $this->owner, ['EX' => $this->seconds, 'NX']) == true; } + return $this->redis->setnx($this->name, $this->owner) == true; } /** * Release the lock. + * + * Uses a Lua script to atomically check ownership before deleting. */ public function release(): bool { - return (bool) $this->redis->eval(LuaScripts::releaseLock(), [$this->name, $this->owner], 1); + return (bool) $this->redis->eval( + <<<'LUA' + if redis.call("get",KEYS[1]) == ARGV[1] then + return redis.call("del",KEYS[1]) + else + return 0 + end + LUA, + [$this->name, $this->owner], + 1 + ); } /** diff --git a/src/cache/src/RedisStore.php b/src/cache/src/RedisStore.php index 90ab1e5d6..37ba5d6a8 100644 --- a/src/cache/src/RedisStore.php +++ b/src/cache/src/RedisStore.php @@ -4,14 +4,43 @@ namespace Hypervel\Cache; +use Closure; +use Hyperf\Redis\Pool\PoolFactory; use Hyperf\Redis\RedisFactory; use Hyperf\Redis\RedisProxy; use Hypervel\Cache\Contracts\LockProvider; +use Hypervel\Cache\Redis\AllTaggedCache; +use Hypervel\Cache\Redis\AllTagSet; +use Hypervel\Cache\Redis\AnyTaggedCache; +use Hypervel\Cache\Redis\AnyTagSet; +use Hypervel\Cache\Redis\Exceptions\RedisCacheException; +use Hypervel\Cache\Redis\Operations\Add; +use Hypervel\Cache\Redis\Operations\AllTagOperations; +use Hypervel\Cache\Redis\Operations\AnyTagOperations; +use Hypervel\Cache\Redis\Operations\Decrement; +use Hypervel\Cache\Redis\Operations\Flush; +use Hypervel\Cache\Redis\Operations\Forever; +use Hypervel\Cache\Redis\Operations\Forget; +use Hypervel\Cache\Redis\Operations\Get; +use Hypervel\Cache\Redis\Operations\Increment; +use Hypervel\Cache\Redis\Operations\Many; +use Hypervel\Cache\Redis\Operations\Put; +use Hypervel\Cache\Redis\Operations\PutMany; +use Hypervel\Cache\Redis\Operations\Remember; +use Hypervel\Cache\Redis\Operations\RememberForever; +use Hypervel\Cache\Redis\Support\Serialization; +use Hypervel\Cache\Redis\Support\StoreContext; +use Hypervel\Cache\Redis\TagMode; class RedisStore extends TaggableStore implements LockProvider { protected RedisFactory $factory; + /** + * The pool factory instance (lazy-loaded if not provided). + */ + protected ?PoolFactory $poolFactory = null; + /** * A string that should be prepended to keys. */ @@ -27,12 +56,66 @@ class RedisStore extends TaggableStore implements LockProvider */ protected string $lockConnection; + /** + * The tag mode (All or Any). + */ + protected TagMode $tagMode = TagMode::All; + + /** + * Cached StoreContext instance. + */ + private ?StoreContext $context = null; + + /** + * Cached Serialization instance. + */ + private ?Serialization $serialization = null; + + /** + * Cached shared operation instances. + */ + private ?Get $getOperation = null; + + private ?Many $manyOperation = null; + + private ?Put $putOperation = null; + + private ?PutMany $putManyOperation = null; + + private ?Add $addOperation = null; + + private ?Forever $foreverOperation = null; + + private ?Forget $forgetOperation = null; + + private ?Increment $incrementOperation = null; + + private ?Decrement $decrementOperation = null; + + private ?Flush $flushOperation = null; + + private ?Remember $rememberOperation = null; + + private ?RememberForever $rememberForeverOperation = null; + + /** + * Cached tag operation containers. + */ + private ?AnyTagOperations $anyTagOperations = null; + + private ?AllTagOperations $allTagOperations = null; + /** * Create a new Redis store. */ - public function __construct(RedisFactory $factory, string $prefix = '', string $connection = 'default') - { + public function __construct( + RedisFactory $factory, + string $prefix = '', + string $connection = 'default', + ?PoolFactory $poolFactory = null, + ) { $this->factory = $factory; + $this->poolFactory = $poolFactory; $this->setPrefix($prefix); $this->setConnection($connection); } @@ -42,9 +125,7 @@ public function __construct(RedisFactory $factory, string $prefix = '', string $ */ public function get(string $key): mixed { - $value = $this->connection()->get($this->prefix . $key); - - return $this->unserialize($value); + return $this->getGetOperation()->execute($key); } /** @@ -53,17 +134,7 @@ public function get(string $key): mixed */ public function many(array $keys): array { - $results = []; - - $values = $this->connection()->mget(array_map(function ($key) { - return $this->prefix . $key; - }, $keys)); - - foreach ($values as $index => $value) { - $results[$keys[$index]] = $this->unserialize($value); - } - - return $results; + return $this->getManyOperation()->execute($keys); } /** @@ -71,11 +142,7 @@ public function many(array $keys): array */ public function put(string $key, mixed $value, int $seconds): bool { - return (bool) $this->connection()->setex( - $this->prefix . $key, - (int) max(1, $seconds), - $this->serialize($value) - ); + return $this->getPutOperation()->execute($key, $value, $seconds); } /** @@ -83,19 +150,7 @@ public function put(string $key, mixed $value, int $seconds): bool */ public function putMany(array $values, int $seconds): bool { - $this->connection()->multi(); - - $manyResult = null; - - foreach ($values as $key => $value) { - $result = $this->put($key, $value, $seconds); - - $manyResult = is_null($manyResult) ? $result : $result && $manyResult; - } - - $this->connection()->exec(); - - return $manyResult ?: false; + return $this->getPutManyOperation()->execute($values, $seconds); } /** @@ -103,17 +158,7 @@ public function putMany(array $values, int $seconds): bool */ public function add(string $key, mixed $value, int $seconds): bool { - $lua = "return redis.call('exists',KEYS[1])<1 and redis.call('setex',KEYS[1],ARGV[2],ARGV[1])"; - - return (bool) $this->connection()->eval( - $lua, - [ - $this->prefix . $key, - $this->serialize($value), - (int) max(1, $seconds), - ], - 1 - ); + return $this->getAddOperation()->execute($key, $value, $seconds); } /** @@ -121,7 +166,7 @@ public function add(string $key, mixed $value, int $seconds): bool */ public function increment(string $key, int $value = 1): int { - return $this->connection()->incrby($this->prefix . $key, $value); + return $this->getIncrementOperation()->execute($key, $value); } /** @@ -129,7 +174,7 @@ public function increment(string $key, int $value = 1): int */ public function decrement(string $key, int $value = 1): int { - return $this->connection()->decrby($this->prefix . $key, $value); + return $this->getDecrementOperation()->execute($key, $value); } /** @@ -137,7 +182,7 @@ public function decrement(string $key, int $value = 1): int */ public function forever(string $key, mixed $value): bool { - return (bool) $this->connection()->set($this->prefix . $key, $this->serialize($value)); + return $this->getForeverOperation()->execute($key, $value); } /** @@ -161,7 +206,7 @@ public function restoreLock(string $name, string $owner): RedisLock */ public function forget(string $key): bool { - return (bool) $this->connection()->del($this->prefix . $key); + return $this->getForgetOperation()->execute($key); } /** @@ -169,22 +214,104 @@ public function forget(string $key): bool */ public function flush(): bool { - $this->connection()->flushdb(); + return $this->getFlushOperation()->execute(); + } + + /** + * Get an item from the cache, or execute the given Closure and store the result. + * + * Optimized to use a single connection for both GET and SET operations, + * avoiding double pool overhead for cache misses. + * + * @param Closure(): mixed $callback + */ + public function remember(string $key, int $seconds, Closure $callback): mixed + { + return $this->getRememberOperation()->execute($key, $seconds, $callback); + } - return true; + /** + * Get an item from the cache, or execute the given Closure and store the result forever. + * + * Optimized to use a single connection for both GET and SET operations, + * avoiding double pool overhead for cache misses. + * + * @param Closure(): mixed $callback + * @return array{0: mixed, 1: bool} Tuple of [value, wasHit] + */ + public function rememberForever(string $key, Closure $callback): array + { + return $this->getRememberForeverOperation()->execute($key, $callback); + } + + /** + * Get the any tag operations container. + * + * Use this to access all any-mode tagged cache operations. + */ + public function anyTagOps(): AnyTagOperations + { + return $this->anyTagOperations ??= new AnyTagOperations( + $this->getContext(), + $this->getSerialization() + ); + } + + /** + * Get the all tag operations container. + * + * Use this to access all all-mode tagged cache operations. + */ + public function allTagOps(): AllTagOperations + { + return $this->allTagOperations ??= new AllTagOperations( + $this->getContext(), + $this->getSerialization() + ); } /** * Begin executing a new tags operation. */ - public function tags(mixed $names): RedisTaggedCache + public function tags(mixed $names): AllTaggedCache|AnyTaggedCache { - return new RedisTaggedCache( + $names = is_array($names) ? $names : func_get_args(); + + if ($this->tagMode === TagMode::Any) { + return new AnyTaggedCache( + $this, + new AnyTagSet($this, $names) + ); + } + + return new AllTaggedCache( $this, - new RedisTagSet($this, is_array($names) ? $names : func_get_args()) + new AllTagSet($this, $names) ); } + /** + * Set the tag mode. + */ + public function setTagMode(TagMode|string $mode): static + { + $this->tagMode = $mode instanceof TagMode + ? $mode + : TagMode::fromConfig($mode); + + $this->clearCachedInstances(); + + return $this; + } + + /** + * Get the tag mode. + */ + public function getTagMode(): TagMode + { + return $this->tagMode; + } + /** * Get the Redis connection instance. */ @@ -207,6 +334,7 @@ public function lockConnection(): RedisProxy public function setConnection(string $connection): void { $this->connection = $connection; + $this->clearCachedInstances(); } /** @@ -240,26 +368,195 @@ public function getRedis(): RedisFactory */ public function setPrefix(string $prefix): void { - $this->prefix = ! empty($prefix) ? $prefix . ':' : ''; + $this->prefix = $prefix; + $this->clearCachedInstances(); + } + + /** + * Get the StoreContext instance. + */ + public function getContext(): StoreContext + { + return $this->context ??= new StoreContext( + $this->getPoolFactory(), + $this->connection, + $this->prefix, + $this->tagMode, + ); + } + + /** + * Get the Serialization instance. + */ + public function getSerialization(): Serialization + { + return $this->serialization ??= new Serialization(); + } + + /** + * Get the PoolFactory instance, lazily resolving if not provided. + */ + protected function getPoolFactory(): PoolFactory + { + return $this->poolFactory ??= $this->resolvePoolFactory(); } /** * Serialize the value. + * + * @deprecated Use Serialization::serialize() with a RedisConnection instead. + * + * This method is intentionally disabled to prevent an N+1 pool checkout bug. + * If serialization methods acquire their own connection, batch operations like + * putMany(1000) would checkout 1001 connections (1 for the operation + 1000 + * for serialization) instead of 1, causing massive performance degradation. + * + * @throws RedisCacheException Always throws - use Serialization::serialize() instead */ - protected function serialize(mixed $value): mixed + protected function serialize(mixed $value): never { - // is_nan() doesn't work in strict mode - return is_numeric($value) && ! in_array($value, [INF, -INF]) && ($value === $value) ? $value : serialize($value); + throw new RedisCacheException( + 'RedisStore::serialize() is disabled to prevent N+1 pool checkout bugs. ' + . 'Use Serialization::serialize($conn, $value) inside a withConnection() callback instead.' + ); } /** * Unserialize the value. + * + * @deprecated Use Serialization::unserialize() with a RedisConnection instead. + * + * This method is intentionally disabled to prevent an N+1 pool checkout bug. + * If serialization methods acquire their own connection, batch operations like + * many(1000) would checkout 1001 connections (1 for the operation + 1000 + * for unserialization) instead of 1, causing massive performance degradation. + * + * @throws RedisCacheException Always throws - use Serialization::unserialize() instead */ - protected function unserialize(mixed $value): mixed + protected function unserialize(mixed $value): never { - if ($value === null || $value === false) { - return null; - } - return is_numeric($value) ? $value : unserialize((string) $value); + throw new RedisCacheException( + 'RedisStore::unserialize() is disabled to prevent N+1 pool checkout bugs. ' + . 'Use Serialization::unserialize($conn, $value) inside a withConnection() callback instead.' + ); + } + + /** + * Resolve the PoolFactory from the container. + */ + private function resolvePoolFactory(): PoolFactory + { + return \Hyperf\Support\make(PoolFactory::class); + } + + /** + * Clear all cached instances when connection or prefix changes. + */ + private function clearCachedInstances(): void + { + $this->context = null; + $this->serialization = null; + + // Shared operations + $this->getOperation = null; + $this->manyOperation = null; + $this->putOperation = null; + $this->putManyOperation = null; + $this->addOperation = null; + $this->foreverOperation = null; + $this->forgetOperation = null; + $this->incrementOperation = null; + $this->decrementOperation = null; + $this->flushOperation = null; + $this->rememberOperation = null; + $this->rememberForeverOperation = null; + + // Tag operation containers + $this->anyTagOperations = null; + $this->allTagOperations = null; + } + + private function getGetOperation(): Get + { + return $this->getOperation ??= new Get( + $this->getContext(), + $this->getSerialization() + ); + } + + private function getManyOperation(): Many + { + return $this->manyOperation ??= new Many( + $this->getContext(), + $this->getSerialization() + ); + } + + private function getPutOperation(): Put + { + return $this->putOperation ??= new Put( + $this->getContext(), + $this->getSerialization() + ); + } + + private function getPutManyOperation(): PutMany + { + return $this->putManyOperation ??= new PutMany( + $this->getContext(), + $this->getSerialization() + ); + } + + private function getAddOperation(): Add + { + return $this->addOperation ??= new Add( + $this->getContext(), + $this->getSerialization() + ); + } + + private function getForeverOperation(): Forever + { + return $this->foreverOperation ??= new Forever( + $this->getContext(), + $this->getSerialization() + ); + } + + private function getForgetOperation(): Forget + { + return $this->forgetOperation ??= new Forget($this->getContext()); + } + + private function getIncrementOperation(): Increment + { + return $this->incrementOperation ??= new Increment($this->getContext()); + } + + private function getDecrementOperation(): Decrement + { + return $this->decrementOperation ??= new Decrement($this->getContext()); + } + + private function getFlushOperation(): Flush + { + return $this->flushOperation ??= new Flush($this->getContext()); + } + + private function getRememberOperation(): Remember + { + return $this->rememberOperation ??= new Remember( + $this->getContext(), + $this->getSerialization() + ); + } + + private function getRememberForeverOperation(): RememberForever + { + return $this->rememberForeverOperation ??= new RememberForever( + $this->getContext(), + $this->getSerialization() + ); } } diff --git a/src/cache/src/RedisTagSet.php b/src/cache/src/RedisTagSet.php deleted file mode 100644 index f5466bfa7..000000000 --- a/src/cache/src/RedisTagSet.php +++ /dev/null @@ -1,122 +0,0 @@ - 0 ? now()->addSeconds($ttl)->getTimestamp() : -1; - - foreach ($this->tagIds() as $tagKey) { - if ($updateWhen) { - $this->store->connection()->zadd($this->store->getPrefix() . $tagKey, $updateWhen, $ttl, $key); - } else { - $this->store->connection()->zadd($this->store->getPrefix() . $tagKey, $ttl, $key); - } - } - } - - /** - * Get all of the cache entry keys for the tag set. - */ - public function entries(): LazyCollection - { - $connection = $this->store->connection(); - - $defaultCursorValue = match (true) { - version_compare(phpversion('redis'), '6.1.0', '>=') => null, - default => '0', - }; - - return new LazyCollection(function () use ($connection, $defaultCursorValue) { - foreach ($this->tagIds() as $tagKey) { - $cursor = $defaultCursorValue; - - do { - $entries = $connection->zScan( - $this->store->getPrefix() . $tagKey, - $cursor, - '*', - 1000 - ); - - if (! is_array($entries)) { - break; - } - - $entries = array_unique(array_keys($entries)); - - if (count($entries) === 0) { - continue; - } - - foreach ($entries as $entry) { - yield $entry; - } - } while (((string) $cursor) !== $defaultCursorValue); - } - }); - } - - /** - * Remove the stale entries from the tag set. - */ - public function flushStaleEntries(): void - { - $this->store->connection()->pipeline(function ($pipe) { - foreach ($this->tagIds() as $tagKey) { - $pipe->zremrangebyscore($this->store->getPrefix() . $tagKey, '0', (string) now()->getTimestamp()); - } - }); - } - - /** - * Flush the tag from the cache. - */ - public function flushTag(string $name): string - { - return $this->resetTag($name); - } - - /** - * Reset the tag and return the new tag identifier. - */ - public function resetTag(string $name): string - { - $this->store->forget($this->tagKey($name)); - - return $this->tagId($name); - } - - /** - * Get the unique tag identifier for a given tag. - */ - public function tagId(string $name): string - { - return "tag:{$name}:entries"; - } - - /** - * Get the tag identifier key for a given tag. - */ - public function tagKey(string $name): string - { - return "tag:{$name}:entries"; - } -} diff --git a/src/cache/src/RedisTaggedCache.php b/src/cache/src/RedisTaggedCache.php deleted file mode 100644 index ae678f86b..000000000 --- a/src/cache/src/RedisTaggedCache.php +++ /dev/null @@ -1,125 +0,0 @@ -tags->addEntry( - $this->itemKey($key), - ! is_null($ttl) ? $this->getSeconds($ttl) : 0 - ); - - return parent::add($key, $value, $ttl); - } - - /** - * Store an item in the cache. - */ - public function put(array|string $key, mixed $value, DateInterval|DateTimeInterface|int|null $ttl = null): bool - { - if (is_array($key)) { - return $this->putMany($key, $value); - } - - if (is_null($ttl)) { - return $this->forever($key, $value); - } - - $this->tags->addEntry( - $this->itemKey($key), - $this->getSeconds($ttl) - ); - - return parent::put($key, $value, $ttl); - } - - /** - * Increment the value of an item in the cache. - */ - public function increment(string $key, int $value = 1): bool|int - { - $this->tags->addEntry($this->itemKey($key), updateWhen: 'NX'); - - return parent::increment($key, $value); - } - - /** - * Decrement the value of an item in the cache. - */ - public function decrement(string $key, int $value = 1): bool|int - { - $this->tags->addEntry($this->itemKey($key), updateWhen: 'NX'); - - return parent::decrement($key, $value); - } - - /** - * Store an item in the cache indefinitely. - */ - public function forever(string $key, mixed $value): bool - { - $this->tags->addEntry($this->itemKey($key)); - - return parent::forever($key, $value); - } - - /** - * Remove all items from the cache. - */ - public function flush(): bool - { - $this->flushValues(); - $this->tags->flush(); - - return true; - } - - /** - * Flush the individual cache entries for the tags. - */ - protected function flushValues(): void - { - $entries = $this->tags->entries() - ->map(fn (string $key) => $this->store->getPrefix() . $key) - ->chunk(1000); - - foreach ($entries as $cacheKeys) { - $this->store->connection()->del(...$cacheKeys); - } - } - - /** - * Remove all stale reference entries from the tag set. - */ - public function flushStale(): bool - { - $this->tags->flushStaleEntries(); - - return true; - } -} diff --git a/src/cache/src/Repository.php b/src/cache/src/Repository.php index 8a772f2da..dc3740d45 100644 --- a/src/cache/src/Repository.php +++ b/src/cache/src/Repository.php @@ -339,6 +339,15 @@ public function forever(string $key, mixed $value): bool */ public function remember(string $key, DateInterval|DateTimeInterface|int|null $ttl, Closure $callback): mixed { + // Use optimized single-connection path for RedisStore + if ($this->store instanceof RedisStore) { + return $this->store->remember( + $this->itemKey($key), + $this->getSeconds($ttl), + $callback + ); + } + $value = $this->get($key); // If the item exists in the cache we will just return this immediately and if @@ -378,6 +387,23 @@ public function sear(string $key, Closure $callback): mixed */ public function rememberForever(string $key, Closure $callback): mixed { + // Use optimized single-connection path for RedisStore + if ($this->store instanceof RedisStore) { + [$value, $wasHit] = $this->store->rememberForever( + $this->itemKey($key), + $callback + ); + + if ($wasHit) { + $this->event(new CacheHit($this->getName(), $key, $value)); + } else { + $this->event(new CacheMissed($this->getName(), $key)); + $this->event(new KeyWritten($this->getName(), $key, $value)); + } + + return $value; + } + $value = $this->get($key); // If the item exists in the cache we will just return this immediately diff --git a/src/cache/src/TagSet.php b/src/cache/src/TagSet.php index da5eefe50..f7d02520d 100644 --- a/src/cache/src/TagSet.php +++ b/src/cache/src/TagSet.php @@ -97,8 +97,10 @@ public function getNames(): array /** * Get an array of tag identifiers for all of the tags in the set. + * + * @return array */ - protected function tagIds(): array + public function tagIds(): array { return array_map([$this, 'tagId'], $this->names); } diff --git a/src/redis/src/Operations/FlushByPattern.php b/src/redis/src/Operations/FlushByPattern.php new file mode 100644 index 000000000..50ffc1f47 --- /dev/null +++ b/src/redis/src/Operations/FlushByPattern.php @@ -0,0 +1,123 @@ +flushByPattern('cache:users:*'); + * + * // Via Redis facade (handles connection lifecycle) + * Redis::flushByPattern('cache:users:*'); + * + * // Direct instantiation (when you have a held connection) + * $flushByPattern = new FlushByPattern($connection); + * $deletedCount = $flushByPattern->execute('cache:users:*'); + * ``` + * + * ## Warning + * + * When used with cache, this bypasses tag management. Only use for: + * - Non-tagged items + * - Administrative cleanup where orphaned tag references are acceptable + * - Test/benchmark data cleanup + */ +final class FlushByPattern +{ + /** + * Number of keys to buffer before executing a batch delete. + * Balances memory usage vs. number of Redis round-trips. + */ + private const BUFFER_SIZE = 1000; + + /** + * Create a new pattern flush instance. + * + * @param RedisConnection $connection A held Redis connection (not released until done) + */ + public function __construct( + private readonly RedisConnection $connection, + ) { + } + + /** + * Execute the pattern flush operation. + * + * @param string $pattern The pattern to match (e.g., "cache:test:*"). + * Should NOT include OPT_PREFIX - it's handled automatically. + * @return int Number of keys deleted + */ + public function execute(string $pattern): int + { + $client = $this->connection->client(); + $optPrefix = (string) $client->getOption(Redis::OPT_PREFIX); + + $safeScan = new SafeScan($client, $optPrefix); + + $deletedCount = 0; + $buffer = []; + + // Iterate using the memory-safe generator + foreach ($safeScan->execute($pattern) as $key) { + $buffer[] = $key; + + if (count($buffer) >= self::BUFFER_SIZE) { + $deletedCount += $this->deleteKeys($buffer); + $buffer = []; + } + } + + // Delete any remaining keys in the buffer + if (! empty($buffer)) { + $deletedCount += $this->deleteKeys($buffer); + } + + return $deletedCount; + } + + /** + * Delete a batch of keys. + * + * Uses UNLINK (async delete) when available for better performance, + * falls back to DEL for older Redis versions. + * + * @param array $keys Keys to delete (without OPT_PREFIX - phpredis adds it) + * @return int Number of keys deleted + */ + private function deleteKeys(array $keys): int + { + if (empty($keys)) { + return 0; + } + + // UNLINK is non-blocking (async) delete, available since Redis 4.0 + $result = $this->connection->unlink(...$keys); + + return is_int($result) ? $result : 0; + } +} diff --git a/src/redis/src/Operations/SafeScan.php b/src/redis/src/Operations/SafeScan.php new file mode 100644 index 000000000..ad66dbeda --- /dev/null +++ b/src/redis/src/Operations/SafeScan.php @@ -0,0 +1,192 @@ +scan($iter, "myapp:cache:*"); // Returns ["myapp:cache:user:1"] + * $redis->del($keys[0]); // Tries to delete "myapp:myapp:cache:user:1" - FAILS! + * + * // CORRECT approach (what SafeScan does): + * $keys = $redis->scan($iter, "myapp:cache:*"); // Returns ["myapp:cache:user:1"] + * $strippedKey = substr($keys[0], strlen("myapp:")); // "cache:user:1" + * $redis->del($strippedKey); // phpredis adds prefix -> deletes "myapp:cache:user:1" - SUCCESS! + * ``` + * + * ## Redis Cluster Support + * + * Redis Cluster requires scanning each master node separately because keys are + * distributed across slots on different nodes. This class handles this automatically: + * + * - For standard Redis: Uses `scan($iter, $pattern, $count)` + * - For RedisCluster: Iterates `_masters()` and uses `scan($iter, $node, $pattern, $count)` + * + * ## Usage + * + * This class is designed to be used within a connection pool callback: + * + * ```php + * $context->withConnection(function (RedisConnection $conn) { + * $safeScan = new SafeScan($conn->client(), $optPrefix); + * foreach ($safeScan->execute('cache:users:*') as $key) { + * // $key is stripped of OPT_PREFIX, safe to use with del(), get(), etc. + * } + * }); + * ``` + */ +final class SafeScan +{ + /** + * Create a new safe scan instance. + * + * @param Redis|RedisCluster $client The raw Redis client (from $connection->client()) + * @param string $optPrefix The OPT_PREFIX value (from $client->getOption(Redis::OPT_PREFIX)) + */ + public function __construct( + private readonly Redis|RedisCluster $client, + private readonly string $optPrefix, + ) { + } + + /** + * Execute the scan operation. + * + * @param string $pattern The pattern to match (e.g., "cache:users:*"). + * Should NOT include OPT_PREFIX - it will be added automatically. + * @param int $count The COUNT hint for SCAN (not a limit, just a hint to Redis) + * @return Generator yields keys with OPT_PREFIX stripped, safe for use with + * other phpredis commands that auto-add the prefix + */ + public function execute(string $pattern, int $count = 1000): Generator + { + $prefixLen = strlen($this->optPrefix); + + // SCAN does not automatically apply OPT_PREFIX to the pattern, + // so we must prepend it manually to match keys stored with auto-prefixing. + $scanPattern = $pattern; + if ($prefixLen > 0 && ! str_starts_with($pattern, $this->optPrefix)) { + $scanPattern = $this->optPrefix . $pattern; + } + + // Route to cluster or standard implementation + if ($this->client instanceof RedisCluster) { + yield from $this->scanCluster($scanPattern, $count, $prefixLen); + } else { + yield from $this->scanStandard($scanPattern, $count, $prefixLen); + } + } + + /** + * Scan a standard (non-cluster) Redis instance. + */ + private function scanStandard(string $scanPattern, int $count, int $prefixLen): Generator + { + // phpredis 6.1.0+ uses null as initial cursor, older versions use 0 + $iterator = $this->getInitialCursor(); + + do { + // SCAN returns keys as they exist in Redis (with full prefix) + $keys = $this->client->scan($iterator, $scanPattern, $count); + + // Normalize result (phpredis returns false on failure/empty) + if ($keys === false || ! is_array($keys)) { + $keys = []; + } + + // Yield keys with OPT_PREFIX stripped so they can be used directly + // with other phpredis commands that auto-add the prefix. + // NOTE: We inline this loop instead of using `yield from` a sub-generator + // because `yield from` would reset auto-increment keys for each batch, + // causing key collisions when the result is passed to iterator_to_array(). + foreach ($keys as $key) { + if ($prefixLen > 0 && str_starts_with($key, $this->optPrefix)) { + yield substr($key, $prefixLen); + } else { + yield $key; + } + } + } while ($iterator > 0); + } + + /** + * Scan a Redis Cluster by iterating all master nodes. + * + * RedisCluster::scan() has a different signature that requires specifying + * which node to scan. We must iterate all masters to find all keys. + */ + private function scanCluster(string $scanPattern, int $count, int $prefixLen): Generator + { + /** @var RedisCluster $client */ + $client = $this->client; + + // Get all master nodes in the cluster + $masters = $client->_masters(); + + foreach ($masters as $master) { + // Each master node needs its own cursor + $iterator = $this->getInitialCursor(); + + do { + // RedisCluster::scan() signature: scan(&$iter, $node, $pattern, $count) + $keys = $client->scan($iterator, $master, $scanPattern, $count); + + // Normalize result (phpredis returns false on failure/empty) + if ($keys === false || ! is_array($keys)) { + $keys = []; + } + + // Yield keys with OPT_PREFIX stripped (see comment in scanStandard) + foreach ($keys as $key) { + if ($prefixLen > 0 && str_starts_with($key, $this->optPrefix)) { + yield substr($key, $prefixLen); + } else { + yield $key; + } + } + } while ($iterator > 0); + } + } + + /** + * Get the initial cursor value based on phpredis version. + * + * phpredis 6.1.0+ uses null as initial cursor, older versions use 0. + */ + private function getInitialCursor(): ?int + { + return match (true) { + version_compare(phpversion('redis') ?: '0', '6.1.0', '>=') => null, + default => 0, + }; + } +} diff --git a/src/redis/src/Redis.php b/src/redis/src/Redis.php index c8da51f78..977d6672a 100644 --- a/src/redis/src/Redis.php +++ b/src/redis/src/Redis.php @@ -9,6 +9,7 @@ use Hyperf\Redis\Pool\PoolFactory; use Hypervel\Context\ApplicationContext; use Hypervel\Context\Context; +use Hypervel\Redis\Traits\MultiExec; use Throwable; /** @@ -16,6 +17,8 @@ */ class Redis { + use MultiExec; + protected string $poolName = 'default'; public function __construct( @@ -140,4 +143,35 @@ public function connection(string $name = 'default'): RedisProxy ->get(RedisFactory::class) ->get($name); } + + /** + * Flush (delete) all Redis keys matching a pattern. + * + * Use this for standalone/one-off flush operations. It handles the connection + * lifecycle automatically (get from pool, flush, release). Uses the default + * connection, or specify one via Redis::connection($name)->flushByPattern(). + * + * If you already have a connection (e.g., inside withConnection()), call + * $connection->flushByPattern() directly to avoid redundant pool operations. + * + * Uses SCAN to iterate keys efficiently and deletes them in batches. + * Correctly handles OPT_PREFIX to avoid the double-prefixing bug. + * + * @param string $pattern The pattern to match (e.g., "cache:test:*"). + * Should NOT include OPT_PREFIX - it's handled automatically. + * @return int Number of keys deleted + */ + public function flushByPattern(string $pattern): int + { + $pool = $this->factory->getPool($this->poolName); + + /** @var RedisConnection $connection */ + $connection = $pool->get(); + + try { + return $connection->flushByPattern($pattern); + } finally { + $connection->release(); + } + } } diff --git a/src/redis/src/RedisConnection.php b/src/redis/src/RedisConnection.php index 9570e7b0e..739fcbe83 100644 --- a/src/redis/src/RedisConnection.php +++ b/src/redis/src/RedisConnection.php @@ -4,10 +4,14 @@ namespace Hypervel\Redis; +use Generator; use Hyperf\Redis\RedisConnection as HyperfRedisConnection; +use Hypervel\Redis\Operations\FlushByPattern; +use Hypervel\Redis\Operations\SafeScan; use Hypervel\Support\Arr; use Hypervel\Support\Collection; use Redis; +use RedisCluster; use Throwable; /** @@ -33,6 +37,9 @@ * @method mixed evalsha(string $script, int $numkeys, mixed ...$arguments) Evaluate Lua script by SHA1 * @method mixed flushdb(mixed ...$arguments) Flush database * @method mixed executeRaw(array $parameters) Execute raw Redis command + * @method static string _digest(mixed $value) + * @method static string _pack(mixed $value) + * @method static mixed _unpack(string $value) * @method static mixed acl(string $subcmd, string ...$args) * @method static \Redis|int|false append(string $key, mixed $value) * @method static \Redis|bool auth(mixed $credentials) @@ -52,7 +59,7 @@ * @method static \Redis|array|false|null blmpop(float $timeout, array $keys, string $from, int $count = 1) * @method static \Redis|array|false|null lmpop(array $keys, string $from, int $count = 1) * @method static bool clearLastError() - * @method static mixed client(string $opt, mixed ...$args) + * @method static mixed client(string $opt = '', mixed ...$args) * @method static mixed command(string|null $opt = null, mixed ...$args) * @method static mixed config(string $operation, array|string|null $key_or_settings = null, string|null $value = null) * @method static bool connect(string $host, int $port = 6379, float $timeout = 0, string|null $persistent_id = null, int $retry_interval = 0, float $read_timeout = 0, array|null $context = null) @@ -62,6 +69,9 @@ * @method static \Redis|int|false decr(string $key, int $by = 1) * @method static \Redis|int|false decrBy(string $key, int $value) * @method static \Redis|int|false del(array|string $key, string ...$other_keys) + * @method static \Redis|int|false delex(string $key, array|null $options = null) + * @method static \Redis|int|false delifeq(string $key, mixed $value) + * @method static \Redis|string|false digest(string $key) * @method static \Redis|bool discard() * @method static \Redis|string|false dump(string $key) * @method static \Redis|string|false echo(string $str) @@ -107,10 +117,23 @@ * @method static float|false getTimeout() * @method static array getTransferredBytes() * @method static void clearTransferredBytes() + * @method static \Redis|array|false getWithMeta(string $key) * @method static \Redis|int|false hDel(string $key, string $field, string ...$other_fields) + * @method static \Redis|array|false hexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method static \Redis|array|false hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method static \Redis|array|false hexpireat(string $key, int $time, array $fields, string|null $mode = null) + * @method static \Redis|array|false hpexpireat(string $key, int $mstime, array $fields, string|null $mode = null) + * @method static \Redis|array|false httl(string $key, array $fields) + * @method static \Redis|array|false hpttl(string $key, array $fields) + * @method static \Redis|array|false hexpiretime(string $key, array $fields) + * @method static \Redis|array|false hpexpiretime(string $key, array $fields) + * @method static \Redis|array|false hpersist(string $key, array $fields) * @method static \Redis|bool hExists(string $key, string $field) * @method static mixed hGet(string $key, string $member) * @method static \Redis|array|false hGetAll(string $key) + * @method static mixed hGetWithMeta(string $key, string $member) + * @method static \Redis|array|false hgetdel(string $key, array $fields) + * @method static \Redis|array|false hgetex(string $key, array $fields, string|array|null $expiry = null) * @method static \Redis|int|false hIncrBy(string $key, string $field, int $value) * @method static \Redis|float|false hIncrByFloat(string $key, string $field, float $value) * @method static \Redis|array|false hKeys(string $key) @@ -120,6 +143,7 @@ * @method static \Redis|array|string|false hRandField(string $key, array|null $options = null) * @method static \Redis|int|false hSet(string $key, mixed ...$fields_and_vals) * @method static \Redis|bool hSetNx(string $key, string $field, mixed $value) + * @method static \Redis|int|false hsetex(string $key, array $fields, array|null $expiry = null) * @method static \Redis|int|false hStrLen(string $key, string $field) * @method static \Redis|array|false hVals(string $key) * @method static \Redis|int|false incr(string $key, int $by = 1) @@ -146,6 +170,7 @@ * @method static \Redis|bool migrate(string $host, int $port, array|string $key, int $dstdb, int $timeout, bool $copy = false, bool $replace = false, mixed $credentials = null) * @method static \Redis|bool move(string $key, int $index) * @method static \Redis|bool mset(array $key_values) + * @method static \Redis|int|false msetex(array $key_values, int|float|array|null $expiry = null) * @method static \Redis|bool msetnx(array $key_values) * @method static \Redis|bool multi(int $value = 1) * @method static \Redis|string|int|false object(string $subcommand, string $key) @@ -190,6 +215,8 @@ * @method static \Redis|int|false scard(string $key) * @method static mixed script(string $command, mixed ...$args) * @method static \Redis|bool select(int $db) + * @method static string|false serverName() + * @method static string|false serverVersion() * @method static \Redis|int|false setBit(string $key, int $idx, bool $value) * @method static \Redis|int|false setRange(string $key, int $index, string $value) * @method static bool setOption(int $option, mixed $value) @@ -212,6 +239,19 @@ * @method static \Redis|int|false unlink(array|string $key, string ...$other_keys) * @method static \Redis|array|bool unsubscribe(array $channels) * @method static \Redis|bool unwatch() + * @method static \Redis|int|false vadd(string $key, array $values, mixed $element, array|null $options = null) + * @method static \Redis|int|false vcard(string $key) + * @method static \Redis|int|false vdim(string $key) + * @method static \Redis|array|false vemb(string $key, mixed $member, bool $raw = false) + * @method static \Redis|array|string|false vgetattr(string $key, mixed $member, bool $decode = true) + * @method static \Redis|array|false vinfo(string $key) + * @method static \Redis|bool vismember(string $key, mixed $member) + * @method static \Redis|array|false vlinks(string $key, mixed $member, bool $withscores = false) + * @method static \Redis|array|string|false vrandmember(string $key, int $count = 0) + * @method static \Redis|array|false vrange(string $key, string $min, string $max, int $count = -1) + * @method static \Redis|int|false vrem(string $key, mixed $member) + * @method static \Redis|int|false vsetattr(string $key, mixed $member, array|string $attributes) + * @method static \Redis|array|false vsim(string $key, mixed $member, array|null $options = null) * @method static \Redis|bool watch(array|string $key, string ...$other_keys) * @method static int|false wait(int $numreplicas, int $timeout) * @method static int|false xack(string $key, string $group, array $ids) @@ -219,6 +259,7 @@ * @method static \Redis|array|bool xautoclaim(string $key, string $group, string $consumer, int $min_idle, string $start, int $count = -1, bool $justid = false) * @method static \Redis|array|bool xclaim(string $key, string $group, string $consumer, int $min_idle, array $ids, array $options) * @method static \Redis|int|false xdel(string $key, array $ids) + * @method static \Redis|array|false xdelex(string $key, array $ids, string|null $mode = null) * @method static mixed xgroup(string $operation, string|null $key = null, string|null $group = null, string|null $id_or_consumer = null, bool $mkstream = false, int $entries_read = -2) * @method static mixed xinfo(string $operation, string|null $arg1 = null, string|null $arg2 = null, int $count = -1) * @method static \Redis|int|false xlen(string $key) @@ -711,4 +752,93 @@ protected function getSubscribeArguments(string $name, array $arguments): array $callback = fn ($redis, $pattern, $channel, $message) => $callback($message, $channel), ]; } + + /** + * Determine if a custom serializer is configured on the connection. + */ + public function serialized(): bool + { + return defined('Redis::OPT_SERIALIZER') + && $this->connection->getOption(Redis::OPT_SERIALIZER) !== Redis::SERIALIZER_NONE; + } + + /** + * Determine if compression is configured on the connection. + */ + public function compressed(): bool + { + return defined('Redis::OPT_COMPRESSION') + && $this->connection->getOption(Redis::OPT_COMPRESSION) !== Redis::COMPRESSION_NONE; + } + + /** + * Pack values for use in Lua script ARGV parameters. + * + * Unlike regular Redis commands where phpredis auto-serializes, + * Lua ARGV parameters must be pre-serialized strings. + * + * Requires phpredis 6.0+ which provides the _pack() method. + * + * @param array $values + * @return array + */ + public function pack(array $values): array + { + if (empty($values)) { + return $values; + } + + return array_map($this->connection->_pack(...), $values); + } + + /** + * Get the underlying Redis client instance. + * + * @return Redis|RedisCluster + */ + public function client(): mixed + { + return $this->connection; + } + + /** + * Safely scan the Redis keyspace for keys matching a pattern. + * + * This method handles the phpredis OPT_PREFIX complexity correctly: + * - Automatically prepends OPT_PREFIX to the scan pattern + * - Strips OPT_PREFIX from returned keys so they work with other commands + * + * @param string $pattern The pattern to match (e.g., "cache:users:*"). + * Should NOT include OPT_PREFIX - it's handled automatically. + * @param int $count The COUNT hint for SCAN (not a limit, just a hint to Redis) + * @return Generator Yields keys with OPT_PREFIX stripped + */ + public function safeScan(string $pattern, int $count = 1000): Generator + { + $optPrefix = (string) $this->connection->getOption(Redis::OPT_PREFIX); + + return (new SafeScan($this->connection, $optPrefix))->execute($pattern, $count); + } + + /** + * Flush (delete) all Redis keys matching a pattern. + * + * Use this when you already have a connection (e.g., inside withConnection() + * or when doing multiple operations on the same connection). No connection + * lifecycle overhead since you're operating on an existing connection. + * + * For standalone/one-off operations, use Redis::flushByPattern() instead, + * which handles connection lifecycle automatically. + * + * Uses SCAN to iterate keys efficiently and deletes them in batches. + * Correctly handles OPT_PREFIX to avoid the double-prefixing bug. + * + * @param string $pattern The pattern to match (e.g., "cache:test:*"). + * Should NOT include OPT_PREFIX - it's handled automatically. + * @return int Number of keys deleted + */ + public function flushByPattern(string $pattern): int + { + return (new FlushByPattern($this))->execute($pattern); + } } diff --git a/src/redis/src/Traits/MultiExec.php b/src/redis/src/Traits/MultiExec.php new file mode 100644 index 000000000..2ba816cb4 --- /dev/null +++ b/src/redis/src/Traits/MultiExec.php @@ -0,0 +1,66 @@ +executeMultiExec('pipeline', $callback); + } + + /** + * Execute commands in a transaction. + * + * @return array|Redis|RedisCluster + */ + public function transaction(?callable $callback = null) + { + return $this->executeMultiExec('multi', $callback); + } + + /** + * Execute multi-exec commands with optional callback. + * + * @return array|Redis|RedisCluster + */ + private function executeMultiExec(string $command, ?callable $callback = null) + { + if (is_null($callback)) { + return $this->__call($command, []); + } + + if (! $this instanceof HypervelRedis) { + return tap($this->__call($command, []), $callback)->exec(); + } + + $hasExistingConnection = Context::has($this->getContextKey()); + $instance = $this->__call($command, []); + + try { + return tap($instance, $callback)->exec(); + } finally { + if (! $hasExistingConnection) { + $this->releaseContextConnection(); + } + } + } +} diff --git a/src/support/src/Facades/Redis.php b/src/support/src/Facades/Redis.php index ed557cb6d..8055d787a 100644 --- a/src/support/src/Facades/Redis.php +++ b/src/support/src/Facades/Redis.php @@ -46,6 +46,9 @@ * @method static mixed evalsha(string $script, int $numkeys, mixed ...$arguments) * @method static mixed flushdb(mixed ...$arguments) * @method static mixed executeRaw(array $parameters) + * @method static string _digest(mixed $value) + * @method static string _pack(mixed $value) + * @method static mixed _unpack(string $value) * @method static mixed acl(string $subcmd, string ...$args) * @method static \Redis|int|false append(string $key, mixed $value) * @method static \Redis|bool auth(mixed $credentials) @@ -75,6 +78,9 @@ * @method static \Redis|int|false decr(string $key, int $by = 1) * @method static \Redis|int|false decrBy(string $key, int $value) * @method static \Redis|int|false del(array|string $key, string ...$other_keys) + * @method static \Redis|int|false delex(string $key, array|null $options = null) + * @method static \Redis|int|false delifeq(string $key, mixed $value) + * @method static \Redis|string|false digest(string $key) * @method static \Redis|bool discard() * @method static \Redis|string|false dump(string $key) * @method static \Redis|string|false echo(string $str) @@ -120,10 +126,23 @@ * @method static float|false getTimeout() * @method static array getTransferredBytes() * @method static void clearTransferredBytes() + * @method static \Redis|array|false getWithMeta(string $key) * @method static \Redis|int|false hDel(string $key, string $field, string ...$other_fields) + * @method static \Redis|array|false hexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method static \Redis|array|false hpexpire(string $key, int $ttl, array $fields, string|null $mode = null) + * @method static \Redis|array|false hexpireat(string $key, int $time, array $fields, string|null $mode = null) + * @method static \Redis|array|false hpexpireat(string $key, int $mstime, array $fields, string|null $mode = null) + * @method static \Redis|array|false httl(string $key, array $fields) + * @method static \Redis|array|false hpttl(string $key, array $fields) + * @method static \Redis|array|false hexpiretime(string $key, array $fields) + * @method static \Redis|array|false hpexpiretime(string $key, array $fields) + * @method static \Redis|array|false hpersist(string $key, array $fields) * @method static \Redis|bool hExists(string $key, string $field) * @method static mixed hGet(string $key, string $member) * @method static \Redis|array|false hGetAll(string $key) + * @method static mixed hGetWithMeta(string $key, string $member) + * @method static \Redis|array|false hgetdel(string $key, array $fields) + * @method static \Redis|array|false hgetex(string $key, array $fields, string|array|null $expiry = null) * @method static \Redis|int|false hIncrBy(string $key, string $field, int $value) * @method static \Redis|float|false hIncrByFloat(string $key, string $field, float $value) * @method static \Redis|array|false hKeys(string $key) @@ -133,6 +152,7 @@ * @method static \Redis|array|string|false hRandField(string $key, array|null $options = null) * @method static \Redis|int|false hSet(string $key, mixed ...$fields_and_vals) * @method static \Redis|bool hSetNx(string $key, string $field, mixed $value) + * @method static \Redis|int|false hsetex(string $key, array $fields, array|null $expiry = null) * @method static \Redis|int|false hStrLen(string $key, string $field) * @method static \Redis|array|false hVals(string $key) * @method static \Redis|int|false incr(string $key, int $by = 1) @@ -159,6 +179,7 @@ * @method static \Redis|bool migrate(string $host, int $port, array|string $key, int $dstdb, int $timeout, bool $copy = false, bool $replace = false, mixed $credentials = null) * @method static \Redis|bool move(string $key, int $index) * @method static \Redis|bool mset(array $key_values) + * @method static \Redis|int|false msetex(array $key_values, int|float|array|null $expiry = null) * @method static \Redis|bool msetnx(array $key_values) * @method static \Redis|bool multi(int $value = 1) * @method static \Redis|string|int|false object(string $subcommand, string $key) @@ -203,6 +224,8 @@ * @method static \Redis|int|false scard(string $key) * @method static mixed script(string $command, mixed ...$args) * @method static \Redis|bool select(int $db) + * @method static string|false serverName() + * @method static string|false serverVersion() * @method static \Redis|int|false setBit(string $key, int $idx, bool $value) * @method static \Redis|int|false setRange(string $key, int $index, string $value) * @method static bool setOption(int $option, mixed $value) @@ -225,6 +248,19 @@ * @method static \Redis|int|false unlink(array|string $key, string ...$other_keys) * @method static \Redis|array|bool unsubscribe(array $channels) * @method static \Redis|bool unwatch() + * @method static \Redis|int|false vadd(string $key, array $values, mixed $element, array|null $options = null) + * @method static \Redis|int|false vcard(string $key) + * @method static \Redis|int|false vdim(string $key) + * @method static \Redis|array|false vemb(string $key, mixed $member, bool $raw = false) + * @method static \Redis|array|string|false vgetattr(string $key, mixed $member, bool $decode = true) + * @method static \Redis|array|false vinfo(string $key) + * @method static \Redis|bool vismember(string $key, mixed $member) + * @method static \Redis|array|false vlinks(string $key, mixed $member, bool $withscores = false) + * @method static \Redis|array|string|false vrandmember(string $key, int $count = 0) + * @method static \Redis|array|false vrange(string $key, string $min, string $max, int $count = -1) + * @method static \Redis|int|false vrem(string $key, mixed $member) + * @method static \Redis|int|false vsetattr(string $key, mixed $member, array|string $attributes) + * @method static \Redis|array|false vsim(string $key, mixed $member, array|null $options = null) * @method static \Redis|bool watch(array|string $key, string ...$other_keys) * @method static int|false wait(int $numreplicas, int $timeout) * @method static int|false xack(string $key, string $group, array $ids) @@ -232,6 +268,7 @@ * @method static \Redis|array|bool xautoclaim(string $key, string $group, string $consumer, int $min_idle, string $start, int $count = -1, bool $justid = false) * @method static \Redis|array|bool xclaim(string $key, string $group, string $consumer, int $min_idle, array $ids, array $options) * @method static \Redis|int|false xdel(string $key, array $ids) + * @method static \Redis|array|false xdelex(string $key, array $ids, string|null $mode = null) * @method static mixed xgroup(string $operation, string|null $key = null, string|null $group = null, string|null $id_or_consumer = null, bool $mkstream = false, int $entries_read = -2) * @method static mixed xinfo(string $operation, string|null $arg1 = null, string|null $arg2 = null, int $count = -1) * @method static \Redis|int|false xlen(string $key) diff --git a/src/support/src/SystemInfo.php b/src/support/src/SystemInfo.php new file mode 100644 index 000000000..ace86f87e --- /dev/null +++ b/src/support/src/SystemInfo.php @@ -0,0 +1,182 @@ + 0 ? $count : null; + } + } elseif (PHP_OS_FAMILY === 'Darwin') { + $output = @shell_exec('sysctl -n hw.ncpu 2>/dev/null'); + + if ($output) { + return (int) trim($output); + } + } elseif (PHP_OS_FAMILY === 'Windows') { + $cores = getenv('NUMBER_OF_PROCESSORS'); + + if ($cores) { + return (int) $cores; + } + } + } catch (Exception) { + // Silently fail + } + + return null; + } + + /** + * Get total system memory as a human-readable string. + * + * Supports Linux (/proc/meminfo), macOS (sysctl), and Windows (wmic). + */ + public function getTotalMemory(): ?string + { + try { + if (PHP_OS_FAMILY === 'Linux') { + $meminfo = @file_get_contents('/proc/meminfo'); + + if ($meminfo && preg_match('/MemTotal:\s+(\d+)\s+kB/i', $meminfo, $matches)) { + $kb = (int) $matches[1]; + + return Number::fileSize($kb * 1024, precision: 1); + } + } elseif (PHP_OS_FAMILY === 'Darwin') { + $output = @shell_exec('sysctl -n hw.memsize 2>/dev/null'); + + if ($output) { + return Number::fileSize((int) trim($output), precision: 1); + } + } elseif (PHP_OS_FAMILY === 'Windows') { + $output = @shell_exec('wmic computersystem get totalphysicalmemory 2>nul'); + + if ($output && preg_match('/\d+/', $output, $matches)) { + return Number::fileSize((int) $matches[0], precision: 1); + } + } + } catch (Exception) { + // Silently fail + } + + return null; + } + + /** + * Detect virtualization type if running in a VM or container. + * + * Returns the detected type (VirtualBox, VMware, KVM, Docker, etc.) or null if not detected. + */ + public function detectVirtualization(): ?string + { + try { + if (PHP_OS_FAMILY === 'Linux') { + // Check for common virtualization indicators + $dmiDecodeOutput = @shell_exec('cat /sys/class/dmi/id/product_name 2>/dev/null'); + + if ($dmiDecodeOutput) { + $product = strtolower(trim($dmiDecodeOutput)); + + if (str_contains($product, 'virtualbox')) { + return 'VirtualBox'; + } + + if (str_contains($product, 'vmware')) { + return 'VMware'; + } + + if (str_contains($product, 'kvm')) { + return 'KVM'; + } + + if (str_contains($product, 'qemu')) { + return 'QEMU'; + } + + if (str_contains($product, 'bochs')) { + return 'Bochs'; + } + } + + // Check for container + if (file_exists('/.dockerenv')) { + return 'Docker'; + } + + $cgroupContent = @file_get_contents('/proc/1/cgroup'); + + if ($cgroupContent && (str_contains($cgroupContent, 'docker') || str_contains($cgroupContent, 'lxc'))) { + return 'Container'; + } + } + } catch (Exception) { + // Silently fail + } + + return null; + } + + /** + * Get PHP memory limit in bytes. + * + * @return int Memory limit in bytes, or -1 if unlimited + */ + public function getMemoryLimitBytes(): int + { + $limit = ini_get('memory_limit') ?: '-1'; + + if ($limit === '-1') { + return -1; + } + + $limit = strtolower(trim($limit)); + $value = (int) $limit; + + $unit = $limit[-1] ?? ''; + + return match ($unit) { + 'g' => $value * 1024 * 1024 * 1024, + 'm' => $value * 1024 * 1024, + 'k' => $value * 1024, + default => $value, + }; + } + + /** + * Get current PHP memory limit as human-readable string. + */ + public function getMemoryLimitFormatted(): string + { + $bytes = $this->getMemoryLimitBytes(); + + if ($bytes === -1) { + return 'Unlimited'; + } + + return Number::fileSize($bytes, precision: 0); + } +} diff --git a/src/testbench/workbench/config/cache.php b/src/testbench/workbench/config/cache.php new file mode 100644 index 000000000..e0b315861 --- /dev/null +++ b/src/testbench/workbench/config/cache.php @@ -0,0 +1,23 @@ + env('CACHE_DRIVER', 'array'), + + 'stores' => [ + 'array' => [ + 'driver' => 'array', + 'serialize' => false, + ], + + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'lock_connection' => 'default', + 'tag_mode' => 'all', + ], + ], + + 'prefix' => env('CACHE_PREFIX', 'cache_prefix:'), +]; diff --git a/src/testbench/workbench/config/database.php b/src/testbench/workbench/config/database.php index fe7bf46d2..1b8aa0e7d 100644 --- a/src/testbench/workbench/config/database.php +++ b/src/testbench/workbench/config/database.php @@ -5,7 +5,20 @@ use Hypervel\Support\Str; return [ + /* + |-------------------------------------------------------------------------- + | Default Database Connection Name + |-------------------------------------------------------------------------- + | + | Here you may specify which of the database connections below you wish + | to use as your default connection for database operations. This is + | the connection which will be utilized unless another connection + | is explicitly specified when you execute a query / statement. + | + */ + 'default' => env('DB_CONNECTION', 'sqlite'), + 'connections' => [ 'sqlite' => [ 'driver' => 'sqlite', @@ -14,6 +27,31 @@ 'foreign_key_constraints' => env('DB_FOREIGN_KEYS', true), ], ], + + /* + |-------------------------------------------------------------------------- + | Migration Repository Table + |-------------------------------------------------------------------------- + | + | This table keeps track of all the migrations that have already run for + | your application. Using this information, we can determine which of + | the migrations on disk haven't actually been run on the database. + | + */ + + 'migrations' => 'migrations', + + /* + |-------------------------------------------------------------------------- + | Redis Databases + |-------------------------------------------------------------------------- + | + | Redis is an open source, fast, and advanced key-value store that also + | provides a richer body of commands than a typical key-value system + | such as Memcached. You may define your connection settings here. + | + */ + 'redis' => [ 'options' => [ 'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'hypervel'), '_') . '_database_'), @@ -33,5 +71,20 @@ 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), ], ], + + 'queue' => [ + 'host' => env('REDIS_HOST', 'localhost'), + 'auth' => env('REDIS_AUTH', null), + 'port' => (int) env('REDIS_PORT', 6379), + 'db' => (int) env('REDIS_DB', 0), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => (float) env('REDIS_MAX_IDLE_TIME', 60), + ], + ], ], ]; diff --git a/tests/Cache/CacheManagerTest.php b/tests/Cache/CacheManagerTest.php index 18349bf48..928ad4063 100644 --- a/tests/Cache/CacheManagerTest.php +++ b/tests/Cache/CacheManagerTest.php @@ -6,15 +6,22 @@ use Hyperf\Config\Config; use Hyperf\Contract\ConfigInterface; +use Hyperf\Redis\Pool\PoolFactory; +use Hyperf\Redis\Pool\RedisPool; +use Hyperf\Redis\RedisFactory; use Hypervel\Cache\CacheManager; use Hypervel\Cache\Contracts\Repository; use Hypervel\Cache\NullStore; +use Hypervel\Cache\Redis\TagMode; +use Hypervel\Cache\RedisStore; +use Hypervel\Redis\RedisConnection; use Hypervel\Tests\TestCase; use InvalidArgumentException; use Mockery as m; use Mockery\MockInterface; use Psr\Container\ContainerInterface; use Psr\EventDispatcher\EventDispatcherInterface; +use Redis; /** * @internal @@ -311,6 +318,80 @@ public function testThrowExceptionWhenUnknownStoreIsUsed() $cacheManager->store('alien_store'); } + public function testRedisDriverDefaultsToIntersectionTaggingMode(): void + { + $userConfig = [ + 'cache' => [ + 'prefix' => 'test', + 'stores' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + ], + ], + ], + ]; + + $app = $this->getAppWithRedis($userConfig); + $cacheManager = new CacheManager($app); + + $repository = $cacheManager->store('redis'); + $store = $repository->getStore(); + + $this->assertInstanceOf(RedisStore::class, $store); + $this->assertSame(TagMode::All, $store->getTagMode()); + } + + public function testRedisDriverUsesConfiguredTagMode(): void + { + $userConfig = [ + 'cache' => [ + 'prefix' => 'test', + 'stores' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'tag_mode' => 'any', + ], + ], + ], + ]; + + $app = $this->getAppWithRedis($userConfig); + $cacheManager = new CacheManager($app); + + $repository = $cacheManager->store('redis'); + $store = $repository->getStore(); + + $this->assertInstanceOf(RedisStore::class, $store); + $this->assertSame(TagMode::Any, $store->getTagMode()); + } + + public function testRedisDriverFallsBackToAllForInvalidTagMode(): void + { + $userConfig = [ + 'cache' => [ + 'prefix' => 'test', + 'stores' => [ + 'redis' => [ + 'driver' => 'redis', + 'connection' => 'default', + 'tag_mode' => 'invalid', + ], + ], + ], + ]; + + $app = $this->getAppWithRedis($userConfig); + $cacheManager = new CacheManager($app); + + $repository = $cacheManager->store('redis'); + $store = $repository->getStore(); + + $this->assertInstanceOf(RedisStore::class, $store); + $this->assertSame(TagMode::All, $store->getTagMode()); + } + protected function getApp(array $userConfig) { /** @var ContainerInterface|MockInterface */ @@ -319,4 +400,46 @@ protected function getApp(array $userConfig) return $app; } + + protected function getAppWithRedis(array $userConfig) + { + $app = $this->getApp($userConfig); + + // Mock Redis client + $redisClient = m::mock(); + $redisClient->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_NONE); + $redisClient->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn(''); + + // Mock RedisConnection + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('serialized')->andReturn(false); + $connection->shouldReceive('client')->andReturn($redisClient); + + // Mock RedisPool + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->andReturn($connection); + + // Mock PoolFactory + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + + // Mock RedisFactory + $redisFactory = m::mock(RedisFactory::class); + + $app->shouldReceive('get')->with(RedisFactory::class)->andReturn($redisFactory); + $app->shouldReceive('has')->with(EventDispatcherInterface::class)->andReturnFalse(); + + // Override make() to return our mocked PoolFactory + // Since make() uses container internally, we need to handle this + \Hyperf\Context\ApplicationContext::setContainer($app); + $app->shouldReceive('get')->with(PoolFactory::class)->andReturn($poolFactory); + $app->shouldReceive('make')->with(PoolFactory::class, m::any())->andReturn($poolFactory); + + return $app; + } } diff --git a/tests/Cache/CacheRedisStoreTest.php b/tests/Cache/CacheRedisStoreTest.php deleted file mode 100644 index a96af0df0..000000000 --- a/tests/Cache/CacheRedisStoreTest.php +++ /dev/null @@ -1,163 +0,0 @@ -getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('get')->once()->with('prefix:foo')->andReturn(null); - $this->assertNull($redis->get('foo')); - } - - public function testRedisValueIsReturned() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('get')->once()->with('prefix:foo')->andReturn(serialize('foo')); - $this->assertSame('foo', $redis->get('foo')); - } - - public function testRedisMultipleValuesAreReturned() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('mget')->once()->with(['prefix:foo', 'prefix:fizz', 'prefix:norf', 'prefix:null']) - ->andReturn([ - serialize('bar'), - serialize('buzz'), - serialize('quz'), - null, - ]); - - $results = $redis->many(['foo', 'fizz', 'norf', 'null']); - - $this->assertSame('bar', $results['foo']); - $this->assertSame('buzz', $results['fizz']); - $this->assertSame('quz', $results['norf']); - $this->assertNull($results['null']); - } - - public function testRedisValueIsReturnedForNumerics() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('get')->once()->with('prefix:foo')->andReturn(1); - $this->assertEquals(1, $redis->get('foo')); - } - - public function testSetMethodProperlyCallsRedis() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('setex')->once()->with('prefix:foo', 60, serialize('foo'))->andReturn('OK'); - $result = $redis->put('foo', 'foo', 60); - $this->assertTrue($result); - } - - public function testSetMultipleMethodProperlyCallsRedis() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('multi')->once(); - $proxy->shouldReceive('setex')->once()->with('prefix:foo', 60, serialize('bar'))->andReturn('OK'); - $proxy->shouldReceive('setex')->once()->with('prefix:baz', 60, serialize('qux'))->andReturn('OK'); - $proxy->shouldReceive('setex')->once()->with('prefix:bar', 60, serialize('norf'))->andReturn('OK'); - $proxy->shouldReceive('exec')->once(); - - $result = $redis->putMany([ - 'foo' => 'bar', - 'baz' => 'qux', - 'bar' => 'norf', - ], 60); - $this->assertTrue($result); - } - - public function testSetMethodProperlyCallsRedisForNumerics() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('setex')->once()->with('prefix:foo', 60, 1); - $result = $redis->put('foo', 1, 60); - $this->assertFalse($result); - } - - public function testIncrementMethodProperlyCallsRedis() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('incrby')->once()->with('prefix:foo', 5)->andReturn(6); - $result = $redis->increment('foo', 5); - $this->assertEquals(6, $result); - } - - public function testDecrementMethodProperlyCallsRedis() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('decrby')->once()->with('prefix:foo', 5)->andReturn(4); - $result = $redis->decrement('foo', 5); - $this->assertEquals(4, $result); - } - - public function testStoreItemForeverProperlyCallsRedis() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('set')->once()->with('prefix:foo', serialize('foo'))->andReturn('OK'); - $result = $redis->forever('foo', 'foo', 60); - $this->assertTrue($result); - } - - public function testForgetMethodProperlyCallsRedis() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('del')->once()->with('prefix:foo'); - $redis->forget('foo'); - $this->assertTrue(true); - } - - public function testFlushesCached() - { - $redis = $this->getRedis(); - $redis->getRedis()->shouldReceive('get')->once()->with('default')->andReturn($proxy = $this->getRedisProxy()); - $proxy->shouldReceive('flushdb')->once()->andReturn('ok'); - $result = $redis->flush(); - $this->assertTrue($result); - } - - public function testGetAndSetPrefix() - { - $redis = $this->getRedis(); - $this->assertSame('prefix:', $redis->getPrefix()); - $redis->setPrefix('foo'); - $this->assertSame('foo:', $redis->getPrefix()); - $redis->setPrefix(''); - $this->assertEmpty($redis->getPrefix()); - } - - protected function getRedis() - { - return new RedisStore(m::mock(Factory::class), 'prefix'); - } - - protected function getRedisProxy() - { - return m::mock(RedisProxy::class); - } -} diff --git a/tests/Cache/CacheRedisTaggedCacheTest.php b/tests/Cache/CacheRedisTaggedCacheTest.php deleted file mode 100644 index 5f87b76ab..000000000 --- a/tests/Cache/CacheRedisTaggedCacheTest.php +++ /dev/null @@ -1,168 +0,0 @@ -mockRedis(); - } - - public function testTagEntriesCanBeStoredForever() - { - $key = sha1('tag:people:entries|tag:author:entries') . ':name'; - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:people:entries', -1, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:author:entries', -1, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('set')->once()->with("prefix:{$key}", serialize('Sally'))->andReturn('OK'); - - $this->redis->tags(['people', 'author'])->forever('name', 'Sally'); - - $key = sha1('tag:people:entries|tag:author:entries') . ':age'; - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:people:entries', -1, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:author:entries', -1, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('set')->once()->with("prefix:{$key}", 30)->andReturn('OK'); - - $this->redis->tags(['people', 'author'])->forever('age', 30); - - $this->redisProxy - ->shouldReceive('zScan') - ->once() - ->with('prefix:tag:people:entries', null, '*', 1000) - ->andReturnUsing(function ($key, &$cursor) { - $cursor = 0; - - return ['tag:people:entries:name' => 0, 'tag:people:entries:age' => 0]; - }); - $this->redisProxy - ->shouldReceive('zScan') - ->once() - ->with('prefix:tag:people:entries', 0, '*', 1000) - ->andReturnNull(); - $this->redisProxy - ->shouldReceive('zScan') - ->once() - ->with('prefix:tag:author:entries', null, '*', 1000) - ->andReturnUsing(function ($key, &$cursor) { - $cursor = 0; - - return ['tag:author:entries:name' => 0, 'tag:author:entries:age' => 0]; - }); - $this->redisProxy - ->shouldReceive('zScan') - ->once() - ->with('prefix:tag:author:entries', 0, '*', 1000) - ->andReturnNull(); - - $this->redisProxy->shouldReceive('del')->once()->with( - 'prefix:tag:people:entries:name', - 'prefix:tag:people:entries:age', - 'prefix:tag:author:entries:name', - 'prefix:tag:author:entries:age' - )->andReturn('OK'); - - $this->redisProxy->shouldReceive('del')->once()->with('prefix:tag:people:entries')->andReturn('OK'); - $this->redisProxy->shouldReceive('del')->once()->with('prefix:tag:author:entries')->andReturn('OK'); - - $this->redis->tags(['people', 'author'])->flush(); - } - - public function testTagEntriesCanBeIncremented() - { - $key = sha1('tag:votes:entries') . ':person-1'; - $this->redisProxy->shouldReceive('zadd')->times(4)->with('prefix:tag:votes:entries', 'NX', -1, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('incrby')->once()->with("prefix:{$key}", 1)->andReturn(1); - $this->redisProxy->shouldReceive('incrby')->once()->with("prefix:{$key}", 1)->andReturn(2); - $this->redisProxy->shouldReceive('decrby')->once()->with("prefix:{$key}", 1)->andReturn(1); - $this->redisProxy->shouldReceive('decrby')->once()->with("prefix:{$key}", 1)->andReturn(0); - - $this->assertSame(1, $this->redis->tags(['votes'])->increment('person-1')); - $this->assertSame(2, $this->redis->tags(['votes'])->increment('person-1')); - - $this->assertSame(1, $this->redis->tags(['votes'])->decrement('person-1')); - $this->assertSame(0, $this->redis->tags(['votes'])->decrement('person-1')); - } - - public function testStaleEntriesCanBeFlushed() - { - Carbon::setTestNow('2000-01-01 00:00:00'); - - $pipe = m::mock(RedisProxy::class); - $pipe->shouldReceive('zremrangebyscore')->once()->with('prefix:tag:people:entries', 0, now()->timestamp)->andReturn('OK'); - $this->redisProxy->shouldReceive('pipeline')->once()->withArgs(function ($callback) use ($pipe) { - $callback($pipe); - - return true; - }); - - $this->redis->tags(['people'])->flushStale(); - } - - public function testPut() - { - Carbon::setTestNow('2000-01-01 00:00:00'); - - $key = sha1('tag:people:entries|tag:author:entries') . ':name'; - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:people:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:author:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('setex')->once()->with("prefix:{$key}", 5, serialize('Sally'))->andReturn('OK'); - - $this->redis->tags(['people', 'author'])->put('name', 'Sally', 5); - - $key = sha1('tag:people:entries|tag:author:entries') . ':age'; - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:people:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:author:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('setex')->once()->with("prefix:{$key}", 5, 30)->andReturn('OK'); - - $this->redis->tags(['people', 'author'])->put('age', 30, 5); - } - - public function testPutWithArray() - { - Carbon::setTestNow('2000-01-01 00:00:00'); - - $key = sha1('tag:people:entries|tag:author:entries') . ':name'; - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:people:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:author:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('setex')->once()->with("prefix:{$key}", 5, serialize('Sally'))->andReturn('OK'); - - $key = sha1('tag:people:entries|tag:author:entries') . ':age'; - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:people:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('zadd')->once()->with('prefix:tag:author:entries', now()->timestamp + 5, $key)->andReturn('OK'); - $this->redisProxy->shouldReceive('setex')->once()->with("prefix:{$key}", 5, 30)->andReturn('OK'); - - $this->redis->tags(['people', 'author'])->put([ - 'name' => 'Sally', - 'age' => 30, - ], 5); - } - - private function mockRedis() - { - $this->redis = new RedisStore(m::mock(RedisFactory::class), 'prefix'); - $this->redisProxy = m::mock(RedisProxy::class); - - $this->redis->getRedis()->shouldReceive('get')->with('default')->andReturn($this->redisProxy); - } -} diff --git a/tests/Cache/Redis/AllTagSetTest.php b/tests/Cache/Redis/AllTagSetTest.php new file mode 100644 index 000000000..f47b8396d --- /dev/null +++ b/tests/Cache/Redis/AllTagSetTest.php @@ -0,0 +1,121 @@ +mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users']); + + // resetTag calls store->forget which uses del + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $result = $tagSet->flushTag('users'); + + // Returns the tag identifier + $this->assertSame('_all:tag:users:entries', $result); + } + + /** + * @test + */ + public function testResetTagDeletesTagAndReturnsId(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users']); + + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $result = $tagSet->resetTag('users'); + + $this->assertSame('_all:tag:users:entries', $result); + } + + /** + * @test + */ + public function testTagIdReturnsCorrectFormat(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users']); + + $this->assertSame('_all:tag:users:entries', $tagSet->tagId('users')); + $this->assertSame('_all:tag:posts:entries', $tagSet->tagId('posts')); + } + + /** + * @test + */ + public function testTagKeyReturnsCorrectFormat(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users']); + + // In AllTagSet, tagKey and tagId return the same value + $this->assertSame('_all:tag:users:entries', $tagSet->tagKey('users')); + } + + /** + * @test + */ + public function testTagIdsReturnsArrayOfTagIdentifiers(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users', 'posts', 'comments']); + + $tagIds = $tagSet->tagIds(); + + $this->assertSame([ + '_all:tag:users:entries', + '_all:tag:posts:entries', + '_all:tag:comments:entries', + ], $tagIds); + } + + /** + * @test + */ + public function testGetNamesReturnsOriginalTagNames(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $tagSet = new AllTagSet($store, ['users', 'posts']); + + $this->assertSame(['users', 'posts'], $tagSet->getNames()); + } +} diff --git a/tests/Cache/Redis/AllTaggedCacheTest.php b/tests/Cache/Redis/AllTaggedCacheTest.php new file mode 100644 index 000000000..1c6f90570 --- /dev/null +++ b/tests/Cache/Redis/AllTaggedCacheTest.php @@ -0,0 +1,583 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:people:entries|_all:tag:author:entries') . ':name'; + + // Combined operation: ZADD for both tags + SET (forever uses score -1) + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:people:entries', -1, $key)->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:author:entries', -1, $key)->andReturn($client); + $client->shouldReceive('set')->once()->with("prefix:{$key}", serialize('Sally'))->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['people', 'author'])->forever('name', 'Sally'); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testTagEntriesCanBeStoredForeverWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:people:entries|_all:tag:author:entries') . ':age'; + + // Numeric values are NOT serialized (optimization) + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:people:entries', -1, $key)->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:author:entries', -1, $key)->andReturn($client); + $client->shouldReceive('set')->once()->with("prefix:{$key}", 30)->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['people', 'author'])->forever('age', 30); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testTagEntriesCanBeIncremented(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:votes:entries') . ':person-1'; + + // Combined operation: ZADD NX + INCRBY in single pipeline + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:votes:entries', ['NX'], -1, $key)->andReturn($client); + $client->shouldReceive('incrby')->once()->with("prefix:{$key}", 1)->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 1]); + + $store = $this->createStore($connection); + $result = $store->tags(['votes'])->increment('person-1'); + + $this->assertSame(1, $result); + } + + /** + * @test + */ + public function testTagEntriesCanBeDecremented(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:votes:entries') . ':person-1'; + + // Combined operation: ZADD NX + DECRBY in single pipeline + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:votes:entries', ['NX'], -1, $key)->andReturn($client); + $client->shouldReceive('decrby')->once()->with("prefix:{$key}", 1)->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 9]); + + $store = $this->createStore($connection); + $result = $store->tags(['votes'])->decrement('person-1'); + + $this->assertSame(9, $result); + } + + /** + * @test + */ + public function testStaleEntriesCanBeFlushed(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // FlushStaleEntries uses pipeline for zRemRangeByScore + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:people:entries', '0', (string) now()->timestamp) + ->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([0]); + + $store = $this->createStore($connection); + $store->tags(['people'])->flushStale(); + } + + /** + * @test + */ + public function testPut(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:people:entries|_all:tag:author:entries') . ':name'; + $expectedScore = now()->timestamp + 5; + + // Combined operation: ZADD for both tags + SETEX in single pipeline + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:people:entries', $expectedScore, $key)->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:author:entries', $expectedScore, $key)->andReturn($client); + $client->shouldReceive('setex')->once()->with("prefix:{$key}", 5, serialize('Sally'))->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['people', 'author'])->put('name', 'Sally', 5); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithNumericValue(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:people:entries|_all:tag:author:entries') . ':age'; + $expectedScore = now()->timestamp + 5; + + // Numeric values are NOT serialized + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:people:entries', $expectedScore, $key)->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:author:entries', $expectedScore, $key)->andReturn($client); + $client->shouldReceive('setex')->once()->with("prefix:{$key}", 5, 30)->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['people', 'author'])->put('age', 30, 5); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithArray(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $namespace = sha1('_all:tag:people:entries|_all:tag:author:entries') . ':'; + $expectedScore = now()->timestamp + 5; + + // PutMany uses variadic ZADD: one command per tag with all keys as members + // First tag (people) gets both keys in one ZADD + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:people:entries', $expectedScore, $namespace . 'name', $expectedScore, $namespace . 'age') + ->andReturn($client); + + // Second tag (author) gets both keys in one ZADD + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:author:entries', $expectedScore, $namespace . 'name', $expectedScore, $namespace . 'age') + ->andReturn($client); + + // SETEX for each key + $client->shouldReceive('setex')->once()->with("prefix:{$namespace}name", 5, serialize('Sally'))->andReturn($client); + $client->shouldReceive('setex')->once()->with("prefix:{$namespace}age", 5, 30)->andReturn($client); + + // Results: 2 ZADDs + 2 SETEXs + $client->shouldReceive('exec')->once()->andReturn([2, 2, true, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['people', 'author'])->put([ + 'name' => 'Sally', + 'age' => 30, + ], 5); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlush(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Flush operation scans tag sets and deletes entries + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:people:entries', null, '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['key1' => 0, 'key2' => 0]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:people:entries', 0, '*', 1000) + ->andReturnNull(); + + // Delete cache entries (via pipeline on client) + $client->shouldReceive('del') + ->once() + ->with('prefix:key1', 'prefix:key2') + ->andReturn(2); + + // Delete tag set (on connection, not client) + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:people:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $result = $store->tags(['people'])->flush(); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutNullTtlCallsForever(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:users:entries') . ':name'; + + // Null TTL should call forever (ZADD with -1 + SET) + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', -1, $key)->andReturn($client); + $client->shouldReceive('set')->once()->with("prefix:{$key}", serialize('John'))->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->put('name', 'John', null); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutZeroTtlDeletesKey(): void + { + $connection = $this->mockConnection(); + + $key = sha1('_all:tag:users:entries') . ':name'; + + // Zero TTL should delete the key (Forget operation uses connection) + $connection->shouldReceive('del') + ->once() + ->with("prefix:{$key}") + ->andReturn(1); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->put('name', 'John', 0); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testIncrementWithCustomValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:counters:entries') . ':hits'; + + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:counters:entries', ['NX'], -1, $key)->andReturn($client); + $client->shouldReceive('incrby')->once()->with("prefix:{$key}", 5)->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 15]); + + $store = $this->createStore($connection); + $result = $store->tags(['counters'])->increment('hits', 5); + + $this->assertSame(15, $result); + } + + /** + * @test + */ + public function testDecrementWithCustomValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $key = sha1('_all:tag:counters:entries') . ':stock'; + + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:counters:entries', ['NX'], -1, $key)->andReturn($client); + $client->shouldReceive('decrby')->once()->with("prefix:{$key}", 3)->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([0, 7]); + + $store = $this->createStore($connection); + $result = $store->tags(['counters'])->decrement('stock', 3); + + $this->assertSame(7, $result); + } + + /** + * @test + */ + public function testRememberReturnsExistingValueOnCacheHit(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:users:entries') . ':profile'; + + // Remember operation uses client->get() directly + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize('cached_value')); + + $store = $this->createStore($connection); + $result = $store->tags(['users'])->remember('profile', 60, fn () => 'new_value'); + + $this->assertSame('cached_value', $result); + } + + /** + * @test + */ + public function testRememberCallsCallbackAndStoresValueOnMiss(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:users:entries') . ':profile'; + $expectedScore = now()->timestamp + 60; + + // Cache miss + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturnNull(); + + // Pipeline for ZADD + SETEX on miss + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', $expectedScore, $key)->andReturn($client); + $client->shouldReceive('setex')->once()->with("prefix:{$key}", 60, serialize('computed_value'))->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, true]); + + $callCount = 0; + $store = $this->createStore($connection); + $result = $store->tags(['users'])->remember('profile', 60, function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }); + + $this->assertSame('computed_value', $result); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberDoesNotCallCallbackOnCacheHit(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:users:entries') . ':data'; + + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize('existing_value')); + + $callCount = 0; + $store = $this->createStore($connection); + $result = $store->tags(['users'])->remember('data', 60, function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }); + + $this->assertSame('existing_value', $result); + $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); + } + + /** + * @test + */ + public function testRememberForeverReturnsExistingValueOnCacheHit(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:config:entries') . ':settings'; + + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturn(serialize('cached_settings')); + + $store = $this->createStore($connection); + $result = $store->tags(['config'])->rememberForever('settings', fn () => 'new_settings'); + + $this->assertSame('cached_settings', $result); + } + + /** + * @test + */ + public function testRememberForeverCallsCallbackAndStoresValueOnMiss(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:config:entries') . ':settings'; + + // Cache miss + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturnNull(); + + // Pipeline for ZADD (score -1) + SET on miss + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:config:entries', -1, $key)->andReturn($client); + $client->shouldReceive('set')->once()->with("prefix:{$key}", serialize('computed_settings'))->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['config'])->rememberForever('settings', fn () => 'computed_settings'); + + $this->assertSame('computed_settings', $result); + } + + /** + * @test + */ + public function testRememberPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:users:entries') . ':data'; + + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $store = $this->createStore($connection); + $store->tags(['users'])->remember('data', 60, function () { + throw new RuntimeException('Callback failed'); + }); + } + + /** + * @test + */ + public function testRememberForeverPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:config:entries') . ':data'; + + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Forever callback failed'); + + $store = $this->createStore($connection); + $store->tags(['config'])->rememberForever('data', function () { + throw new RuntimeException('Forever callback failed'); + }); + } + + /** + * @test + */ + public function testRememberWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $key = sha1('_all:tag:users:entries|_all:tag:posts:entries') . ':activity'; + $expectedScore = now()->timestamp + 120; + + // Cache miss + $client->shouldReceive('get') + ->once() + ->with("prefix:{$key}") + ->andReturnNull(); + + // Pipeline for ZADDs + SETEX on miss + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:users:entries', $expectedScore, $key)->andReturn($client); + $client->shouldReceive('zadd')->once()->with('prefix:_all:tag:posts:entries', $expectedScore, $key)->andReturn($client); + $client->shouldReceive('setex')->once()->with("prefix:{$key}", 120, serialize('activity_data'))->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->tags(['users', 'posts'])->remember('activity', 120, fn () => 'activity_data'); + + $this->assertSame('activity_data', $result); + } +} diff --git a/tests/Cache/Redis/AnyTagSetTest.php b/tests/Cache/Redis/AnyTagSetTest.php new file mode 100644 index 000000000..13122276e --- /dev/null +++ b/tests/Cache/Redis/AnyTagSetTest.php @@ -0,0 +1,358 @@ +setupStore(); + } + + /** + * @test + */ + public function testGetNamesReturnsTagNames(): void + { + $tagSet = new AnyTagSet($this->store, ['users', 'posts']); + + $this->assertSame(['users', 'posts'], $tagSet->getNames()); + } + + /** + * @test + */ + public function testGetNamesReturnsEmptyArrayWhenNoTags(): void + { + $tagSet = new AnyTagSet($this->store, []); + + $this->assertSame([], $tagSet->getNames()); + } + + /** + * @test + */ + public function testTagIdReturnsTagNameDirectly(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + // Unlike AllTagSet, any mode uses tag name directly (no UUID) + $this->assertSame('users', $tagSet->tagId('users')); + $this->assertSame('posts', $tagSet->tagId('posts')); + } + + /** + * @test + */ + public function testTagIdsReturnsAllTagNames(): void + { + $tagSet = new AnyTagSet($this->store, ['users', 'posts', 'comments']); + + $this->assertSame(['users', 'posts', 'comments'], $tagSet->tagIds()); + } + + /** + * @test + */ + public function testTagHashKeyReturnsCorrectFormat(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + $result = $tagSet->tagHashKey('users'); + + $this->assertSame('prefix:_any:tag:users:entries', $result); + } + + /** + * @test + */ + public function testEntriesReturnsGeneratorOfKeys(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + // GetTaggedKeys checks HLEN then uses HKEYS for small hashes + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(3); + + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['key1', 'key2', 'key3']); + + $entries = $tagSet->entries(); + + $this->assertInstanceOf(Generator::class, $entries); + $this->assertSame(['key1', 'key2', 'key3'], iterator_to_array($entries)); + } + + /** + * @test + */ + public function testEntriesDeduplicatesAcrossTags(): void + { + $tagSet = new AnyTagSet($this->store, ['users', 'posts']); + + // First tag 'users' has keys key1, key2 + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(2); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['key1', 'key2']); + + // Second tag 'posts' has keys key2, key3 (key2 is duplicate) + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:posts:entries') + ->andReturn(2); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:posts:entries') + ->andReturn(['key2', 'key3']); + + $entries = $tagSet->entries(); + + // Should deduplicate 'key2' + $result = iterator_to_array($entries); + $this->assertCount(3, $result); + $this->assertSame(['key1', 'key2', 'key3'], array_values($result)); + } + + /** + * @test + */ + public function testEntriesWithEmptyTagReturnsEmpty(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(0); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn([]); + + $entries = $tagSet->entries(); + + $this->assertSame([], iterator_to_array($entries)); + } + + /** + * @test + */ + public function testEntriesWithNoTagsReturnsEmpty(): void + { + $tagSet = new AnyTagSet($this->store, []); + + $entries = $tagSet->entries(); + + $this->assertSame([], iterator_to_array($entries)); + } + + /** + * @test + */ + public function testFlushDeletesKeysAndTagHashes(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + // GetTaggedKeys for the flush operation + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(2); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['key1', 'key2']); + + // Pipeline for deleting cache keys, reverse indexes, tag hashes, registry entries + $this->client->shouldReceive('pipeline')->andReturn($this->pipeline); + $this->pipeline->shouldReceive('del')->andReturnSelf(); + $this->pipeline->shouldReceive('unlink')->andReturnSelf(); + $this->pipeline->shouldReceive('zrem')->andReturnSelf(); + $this->pipeline->shouldReceive('exec')->andReturn([]); + + $tagSet->flush(); + + // If we get here without exception, the flush executed through the full chain + $this->assertTrue(true); + } + + /** + * @test + */ + public function testFlushTagDeletesSingleTag(): void + { + $tagSet = new AnyTagSet($this->store, ['users', 'posts']); + + // GetTaggedKeys for the flush operation (only 'users' tag) + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(1); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['key1']); + + // Pipeline for flush operations + $this->client->shouldReceive('pipeline')->andReturn($this->pipeline); + $this->pipeline->shouldReceive('del')->andReturnSelf(); + $this->pipeline->shouldReceive('unlink')->andReturnSelf(); + $this->pipeline->shouldReceive('zrem')->andReturnSelf(); + $this->pipeline->shouldReceive('exec')->andReturn([]); + + $result = $tagSet->flushTag('users'); + + $this->assertSame('prefix:_any:tag:users:entries', $result); + } + + /** + * @test + */ + public function testGetNamespaceReturnsEmptyString(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + // Union mode doesn't namespace keys by tags + $this->assertSame('', $tagSet->getNamespace()); + } + + /** + * @test + */ + public function testResetTagFlushesTagAndReturnsName(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + // GetTaggedKeys for the flush operation + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(1); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['key1']); + + // Pipeline for flush operations + $this->client->shouldReceive('pipeline')->andReturn($this->pipeline); + $this->pipeline->shouldReceive('del')->andReturnSelf(); + $this->pipeline->shouldReceive('unlink')->andReturnSelf(); + $this->pipeline->shouldReceive('zrem')->andReturnSelf(); + $this->pipeline->shouldReceive('exec')->andReturn([]); + + $result = $tagSet->resetTag('users'); + + // Returns the tag name (not a UUID like AllTagSet) + $this->assertSame('users', $result); + } + + /** + * @test + */ + public function testTagKeyReturnsSameAsTagHashKey(): void + { + $tagSet = new AnyTagSet($this->store, ['users']); + + $result = $tagSet->tagKey('users'); + + $this->assertSame('prefix:_any:tag:users:entries', $result); + } + + /** + * @test + */ + public function testResetCallsFlush(): void + { + $tagSet = new AnyTagSet($this->store, ['users', 'posts']); + + // GetTaggedKeys for both tags + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(1); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['key1']); + + $this->client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:posts:entries') + ->andReturn(1); + $this->client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:posts:entries') + ->andReturn(['key2']); + + // Pipeline for flush operations + $this->client->shouldReceive('pipeline')->andReturn($this->pipeline); + $this->pipeline->shouldReceive('del')->andReturnSelf(); + $this->pipeline->shouldReceive('unlink')->andReturnSelf(); + $this->pipeline->shouldReceive('zrem')->andReturnSelf(); + $this->pipeline->shouldReceive('exec')->andReturn([]); + + $tagSet->reset(); + + // If we get here without exception, reset executed flush correctly + $this->assertTrue(true); + } + + /** + * Set up the store with mocked Redis connection. + */ + private function setupStore(): void + { + $connection = $this->mockConnection(); + $this->client = $connection->_mockClient; + + // Mock pipeline + $this->pipeline = m::mock(); + + // Add pipeline support to client + $this->client->shouldReceive('pipeline')->andReturn($this->pipeline)->byDefault(); + + $this->store = $this->createStore($connection); + $this->store->setTagMode('any'); + } +} diff --git a/tests/Cache/Redis/AnyTaggedCacheTest.php b/tests/Cache/Redis/AnyTaggedCacheTest.php new file mode 100644 index 000000000..ad19845ae --- /dev/null +++ b/tests/Cache/Redis/AnyTaggedCacheTest.php @@ -0,0 +1,724 @@ +mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->assertInstanceOf(TaggedCache::class, $cache); + $this->assertInstanceOf(AnyTaggedCache::class, $cache); + } + + /** + * @test + */ + public function testGetThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot get items via tags in any mode'); + + $cache->get('key'); + } + + /** + * @test + */ + public function testManyThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot get items via tags in any mode'); + + $cache->many(['key1', 'key2']); + } + + /** + * @test + */ + public function testHasThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot check existence via tags in any mode'); + + $cache->has('key'); + } + + /** + * @test + */ + public function testPullThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot pull items via tags in any mode'); + + $cache->pull('key'); + } + + /** + * @test + */ + public function testForgetThrowsBadMethodCallException(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot forget items via tags in any mode'); + + $cache->forget('key'); + } + + /** + * @test + */ + public function testPutStoresValueWithTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Union mode uses Lua script for atomic put with tags + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users', 'posts'])->put('mykey', 'myvalue', 60); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithNullTtlCallsForever(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Forever operation uses Lua script + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users', 'posts'])->put('mykey', 'myvalue', null); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithZeroTtlReturnsFalse(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $result = $cache->put('mykey', 'myvalue', 0); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutWithArrayCallsPutMany(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // PutMany uses pipeline with Lua operations + $client->shouldReceive('pipeline')->andReturn($client); + $client->shouldReceive('smembers')->andReturn($client); + $client->shouldReceive('exec')->andReturn([[], []]); + $client->shouldReceive('setex')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('sadd')->andReturn($client); + $client->shouldReceive('expire')->andReturn($client); + $client->shouldReceive('hSet')->andReturn($client); + $client->shouldReceive('hexpire')->andReturn($client); + $client->shouldReceive('zadd')->andReturn($client); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->put(['key1' => 'value1', 'key2' => 'value2'], 60); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyStoresMultipleValues(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // PutMany uses pipeline + $client->shouldReceive('pipeline')->andReturn($client); + $client->shouldReceive('smembers')->andReturn($client); + $client->shouldReceive('exec')->andReturn([[], []]); + $client->shouldReceive('setex')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('sadd')->andReturn($client); + $client->shouldReceive('expire')->andReturn($client); + $client->shouldReceive('hSet')->andReturn($client); + $client->shouldReceive('hexpire')->andReturn($client); + $client->shouldReceive('zadd')->andReturn($client); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->putMany(['key1' => 'value1', 'key2' => 'value2'], 120); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyWithNullTtlCallsForeverForEach(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Forever for each key - called twice for 2 keys + $client->shouldReceive('evalSha') + ->twice() + ->andReturn(false); + $client->shouldReceive('eval') + ->twice() + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->putMany(['key1' => 'value1', 'key2' => 'value2'], null); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyWithZeroTtlReturnsFalse(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users']); + + $result = $cache->putMany(['key1' => 'value1'], 0); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testAddStoresValueIfNotExists(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Add uses Lua script with SET NX + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->add('mykey', 'myvalue', 60); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddWithNullTtlDefaultsToOneYear(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Add with null TTL defaults to 1 year (31536000 seconds) + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // Check that TTL argument is ~1 year + $this->assertSame(31536000, $args[3]); + + return true; + }) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->add('mykey', 'myvalue', null); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddWithZeroTtlReturnsFalse(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users']); + + $result = $cache->add('mykey', 'myvalue', 0); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testForeverStoresValueIndefinitely(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Forever uses Lua script without expiration + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->forever('mykey', 'myvalue'); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testIncrementReturnsNewValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Increment uses Lua script with INCRBY + $client->shouldReceive('evalSha') + ->once() + ->andReturn(5); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->increment('counter'); + + $this->assertSame(5, $result); + } + + /** + * @test + */ + public function testIncrementWithCustomValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(15); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->increment('counter', 10); + + $this->assertSame(15, $result); + } + + /** + * @test + */ + public function testDecrementReturnsNewValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Decrement uses Lua script with DECRBY + $client->shouldReceive('evalSha') + ->once() + ->andReturn(3); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->decrement('counter'); + + $this->assertSame(3, $result); + } + + /** + * @test + */ + public function testDecrementWithCustomValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(0); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->decrement('counter', 5); + + $this->assertSame(0, $result); + } + + /** + * @test + */ + public function testFlushDeletesAllTaggedItems(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // GetTaggedKeys uses hlen to check size + // When small (< threshold), it uses hkeys directly instead of scan + $client->shouldReceive('hlen') + ->andReturn(2); + $client->shouldReceive('hkeys') + ->once() + ->andReturn(['key1', 'key2']); + + // After getting keys, Flush uses pipeline for delete operations + $client->shouldReceive('pipeline')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('unlink')->andReturn($client); + $client->shouldReceive('zrem')->andReturn($client); + $client->shouldReceive('exec')->andReturn([2, 1]); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->flush(); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testRememberRetrievesExistingValueFromStore(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // The Remember operation calls $client->get() directly + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize('cached_value')); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->remember('mykey', 60, fn () => 'new_value'); + + $this->assertSame('cached_value', $result); + } + + /** + * @test + */ + public function testRememberCallsCallbackAndStoresValueWhenMiss(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Client returns null (miss) - Remember operation uses client->get() directly + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturnNull(); + + // Should store the value with tags via Lua script + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + $callCount = 0; + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->remember('mykey', 60, function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }); + + $this->assertSame('computed_value', $result); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberForeverRetrievesExistingValueFromStore(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // RememberForever operation uses $client->get() directly + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize('cached_value')); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->rememberForever('mykey', fn () => 'new_value'); + + $this->assertSame('cached_value', $result); + } + + /** + * @test + */ + public function testRememberForeverCallsCallbackAndStoresValueWhenMiss(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // RememberForever operation uses $client->get() directly - returns null (miss) + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturnNull(); + + // Should store the value forever with tags using Lua script + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->rememberForever('mykey', fn () => 'computed_value'); + + $this->assertSame('computed_value', $result); + } + + /** + * @test + */ + public function testGetTagsReturnsTagSet(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $cache = $store->setTagMode('any')->tags(['users', 'posts']); + + $this->assertInstanceOf(AnyTagSet::class, $cache->getTags()); + } + + /** + * @test + */ + public function testItemKeyReturnsKeyUnchanged(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // In any mode, keys are NOT namespaced by tags + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') // Should NOT have tag namespace prefix + ->andReturn(serialize('value')); + + $store = $this->createStore($connection); + $store->setTagMode('any')->tags(['users'])->remember('mykey', 60, fn () => 'fallback'); + } + + /** + * @test + */ + public function testIncrementReturnsFalseOnFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(false); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->increment('counter'); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testDecrementReturnsFalseOnFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(false); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->decrement('counter'); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testRememberPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Client returns null (cache miss) - callback will be executed + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $store = $this->createStore($connection); + $store->setTagMode('any')->tags(['users'])->remember('mykey', 60, function () { + throw new RuntimeException('Callback failed'); + }); + } + + /** + * @test + */ + public function testRememberForeverPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Client returns null (cache miss) - callback will be executed + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Forever callback failed'); + + $store = $this->createStore($connection); + $store->setTagMode('any')->tags(['users'])->rememberForever('mykey', function () { + throw new RuntimeException('Forever callback failed'); + }); + } + + /** + * @test + */ + public function testRememberDoesNotCallCallbackWhenValueExists(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Client returns existing value (cache hit) + $client->shouldReceive('get') + ->once() + ->with('prefix:mykey') + ->andReturn(serialize('cached_value')); + + $callCount = 0; + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->remember('mykey', 60, function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }); + + $this->assertSame('cached_value', $result); + $this->assertSame(0, $callCount, 'Callback should not be called when cache hit'); + } + + /** + * @test + */ + public function testItemsReturnsGenerator(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // GetTaggedKeys uses hlen to check size first + $client->shouldReceive('hlen') + ->andReturn(2); + + // When small (< threshold), it uses hkeys directly + $client->shouldReceive('hkeys') + ->once() + ->andReturn(['key1', 'key2']); + + // Get values for found keys (mget receives array) + $client->shouldReceive('mget') + ->once() + ->with(['prefix:key1', 'prefix:key2']) + ->andReturn([serialize('value1'), serialize('value2')]); + + $store = $this->createStore($connection); + $result = $store->setTagMode('any')->tags(['users'])->items(); + + $this->assertInstanceOf(Generator::class, $result); + + // Iterate the generator to verify it works and trigger the Redis calls + $items = iterator_to_array($result); + $this->assertCount(2, $items); + } +} diff --git a/tests/Cache/Redis/Concerns/MocksRedisConnections.php b/tests/Cache/Redis/Concerns/MocksRedisConnections.php new file mode 100644 index 000000000..23683d789 --- /dev/null +++ b/tests/Cache/Redis/Concerns/MocksRedisConnections.php @@ -0,0 +1,205 @@ +mockConnection(); + * $client = $connection->_mockClient; + * $client->shouldReceive('set')->once()->andReturn(true); + * + * $store = $this->createStore($connection); + * // or with tag mode: + * $store = $this->createStore($connection, tagMode: 'any'); + * ``` + * + * ### Cluster mode tests: + * ```php + * [$store, $clusterClient] = $this->createClusterStore(); + * $clusterClient->shouldNotReceive('pipeline'); + * $clusterClient->shouldReceive('set')->once()->andReturn(true); + * ``` + */ +trait MocksRedisConnections +{ + /** + * Create a mock RedisConnection with standard expectations. + * + * By default creates a mock with a standard Redis client (not cluster). + * Use createClusterStore() for cluster mode tests. + * + * We use an anonymous mock for the client (not m::mock(Redis::class)) + * because mocking the native phpredis extension class can cause + * unexpected fallthrough to real Redis connections when expectations + * don't match. + * + * @return m\MockInterface|RedisConnection Connection with _mockClient property for setting expectations + */ + protected function mockConnection(): m\MockInterface|RedisConnection + { + // Anonymous mock - not bound to Redis extension class + // This prevents fallthrough to real Redis when expectations don't match + $client = m::mock(); + $client->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_NONE) + ->byDefault(); + $client->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn('') + ->byDefault(); + + // Default pipeline() returns self for chaining (can be overridden in tests) + $client->shouldReceive('pipeline')->andReturn($client)->byDefault(); + $client->shouldReceive('exec')->andReturn([])->byDefault(); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('serialized')->andReturn(false)->byDefault(); + $connection->shouldReceive('client')->andReturn($client)->byDefault(); + + // Store client reference for tests that need to set expectations on it + $connection->_mockClient = $client; + + return $connection; + } + + /** + * Create a mock RedisConnection configured as a cluster connection. + * + * The client mock is configured to pass instanceof RedisCluster checks + * which triggers cluster mode (sequential commands instead of pipelines). + * + * @return m\MockInterface|RedisConnection Connection with _mockClient property for setting expectations + */ + protected function mockClusterConnection(): m\MockInterface|RedisConnection + { + // Mock that identifies as RedisCluster for instanceof checks + $client = m::mock(RedisCluster::class); + $client->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_NONE) + ->byDefault(); + $client->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn('') + ->byDefault(); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('serialized')->andReturn(false)->byDefault(); + $connection->shouldReceive('client')->andReturn($client)->byDefault(); + + // Store client reference for tests that need to set expectations on it + $connection->_mockClient = $client; + + return $connection; + } + + /** + * Create a PoolFactory mock that returns the given connection. + */ + protected function createPoolFactory( + m\MockInterface|RedisConnection $connection, + string $connectionName = 'default' + ): m\MockInterface|PoolFactory { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + + $poolFactory->shouldReceive('getPool') + ->with($connectionName) + ->andReturn($pool); + + $pool->shouldReceive('get')->andReturn($connection); + + return $poolFactory; + } + + /** + * Create a RedisStore with a mocked connection. + * + * @param m\MockInterface|RedisConnection $connection The mocked connection (from mockConnection()) + * @param string $prefix Cache key prefix + * @param string $connectionName Redis connection name + * @param null|string $tagMode Optional tag mode ('any' or 'all'). If provided, setTagMode() is called. + */ + protected function createStore( + m\MockInterface|RedisConnection $connection, + string $prefix = 'prefix:', + string $connectionName = 'default', + ?string $tagMode = null, + ): RedisStore { + $store = new RedisStore( + m::mock(RedisFactory::class), + $prefix, + $connectionName, + $this->createPoolFactory($connection, $connectionName) + ); + + if ($tagMode !== null) { + $store->setTagMode($tagMode); + } + + return $store; + } + + /** + * Create a RedisStore configured for cluster mode testing. + * + * This eliminates the boilerplate of manually setting up RedisCluster mocks, + * connection mocks, pool mocks, and pool factory mocks for each cluster test. + * + * Returns the store, cluster client mock, and connection mock so tests can set expectations: + * ```php + * [$store, $clusterClient, $connection] = $this->createClusterStore(); + * $clusterClient->shouldNotReceive('pipeline'); + * $clusterClient->shouldReceive('zadd')->once()->andReturn(1); + * $connection->shouldReceive('del')->once()->andReturn(1); // connection-level operations + * ``` + * + * @param string $prefix Cache key prefix + * @param string $connectionName Redis connection name + * @param null|string $tagMode Optional tag mode ('any' or 'all') + * @return array{0: RedisStore, 1: m\MockInterface, 2: m\MockInterface} [store, clusterClient, connection] + */ + protected function createClusterStore( + string $prefix = 'prefix:', + string $connectionName = 'default', + ?string $tagMode = null, + ): array { + $connection = $this->mockClusterConnection(); + $clusterClient = $connection->_mockClient; + + $store = new RedisStore( + m::mock(RedisFactory::class), + $prefix, + $connectionName, + $this->createPoolFactory($connection, $connectionName) + ); + + if ($tagMode !== null) { + $store->setTagMode($tagMode); + } + + return [$store, $clusterClient, $connection]; + } +} diff --git a/tests/Cache/Redis/Console/DoctorCommandTest.php b/tests/Cache/Redis/Console/DoctorCommandTest.php new file mode 100644 index 000000000..8e88fedc7 --- /dev/null +++ b/tests/Cache/Redis/Console/DoctorCommandTest.php @@ -0,0 +1,283 @@ +shouldReceive('getStore') + ->andReturn($nonRedisStore); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('file') + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new DoctorCommand(); + $result = $command->run(new ArrayInput(['--store' => 'file']), new NullOutput()); + + $this->assertSame(1, $result); + } + + public function testDoctorDetectsRedisStoreFromConfig(): void + { + // Set up config with a redis store + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('cache.stores', []) + ->andReturn([ + 'file' => ['driver' => 'file'], + 'redis' => ['driver' => 'redis', 'connection' => 'default'], + ]); + $config->shouldReceive('get') + ->with('cache.default', 'file') + ->andReturn('file'); + $config->shouldReceive('get') + ->with('cache.stores.redis.connection', 'default') + ->andReturn('default'); + + $this->app->set(ConfigInterface::class, $config); + + // Mock Redis store + $context = m::mock(StoreContext::class); + $context->shouldReceive('withConnection') + ->andReturnUsing(function ($callback) { + $conn = m::mock(RedisConnection::class); + $conn->shouldReceive('info')->with('server')->andReturn(['redis_version' => '7.0.0']); + return $callback($conn); + }); + + $store = m::mock(RedisStore::class); + $store->shouldReceive('getTagMode')->andReturn(TagMode::Any); + $store->shouldReceive('getContext')->andReturn($context); + $store->shouldReceive('getPrefix')->andReturn('cache:'); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore')->andReturn($store); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('redis') + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + // The command will fail at environment checks (Redis version check for 'any' mode) + // but this tests that store detection works + $command = new DoctorCommand(); + $output = new BufferedOutput(); + $command->run(new ArrayInput([]), $output); + + // Verify it detected the redis store (case-insensitive check) + $outputText = $output->fetch(); + $this->assertStringContainsString('Redis', $outputText); + $this->assertStringContainsString('Tag Mode: any', $outputText); + } + + public function testDoctorUsesSpecifiedStore(): void + { + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('cache.default', 'file') + ->andReturn('file'); + $config->shouldReceive('get') + ->with('cache.stores.custom-redis.connection', 'default') + ->andReturn('custom'); + + $this->app->set(ConfigInterface::class, $config); + + // Mock Redis store + $context = m::mock(StoreContext::class); + $context->shouldReceive('withConnection') + ->andReturnUsing(function ($callback) { + $conn = m::mock(RedisConnection::class); + $conn->shouldReceive('info')->with('server')->andReturn(['redis_version' => '7.0.0']); + return $callback($conn); + }); + + $store = m::mock(RedisStore::class); + $store->shouldReceive('getTagMode')->andReturn(TagMode::All); + $store->shouldReceive('getContext')->andReturn($context); + $store->shouldReceive('getPrefix')->andReturn('cache:'); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore')->andReturn($store); + + $cacheManager = m::mock(CacheManager::class); + // Should use the specified store name (called multiple times during command) + $cacheManager->shouldReceive('store') + ->with('custom-redis') + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new DoctorCommand(); + $output = new BufferedOutput(); + $command->run(new ArrayInput(['--store' => 'custom-redis']), $output); + + // Verify the custom store was used + $outputText = $output->fetch(); + $this->assertStringContainsString('custom-redis', $outputText); + } + + public function testDoctorDisplaysTagMode(): void + { + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('cache.default', 'file') + ->andReturn('redis'); + $config->shouldReceive('get') + ->with('cache.stores.redis.connection', 'default') + ->andReturn('default'); + + $this->app->set(ConfigInterface::class, $config); + + // Mock Redis store with 'all' mode + $context = m::mock(StoreContext::class); + $context->shouldReceive('withConnection') + ->andReturnUsing(function ($callback) { + $conn = m::mock(RedisConnection::class); + $conn->shouldReceive('info')->with('server')->andReturn(['redis_version' => '7.0.0']); + return $callback($conn); + }); + + $store = m::mock(RedisStore::class); + $store->shouldReceive('getTagMode')->andReturn(TagMode::All); + $store->shouldReceive('getContext')->andReturn($context); + $store->shouldReceive('getPrefix')->andReturn('cache:'); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore')->andReturn($store); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('redis') + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new DoctorCommand(); + $output = new BufferedOutput(); + $command->run(new ArrayInput(['--store' => 'redis']), $output); + + // Verify tag mode is displayed + $outputText = $output->fetch(); + $this->assertStringContainsString('all', $outputText); + } + + public function testDoctorFailsWhenNoRedisStoreDetected(): void + { + // Set up config with NO redis stores + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('cache.stores', []) + ->andReturn([ + 'file' => ['driver' => 'file'], + 'array' => ['driver' => 'array'], + ]); + $config->shouldReceive('get') + ->with('cache.default', 'file') + ->andReturn('file'); + + $this->app->set(ConfigInterface::class, $config); + + $command = new DoctorCommand(); + $output = new BufferedOutput(); + $result = $command->run(new ArrayInput([]), $output); + + $this->assertSame(1, $result); + $outputText = $output->fetch(); + $this->assertStringContainsString('Could not detect', $outputText); + } + + public function testDoctorDisplaysSystemInformation(): void + { + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get') + ->with('cache.stores', []) + ->andReturn([ + 'redis' => ['driver' => 'redis', 'connection' => 'default'], + ]); + $config->shouldReceive('get') + ->with('cache.default', 'file') + ->andReturn('redis'); + $config->shouldReceive('get') + ->with('cache.stores.redis.connection', 'default') + ->andReturn('default'); + + $this->app->set(ConfigInterface::class, $config); + + $context = m::mock(StoreContext::class); + $context->shouldReceive('withConnection') + ->andReturnUsing(function ($callback) { + $conn = m::mock(RedisConnection::class); + $conn->shouldReceive('info')->with('server')->andReturn(['redis_version' => '7.2.4']); + return $callback($conn); + }); + + $store = m::mock(RedisStore::class); + $store->shouldReceive('getTagMode')->andReturn(TagMode::Any); + $store->shouldReceive('getContext')->andReturn($context); + $store->shouldReceive('getPrefix')->andReturn('cache:'); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore')->andReturn($store); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('redis') + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new DoctorCommand(); + $output = new BufferedOutput(); + $command->run(new ArrayInput([]), $output); + + $outputText = $output->fetch(); + + // Verify system information is displayed + $this->assertStringContainsString('System Information', $outputText); + $this->assertStringContainsString('PHP Version', $outputText); + $this->assertStringContainsString('Hypervel', $outputText); + } +} diff --git a/tests/Cache/Redis/Console/PruneStaleTagsCommandTest.php b/tests/Cache/Redis/Console/PruneStaleTagsCommandTest.php new file mode 100644 index 000000000..09eb479c6 --- /dev/null +++ b/tests/Cache/Redis/Console/PruneStaleTagsCommandTest.php @@ -0,0 +1,191 @@ +shouldReceive('execute') + ->once() + ->andReturn([ + 'tags_scanned' => 10, + 'entries_removed' => 5, + 'empty_sets_deleted' => 2, + ]); + + $intersectionOps = m::mock(AllTagOperations::class); + $intersectionOps->shouldReceive('prune') + ->once() + ->andReturn($intersectionPrune); + + $store = m::mock(RedisStore::class); + $store->shouldReceive('getTagMode') + ->once() + ->andReturn(TagMode::All); + $store->shouldReceive('allTagOps') + ->once() + ->andReturn($intersectionOps); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore') + ->once() + ->andReturn($store); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('redis') + ->once() + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new PruneStaleTagsCommand(); + $command->run(new ArrayInput([]), new NullOutput()); + + // Mockery will verify expectations in tearDown + } + + public function testPruneAnyModeCallsCorrectOperation(): void + { + $unionPrune = m::mock(UnionPrune::class); + $unionPrune->shouldReceive('execute') + ->once() + ->andReturn([ + 'hashes_scanned' => 8, + 'fields_checked' => 100, + 'orphans_removed' => 15, + 'empty_hashes_deleted' => 3, + 'expired_tags_removed' => 2, + ]); + + $unionOps = m::mock(AnyTagOperations::class); + $unionOps->shouldReceive('prune') + ->once() + ->andReturn($unionPrune); + + $store = m::mock(RedisStore::class); + $store->shouldReceive('getTagMode') + ->once() + ->andReturn(TagMode::Any); + $store->shouldReceive('anyTagOps') + ->once() + ->andReturn($unionOps); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore') + ->once() + ->andReturn($store); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('redis') + ->once() + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new PruneStaleTagsCommand(); + $command->run(new ArrayInput([]), new NullOutput()); + + // Mockery will verify expectations in tearDown + } + + public function testPruneUsesSpecifiedStore(): void + { + $intersectionPrune = m::mock(IntersectionPrune::class); + $intersectionPrune->shouldReceive('execute') + ->once() + ->andReturn([ + 'tags_scanned' => 0, + 'entries_removed' => 0, + 'empty_sets_deleted' => 0, + ]); + + $intersectionOps = m::mock(AllTagOperations::class); + $intersectionOps->shouldReceive('prune') + ->once() + ->andReturn($intersectionPrune); + + $store = m::mock(RedisStore::class); + $store->shouldReceive('getTagMode') + ->once() + ->andReturn(TagMode::All); + $store->shouldReceive('allTagOps') + ->once() + ->andReturn($intersectionOps); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore') + ->once() + ->andReturn($store); + + $cacheManager = m::mock(CacheManager::class); + // Should use the specified store name + $cacheManager->shouldReceive('store') + ->with('custom-redis') + ->once() + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new PruneStaleTagsCommand(); + $command->run(new ArrayInput(['store' => 'custom-redis']), new NullOutput()); + + // Mockery will verify expectations in tearDown + } + + public function testPruneFailsForNonRedisStore(): void + { + $nonRedisStore = m::mock(Store::class); + + $repository = m::mock(Repository::class); + $repository->shouldReceive('getStore') + ->once() + ->andReturn($nonRedisStore); + + $cacheManager = m::mock(CacheManager::class); + $cacheManager->shouldReceive('store') + ->with('file') + ->once() + ->andReturn($repository); + + $this->app->set(CacheContract::class, $cacheManager); + + $command = new PruneStaleTagsCommand(); + $result = $command->run(new ArrayInput(['store' => 'file']), new NullOutput()); + + // Should return failure code for non-Redis store + $this->assertSame(1, $result); + } +} diff --git a/tests/Cache/Redis/ExceptionPropagationTest.php b/tests/Cache/Redis/ExceptionPropagationTest.php new file mode 100644 index 000000000..e55c1582e --- /dev/null +++ b/tests/Cache/Redis/ExceptionPropagationTest.php @@ -0,0 +1,228 @@ +mockConnection(); + $connection->shouldReceive('setex') + ->andThrow(new RedisException('Connection refused')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('Connection refused'); + + $store->put('key', 'value', 60); + } + + public function testGetThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get') + ->andThrow(new RedisException('Connection timed out')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('Connection timed out'); + + $store->get('key'); + } + + public function testForgetThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('del') + ->andThrow(new RedisException('READONLY You can\'t write against a read only replica')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('READONLY'); + + $store->forget('key'); + } + + public function testIncrementThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('incrby') + ->andThrow(new RedisException('OOM command not allowed when used memory > maxmemory')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('OOM'); + + $store->increment('counter', 1); + } + + public function testDecrementThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('decrby') + ->andThrow(new RedisException('NOAUTH Authentication required')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('NOAUTH'); + + $store->decrement('counter', 1); + } + + public function testForeverThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('set') + ->andThrow(new RedisException('ERR invalid DB index')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('ERR invalid DB index'); + + $store->forever('key', 'value'); + } + + // ========================================================================= + // TAGGED OPERATIONS (ANY MODE) + // ========================================================================= + + public function testTaggedPutThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + + // Tagged put uses evalSha for Lua script + $connection->_mockClient->shouldReceive('evalSha') + ->andThrow(new RedisException('Connection lost')); + + $store = $this->createStore($connection, tagMode: 'any'); + $taggedCache = new AnyTaggedCache($store, new AnyTagSet($store, ['test-tag'])); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('Connection lost'); + + $taggedCache->put('key', 'value', 60); + } + + public function testTaggedIncrementThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + + // Tagged increment uses evalSha for Lua script + $connection->_mockClient->shouldReceive('evalSha') + ->andThrow(new RedisException('Connection reset by peer')); + + $store = $this->createStore($connection, tagMode: 'any'); + $taggedCache = new AnyTaggedCache($store, new AnyTagSet($store, ['test-tag'])); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('Connection reset by peer'); + + $taggedCache->increment('counter', 1); + } + + public function testTaggedFlushThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + + // Flush calls hlen on the raw client to check hash size + $connection->_mockClient->shouldReceive('hlen') + ->andThrow(new RedisException('ERR unknown command')); + + $store = $this->createStore($connection, tagMode: 'any'); + $taggedCache = new AnyTaggedCache($store, new AnyTagSet($store, ['test-tag'])); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('ERR unknown command'); + + $taggedCache->flush(); + } + + // ========================================================================= + // BULK OPERATIONS + // ========================================================================= + + public function testPutManyThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + + // PutMany uses evalSha for Lua script + $connection->_mockClient->shouldReceive('evalSha') + ->andThrow(new RedisException('CLUSTERDOWN The cluster is down')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('CLUSTERDOWN'); + + $store->putMany(['key1' => 'value1', 'key2' => 'value2'], 60); + } + + public function testManyThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('mget') + ->andThrow(new RedisException('LOADING Redis is loading the dataset in memory')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('LOADING'); + + $store->many(['key1', 'key2']); + } + + // ========================================================================= + // STORE-LEVEL FLUSH + // ========================================================================= + + public function testFlushThrowsOnRedisError(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('flushdb') + ->andThrow(new RedisException('MISCONF Redis is configured to save RDB snapshots')); + + $store = $this->createStore($connection); + + $this->expectException(RedisException::class); + $this->expectExceptionMessage('MISCONF'); + + $store->flush(); + } +} diff --git a/tests/Cache/Redis/Integration/BasicOperationsIntegrationTest.php b/tests/Cache/Redis/Integration/BasicOperationsIntegrationTest.php new file mode 100644 index 000000000..881d6ea8d --- /dev/null +++ b/tests/Cache/Redis/Integration/BasicOperationsIntegrationTest.php @@ -0,0 +1,572 @@ +setTagMode(TagMode::All); + + Cache::put('basic_key', 'basic_value', 60); + + $this->assertSame('basic_value', Cache::get('basic_key')); + } + + public function testPutAndGetInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('basic_key', 'basic_value', 60); + + $this->assertSame('basic_value', Cache::get('basic_key')); + } + + public function testForgetInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::put('forget_key', 'forget_value', 60); + $this->assertSame('forget_value', Cache::get('forget_key')); + + Cache::forget('forget_key'); + $this->assertNull(Cache::get('forget_key')); + } + + public function testForgetInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('forget_key', 'forget_value', 60); + $this->assertSame('forget_value', Cache::get('forget_key')); + + Cache::forget('forget_key'); + $this->assertNull(Cache::get('forget_key')); + } + + public function testHasInAllMode(): void + { + $this->setTagMode(TagMode::All); + + $this->assertFalse(Cache::has('has_key')); + + Cache::put('has_key', 'has_value', 60); + $this->assertTrue(Cache::has('has_key')); + + Cache::forget('has_key'); + $this->assertFalse(Cache::has('has_key')); + } + + public function testHasInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + $this->assertFalse(Cache::has('has_key')); + + Cache::put('has_key', 'has_value', 60); + $this->assertTrue(Cache::has('has_key')); + + Cache::forget('has_key'); + $this->assertFalse(Cache::has('has_key')); + } + + public function testAddInAllMode(): void + { + $this->setTagMode(TagMode::All); + + // Add to non-existent key should succeed + $result = Cache::add('add_key', 'first_value', 60); + $this->assertTrue($result); + $this->assertSame('first_value', Cache::get('add_key')); + + // Add to existing key should fail + $result = Cache::add('add_key', 'second_value', 60); + $this->assertFalse($result); + $this->assertSame('first_value', Cache::get('add_key')); + } + + public function testAddInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + // Add to non-existent key should succeed + $result = Cache::add('add_key', 'first_value', 60); + $this->assertTrue($result); + $this->assertSame('first_value', Cache::get('add_key')); + + // Add to existing key should fail + $result = Cache::add('add_key', 'second_value', 60); + $this->assertFalse($result); + $this->assertSame('first_value', Cache::get('add_key')); + } + + public function testIncrementInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::put('counter', 10, 60); + + $result = Cache::increment('counter', 5); + $this->assertEquals(15, $result); + $this->assertEquals(15, Cache::get('counter')); + + $result = Cache::increment('counter'); + $this->assertEquals(16, $result); + } + + public function testIncrementInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('counter', 10, 60); + + $result = Cache::increment('counter', 5); + $this->assertEquals(15, $result); + $this->assertEquals(15, Cache::get('counter')); + + $result = Cache::increment('counter'); + $this->assertEquals(16, $result); + } + + public function testDecrementInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::put('counter', 10, 60); + + $result = Cache::decrement('counter', 3); + $this->assertEquals(7, $result); + $this->assertEquals(7, Cache::get('counter')); + + $result = Cache::decrement('counter'); + $this->assertEquals(6, $result); + } + + public function testDecrementInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('counter', 10, 60); + + $result = Cache::decrement('counter', 3); + $this->assertEquals(7, $result); + $this->assertEquals(7, Cache::get('counter')); + + $result = Cache::decrement('counter'); + $this->assertEquals(6, $result); + } + + public function testForeverInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::forever('eternal_key', 'eternal_value'); + + $this->assertSame('eternal_value', Cache::get('eternal_key')); + + // Verify TTL is -1 (no expiration) + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'eternal_key'); + $this->assertEquals(-1, $ttl); + } + + public function testForeverInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::forever('eternal_key', 'eternal_value'); + + $this->assertSame('eternal_value', Cache::get('eternal_key')); + + // Verify TTL is -1 (no expiration) + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'eternal_key'); + $this->assertEquals(-1, $ttl); + } + + // ========================================================================= + // BASIC OPERATIONS WITH TAGS - ALL MODE + // ========================================================================= + + public function testTaggedPutAndGetInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts', 'user:1'])->put('post:1', 'Post content', 60); + + // In all mode, must retrieve with same tags + $this->assertSame('Post content', Cache::tags(['posts', 'user:1'])->get('post:1')); + } + + public function testTaggedForgetInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts'])->put('post:1', 'content', 60); + $this->assertSame('content', Cache::tags(['posts'])->get('post:1')); + + Cache::tags(['posts'])->forget('post:1'); + $this->assertNull(Cache::tags(['posts'])->get('post:1')); + } + + public function testTaggedAddInAllMode(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::tags(['users'])->add('user:1', 'John', 60); + $this->assertTrue($result); + $this->assertSame('John', Cache::tags(['users'])->get('user:1')); + + $result = Cache::tags(['users'])->add('user:1', 'Jane', 60); + $this->assertFalse($result); + $this->assertSame('John', Cache::tags(['users'])->get('user:1')); + } + + public function testTaggedIncrementInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['counters'])->put('views', 10, 60); + + $result = Cache::tags(['counters'])->increment('views', 5); + $this->assertEquals(15, $result); + $this->assertEquals(15, Cache::tags(['counters'])->get('views')); + } + + public function testTaggedDecrementInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['counters'])->put('views', 10, 60); + + $result = Cache::tags(['counters'])->decrement('views', 3); + $this->assertEquals(7, $result); + $this->assertEquals(7, Cache::tags(['counters'])->get('views')); + } + + public function testTaggedForeverInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts'])->forever('eternal_post', 'Forever content'); + + $this->assertSame('Forever content', Cache::tags(['posts'])->get('eternal_post')); + } + + // ========================================================================= + // BASIC OPERATIONS WITH TAGS - ANY MODE + // ========================================================================= + + public function testTaggedPutAndGetInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'user:1'])->put('post:1', 'Post content', 60); + + // In any mode, can retrieve WITHOUT tags + $this->assertSame('Post content', Cache::get('post:1')); + } + + public function testTaggedForgetDirectlyInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->put('post:1', 'content', 60); + $this->assertSame('content', Cache::get('post:1')); + + // In any mode, can forget directly (without tags) + Cache::forget('post:1'); + $this->assertNull(Cache::get('post:1')); + } + + public function testTaggedAddInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['users'])->add('user:1', 'John', 60); + $this->assertTrue($result); + $this->assertSame('John', Cache::get('user:1')); + + // Add should fail because key exists (checked by key, not by tags) + $result = Cache::tags(['users'])->add('user:1', 'Jane', 60); + $this->assertFalse($result); + $this->assertSame('John', Cache::get('user:1')); + } + + public function testTaggedIncrementInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['counters'])->put('views', 10, 60); + + $result = Cache::tags(['counters'])->increment('views', 5); + $this->assertEquals(15, $result); + $this->assertEquals(15, Cache::get('views')); + } + + public function testTaggedDecrementInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['counters'])->put('views', 10, 60); + + $result = Cache::tags(['counters'])->decrement('views', 3); + $this->assertEquals(7, $result); + $this->assertEquals(7, Cache::get('views')); + } + + public function testTaggedForeverInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->forever('eternal_post', 'Forever content'); + + // In any mode, can retrieve WITHOUT tags + $this->assertSame('Forever content', Cache::get('eternal_post')); + } + + // ========================================================================= + // DATA TYPES AND VALUES + // ========================================================================= + + public function testStoresVariousDataTypesInAllMode(): void + { + $this->setTagMode(TagMode::All); + $this->assertDataTypesStoredCorrectly(); + } + + public function testStoresVariousDataTypesInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + $this->assertDataTypesStoredCorrectly(); + } + + public function testStoresVariousDataTypesWithTagsInAllMode(): void + { + $this->setTagMode(TagMode::All); + $this->assertDataTypesStoredCorrectlyWithTags(); + } + + public function testStoresVariousDataTypesWithTagsInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + $this->assertDataTypesStoredCorrectlyWithTags(); + } + + // ========================================================================= + // MANY OPERATIONS + // ========================================================================= + + public function testPutManyAndManyInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::putMany([ + 'many_key1' => 'value1', + 'many_key2' => 'value2', + 'many_key3' => 'value3', + ], 60); + + $result = Cache::many(['many_key1', 'many_key2', 'many_key3', 'nonexistent']); + + $this->assertSame('value1', $result['many_key1']); + $this->assertSame('value2', $result['many_key2']); + $this->assertSame('value3', $result['many_key3']); + $this->assertNull($result['nonexistent']); + } + + public function testPutManyAndManyInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::putMany([ + 'many_key1' => 'value1', + 'many_key2' => 'value2', + 'many_key3' => 'value3', + ], 60); + + $result = Cache::many(['many_key1', 'many_key2', 'many_key3', 'nonexistent']); + + $this->assertSame('value1', $result['many_key1']); + $this->assertSame('value2', $result['many_key2']); + $this->assertSame('value3', $result['many_key3']); + $this->assertNull($result['nonexistent']); + } + + public function testTaggedPutManyInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['batch'])->putMany([ + 'batch:1' => 'value1', + 'batch:2' => 'value2', + ], 60); + + $this->assertSame('value1', Cache::tags(['batch'])->get('batch:1')); + $this->assertSame('value2', Cache::tags(['batch'])->get('batch:2')); + } + + public function testTaggedPutManyInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['batch'])->putMany([ + 'batch:1' => 'value1', + 'batch:2' => 'value2', + ], 60); + + // In any mode, retrieve without tags + $this->assertSame('value1', Cache::get('batch:1')); + $this->assertSame('value2', Cache::get('batch:2')); + } + + // ========================================================================= + // FLUSH OPERATIONS + // ========================================================================= + + public function testFlushInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::put('flush_key1', 'value1', 60); + Cache::put('flush_key2', 'value2', 60); + + Cache::flush(); + + $this->assertNull(Cache::get('flush_key1')); + $this->assertNull(Cache::get('flush_key2')); + } + + public function testFlushInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('flush_key1', 'value1', 60); + Cache::put('flush_key2', 'value2', 60); + + Cache::flush(); + + $this->assertNull(Cache::get('flush_key1')); + $this->assertNull(Cache::get('flush_key2')); + } + + // ========================================================================= + // HELPER METHODS + // ========================================================================= + + private function assertDataTypesStoredCorrectly(): void + { + // String + Cache::put('type_string', 'hello', 60); + $this->assertSame('hello', Cache::get('type_string')); + + // Integer + Cache::put('type_int', 42, 60); + $this->assertEquals(42, Cache::get('type_int')); + + // Float + Cache::put('type_float', 3.14, 60); + $this->assertEquals(3.14, Cache::get('type_float')); + + // Boolean true + Cache::put('type_bool_true', true, 60); + $this->assertTrue(Cache::get('type_bool_true')); + + // Boolean false + Cache::put('type_bool_false', false, 60); + $this->assertFalse(Cache::get('type_bool_false')); + + // Null + Cache::put('type_null', null, 60); + $this->assertNull(Cache::get('type_null')); + + // Array + Cache::put('type_array', ['a' => 1, 'b' => 2], 60); + $this->assertEquals(['a' => 1, 'b' => 2], Cache::get('type_array')); + + // Object (as array after serialization) + $obj = new stdClass(); + $obj->name = 'test'; + Cache::put('type_object', $obj, 60); + $retrieved = Cache::get('type_object'); + $this->assertEquals('test', $retrieved->name); + + // Zero + Cache::put('type_zero', 0, 60); + $this->assertEquals(0, Cache::get('type_zero')); + + // Empty string + Cache::put('type_empty_string', '', 60); + $this->assertSame('', Cache::get('type_empty_string')); + + // Empty array + Cache::put('type_empty_array', [], 60); + $this->assertEquals([], Cache::get('type_empty_array')); + } + + private function assertDataTypesStoredCorrectlyWithTags(): void + { + $tags = ['types']; + $isAnyMode = $this->getTagMode()->isAnyMode(); + + // Use a helper to get the value based on mode + $get = fn (string $key) => $isAnyMode + ? Cache::get($key) + : Cache::tags($tags)->get($key); + + // String + Cache::tags($tags)->put('type_string', 'hello', 60); + $this->assertSame('hello', $get('type_string')); + + // Integer + Cache::tags($tags)->put('type_int', 42, 60); + $this->assertEquals(42, $get('type_int')); + + // Float + Cache::tags($tags)->put('type_float', 3.14, 60); + $this->assertEquals(3.14, $get('type_float')); + + // Boolean true + Cache::tags($tags)->put('type_bool_true', true, 60); + $this->assertTrue($get('type_bool_true')); + + // Boolean false + Cache::tags($tags)->put('type_bool_false', false, 60); + $this->assertFalse($get('type_bool_false')); + + // Array + Cache::tags($tags)->put('type_array', ['a' => 1, 'b' => 2], 60); + $this->assertEquals(['a' => 1, 'b' => 2], $get('type_array')); + + // Zero + Cache::tags($tags)->put('type_zero', 0, 60); + $this->assertEquals(0, $get('type_zero')); + + // Empty string + Cache::tags($tags)->put('type_empty_string', '', 60); + $this->assertSame('', $get('type_empty_string')); + + // Empty array + Cache::tags($tags)->put('type_empty_array', [], 60); + $this->assertEquals([], $get('type_empty_array')); + } +} diff --git a/tests/Cache/Redis/Integration/BlockedOperationsIntegrationTest.php b/tests/Cache/Redis/Integration/BlockedOperationsIntegrationTest.php new file mode 100644 index 000000000..9b05ac7e8 --- /dev/null +++ b/tests/Cache/Redis/Integration/BlockedOperationsIntegrationTest.php @@ -0,0 +1,247 @@ +setTagMode(TagMode::Any); + } + + // ========================================================================= + // GET OPERATIONS + // ========================================================================= + + public function testGetViaTagsThrowsException(): void + { + // First store an item with a tag + Cache::tags(['blocked_tag'])->put('blocked_key', 'value', 60); + + // Verify the item exists via non-tagged get + $this->assertSame('value', Cache::get('blocked_key')); + + // Attempting to get via tags should throw + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot get items via tags in any mode'); + + Cache::tags(['blocked_tag'])->get('blocked_key'); + } + + public function testGetViaTagsReturnsDefaultInsteadOfThrowing(): void + { + // This test documents that get() with default throws regardless + Cache::tags(['blocked_tag'])->put('blocked_key', 'value', 60); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot get items via tags in any mode'); + + Cache::tags(['blocked_tag'])->get('blocked_key', 'default_value'); + } + + public function testManyViaTagsThrowsException(): void + { + Cache::tags(['blocked_tag'])->put('key1', 'value1', 60); + Cache::tags(['blocked_tag'])->put('key2', 'value2', 60); + + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot get items via tags in any mode'); + + Cache::tags(['blocked_tag'])->many(['key1', 'key2']); + } + + // ========================================================================= + // HAS OPERATION + // ========================================================================= + + public function testHasViaTagsThrowsException(): void + { + Cache::tags(['blocked_tag'])->put('blocked_key', 'value', 60); + + // Verify via non-tagged has + $this->assertTrue(Cache::has('blocked_key')); + + // Attempting to check via tags should throw + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot check existence via tags in any mode'); + + Cache::tags(['blocked_tag'])->has('blocked_key'); + } + + public function testMissingViaTagsThrowsException(): void + { + // missing() is the inverse of has() + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot check existence via tags in any mode'); + + Cache::tags(['blocked_tag'])->missing('nonexistent_key'); + } + + // ========================================================================= + // PULL OPERATION + // ========================================================================= + + public function testPullViaTagsThrowsException(): void + { + Cache::tags(['blocked_tag'])->put('blocked_key', 'value', 60); + + // Verify the item exists + $this->assertSame('value', Cache::get('blocked_key')); + + // Attempting to pull via tags should throw + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot pull items via tags in any mode'); + + Cache::tags(['blocked_tag'])->pull('blocked_key'); + } + + // ========================================================================= + // FORGET OPERATION + // ========================================================================= + + public function testForgetViaTagsThrowsException(): void + { + Cache::tags(['blocked_tag'])->put('blocked_key', 'value', 60); + + // Verify the item exists + $this->assertSame('value', Cache::get('blocked_key')); + + // Attempting to forget via tags should throw + $this->expectException(BadMethodCallException::class); + $this->expectExceptionMessage('Cannot forget items via tags in any mode'); + + Cache::tags(['blocked_tag'])->forget('blocked_key'); + } + + // ========================================================================= + // WORKAROUND TESTS - DOCUMENT CORRECT PATTERNS + // ========================================================================= + + public function testCorrectPatternForGettingItems(): void + { + Cache::tags(['correct_tag'])->put('correct_key', 'correct_value', 60); + + // Correct way: get via Cache directly without tags + $this->assertSame('correct_value', Cache::get('correct_key')); + } + + public function testCorrectPatternForCheckingExistence(): void + { + Cache::tags(['correct_tag'])->put('correct_key', 'correct_value', 60); + + // Correct way: check via Cache directly without tags + $this->assertTrue(Cache::has('correct_key')); + } + + public function testCorrectPatternForRemovingItem(): void + { + Cache::tags(['correct_tag'])->put('correct_key', 'correct_value', 60); + + // Correct way: forget via Cache directly without tags + $this->assertTrue(Cache::forget('correct_key')); + $this->assertNull(Cache::get('correct_key')); + } + + public function testCorrectPatternForFlushingByTag(): void + { + Cache::tags(['flush_tag'])->put('key1', 'value1', 60); + Cache::tags(['flush_tag'])->put('key2', 'value2', 60); + Cache::tags(['other_tag'])->put('key3', 'value3', 60); + + // Correct way: flush entire tag (removes all items with that tag) + Cache::tags(['flush_tag'])->flush(); + + $this->assertNull(Cache::get('key1')); + $this->assertNull(Cache::get('key2')); + // key3 was not in flush_tag, so it remains + $this->assertSame('value3', Cache::get('key3')); + } + + public function testItemsMethodWorksForQueryingTaggedItems(): void + { + Cache::tags(['query_tag'])->put('item1', 'value1', 60); + Cache::tags(['query_tag'])->put('item2', 'value2', 60); + + // Correct way to query what's in a tag: use items() + $items = iterator_to_array(Cache::tags(['query_tag'])->items()); + + $this->assertCount(2, $items); + $this->assertArrayHasKey('item1', $items); + $this->assertArrayHasKey('item2', $items); + $this->assertSame('value1', $items['item1']); + $this->assertSame('value2', $items['item2']); + } + + // ========================================================================= + // ALL MODE DOES NOT BLOCK THESE OPERATIONS + // ========================================================================= + + public function testAllModeAllowsGetViaTags(): void + { + // Switch to all mode + $this->setTagMode(TagMode::All); + + Cache::tags(['allowed_tag'])->put('allowed_key', 'allowed_value', 60); + + // All mode allows get via tags + $this->assertSame('allowed_value', Cache::tags(['allowed_tag'])->get('allowed_key')); + } + + public function testAllModeAllowsHasViaTags(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['allowed_tag'])->put('allowed_key', 'allowed_value', 60); + + // All mode allows has via tags + $this->assertTrue(Cache::tags(['allowed_tag'])->has('allowed_key')); + } + + public function testAllModeAllowsForgetViaTags(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['allowed_tag'])->put('allowed_key', 'allowed_value', 60); + + // All mode allows forget via tags + $this->assertTrue(Cache::tags(['allowed_tag'])->forget('allowed_key')); + $this->assertNull(Cache::tags(['allowed_tag'])->get('allowed_key')); + } + + public function testAllModeAllowsPullViaTags(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['allowed_tag'])->put('allowed_key', 'allowed_value', 60); + + // All mode allows pull via tags + $this->assertSame('allowed_value', Cache::tags(['allowed_tag'])->pull('allowed_key')); + $this->assertNull(Cache::tags(['allowed_tag'])->get('allowed_key')); + } +} diff --git a/tests/Cache/Redis/Integration/ClusterFallbackIntegrationTest.php b/tests/Cache/Redis/Integration/ClusterFallbackIntegrationTest.php new file mode 100644 index 000000000..6b1e72272 --- /dev/null +++ b/tests/Cache/Redis/Integration/ClusterFallbackIntegrationTest.php @@ -0,0 +1,429 @@ +clusterContext ??= new ClusterModeStoreContext( + $this->getPoolFactoryInternal(), + $this->connection, + $this->getPrefix(), + $this->getTagMode(), + ); + } + + public function getPoolFactoryInternal(): PoolFactory + { + return parent::getPoolFactory(); + } +} + +/** + * Integration tests for cluster mode code paths (PHP fallbacks). + * + * These tests verify that when isCluster() returns true, the PHP fallback + * code paths work correctly. This is important because: + * - RedisCluster does not support Lua scripts across slots + * - RedisCluster does not support pipeline() method + * - Operations must use sequential commands or multi() instead + * + * We test against real single-instance Redis with isCluster() mocked to true. + * + * @group redis-integration + * + * @internal + * @coversNothing + */ +class ClusterFallbackIntegrationTest extends RedisCacheIntegrationTestCase +{ + private ?ClusterModeRedisStore $clusterStore = null; + + protected function setUp(): void + { + parent::setUp(); + + // Create cluster-mode store using the same factory as the real store + $factory = $this->app->get(\Hyperf\Redis\RedisFactory::class); + $realStore = Cache::store('redis')->getStore(); + + $this->clusterStore = new ClusterModeRedisStore( + $factory, + $realStore->getPrefix(), + 'default', + ); + $this->clusterStore->setTagMode('any'); + } + + protected function tearDown(): void + { + $this->clusterStore = null; + parent::tearDown(); + } + + /** + * Helper to get the cluster-mode tagged cache. + */ + private function clusterTags(array $tags): AnyTaggedCache + { + return new AnyTaggedCache( + $this->clusterStore, + new AnyTagSet($this->clusterStore, $tags) + ); + } + + // ========================================================================= + // PUT WITH TAGS - PHP FALLBACK + // ========================================================================= + + public function testClusterModePutWithTags(): void + { + $this->clusterTags(['cluster-tag'])->put('cluster-put', 'value', 60); + + $this->assertSame('value', $this->clusterStore->get('cluster-put')); + + // Verify tag tracking exists + $this->assertTrue($this->anyModeTagHasEntry('cluster-tag', 'cluster-put')); + } + + public function testClusterModePutWithMultipleTags(): void + { + $this->clusterTags(['tag1', 'tag2', 'tag3'])->put('multi-tag-item', 'value', 60); + + $this->assertSame('value', $this->clusterStore->get('multi-tag-item')); + + // All tags should have the entry + $this->assertTrue($this->anyModeTagHasEntry('tag1', 'multi-tag-item')); + $this->assertTrue($this->anyModeTagHasEntry('tag2', 'multi-tag-item')); + $this->assertTrue($this->anyModeTagHasEntry('tag3', 'multi-tag-item')); + } + + // ========================================================================= + // ADD WITH TAGS - PHP FALLBACK + // ========================================================================= + + public function testClusterModeAddWithTagsSucceeds(): void + { + $result = $this->clusterTags(['cluster-tag'])->add('cluster-add', 'value', 60); + + $this->assertTrue($result); + $this->assertSame('value', $this->clusterStore->get('cluster-add')); + + // Verify tag tracking + $this->assertTrue($this->anyModeTagHasEntry('cluster-tag', 'cluster-add')); + } + + public function testClusterModeAddWithTagsFailsForExistingKey(): void + { + // First add succeeds + $result1 = $this->clusterTags(['cluster-tag'])->add('cluster-add-fail', 'first', 60); + $this->assertTrue($result1); + + // Second add fails + $result2 = $this->clusterTags(['cluster-tag'])->add('cluster-add-fail', 'second', 60); + $this->assertFalse($result2); + + // Value should remain the first + $this->assertSame('first', $this->clusterStore->get('cluster-add-fail')); + } + + // ========================================================================= + // FOREVER WITH TAGS - PHP FALLBACK + // ========================================================================= + + public function testClusterModeForeverWithTags(): void + { + $this->clusterTags(['cluster-tag'])->forever('cluster-forever', 'forever-value'); + + $this->assertSame('forever-value', $this->clusterStore->get('cluster-forever')); + + // Verify no TTL (forever) + $prefix = $this->getCachePrefix(); + $ttl = $this->redis()->ttl($prefix . 'cluster-forever'); + $this->assertEquals(-1, $ttl); + } + + // ========================================================================= + // INCREMENT/DECREMENT WITH TAGS - PHP FALLBACK + // ========================================================================= + + public function testClusterModeIncrementWithTags(): void + { + $this->clusterTags(['cluster-tag'])->put('cluster-incr', 10, 60); + + $newValue = $this->clusterTags(['cluster-tag'])->increment('cluster-incr'); + + $this->assertEquals(11, $newValue); + $this->assertEquals(11, $this->clusterStore->get('cluster-incr')); + } + + public function testClusterModeIncrementWithTagsByAmount(): void + { + $this->clusterTags(['cluster-tag'])->put('cluster-incr-by', 10, 60); + + $newValue = $this->clusterTags(['cluster-tag'])->increment('cluster-incr-by', 5); + + $this->assertEquals(15, $newValue); + $this->assertEquals(15, $this->clusterStore->get('cluster-incr-by')); + } + + public function testClusterModeDecrementWithTags(): void + { + $this->clusterTags(['cluster-tag'])->put('cluster-decr', 10, 60); + + $newValue = $this->clusterTags(['cluster-tag'])->decrement('cluster-decr'); + + $this->assertEquals(9, $newValue); + $this->assertEquals(9, $this->clusterStore->get('cluster-decr')); + } + + public function testClusterModeDecrementWithTagsByAmount(): void + { + $this->clusterTags(['cluster-tag'])->put('cluster-decr-by', 10, 60); + + $newValue = $this->clusterTags(['cluster-tag'])->decrement('cluster-decr-by', 3); + + $this->assertEquals(7, $newValue); + $this->assertEquals(7, $this->clusterStore->get('cluster-decr-by')); + } + + // ========================================================================= + // PUTMANY WITH TAGS - PHP FALLBACK + // ========================================================================= + + public function testClusterModePutManyWithTags(): void + { + $this->clusterTags(['cluster-tag'])->putMany([ + 'cluster-k1' => 'v1', + 'cluster-k2' => 'v2', + 'cluster-k3' => 'v3', + ], 60); + + $this->assertSame('v1', $this->clusterStore->get('cluster-k1')); + $this->assertSame('v2', $this->clusterStore->get('cluster-k2')); + $this->assertSame('v3', $this->clusterStore->get('cluster-k3')); + + // All should have tag tracking + $this->assertTrue($this->anyModeTagHasEntry('cluster-tag', 'cluster-k1')); + $this->assertTrue($this->anyModeTagHasEntry('cluster-tag', 'cluster-k2')); + $this->assertTrue($this->anyModeTagHasEntry('cluster-tag', 'cluster-k3')); + } + + // ========================================================================= + // FLUSH - PHP FALLBACK + // ========================================================================= + + public function testClusterModeFlush(): void + { + $this->clusterTags(['flush-tag'])->put('flush-item1', 'value1', 60); + $this->clusterTags(['flush-tag'])->put('flush-item2', 'value2', 60); + + // Verify items exist + $this->assertSame('value1', $this->clusterStore->get('flush-item1')); + $this->assertSame('value2', $this->clusterStore->get('flush-item2')); + + // Flush + $this->clusterTags(['flush-tag'])->flush(); + + // Items should be gone + $this->assertNull($this->clusterStore->get('flush-item1')); + $this->assertNull($this->clusterStore->get('flush-item2')); + } + + public function testClusterModeFlushMultipleTags(): void + { + $this->clusterTags(['tag-a'])->put('item-a', 'value-a', 60); + $this->clusterTags(['tag-b'])->put('item-b', 'value-b', 60); + $this->clusterTags(['tag-c'])->put('item-c', 'value-c', 60); + + // Flush tag-a and tag-b together + $this->clusterTags(['tag-a', 'tag-b'])->flush(); + + // Items with tag-a or tag-b should be gone + $this->assertNull($this->clusterStore->get('item-a')); + $this->assertNull($this->clusterStore->get('item-b')); + + // Item with only tag-c should remain + $this->assertSame('value-c', $this->clusterStore->get('item-c')); + } + + // ========================================================================= + // TAG REPLACEMENT - PHP FALLBACK + // ========================================================================= + + public function testClusterModeTagReplacement(): void + { + // Initial: item with tag1 + $this->clusterTags(['tag1'])->put('replace-test', 10, 60); + + // Update: item with tag2 (replaces tag1) + $this->clusterTags(['tag2'])->increment('replace-test', 1); + + // Verify value + $this->assertEquals(11, $this->clusterStore->get('replace-test')); + + // Flush old tag should NOT remove the item + $this->clusterTags(['tag1'])->flush(); + $this->assertEquals(11, $this->clusterStore->get('replace-test')); + + // Flush new tag SHOULD remove the item + $this->clusterTags(['tag2'])->flush(); + $this->assertNull($this->clusterStore->get('replace-test')); + } + + public function testClusterModeTagReplacementOnPut(): void + { + // Initial: item with tag1 + $this->clusterTags(['tag1'])->put('replace-put', 'original', 60); + $this->assertTrue($this->anyModeTagHasEntry('tag1', 'replace-put')); + + // Update: same key with different tag + $this->clusterTags(['tag2'])->put('replace-put', 'updated', 60); + $this->assertTrue($this->anyModeTagHasEntry('tag2', 'replace-put')); + + // Value should be updated + $this->assertSame('updated', $this->clusterStore->get('replace-put')); + + // Old tag should no longer have the entry (reverse index cleaned up) + // Note: The old tag hash may still have orphaned entry until prune runs + } + + // ========================================================================= + // REMEMBER - PHP FALLBACK + // ========================================================================= + + public function testClusterModeRememberMiss(): void + { + $value = $this->clusterTags(['remember-tag'])->remember('remember-miss', 60, fn () => 'computed'); + + $this->assertSame('computed', $value); + $this->assertSame('computed', $this->clusterStore->get('remember-miss')); + $this->assertTrue($this->anyModeTagHasEntry('remember-tag', 'remember-miss')); + } + + public function testClusterModeRememberHit(): void + { + // Pre-populate + $this->clusterTags(['remember-tag'])->put('remember-hit', 'existing', 60); + + $callbackCalled = false; + $value = $this->clusterTags(['remember-tag'])->remember('remember-hit', 60, function () use (&$callbackCalled) { + $callbackCalled = true; + return 'computed'; + }); + + $this->assertSame('existing', $value); + $this->assertFalse($callbackCalled); + } + + public function testClusterModeRememberForever(): void + { + $value = $this->clusterTags(['remember-tag'])->rememberForever('remember-forever', fn () => 'forever-value'); + + $this->assertSame('forever-value', $value); + $this->assertSame('forever-value', $this->clusterStore->get('remember-forever')); + + // Verify no TTL + $prefix = $this->getCachePrefix(); + $ttl = $this->redis()->ttl($prefix . 'remember-forever'); + $this->assertEquals(-1, $ttl); + } + + // ========================================================================= + // COMPLEX SCENARIOS - PHP FALLBACK + // ========================================================================= + + public function testClusterModeMixedOperations(): void + { + // Various operations + $this->clusterTags(['mixed'])->put('put-item', 'put-value', 60); + $this->clusterTags(['mixed'])->forever('forever-item', 'forever-value'); + $this->clusterTags(['mixed'])->put('counter', 0, 60); + $this->clusterTags(['mixed'])->increment('counter', 10); + $this->clusterTags(['mixed'])->decrement('counter', 3); + $this->clusterTags(['mixed'])->add('add-item', 'add-value', 60); + + // Verify all operations worked + $this->assertSame('put-value', $this->clusterStore->get('put-item')); + $this->assertSame('forever-value', $this->clusterStore->get('forever-item')); + $this->assertEquals(7, $this->clusterStore->get('counter')); + $this->assertSame('add-value', $this->clusterStore->get('add-item')); + + // Flush should remove all + $this->clusterTags(['mixed'])->flush(); + + $this->assertNull($this->clusterStore->get('put-item')); + $this->assertNull($this->clusterStore->get('forever-item')); + $this->assertNull($this->clusterStore->get('counter')); + $this->assertNull($this->clusterStore->get('add-item')); + } + + public function testClusterModeOverlappingTags(): void + { + // Items with overlapping tags + $this->clusterTags(['shared', 'unique-1'])->put('item-1', 'value-1', 60); + $this->clusterTags(['shared', 'unique-2'])->put('item-2', 'value-2', 60); + $this->clusterTags(['unique-3'])->put('item-3', 'value-3', 60); + + // Flush shared tag + $this->clusterTags(['shared'])->flush(); + + // Items with shared tag should be gone + $this->assertNull($this->clusterStore->get('item-1')); + $this->assertNull($this->clusterStore->get('item-2')); + + // Item without shared tag should remain + $this->assertSame('value-3', $this->clusterStore->get('item-3')); + } + + public function testClusterModeLargeTagSet(): void + { + // Create items with many tags + $tags = []; + for ($i = 0; $i < 10; ++$i) { + $tags[] = "large-tag-{$i}"; + } + + $this->clusterTags($tags)->put('large-tag-item', 'value', 60); + + $this->assertSame('value', $this->clusterStore->get('large-tag-item')); + + // All tags should have the entry + foreach ($tags as $tag) { + $this->assertTrue($this->anyModeTagHasEntry($tag, 'large-tag-item')); + } + + // Flushing any single tag should remove the item + $this->clusterTags(['large-tag-5'])->flush(); + $this->assertNull($this->clusterStore->get('large-tag-item')); + } +} diff --git a/tests/Cache/Redis/Integration/ConcurrencyIntegrationTest.php b/tests/Cache/Redis/Integration/ConcurrencyIntegrationTest.php new file mode 100644 index 000000000..44e297034 --- /dev/null +++ b/tests/Cache/Redis/Integration/ConcurrencyIntegrationTest.php @@ -0,0 +1,515 @@ +setTagMode(TagMode::All); + $this->assertConcurrentWritesToSameKey(); + } + + public function testAnyModeConcurrentWritesToSameKey(): void + { + $this->setTagMode(TagMode::Any); + $this->assertConcurrentWritesToSameKey(); + } + + private function assertConcurrentWritesToSameKey(): void + { + $iterations = 10; + $key = 'concurrent-key'; + $isAnyMode = $this->getTagMode()->isAnyMode(); + + for ($i = 0; $i < $iterations; ++$i) { + Cache::tags(['concurrent'])->put($key, "value-{$i}", 60); + } + + // Last write should win + $value = $isAnyMode ? Cache::get($key) : Cache::tags(['concurrent'])->get($key); + $this->assertSame('value-' . ($iterations - 1), $value); + } + + // ========================================================================= + // CONCURRENT TAG FLUSHES - BOTH MODES + // ========================================================================= + + public function testAllModeConcurrentTagFlushes(): void + { + $this->setTagMode(TagMode::All); + $this->assertConcurrentTagFlushes(); + } + + public function testAnyModeConcurrentTagFlushes(): void + { + $this->setTagMode(TagMode::Any); + $this->assertConcurrentTagFlushes(); + } + + private function assertConcurrentTagFlushes(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + + // Create items with overlapping tags + for ($i = 0; $i < 20; ++$i) { + Cache::tags(['shared', "unique-{$i}"])->put("item-{$i}", "value-{$i}", 60); + } + + // Multiple flushes + Cache::tags(['shared'])->flush(); + Cache::tags(['unique-5'])->flush(); + Cache::tags(['unique-10'])->flush(); + + // All items should be gone (since they all have 'shared' tag) + for ($i = 0; $i < 20; ++$i) { + $this->assertNull( + $isAnyMode ? Cache::get("item-{$i}") : Cache::tags(['shared', "unique-{$i}"])->get("item-{$i}") + ); + } + } + + // ========================================================================= + // CONCURRENT ADDS - BOTH MODES + // ========================================================================= + + public function testAllModeConcurrentAdds(): void + { + $this->setTagMode(TagMode::All); + $this->assertConcurrentAdds(); + } + + public function testAnyModeConcurrentAdds(): void + { + $this->setTagMode(TagMode::Any); + $this->assertConcurrentAdds(); + } + + private function assertConcurrentAdds(): void + { + $key = 'add-race'; + $isAnyMode = $this->getTagMode()->isAnyMode(); + + // First add should succeed + $result1 = Cache::tags(['race'])->add($key, 'first', 60); + $this->assertTrue($result1); + + // Subsequent adds should fail + $result2 = Cache::tags(['race'])->add($key, 'second', 60); + $this->assertFalse($result2); + + $result3 = Cache::tags(['race'])->add($key, 'third', 60); + $this->assertFalse($result3); + + // Value should still be the first + $value = $isAnyMode ? Cache::get($key) : Cache::tags(['race'])->get($key); + $this->assertSame('first', $value); + } + + // ========================================================================= + // CONCURRENT INCREMENTS/DECREMENTS - BOTH MODES + // ========================================================================= + + public function testAllModeConcurrentIncrements(): void + { + $this->setTagMode(TagMode::All); + $this->assertConcurrentIncrements(); + } + + public function testAnyModeConcurrentIncrements(): void + { + $this->setTagMode(TagMode::Any); + $this->assertConcurrentIncrements(); + } + + private function assertConcurrentIncrements(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + Cache::tags(['counters'])->put('counter', 0, 60); + + $increments = 100; + + for ($i = 0; $i < $increments; ++$i) { + Cache::tags(['counters'])->increment('counter'); + } + + $value = $isAnyMode ? Cache::get('counter') : Cache::tags(['counters'])->get('counter'); + $this->assertEquals($increments, $value); + } + + public function testAllModeConcurrentDecrements(): void + { + $this->setTagMode(TagMode::All); + $this->assertConcurrentDecrements(); + } + + public function testAnyModeConcurrentDecrements(): void + { + $this->setTagMode(TagMode::Any); + $this->assertConcurrentDecrements(); + } + + private function assertConcurrentDecrements(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + Cache::tags(['counters'])->put('counter', 1000, 60); + + $decrements = 100; + + for ($i = 0; $i < $decrements; ++$i) { + Cache::tags(['counters'])->decrement('counter'); + } + + $value = $isAnyMode ? Cache::get('counter') : Cache::tags(['counters'])->get('counter'); + $this->assertEquals(1000 - $decrements, $value); + } + + // ========================================================================= + // RACE BETWEEN PUT AND FLUSH - BOTH MODES + // ========================================================================= + + public function testAllModeRaceBetweenPutAndFlush(): void + { + $this->setTagMode(TagMode::All); + $this->assertRaceBetweenPutAndFlush(); + } + + public function testAnyModeRaceBetweenPutAndFlush(): void + { + $this->setTagMode(TagMode::Any); + $this->assertRaceBetweenPutAndFlush(); + } + + private function assertRaceBetweenPutAndFlush(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + + // Add initial items + for ($i = 0; $i < 10; ++$i) { + Cache::tags(['race-flush'])->put("initial-{$i}", 'value', 60); + } + + // Flush + Cache::tags(['race-flush'])->flush(); + + // Immediately add new items + for ($i = 0; $i < 5; ++$i) { + Cache::tags(['race-flush'])->put("new-{$i}", 'value', 60); + } + + // New items should exist + for ($i = 0; $i < 5; ++$i) { + $value = $isAnyMode ? Cache::get("new-{$i}") : Cache::tags(['race-flush'])->get("new-{$i}"); + $this->assertSame('value', $value); + } + + // Old items should be gone + for ($i = 0; $i < 10; ++$i) { + $value = $isAnyMode ? Cache::get("initial-{$i}") : Cache::tags(['race-flush'])->get("initial-{$i}"); + $this->assertNull($value); + } + } + + // ========================================================================= + // OPERATIONS ON DIFFERENT TAGS - BOTH MODES + // ========================================================================= + + public function testAllModeOperationsOnDifferentTags(): void + { + $this->setTagMode(TagMode::All); + $this->assertOperationsOnDifferentTags(); + } + + public function testAnyModeOperationsOnDifferentTags(): void + { + $this->setTagMode(TagMode::Any); + $this->assertOperationsOnDifferentTags(); + } + + private function assertOperationsOnDifferentTags(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + + // Set up items with different tags + Cache::tags(['tag-a'])->put('item-a', 'value-a', 60); + Cache::tags(['tag-b'])->put('item-b', 'value-b', 60); + Cache::tags(['tag-c'])->put('item-c', 'value-c', 60); + + // Operations + Cache::tags(['tag-a'])->flush(); + Cache::tags(['tag-b'])->put('item-b2', 'value-b2', 60); + Cache::tags(['tag-c'])->put('counter-c', 0, 60); + Cache::tags(['tag-c'])->increment('counter-c', 5); + + // Check results + $valueA = $isAnyMode ? Cache::get('item-a') : Cache::tags(['tag-a'])->get('item-a'); + $valueB = $isAnyMode ? Cache::get('item-b') : Cache::tags(['tag-b'])->get('item-b'); + $valueB2 = $isAnyMode ? Cache::get('item-b2') : Cache::tags(['tag-b'])->get('item-b2'); + $valueC = $isAnyMode ? Cache::get('item-c') : Cache::tags(['tag-c'])->get('item-c'); + $counterC = $isAnyMode ? Cache::get('counter-c') : Cache::tags(['tag-c'])->get('counter-c'); + + $this->assertNull($valueA); // Flushed + $this->assertSame('value-b', $valueB); // Untouched + $this->assertSame('value-b2', $valueB2); // New item + $this->assertSame('value-c', $valueC); // Untouched + $this->assertEquals(5, $counterC); // Incremented + } + + // ========================================================================= + // CONCURRENT PUTMANY - BOTH MODES + // ========================================================================= + + public function testAllModeConcurrentPutMany(): void + { + $this->setTagMode(TagMode::All); + $this->assertConcurrentPutMany(); + } + + public function testAnyModeConcurrentPutMany(): void + { + $this->setTagMode(TagMode::Any); + $this->assertConcurrentPutMany(); + } + + private function assertConcurrentPutMany(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + + $batch1 = []; + $batch2 = []; + + for ($i = 0; $i < 50; ++$i) { + $batch1["batch1-{$i}"] = "value1-{$i}"; + $batch2["batch2-{$i}"] = "value2-{$i}"; + } + + Cache::tags(['batch'])->putMany($batch1, 60); + Cache::tags(['batch'])->putMany($batch2, 60); + + // All items should exist + foreach ($batch1 as $key => $value) { + $cached = $isAnyMode ? Cache::get($key) : Cache::tags(['batch'])->get($key); + $this->assertSame($value, $cached); + } + + foreach ($batch2 as $key => $value) { + $cached = $isAnyMode ? Cache::get($key) : Cache::tags(['batch'])->get($key); + $this->assertSame($value, $cached); + } + } + + // ========================================================================= + // OVERLAPPING TAG SETS - BOTH MODES + // ========================================================================= + + public function testAllModeOverlappingTagFlushes(): void + { + $this->setTagMode(TagMode::All); + $this->assertOverlappingTagFlushes(); + } + + public function testAnyModeOverlappingTagFlushes(): void + { + $this->setTagMode(TagMode::Any); + $this->assertOverlappingTagFlushes(); + } + + private function assertOverlappingTagFlushes(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + + // Create items with various tag combinations + Cache::tags(['red', 'blue'])->put('purple', 'value', 60); + Cache::tags(['red', 'yellow'])->put('orange', 'value', 60); + Cache::tags(['blue', 'yellow'])->put('green', 'value', 60); + Cache::tags(['red'])->put('red-only', 'value', 60); + Cache::tags(['blue'])->put('blue-only', 'value', 60); + Cache::tags(['yellow'])->put('yellow-only', 'value', 60); + + // Flush red tag + Cache::tags(['red'])->flush(); + + // Check results + $purple = $isAnyMode ? Cache::get('purple') : Cache::tags(['red', 'blue'])->get('purple'); + $orange = $isAnyMode ? Cache::get('orange') : Cache::tags(['red', 'yellow'])->get('orange'); + $redOnly = $isAnyMode ? Cache::get('red-only') : Cache::tags(['red'])->get('red-only'); + $green = $isAnyMode ? Cache::get('green') : Cache::tags(['blue', 'yellow'])->get('green'); + $blueOnly = $isAnyMode ? Cache::get('blue-only') : Cache::tags(['blue'])->get('blue-only'); + $yellowOnly = $isAnyMode ? Cache::get('yellow-only') : Cache::tags(['yellow'])->get('yellow-only'); + + $this->assertNull($purple); + $this->assertNull($orange); + $this->assertNull($redOnly); + $this->assertSame('value', $green); + $this->assertSame('value', $blueOnly); + $this->assertSame('value', $yellowOnly); + } + + // ========================================================================= + // ATOMIC ADD OPERATIONS - BOTH MODES + // ========================================================================= + + public function testAllModeAtomicAdd(): void + { + $this->setTagMode(TagMode::All); + $this->assertAtomicAdd(); + } + + public function testAnyModeAtomicAdd(): void + { + $this->setTagMode(TagMode::Any); + $this->assertAtomicAdd(); + } + + private function assertAtomicAdd(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + $key = 'atomic-test'; + + Cache::forget($key); + + $results = []; + for ($i = 0; $i < 5; ++$i) { + $results[] = Cache::tags(['atomic'])->add($key, "value-{$i}", 60); + } + + // Only first should succeed + $this->assertTrue($results[0]); + for ($i = 1; $i < 5; ++$i) { + $this->assertFalse($results[$i]); + } + + // Value should be from first add + $value = $isAnyMode ? Cache::get($key) : Cache::tags(['atomic'])->get($key); + $this->assertSame('value-0', $value); + } + + // ========================================================================= + // RAPID TAG CREATION/DELETION - BOTH MODES + // ========================================================================= + + public function testAllModeRapidTagOperations(): void + { + $this->setTagMode(TagMode::All); + + for ($i = 0; $i < 20; ++$i) { + $tag = "rapid-{$i}"; + + Cache::tags([$tag])->put("item-{$i}", "value-{$i}", 60); + $this->assertRedisKeyExists($this->allModeTagKey($tag)); + + Cache::tags([$tag])->flush(); + $this->assertRedisKeyNotExists($this->allModeTagKey($tag)); + } + } + + public function testAnyModeRapidTagOperations(): void + { + $this->setTagMode(TagMode::Any); + + for ($i = 0; $i < 20; ++$i) { + $tag = "rapid-{$i}"; + + Cache::tags([$tag])->put("item-{$i}", "value-{$i}", 60); + $this->assertRedisKeyExists($this->anyModeTagKey($tag)); + + Cache::tags([$tag])->flush(); + $this->assertRedisKeyNotExists($this->anyModeTagKey($tag)); + } + } + + // ========================================================================= + // SWOOLE COROUTINE CONCURRENCY - ANY MODE ONLY + // (All mode uses namespaced keys which would collide in parallel) + // ========================================================================= + + public function testAnyModeParallelIncrementsWithCoroutines(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['parallel'])->put('parallel-counter', 0, 60); + + // Limit concurrency to stay within connection pool limits + $parallel = new Parallel(5); + $incrementCount = 50; + + for ($i = 0; $i < $incrementCount; ++$i) { + $parallel->add(function () { + Cache::tags(['parallel'])->increment('parallel-counter'); + }); + } + + $parallel->wait(); + + // All increments should be counted (Redis INCRBY is atomic) + $this->assertEquals($incrementCount, Cache::get('parallel-counter')); + } + + public function testAnyModeParallelPutsWithCoroutines(): void + { + $this->setTagMode(TagMode::Any); + + // Limit concurrency to stay within connection pool limits + $parallel = new Parallel(5); + $count = 20; + + for ($i = 0; $i < $count; ++$i) { + $parallel->add(function () use ($i) { + Cache::tags(['parallel-puts'])->put("parallel-key-{$i}", "value-{$i}", 60); + }); + } + + $parallel->wait(); + + // All items should exist + for ($i = 0; $i < $count; ++$i) { + $this->assertSame("value-{$i}", Cache::get("parallel-key-{$i}")); + } + } + + public function testAnyModeParallelAddsWithCoroutines(): void + { + $this->setTagMode(TagMode::Any); + + // Limit concurrency to stay within connection pool limits + $parallel = new Parallel(5); + $results = []; + + // Multiple coroutines trying to add the same key + for ($i = 0; $i < 10; ++$i) { + $parallel->add(function () use ($i) { + return Cache::tags(['parallel-add'])->add('contested-key', "value-{$i}", 60); + }); + } + + $results = $parallel->wait(); + + // Exactly one should succeed + $successCount = array_sum(array_map(fn ($r) => $r ? 1 : 0, $results)); + $this->assertEquals(1, $successCount); + + // Value should exist + $this->assertNotNull(Cache::get('contested-key')); + } +} diff --git a/tests/Cache/Redis/Integration/EdgeCasesIntegrationTest.php b/tests/Cache/Redis/Integration/EdgeCasesIntegrationTest.php new file mode 100644 index 000000000..9fbe4ec17 --- /dev/null +++ b/tests/Cache/Redis/Integration/EdgeCasesIntegrationTest.php @@ -0,0 +1,424 @@ +setTagMode(TagMode::All); + $this->assertSpecialCharacterKeysWork(); + } + + public function testAnyModeHandlesSpecialCharactersInCacheKeys(): void + { + $this->setTagMode(TagMode::Any); + $this->assertSpecialCharacterKeysWork(); + } + + // ========================================================================= + // SPECIAL CHARACTERS IN TAG NAMES - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesSpecialCharactersInTagNames(): void + { + $this->setTagMode(TagMode::All); + $this->assertSpecialCharacterTagsWork(); + } + + public function testAnyModeHandlesSpecialCharactersInTagNames(): void + { + $this->setTagMode(TagMode::Any); + $this->assertSpecialCharacterTagsWork(); + } + + // ========================================================================= + // VERY LONG KEYS AND TAGS - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesVeryLongCacheKeys(): void + { + $this->setTagMode(TagMode::All); + $longKey = str_repeat('a', 1000); + + Cache::tags(['long'])->put($longKey, 'value', 60); + $this->assertSame('value', Cache::tags(['long'])->get($longKey)); + + Cache::tags(['long'])->flush(); + $this->assertNull(Cache::tags(['long'])->get($longKey)); + } + + public function testAnyModeHandlesVeryLongCacheKeys(): void + { + $this->setTagMode(TagMode::Any); + $longKey = str_repeat('a', 1000); + + Cache::tags(['long'])->put($longKey, 'value', 60); + $this->assertSame('value', Cache::get($longKey)); + + Cache::tags(['long'])->flush(); + $this->assertNull(Cache::get($longKey)); + } + + public function testAllModeHandlesVeryLongTagNames(): void + { + $this->setTagMode(TagMode::All); + $longTag = str_repeat('tag', 100); + + Cache::tags([$longTag])->put('item', 'value', 60); + $this->assertSame('value', Cache::tags([$longTag])->get('item')); + + Cache::tags([$longTag])->flush(); + $this->assertNull(Cache::tags([$longTag])->get('item')); + } + + public function testAnyModeHandlesVeryLongTagNames(): void + { + $this->setTagMode(TagMode::Any); + $longTag = str_repeat('tag', 100); + + Cache::tags([$longTag])->put('item', 'value', 60); + $this->assertSame('value', Cache::get('item')); + + Cache::tags([$longTag])->flush(); + $this->assertNull(Cache::get('item')); + } + + // ========================================================================= + // UNICODE CHARACTERS - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesUnicodeCharactersInKeys(): void + { + $this->setTagMode(TagMode::All); + $this->assertUnicodeKeysWork(); + } + + public function testAnyModeHandlesUnicodeCharactersInKeys(): void + { + $this->setTagMode(TagMode::Any); + $this->assertUnicodeKeysWork(); + } + + // ========================================================================= + // NUMERIC TAGS - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesNumericTagNames(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['123', '456'])->put('numeric', 'value', 60); + $this->assertSame('value', Cache::tags(['123', '456'])->get('numeric')); + + Cache::tags(['123'])->flush(); + $this->assertNull(Cache::tags(['123', '456'])->get('numeric')); + } + + public function testAnyModeHandlesNumericTagNames(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['123', '456'])->put('numeric', 'value', 60); + $this->assertSame('value', Cache::get('numeric')); + + Cache::tags(['123'])->flush(); + $this->assertNull(Cache::get('numeric')); + } + + // ========================================================================= + // ZERO AS VALUE - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesZeroAsValue(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['zeros'])->put('int-zero', 0, 60); + Cache::tags(['zeros'])->put('float-zero', 0.0, 60); + Cache::tags(['zeros'])->put('string-zero', '0', 60); + + $this->assertEquals(0, Cache::tags(['zeros'])->get('int-zero')); + $this->assertEquals(0.0, Cache::tags(['zeros'])->get('float-zero')); + $this->assertSame('0', Cache::tags(['zeros'])->get('string-zero')); + } + + public function testAnyModeHandlesZeroAsValue(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['zeros'])->put('int-zero', 0, 60); + Cache::tags(['zeros'])->put('float-zero', 0.0, 60); + Cache::tags(['zeros'])->put('string-zero', '0', 60); + + $this->assertEquals(0, Cache::get('int-zero')); + $this->assertEquals(0.0, Cache::get('float-zero')); + $this->assertSame('0', Cache::get('string-zero')); + } + + // ========================================================================= + // KEYS RESEMBLING REDIS COMMANDS - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesKeysLikeRedisCommands(): void + { + $this->setTagMode(TagMode::All); + $this->assertRedisCommandLikeKeysWork(); + } + + public function testAnyModeHandlesKeysLikeRedisCommands(): void + { + $this->setTagMode(TagMode::Any); + $this->assertRedisCommandLikeKeysWork(); + } + + // ========================================================================= + // BINARY DATA - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesBinaryData(): void + { + $this->setTagMode(TagMode::All); + + $binaryData = random_bytes(256); + + Cache::tags(['binary'])->put('binary-data', $binaryData, 60); + $retrieved = Cache::tags(['binary'])->get('binary-data'); + + $this->assertSame($binaryData, $retrieved); + } + + public function testAnyModeHandlesBinaryData(): void + { + $this->setTagMode(TagMode::Any); + + $binaryData = random_bytes(256); + + Cache::tags(['binary'])->put('binary-data', $binaryData, 60); + $retrieved = Cache::get('binary-data'); + + $this->assertSame($binaryData, $retrieved); + } + + // ========================================================================= + // MAXIMUM NUMBER OF TAGS - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesManyTags(): void + { + $this->setTagMode(TagMode::All); + + $tags = []; + for ($i = 0; $i < 50; ++$i) { + $tags[] = "tag_{$i}"; + } + + Cache::tags($tags)->put('many-tags', 'value', 60); + $this->assertSame('value', Cache::tags($tags)->get('many-tags')); + + // Flush by any one of the tags + Cache::tags(['tag_25'])->flush(); + $this->assertNull(Cache::tags($tags)->get('many-tags')); + } + + public function testAnyModeHandlesManyTags(): void + { + $this->setTagMode(TagMode::Any); + + $tags = []; + for ($i = 0; $i < 50; ++$i) { + $tags[] = "tag_{$i}"; + } + + Cache::tags($tags)->put('many-tags', 'value', 60); + $this->assertSame('value', Cache::get('many-tags')); + + // Flush by any one of the tags + Cache::tags(['tag_25'])->flush(); + $this->assertNull(Cache::get('many-tags')); + } + + // ========================================================================= + // WHITESPACE IN KEYS - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesWhitespaceInKeys(): void + { + $this->setTagMode(TagMode::All); + $this->assertWhitespaceKeysWork(); + } + + public function testAnyModeHandlesWhitespaceInKeys(): void + { + $this->setTagMode(TagMode::Any); + $this->assertWhitespaceKeysWork(); + } + + // ========================================================================= + // NON-EXISTENT KEYS - BOTH MODES + // ========================================================================= + + public function testAllModeReturnsNullForNonExistentKeys(): void + { + $this->setTagMode(TagMode::All); + $this->assertNull(Cache::get('non.existent')); + $this->assertNull(Cache::tags(['sometag'])->get('non.existent')); + } + + public function testAnyModeReturnsNullForNonExistentKeys(): void + { + $this->setTagMode(TagMode::Any); + $this->assertNull(Cache::get('non.existent')); + } + + // ========================================================================= + // HELPER METHODS + // ========================================================================= + + private function assertSpecialCharacterKeysWork(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + $get = fn (string $key) => $isAnyMode ? Cache::get($key) : Cache::tags(['special'])->get($key); + + $keys = [ + 'key:with:colons' => 'value1', + 'key-with-dashes' => 'value2', + 'key_with_underscores' => 'value3', + 'key.with.dots' => 'value4', + 'key@with#special$chars' => 'value5', + 'key with spaces' => 'value6', + 'key[with]brackets' => 'value7', + 'key{with}braces' => 'value8', + ]; + + foreach ($keys as $key => $value) { + Cache::tags(['special'])->put($key, $value, 60); + } + + foreach ($keys as $key => $value) { + $this->assertSame($value, $get($key)); + } + + Cache::tags(['special'])->flush(); + + foreach ($keys as $key => $value) { + $this->assertNull($get($key)); + } + } + + private function assertSpecialCharacterTagsWork(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + + $tags = [ + 'tag:with:colons', + 'tag-with-dashes', + 'tag_with_underscores', + 'tag.with.dots', + 'tag@special', + 'user:123', + 'namespace::class', + ]; + + foreach ($tags as $tag) { + Cache::tags([$tag])->put('item', 'value', 60); + $value = $isAnyMode ? Cache::get('item') : Cache::tags([$tag])->get('item'); + $this->assertSame('value', $value, "Failed to retrieve item for tag: {$tag}"); + + Cache::tags([$tag])->flush(); + $value = $isAnyMode ? Cache::get('item') : Cache::tags([$tag])->get('item'); + $this->assertNull($value, "Failed to flush item for tag: {$tag}"); + } + } + + private function assertUnicodeKeysWork(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + $get = fn (string $key) => $isAnyMode ? Cache::get($key) : Cache::tags(['unicode'])->get($key); + + $unicodeKeys = [ + 'key_中文_chinese' => 'value1', + 'key_العربية_arabic' => 'value2', + 'key_한글_korean' => 'value3', + 'key_русский_russian' => 'value4', + 'key_日本語_japanese' => 'value5', + ]; + + foreach ($unicodeKeys as $key => $value) { + Cache::tags(['unicode'])->put($key, $value, 60); + } + + foreach ($unicodeKeys as $key => $value) { + $this->assertSame($value, $get($key), "Failed to retrieve unicode key: {$key}"); + } + } + + private function assertRedisCommandLikeKeysWork(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + $get = fn (string $key) => $isAnyMode ? Cache::get($key) : Cache::tags(['commands'])->get($key); + + $suspiciousKeys = [ + 'SET' => 'value1', + 'GET' => 'value2', + 'DEL' => 'value3', + 'FLUSHDB' => 'value4', + 'EVAL' => 'value5', + ]; + + foreach ($suspiciousKeys as $key => $value) { + Cache::tags(['commands'])->put($key, $value, 60); + } + + foreach ($suspiciousKeys as $key => $value) { + $this->assertSame($value, $get($key)); + } + } + + private function assertWhitespaceKeysWork(): void + { + $isAnyMode = $this->getTagMode()->isAnyMode(); + $get = fn (string $key) => $isAnyMode ? Cache::get($key) : Cache::tags(['whitespace'])->get($key); + + $whitespaceKeys = [ + "key\twith\ttabs" => 'value1', + ' key with leading spaces' => 'value2', + 'key with trailing spaces ' => 'value3', + ]; + + foreach ($whitespaceKeys as $key => $value) { + Cache::tags(['whitespace'])->put($key, $value, 60); + } + + foreach ($whitespaceKeys as $key => $value) { + $this->assertSame($value, $get($key)); + } + } +} diff --git a/tests/Cache/Redis/Integration/FlushOperationsIntegrationTest.php b/tests/Cache/Redis/Integration/FlushOperationsIntegrationTest.php new file mode 100644 index 000000000..aa71a6eea --- /dev/null +++ b/tests/Cache/Redis/Integration/FlushOperationsIntegrationTest.php @@ -0,0 +1,423 @@ +setTagMode(TagMode::Any); + + // Store items with different tag combinations + Cache::tags(['posts', 'user:1'])->put('post.1', 'Post 1', 60); + Cache::tags(['posts', 'featured'])->put('post.2', 'Post 2', 60); + Cache::tags(['posts'])->put('post.3', 'Post 3', 60); + Cache::tags(['videos', 'user:1'])->put('video.1', 'Video 1', 60); + + // Flushing 'posts' should remove all posts but not videos + Cache::tags(['posts'])->flush(); + + $this->assertNull(Cache::get('post.1')); + $this->assertNull(Cache::get('post.2')); + $this->assertNull(Cache::get('post.3')); + $this->assertSame('Video 1', Cache::get('video.1')); + } + + public function testAnyModeFlushesItemsWhenAnyTagMatches(): void + { + $this->setTagMode(TagMode::Any); + + // Item with multiple tags + Cache::tags(['products', 'electronics', 'featured'])->put('laptop', 'MacBook', 60); + Cache::tags(['products', 'clothing'])->put('shirt', 'T-Shirt', 60); + + // Flushing 'electronics' should only remove the laptop + Cache::tags(['electronics'])->flush(); + + $this->assertNull(Cache::get('laptop')); + $this->assertSame('T-Shirt', Cache::get('shirt')); + + // Now flush 'products' - should remove the shirt + Cache::tags(['products'])->flush(); + + $this->assertNull(Cache::get('shirt')); + } + + public function testAnyModeFlushMultipleTagsAsUnion(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['tag1'])->put('item1', 'value1', 60); + Cache::tags(['tag2'])->put('item2', 'value2', 60); + Cache::tags(['tag3'])->put('item3', 'value3', 60); + + // Flush items with tag1 OR tag2 + Cache::tags(['tag1', 'tag2'])->flush(); + + $this->assertNull(Cache::get('item1')); + $this->assertNull(Cache::get('item2')); + $this->assertSame('value3', Cache::get('item3')); + } + + public function testAnyModeRemovesTagFromRegistryWhenFlushed(): void + { + $this->setTagMode(TagMode::Any); + + // Create items with tags + Cache::tags(['tag-a', 'tag-b'])->put('item', 'value', 60); + + // Verify tags are in registry + $this->assertTrue($this->anyModeRegistryHasTag('tag-a')); + $this->assertTrue($this->anyModeRegistryHasTag('tag-b')); + + // Flush one tag + Cache::tags(['tag-a'])->flush(); + + // Verify tag-a is gone from registry + $this->assertFalse($this->anyModeRegistryHasTag('tag-a')); + + // tag-b should still exist (it wasn't flushed and still has items referencing it) + // Note: In lazy cleanup mode, tag-b may still be in registry until prune runs + } + + public function testAnyModeRemovesItemWhenFlushingAnyOfItsTags(): void + { + $this->setTagMode(TagMode::Any); + + // Scenario 1: Flush first tag + Cache::tags(['tag_a', 'tag_b'])->put('key_1', 'value_1', 60); + $this->assertSame('value_1', Cache::get('key_1')); + + Cache::tags(['tag_a'])->flush(); + $this->assertNull(Cache::get('key_1')); + + // Scenario 2: Flush second tag + Cache::tags(['tag_a', 'tag_b'])->put('key_2', 'value_2', 60); + $this->assertSame('value_2', Cache::get('key_2')); + + Cache::tags(['tag_b'])->flush(); + $this->assertNull(Cache::get('key_2')); + + // Scenario 3: Flush unrelated tag should not affect item + Cache::tags(['tag_a', 'tag_b'])->put('key_3', 'value_3', 60); + $this->assertSame('value_3', Cache::get('key_3')); + + Cache::tags(['tag_c'])->flush(); + $this->assertSame('value_3', Cache::get('key_3')); + } + + public function testAnyModeHandlesComplexTagIntersections(): void + { + $this->setTagMode(TagMode::Any); + + // Item 1: tags [A, B] + Cache::tags(['A', 'B'])->put('item_1', 'val_1', 60); + + // Item 2: tags [B, C] + Cache::tags(['B', 'C'])->put('item_2', 'val_2', 60); + + // Item 3: tags [A, C] + Cache::tags(['A', 'C'])->put('item_3', 'val_3', 60); + + // Flush B + Cache::tags(['B'])->flush(); + + // Item 1 (A, B) -> Should be gone + $this->assertNull(Cache::get('item_1')); + + // Item 2 (B, C) -> Should be gone + $this->assertNull(Cache::get('item_2')); + + // Item 3 (A, C) -> Should remain (didn't have tag B) + $this->assertSame('val_3', Cache::get('item_3')); + } + + // ========================================================================= + // ALL MODE - FLUSH BEHAVIOR + // ========================================================================= + + public function testAllModeFlushRemovesItemsWithTag(): void + { + $this->setTagMode(TagMode::All); + + // Store items with different tag combinations + Cache::tags(['posts'])->put('post.1', 'Post 1', 60); + Cache::tags(['posts', 'featured'])->put('post.2', 'Post 2', 60); + Cache::tags(['videos'])->put('video.1', 'Video 1', 60); + + // Flushing 'posts' should remove items tagged with 'posts' + Cache::tags(['posts'])->flush(); + + // Items that had 'posts' tag should be gone + $this->assertNull(Cache::tags(['posts'])->get('post.1')); + + // Items with 'posts' + 'featured' are also removed (posts ZSET was flushed) + $this->assertNull(Cache::tags(['posts', 'featured'])->get('post.2')); + + // Videos should remain + $this->assertSame('Video 1', Cache::tags(['videos'])->get('video.1')); + } + + public function testAllModeFlushMultipleTags(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['tag1'])->put('item1', 'value1', 60); + Cache::tags(['tag2'])->put('item2', 'value2', 60); + Cache::tags(['tag3'])->put('item3', 'value3', 60); + + // Flush tag1 and tag2 + Cache::tags(['tag1'])->flush(); + Cache::tags(['tag2'])->flush(); + + $this->assertNull(Cache::tags(['tag1'])->get('item1')); + $this->assertNull(Cache::tags(['tag2'])->get('item2')); + $this->assertSame('value3', Cache::tags(['tag3'])->get('item3')); + } + + public function testAllModeTagZsetIsDeletedOnFlush(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts'])->put('post.1', 'content', 60); + + // Verify ZSET exists + $this->assertRedisKeyExists($this->allModeTagKey('posts')); + + // Flush + Cache::tags(['posts'])->flush(); + + // ZSET should be deleted + $this->assertRedisKeyNotExists($this->allModeTagKey('posts')); + } + + // ========================================================================= + // BOTH MODES - COMMON FLUSH BEHAVIOR + // ========================================================================= + + public function testFlushNonExistentTagGracefullyInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['real-tag'])->put('item', 'value', 60); + + // Flushing non-existent tag should not throw errors + try { + Cache::tags(['non-existent'])->flush(); + $this->assertTrue(true); + } catch (Throwable $e) { + $this->fail('Flushing non-existent tag should not throw: ' . $e->getMessage()); + } + + // Real item should still exist + $this->assertSame('value', Cache::tags(['real-tag'])->get('item')); + } + + public function testFlushNonExistentTagGracefullyInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['real-tag'])->put('item', 'value', 60); + + // Flushing non-existent tag should not throw errors + try { + Cache::tags(['non-existent'])->flush(); + $this->assertTrue(true); + } catch (Throwable $e) { + $this->fail('Flushing non-existent tag should not throw: ' . $e->getMessage()); + } + + // Real item should still exist + $this->assertSame('value', Cache::get('item')); + } + + public function testFlushLargeTagSetInAllMode(): void + { + $this->setTagMode(TagMode::All); + + // Create many items with the same tag + for ($i = 0; $i < 100; ++$i) { + Cache::tags(['bulk'])->put("item.{$i}", "value.{$i}", 60); + } + + // Verify some items exist + $this->assertSame('value.0', Cache::tags(['bulk'])->get('item.0')); + $this->assertSame('value.50', Cache::tags(['bulk'])->get('item.50')); + $this->assertSame('value.99', Cache::tags(['bulk'])->get('item.99')); + + // Flush all at once + Cache::tags(['bulk'])->flush(); + + // Verify all items are gone + for ($i = 0; $i < 100; ++$i) { + $this->assertNull(Cache::tags(['bulk'])->get("item.{$i}")); + } + } + + public function testFlushLargeTagSetInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + // Create many items with the same tag + for ($i = 0; $i < 100; ++$i) { + Cache::tags(['bulk'])->put("item.{$i}", "value.{$i}", 60); + } + + // Verify some items exist + $this->assertSame('value.0', Cache::get('item.0')); + $this->assertSame('value.50', Cache::get('item.50')); + $this->assertSame('value.99', Cache::get('item.99')); + + // Flush all at once + Cache::tags(['bulk'])->flush(); + + // Verify all items are gone + for ($i = 0; $i < 100; ++$i) { + $this->assertNull(Cache::get("item.{$i}")); + } + } + + public function testFlushDoesNotAffectUntaggedItemsInAllMode(): void + { + $this->setTagMode(TagMode::All); + + // Store some untagged items + Cache::put('untagged.1', 'value1', 60); + Cache::put('untagged.2', 'value2', 60); + + // Store some tagged items + Cache::tags(['tagged'])->put('tagged.1', 'tagged1', 60); + + // Flush tagged items + Cache::tags(['tagged'])->flush(); + + // Untagged items should remain + $this->assertSame('value1', Cache::get('untagged.1')); + $this->assertSame('value2', Cache::get('untagged.2')); + $this->assertNull(Cache::tags(['tagged'])->get('tagged.1')); + } + + public function testFlushDoesNotAffectUntaggedItemsInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + // Store some untagged items + Cache::put('untagged.1', 'value1', 60); + Cache::put('untagged.2', 'value2', 60); + + // Store some tagged items + Cache::tags(['tagged'])->put('tagged.1', 'tagged1', 60); + + // Flush tagged items + Cache::tags(['tagged'])->flush(); + + // Untagged items should remain + $this->assertSame('value1', Cache::get('untagged.1')); + $this->assertSame('value2', Cache::get('untagged.2')); + $this->assertNull(Cache::get('tagged.1')); + } + + public function testAnyModeTagHashIsDeletedOnFlush(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->put('post.1', 'content', 60); + + // Verify HASH exists + $this->assertRedisKeyExists($this->anyModeTagKey('posts')); + + // Flush + Cache::tags(['posts'])->flush(); + + // HASH should be deleted + $this->assertRedisKeyNotExists($this->anyModeTagKey('posts')); + } + + public function testAnyModeReverseIndexIsDeletedOnFlush(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->put('post.1', 'content', 60); + + // Verify reverse index exists + $this->assertRedisKeyExists($this->anyModeReverseIndexKey('post.1')); + + // Flush + Cache::tags(['posts'])->flush(); + + // Reverse index should be deleted + $this->assertRedisKeyNotExists($this->anyModeReverseIndexKey('post.1')); + } + + // ========================================================================= + // FLUSH WITH SHARED TAGS (ORPHAN CREATION) + // ========================================================================= + + public function testAnyModeFlushCreatesOrphanedFieldsInOtherTags(): void + { + $this->setTagMode(TagMode::Any); + + // Item belongs to both alpha and beta tags + Cache::tags(['alpha', 'beta'])->put('shared', 'value', 60); + + // Verify item is in both tag hashes + $this->assertTrue($this->anyModeTagHasEntry('alpha', 'shared')); + $this->assertTrue($this->anyModeTagHasEntry('beta', 'shared')); + + // Flush by alpha tag only + Cache::tags(['alpha'])->flush(); + + // Item should be gone from cache + $this->assertNull(Cache::get('shared')); + + // Alpha hash should be deleted + $this->assertRedisKeyNotExists($this->anyModeTagKey('alpha')); + + // Beta hash may still have an orphaned field + // (this is expected behavior - prune command cleans these up) + // The field will have expired TTL or the cache key won't exist + } + + public function testAllModeFlushCreatesOrphanedEntriesInOtherTags(): void + { + $this->setTagMode(TagMode::All); + + // Item belongs to both alpha and beta tags + Cache::tags(['alpha', 'beta'])->put('shared', 'value', 60); + + // Verify item is in both tag ZSETs + $this->assertNotEmpty($this->getAllModeTagEntries('alpha')); + $this->assertNotEmpty($this->getAllModeTagEntries('beta')); + + // Flush by alpha tag only + Cache::tags(['alpha'])->flush(); + + // Alpha ZSET should be deleted + $this->assertRedisKeyNotExists($this->allModeTagKey('alpha')); + + // Beta ZSET may still have an orphaned entry + // (this is expected behavior - prune command cleans these up) + } +} diff --git a/tests/Cache/Redis/Integration/HashExpirationIntegrationTest.php b/tests/Cache/Redis/Integration/HashExpirationIntegrationTest.php new file mode 100644 index 000000000..4c380f83a --- /dev/null +++ b/tests/Cache/Redis/Integration/HashExpirationIntegrationTest.php @@ -0,0 +1,207 @@ +setTagMode(TagMode::Any); + } + + // ========================================================================= + // HASH FIELD TTL VERIFICATION + // ========================================================================= + + public function testHashFieldExpirationMatchesCacheTtl(): void + { + Cache::tags(['expiring'])->put('short-lived', 'value', 10); + + // Check that tag hash exists and has the field + $this->assertTrue($this->anyModeTagHasEntry('expiring', 'short-lived')); + + // Check that TTL is set on the hash field using HTTL + $tagKey = $this->anyModeTagKey('expiring'); + $ttlResult = $this->redis()->httl($tagKey, ['short-lived']); + $ttl = $ttlResult[0] ?? $ttlResult; + + $this->assertGreaterThan(0, $ttl); + $this->assertLessThanOrEqual(10, $ttl); + } + + public function testHashFieldsExpireAutomaticallyWithCacheKeys(): void + { + // Store with 1 second TTL + Cache::tags(['quick'])->put('flash', 'value', 1); + + // Should exist initially + $this->assertTrue($this->anyModeTagHasEntry('quick', 'flash')); + $this->assertSame('value', Cache::get('flash')); + + // Wait for expiration + sleep(2); + + // Cache key should be gone + $this->assertNull(Cache::get('flash')); + + // Hash field should also be gone (handled by Redis HSETEX auto-expiration) + $this->assertFalse($this->anyModeTagHasEntry('quick', 'flash')); + } + + public function testDifferentTtlsForItemsWithSameTag(): void + { + Cache::tags(['mixed-ttl'])->put('short', 'value1', 1); + Cache::tags(['mixed-ttl'])->put('long', 'value2', 60); + + // Both should exist initially + $this->assertTrue($this->anyModeTagHasEntry('mixed-ttl', 'short')); + $this->assertTrue($this->anyModeTagHasEntry('mixed-ttl', 'long')); + + // Wait for short to expire + sleep(2); + + // Short should be gone (both cache key and hash field) + $this->assertNull(Cache::get('short')); + $this->assertFalse($this->anyModeTagHasEntry('mixed-ttl', 'short')); + + // Long should remain + $this->assertTrue($this->anyModeTagHasEntry('mixed-ttl', 'long')); + $this->assertSame('value2', Cache::get('long')); + } + + public function testForeverItemsDoNotSetHashFieldExpiration(): void + { + Cache::tags(['permanent'])->forever('eternal', 'forever value'); + + // Field should exist + $this->assertTrue($this->anyModeTagHasEntry('permanent', 'eternal')); + + // TTL should be -1 (no expiration) + $tagKey = $this->anyModeTagKey('permanent'); + $ttlResult = $this->redis()->httl($tagKey, ['eternal']); + $ttl = $ttlResult[0] ?? $ttlResult; + + $this->assertEquals(-1, $ttl); + } + + public function testUpdatingItemUpdatesHashFieldExpiration(): void + { + // Store with short TTL + Cache::tags(['updating'])->put('item', 'value1', 5); + $tagKey = $this->anyModeTagKey('updating'); + $ttlResult1 = $this->redis()->httl($tagKey, ['item']); + $ttl1 = $ttlResult1[0] ?? $ttlResult1; + + // Update with longer TTL + Cache::tags(['updating'])->put('item', 'value2', 60); + $ttlResult2 = $this->redis()->httl($tagKey, ['item']); + $ttl2 = $ttlResult2[0] ?? $ttlResult2; + + // New TTL should be longer + $this->assertGreaterThan($ttl1, $ttl2); + $this->assertGreaterThan(50, $ttl2); + } + + // ========================================================================= + // EXPIRATION WITH MULTIPLE TAGS + // ========================================================================= + + public function testExpirationSetOnAllTagHashes(): void + { + Cache::tags(['tag1', 'tag2', 'tag3'])->put('multi-tag-item', 'value', 30); + + // All tag hashes should have the field with TTL + foreach (['tag1', 'tag2', 'tag3'] as $tag) { + $this->assertTrue($this->anyModeTagHasEntry($tag, 'multi-tag-item')); + + $tagKey = $this->anyModeTagKey($tag); + $ttlResult = $this->redis()->httl($tagKey, ['multi-tag-item']); + $ttl = $ttlResult[0] ?? $ttlResult; + + $this->assertGreaterThan(0, $ttl); + $this->assertLessThanOrEqual(30, $ttl); + } + } + + public function testFieldsExpireAcrossAllTagHashes(): void + { + Cache::tags(['exp1', 'exp2'])->put('expiring-multi', 'value', 1); + + // Both tag hashes should have the field initially + $this->assertTrue($this->anyModeTagHasEntry('exp1', 'expiring-multi')); + $this->assertTrue($this->anyModeTagHasEntry('exp2', 'expiring-multi')); + + // Wait for expiration + sleep(2); + + // Fields should be gone from both tag hashes + $this->assertFalse($this->anyModeTagHasEntry('exp1', 'expiring-multi')); + $this->assertFalse($this->anyModeTagHasEntry('exp2', 'expiring-multi')); + } + + // ========================================================================= + // EXPIRATION AND CACHE OPERATIONS + // ========================================================================= + + public function testIncrementMaintainsTagTracking(): void + { + Cache::tags(['counters'])->put('views', 10, 60); + $this->assertTrue($this->anyModeTagHasEntry('counters', 'views')); + + Cache::tags(['counters'])->increment('views'); + $this->assertEquals(11, Cache::get('views')); + + // Field should still exist in tag hash + $this->assertTrue($this->anyModeTagHasEntry('counters', 'views')); + } + + public function testDecrementMaintainsTagTracking(): void + { + Cache::tags(['counters'])->put('balance', 100, 60); + $this->assertTrue($this->anyModeTagHasEntry('counters', 'balance')); + + Cache::tags(['counters'])->decrement('balance', 25); + $this->assertEquals(75, Cache::get('balance')); + + // Field should still exist in tag hash + $this->assertTrue($this->anyModeTagHasEntry('counters', 'balance')); + } + + public function testAddWithExpirationSetsHashFieldTtl(): void + { + $result = Cache::tags(['add_test'])->add('new_item', 'value', 30); + $this->assertTrue($result); + + $this->assertTrue($this->anyModeTagHasEntry('add_test', 'new_item')); + + $tagKey = $this->anyModeTagKey('add_test'); + $ttlResult = $this->redis()->httl($tagKey, ['new_item']); + $ttl = $ttlResult[0] ?? $ttlResult; + + $this->assertGreaterThan(0, $ttl); + $this->assertLessThanOrEqual(30, $ttl); + } +} diff --git a/tests/Cache/Redis/Integration/HashLifecycleIntegrationTest.php b/tests/Cache/Redis/Integration/HashLifecycleIntegrationTest.php new file mode 100644 index 000000000..c63f71c29 --- /dev/null +++ b/tests/Cache/Redis/Integration/HashLifecycleIntegrationTest.php @@ -0,0 +1,219 @@ +setTagMode(TagMode::Any); + } + + // ========================================================================= + // AUTOMATIC HASH DELETION WHEN ALL FIELDS EXPIRE + // ========================================================================= + + public function testAutoDeletesHashWhenAllFieldsExpireNaturally(): void + { + // Create items with short TTL + Cache::tags(['lifecycle-test'])->put('lifecycle:item1', 'value1', 1); + Cache::tags(['lifecycle-test'])->put('lifecycle:item2', 'value2', 1); + + $tagHash = $this->anyModeTagKey('lifecycle-test'); + + // Verify hash exists with fields + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(2, $this->redis()->hlen($tagHash)); + + // Hash structure itself has no TTL (only fields have TTL) + $this->assertEquals(-1, $this->redis()->ttl($tagHash)); + + // Wait for fields to expire + usleep(1500000); // 1.5 seconds + + // Redis should have automatically deleted the entire hash + $this->assertRedisKeyNotExists($tagHash); + } + + public function testAutoDeletesHashWhenLastRemainingFieldExpires(): void + { + // Create items with different TTLs + Cache::tags(['staggered-test'])->put('lifecycle:short', 'value1', 1); // 1 second + Cache::tags(['staggered-test'])->put('lifecycle:long', 'value2', 2); // 2 seconds + + $tagHash = $this->anyModeTagKey('staggered-test'); + + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(2, $this->redis()->hlen($tagHash)); + + // After 1.5 seconds, short field should expire but long field remains + usleep(1500000); // 1.5 seconds + + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(1, $this->redis()->hlen($tagHash)); + + // After 1 more second, last field expires + sleep(1); + + // Redis should automatically delete the empty hash + $this->assertRedisKeyNotExists($tagHash); + } + + public function testKeepsHashAliveWhileAnyFieldRemainsUnexpired(): void + { + // Create one item with TTL and one forever + Cache::tags(['mixed-ttl-test'])->put('lifecycle:short', 'value1', 1); + Cache::tags(['mixed-ttl-test'])->forever('lifecycle:forever', 'value2'); + + $tagHash = $this->anyModeTagKey('mixed-ttl-test'); + + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(2, $this->redis()->hlen($tagHash)); + + // After 1.5 seconds, short field expires + usleep(1500000); // 1.5 seconds + + // Hash still exists because forever field remains + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(1, $this->redis()->hlen($tagHash)); + + // Forever field should still be there + $this->assertTrue($this->anyModeTagHasEntry('mixed-ttl-test', 'lifecycle:forever')); + } + + // ========================================================================= + // ORPHANED FIELDS BEHAVIOR (LAZY CLEANUP MODE) + // ========================================================================= + + public function testCreatesOrphanedFieldsWhenCacheKeyDeletedButFieldRemains(): void + { + // Create forever item (no field expiration) + Cache::tags(['orphan-test'])->forever('lifecycle:orphan', 'value'); + + $tagHash = $this->anyModeTagKey('orphan-test'); + + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(1, $this->redis()->hlen($tagHash)); + + // Manually delete the cache key (simulates flush of another tag) + Cache::forget('lifecycle:orphan'); + + // Hash field still exists even though cache key is gone + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(1, $this->redis()->hlen($tagHash)); + + // The field is now "orphaned" - points to non-existent cache key + $prefix = $this->getCachePrefix(); + $this->assertFalse($this->redis()->exists($prefix . 'lifecycle:orphan') > 0); + + // This is what prune command is designed to clean up + } + + public function testOrphanedFieldsFromLazyModeFlushExpireNaturallyIfTheyHaveTtl(): void + { + // Create item with TTL + Cache::tags(['natural-cleanup'])->put('lifecycle:temp', 'value', 1); + + $tagHash = $this->anyModeTagKey('natural-cleanup'); + + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(1, $this->redis()->hlen($tagHash)); + + // Simulate flush by deleting cache key but leaving field + Cache::forget('lifecycle:temp'); + + // Orphaned field still exists + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(1, $this->redis()->hlen($tagHash)); + + // Wait for original TTL to expire + usleep(1500000); // 1.5 seconds + + // Hash should be auto-deleted when orphaned field expired naturally + $this->assertRedisKeyNotExists($tagHash); + } + + // ========================================================================= + // HASH STRUCTURE CHARACTERISTICS + // ========================================================================= + + public function testHashHasNoTtlOnlyFieldsHaveTtl(): void + { + Cache::tags(['no-hash-ttl'])->put('item1', 'value1', 60); + Cache::tags(['no-hash-ttl'])->put('item2', 'value2', 30); + + $tagHash = $this->anyModeTagKey('no-hash-ttl'); + + // Hash structure itself should have no TTL (indefinite) + $hashTtl = $this->redis()->ttl($tagHash); + $this->assertEquals(-1, $hashTtl); + + // But individual fields should have TTL + $ttlResult1 = $this->redis()->httl($tagHash, ['item1']); + $ttl1 = $ttlResult1[0] ?? $ttlResult1; + $this->assertGreaterThan(0, $ttl1); + + $ttlResult2 = $this->redis()->httl($tagHash, ['item2']); + $ttl2 = $ttlResult2[0] ?? $ttlResult2; + $this->assertGreaterThan(0, $ttl2); + } + + public function testMultipleTagsAllFieldsExpire(): void + { + // Create item with multiple tags, all with short TTL + Cache::tags(['multi-expire-1', 'multi-expire-2'])->put('multi-item', 'value', 1); + + $tagHash1 = $this->anyModeTagKey('multi-expire-1'); + $tagHash2 = $this->anyModeTagKey('multi-expire-2'); + + $this->assertRedisKeyExists($tagHash1); + $this->assertRedisKeyExists($tagHash2); + + // Wait for fields to expire + usleep(1500000); // 1.5 seconds + + // Both hashes should be auto-deleted + $this->assertRedisKeyNotExists($tagHash1); + $this->assertRedisKeyNotExists($tagHash2); + } + + public function testForeverFieldsPreventHashDeletion(): void + { + // Create only forever items + Cache::tags(['forever-only'])->forever('item1', 'value1'); + Cache::tags(['forever-only'])->forever('item2', 'value2'); + + $tagHash = $this->anyModeTagKey('forever-only'); + + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(2, $this->redis()->hlen($tagHash)); + + // Wait some time + sleep(1); + + // Hash should still exist with both fields + $this->assertRedisKeyExists($tagHash); + $this->assertEquals(2, $this->redis()->hlen($tagHash)); + } +} diff --git a/tests/Cache/Redis/Integration/KeyNamingIntegrationTest.php b/tests/Cache/Redis/Integration/KeyNamingIntegrationTest.php new file mode 100644 index 000000000..a9fc362ab --- /dev/null +++ b/tests/Cache/Redis/Integration/KeyNamingIntegrationTest.php @@ -0,0 +1,445 @@ +setTagMode(TagMode::All); + + Cache::tags(['products'])->put('item1', 'value', 60); + + // Find the tag ZSET key + $tagKey = $this->allModeTagKey('products'); + $this->assertRedisKeyExists($tagKey); + $this->assertKeyContainsSegment('_all:tag:', $tagKey); + $this->assertKeyContainsSegment(':entries', $tagKey); + } + + public function testAllModeCreatesCorrectKeyStructure(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['category'])->put('product123', 'data', 60); + + // In all mode, we should have: + // 1. Cache value key (namespaced based on tags) + // 2. Tag ZSET: {prefix}_all:tag:category:entries + + $tagZsetKey = $this->allModeTagKey('category'); + $this->assertRedisKeyExists($tagZsetKey); + $this->assertEquals(Redis::REDIS_ZSET, $this->redis()->type($tagZsetKey)); + } + + public function testAllModeCreatesMultipleTagZsets(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts', 'featured', 'user:123'])->put('post1', 'content', 60); + + // Each tag should have its own ZSET + $this->assertRedisKeyExists($this->allModeTagKey('posts')); + $this->assertRedisKeyExists($this->allModeTagKey('featured')); + $this->assertRedisKeyExists($this->allModeTagKey('user:123')); + + // All should be ZSET type + $this->assertEquals(Redis::REDIS_ZSET, $this->redis()->type($this->allModeTagKey('posts'))); + $this->assertEquals(Redis::REDIS_ZSET, $this->redis()->type($this->allModeTagKey('featured'))); + $this->assertEquals(Redis::REDIS_ZSET, $this->redis()->type($this->allModeTagKey('user:123'))); + } + + public function testAllModeStoresNamespacedKeyInZset(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['mytag'])->put('mykey', 'value', 60); + + // In all mode, the ZSET stores the namespaced key (sha1 of tags + key) + $entries = $this->getAllModeTagEntries('mytag'); + $this->assertCount(1, $entries); + } + + public function testAllModeZsetScoreIsExpiryTimestamp(): void + { + $this->setTagMode(TagMode::All); + + $beforeTime = time(); + Cache::tags(['registrytest'])->put('key1', 'value', 3600); + $afterTime = time(); + + $entries = $this->getAllModeTagEntries('registrytest'); + $score = (int) reset($entries); + + // Score should be approximately now + 3600 seconds + $expectedMin = $beforeTime + 3600; + $expectedMax = $afterTime + 3600 + 1; + + $this->assertGreaterThanOrEqual($expectedMin, $score); + $this->assertLessThanOrEqual($expectedMax, $score); + } + + // ========================================================================= + // ANY MODE - KEY STRUCTURE VERIFICATION + // ========================================================================= + + public function testAnyModeTagKeyContainsAnySegment(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['products'])->put('item1', 'value', 60); + + // Find the tag HASH key + $tagKey = $this->anyModeTagKey('products'); + $this->assertRedisKeyExists($tagKey); + $this->assertKeyContainsSegment('_any:tag:', $tagKey); + $this->assertKeyContainsSegment(':entries', $tagKey); + } + + public function testAnyModeReverseIndexKeyContainsAnySegment(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['products'])->put('item1', 'value', 60); + + // Find the reverse index SET key + $reverseKey = $this->anyModeReverseIndexKey('item1'); + $this->assertRedisKeyExists($reverseKey); + $this->assertKeyContainsSegment(':_any:tags', $reverseKey); + } + + public function testAnyModeRegistryKeyContainsAnySegment(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['products'])->put('item1', 'value', 60); + + // Find the registry ZSET key + $registryKey = $this->anyModeRegistryKey(); + $this->assertRedisKeyExists($registryKey); + $this->assertKeyContainsSegment('_any:tag:', $registryKey); + $this->assertKeyContainsSegment('registry', $registryKey); + } + + public function testAnyModeCreatesAllFourKeys(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['category'])->put('product123', 'data', 60); + + // In any mode, we should have exactly 4 keys: + // 1. Cache value key: {prefix}product123 + // 2. Tag HASH: {prefix}_any:tag:category:entries + // 3. Reverse index: {prefix}product123:_any:tags + // 4. Registry: {prefix}_any:tag:registry + + $prefix = $this->getCachePrefix(); + + $this->assertRedisKeyExists($prefix . 'product123'); + $this->assertRedisKeyExists($this->anyModeTagKey('category')); + $this->assertRedisKeyExists($this->anyModeReverseIndexKey('product123')); + $this->assertRedisKeyExists($this->anyModeRegistryKey()); + + // Verify correct types + $this->assertEquals(Redis::REDIS_STRING, $this->redis()->type($prefix . 'product123')); + $this->assertEquals(Redis::REDIS_HASH, $this->redis()->type($this->anyModeTagKey('category'))); + $this->assertEquals(Redis::REDIS_SET, $this->redis()->type($this->anyModeReverseIndexKey('product123'))); + $this->assertEquals(Redis::REDIS_ZSET, $this->redis()->type($this->anyModeRegistryKey())); + } + + public function testAnyModeCreatesMultipleTagHashes(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'featured', 'user:123'])->put('post1', 'content', 60); + + // Each tag should have its own HASH + $this->assertRedisKeyExists($this->anyModeTagKey('posts')); + $this->assertRedisKeyExists($this->anyModeTagKey('featured')); + $this->assertRedisKeyExists($this->anyModeTagKey('user:123')); + + // All should be HASH type + $this->assertEquals(Redis::REDIS_HASH, $this->redis()->type($this->anyModeTagKey('posts'))); + $this->assertEquals(Redis::REDIS_HASH, $this->redis()->type($this->anyModeTagKey('featured'))); + $this->assertEquals(Redis::REDIS_HASH, $this->redis()->type($this->anyModeTagKey('user:123'))); + } + + public function testAnyModeReverseIndexContainsTagNames(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['alpha', 'beta'])->put('mykey', 'value', 60); + + // Check the reverse index SET contains the tag names + $tags = $this->getAnyModeReverseIndex('mykey'); + + $this->assertContains('alpha', $tags); + $this->assertContains('beta', $tags); + $this->assertCount(2, $tags); + } + + public function testAnyModeTagHashContainsCacheKey(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['mytag'])->put('mykey', 'value', 60); + + // Check the tag hash contains the cache key as a field + $this->assertTrue($this->anyModeTagHasEntry('mytag', 'mykey')); + + // Verify the field value is '1' (our placeholder) + $tagKey = $this->anyModeTagKey('mytag'); + $value = $this->redis()->hget($tagKey, 'mykey'); + $this->assertEquals(StoreContext::TAG_FIELD_VALUE, $value); + } + + public function testAnyModeRegistryContainsTagWithExpiryScore(): void + { + $this->setTagMode(TagMode::Any); + + $beforeTime = time(); + Cache::tags(['registrytest'])->put('key1', 'value', 3600); + $afterTime = time(); + + // Check the registry contains the tag + $registry = $this->getAnyModeRegistry(); + $this->assertArrayHasKey('registrytest', $registry); + + $score = (int) $registry['registrytest']; + + // Score should be approximately now + 3600 seconds + $expectedMin = $beforeTime + 3600; + $expectedMax = $afterTime + 3600 + 1; + + $this->assertGreaterThanOrEqual($expectedMin, $score); + $this->assertLessThanOrEqual($expectedMax, $score); + } + + // ========================================================================= + // FLUSH BEHAVIOR - KEYS SHOULD BE CLEANED UP + // ========================================================================= + + public function testAllModeFlushRemovesTagZset(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['flushtest'])->put('item1', 'value1', 60); + Cache::tags(['flushtest'])->put('item2', 'value2', 60); + + $this->assertRedisKeyExists($this->allModeTagKey('flushtest')); + + Cache::tags(['flushtest'])->flush(); + + // Tag ZSET should be deleted after flush + $this->assertRedisKeyNotExists($this->allModeTagKey('flushtest')); + } + + public function testAnyModeFlushRemovesAllStructures(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['flushtest'])->put('item1', 'value1', 60); + Cache::tags(['flushtest'])->put('item2', 'value2', 60); + + // Verify structures exist + $this->assertRedisKeyExists($this->anyModeTagKey('flushtest')); + $this->assertRedisKeyExists($this->anyModeReverseIndexKey('item1')); + $this->assertRedisKeyExists($this->anyModeReverseIndexKey('item2')); + + Cache::tags(['flushtest'])->flush(); + + // All structures should be deleted + $this->assertRedisKeyNotExists($this->anyModeTagKey('flushtest')); + $this->assertRedisKeyNotExists($this->anyModeReverseIndexKey('item1')); + $this->assertRedisKeyNotExists($this->anyModeReverseIndexKey('item2')); + + // Cache values should be gone + $this->assertNull(Cache::get('item1')); + $this->assertNull(Cache::get('item2')); + } + + // ========================================================================= + // COLLISION PREVENTION TESTS + // ========================================================================= + + public function testAllModeNoCollisionWhenTagIsNamedEntries(): void + { + $this->setTagMode(TagMode::All); + + // A tag named 'entries' should not collide with internal structures + Cache::tags(['entries'])->put('item', 'value', 60); + + // Tag ZSET: {prefix}_all:tag:entries:entries + $tagKey = $this->allModeTagKey('entries'); + $this->assertRedisKeyExists($tagKey); + $this->assertKeyEndsWithSuffix(':entries:entries', $tagKey); + + // Verify item works + $this->assertSame('value', Cache::tags(['entries'])->get('item')); + + Cache::tags(['entries'])->flush(); + $this->assertNull(Cache::tags(['entries'])->get('item')); + } + + public function testAnyModeNoCollisionWhenTagIsNamedRegistry(): void + { + $this->setTagMode(TagMode::Any); + + // 'registry' is the name of our internal ZSET, but tag hashes have :entries suffix + Cache::tags(['registry'])->put('item', 'value', 60); + + // Tag hash for 'registry' tag: {prefix}_any:tag:registry:entries (HASH) + // Actual registry: {prefix}_any:tag:registry (ZSET) + // These are different keys + $tagHashKey = $this->anyModeTagKey('registry'); + $registryKey = $this->anyModeRegistryKey(); + + $this->assertRedisKeyExists($tagHashKey); + $this->assertRedisKeyExists($registryKey); + $this->assertNotEquals($tagHashKey, $registryKey); + + // Verify they are different types + $this->assertEquals(Redis::REDIS_HASH, $this->redis()->type($tagHashKey)); + $this->assertEquals(Redis::REDIS_ZSET, $this->redis()->type($registryKey)); + + // Verify both work correctly + $this->assertSame('value', Cache::get('item')); + Cache::tags(['registry'])->flush(); + $this->assertNull(Cache::get('item')); + } + + public function testAnyModeNoCollisionWhenTagContainsEntriesSuffix(): void + { + $this->setTagMode(TagMode::Any); + + // A tag named 'posts:entries' should not collide with the tag hash for 'posts' + Cache::tags(['posts'])->put('item1', 'value1', 60); + Cache::tags(['posts:entries'])->put('item2', 'value2', 60); + + // Tag hash for 'posts': {prefix}_any:tag:posts:entries + // Tag hash for 'posts:entries': {prefix}_any:tag:posts:entries:entries + $postsTagKey = $this->anyModeTagKey('posts'); + $postsEntriesTagKey = $this->anyModeTagKey('posts:entries'); + + $this->assertRedisKeyExists($postsTagKey); + $this->assertRedisKeyExists($postsEntriesTagKey); + $this->assertNotEquals($postsTagKey, $postsEntriesTagKey); + + // Verify both items exist independently + $this->assertSame('value1', Cache::get('item1')); + $this->assertSame('value2', Cache::get('item2')); + + // Flushing 'posts' should not affect 'posts:entries' + Cache::tags(['posts'])->flush(); + $this->assertNull(Cache::get('item1')); + $this->assertSame('value2', Cache::get('item2')); + } + + public function testAnyModeNoCollisionWhenTagLooksLikeInternalSegment(): void + { + $this->setTagMode(TagMode::Any); + + // Tags that look like internal segments should still work + Cache::tags(['_any:tag:fake'])->put('item', 'value', 60); + + $this->assertSame('value', Cache::get('item')); + + // The tag hash key will be: {prefix}_any:tag:_any:tag:fake:entries + // This is ugly but doesn't collide with anything + $tagKey = $this->anyModeTagKey('_any:tag:fake'); + $this->assertRedisKeyExists($tagKey); + + Cache::tags(['_any:tag:fake'])->flush(); + $this->assertNull(Cache::get('item')); + } + + public function testAllModeNoCollisionWhenTagLooksLikeInternalSegment(): void + { + $this->setTagMode(TagMode::All); + + // Tags that look like internal segments should still work + Cache::tags(['_all:tag:fake'])->put('item', 'value', 60); + + $this->assertSame('value', Cache::tags(['_all:tag:fake'])->get('item')); + + // The tag ZSET key will be: {prefix}_all:tag:_all:tag:fake:entries + $tagKey = $this->allModeTagKey('_all:tag:fake'); + $this->assertRedisKeyExists($tagKey); + + Cache::tags(['_all:tag:fake'])->flush(); + $this->assertNull(Cache::tags(['_all:tag:fake'])->get('item')); + } + + // ========================================================================= + // SPECIAL CHARACTERS IN TAG NAMES + // ========================================================================= + + public function testAllModeHandlesSpecialCharactersInTags(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['user:123', 'role:admin'])->put('special', 'value', 60); + + $this->assertRedisKeyExists($this->allModeTagKey('user:123')); + $this->assertRedisKeyExists($this->allModeTagKey('role:admin')); + + $this->assertSame('value', Cache::tags(['user:123', 'role:admin'])->get('special')); + } + + public function testAnyModeHandlesSpecialCharactersInTags(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['user:123', 'role:admin'])->put('special', 'value', 60); + + $this->assertRedisKeyExists($this->anyModeTagKey('user:123')); + $this->assertRedisKeyExists($this->anyModeTagKey('role:admin')); + + $this->assertSame('value', Cache::get('special')); + } + + // ========================================================================= + // HELPER METHODS + // ========================================================================= + + private function assertKeyContainsSegment(string $segment, string $key): void + { + $this->assertTrue( + str_contains($key, $segment), + "Failed asserting that key '{$key}' contains segment '{$segment}'" + ); + } + + private function assertKeyEndsWithSuffix(string $suffix, string $key): void + { + $this->assertTrue( + str_ends_with($key, $suffix), + "Failed asserting that key '{$key}' ends with suffix '{$suffix}'" + ); + } +} diff --git a/tests/Cache/Redis/Integration/PrefixHandlingIntegrationTest.php b/tests/Cache/Redis/Integration/PrefixHandlingIntegrationTest.php new file mode 100644 index 000000000..1eb1e2833 --- /dev/null +++ b/tests/Cache/Redis/Integration/PrefixHandlingIntegrationTest.php @@ -0,0 +1,491 @@ +app->get(RedisFactory::class); + $store = new RedisStore($factory, $cachePrefix, 'default'); + $store->setTagMode(TagMode::Any); + + return $store; + } + + // ========================================================================= + // BASIC OPERATIONS WITH DIFFERENT PREFIXES + // ========================================================================= + + public function testPutGetWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('custom:'); + + $store->put('test_key', 'test_value', 60); + $this->assertSame('test_value', $store->get('test_key')); + } + + public function testPutGetWithEmptyPrefix(): void + { + $store = $this->createStoreWithPrefix(''); + + $store->put('test_key', 'test_value', 60); + $this->assertSame('test_value', $store->get('test_key')); + } + + public function testPutGetWithLongPrefix(): void + { + $store = $this->createStoreWithPrefix('very:long:nested:prefix:structure:'); + + $store->put('test_key', 'test_value', 60); + $this->assertSame('test_value', $store->get('test_key')); + } + + public function testForgetWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('forget_test:'); + + $store->put('key_to_forget', 'value', 60); + $this->assertSame('value', $store->get('key_to_forget')); + + $store->forget('key_to_forget'); + $this->assertNull($store->get('key_to_forget')); + } + + // ========================================================================= + // PREFIX ISOLATION - DIFFERENT PREFIXES ARE ISOLATED + // ========================================================================= + + public function testDifferentPrefixesAreIsolated(): void + { + $store1 = $this->createStoreWithPrefix('app1:'); + $store2 = $this->createStoreWithPrefix('app2:'); + + // Same key name in different stores + $store1->put('shared_key', 'value_from_app1', 60); + $store2->put('shared_key', 'value_from_app2', 60); + + // Each store sees only its own value + $this->assertSame('value_from_app1', $store1->get('shared_key')); + $this->assertSame('value_from_app2', $store2->get('shared_key')); + } + + public function testForgetOnlyAffectsOwnPrefix(): void + { + $store1 = $this->createStoreWithPrefix('app1:'); + $store2 = $this->createStoreWithPrefix('app2:'); + + $store1->put('key', 'value1', 60); + $store2->put('key', 'value2', 60); + + // Forget from store1 only affects store1 + $store1->forget('key'); + + $this->assertNull($store1->get('key')); + $this->assertSame('value2', $store2->get('key')); + } + + public function testMultipleStoresWithDifferentPrefixes(): void + { + $stores = [ + 'a' => $this->createStoreWithPrefix('prefix_a:'), + 'b' => $this->createStoreWithPrefix('prefix_b:'), + 'c' => $this->createStoreWithPrefix('prefix_c:'), + ]; + + // Each store writes to same key name + foreach ($stores as $name => $store) { + $store->put('common_key', "value_from_{$name}", 60); + } + + // Each store reads its own value + foreach ($stores as $name => $store) { + $this->assertSame("value_from_{$name}", $store->get('common_key')); + } + } + + // ========================================================================= + // TAGGED OPERATIONS WITH DIFFERENT PREFIXES + // ========================================================================= + + public function testTaggedOperationsWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('tagged_app:'); + $tagged = new AnyTaggedCache($store, new AnyTagSet($store, ['my_tag'])); + + $tagged->put('tagged_item', 'tagged_value', 60); + $this->assertSame('tagged_value', $store->get('tagged_item')); + + $tagged->flush(); + $this->assertNull($store->get('tagged_item')); + } + + public function testTaggedOperationsIsolatedByPrefix(): void + { + $store1 = $this->createStoreWithPrefix('app1:'); + $store2 = $this->createStoreWithPrefix('app2:'); + + $tagged1 = new AnyTaggedCache($store1, new AnyTagSet($store1, ['shared_tag'])); + $tagged2 = new AnyTaggedCache($store2, new AnyTagSet($store2, ['shared_tag'])); + + // Same tag name, different stores + $tagged1->put('item', 'from_app1', 60); + $tagged2->put('item', 'from_app2', 60); + + // Flush tag in store1 only affects store1 + $tagged1->flush(); + + $this->assertNull($store1->get('item')); + $this->assertSame('from_app2', $store2->get('item')); + } + + public function testMultipleTagsWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('multi_tag:'); + $tagged = new AnyTaggedCache($store, new AnyTagSet($store, ['tag1', 'tag2', 'tag3'])); + + $tagged->put('multi_tagged_item', 'value', 60); + $this->assertSame('value', $store->get('multi_tagged_item')); + + // Flushing any tag should remove the item + $singleTag = new AnyTaggedCache($store, new AnyTagSet($store, ['tag2'])); + $singleTag->flush(); + + $this->assertNull($store->get('multi_tagged_item')); + } + + // ========================================================================= + // INCREMENT/DECREMENT WITH DIFFERENT PREFIXES + // ========================================================================= + + public function testIncrementDecrementWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('counter:'); + + $store->put('my_counter', 10, 60); + + $newValue = $store->increment('my_counter', 5); + $this->assertEquals(15, $newValue); + + $newValue = $store->decrement('my_counter', 3); + $this->assertEquals(12, $newValue); + } + + public function testIncrementIsolatedByPrefix(): void + { + $store1 = $this->createStoreWithPrefix('app1:'); + $store2 = $this->createStoreWithPrefix('app2:'); + + $store1->put('counter', 100, 60); + $store2->put('counter', 200, 60); + + $store1->increment('counter', 10); + $store2->increment('counter', 20); + + $this->assertEquals(110, $store1->get('counter')); + $this->assertEquals(220, $store2->get('counter')); + } + + // ========================================================================= + // PUTMANY WITH DIFFERENT PREFIXES + // ========================================================================= + + public function testPutManyWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('batch:'); + + $store->putMany([ + 'key1' => 'value1', + 'key2' => 'value2', + 'key3' => 'value3', + ], 60); + + $this->assertSame('value1', $store->get('key1')); + $this->assertSame('value2', $store->get('key2')); + $this->assertSame('value3', $store->get('key3')); + } + + public function testManyRetrievalWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('many:'); + + $store->putMany([ + 'a' => '1', + 'b' => '2', + 'c' => '3', + ], 60); + + $result = $store->many(['a', 'b', 'c', 'nonexistent']); + + $this->assertSame('1', $result['a']); + $this->assertSame('2', $result['b']); + $this->assertSame('3', $result['c']); + $this->assertNull($result['nonexistent']); + } + + // ========================================================================= + // SPECIAL CHARACTERS IN PREFIX + // ========================================================================= + + public function testPrefixWithColons(): void + { + $store = $this->createStoreWithPrefix('app:v2:prod:'); + + $store->put('key', 'value', 60); + $this->assertSame('value', $store->get('key')); + + $store->forget('key'); + $this->assertNull($store->get('key')); + } + + public function testPrefixWithNumbers(): void + { + $store = $this->createStoreWithPrefix('cache123:'); + + $store->put('key', 'value', 60); + $this->assertSame('value', $store->get('key')); + } + + public function testPrefixWithUnderscores(): void + { + $store = $this->createStoreWithPrefix('my_app_cache_'); + + $store->put('key', 'value', 60); + $this->assertSame('value', $store->get('key')); + } + + // ========================================================================= + // ADD OPERATION WITH DIFFERENT PREFIXES + // ========================================================================= + + public function testAddWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('add_test:'); + + // First add succeeds + $result = $store->add('unique_key', 'first_value', 60); + $this->assertTrue($result); + $this->assertSame('first_value', $store->get('unique_key')); + + // Second add fails + $result = $store->add('unique_key', 'second_value', 60); + $this->assertFalse($result); + $this->assertSame('first_value', $store->get('unique_key')); + } + + public function testAddIsolatedByPrefix(): void + { + $store1 = $this->createStoreWithPrefix('app1:'); + $store2 = $this->createStoreWithPrefix('app2:'); + + // Both can add the same key name + $this->assertTrue($store1->add('key', 'value1', 60)); + $this->assertTrue($store2->add('key', 'value2', 60)); + + $this->assertSame('value1', $store1->get('key')); + $this->assertSame('value2', $store2->get('key')); + } + + // ========================================================================= + // FOREVER OPERATION WITH DIFFERENT PREFIXES + // ========================================================================= + + public function testForeverWithCustomPrefix(): void + { + $store = $this->createStoreWithPrefix('forever:'); + + $store->forever('permanent_key', 'permanent_value'); + $this->assertSame('permanent_value', $store->get('permanent_key')); + } + + public function testForeverIsolatedByPrefix(): void + { + $store1 = $this->createStoreWithPrefix('app1:'); + $store2 = $this->createStoreWithPrefix('app2:'); + + $store1->forever('key', 'value1'); + $store2->forever('key', 'value2'); + + $this->assertSame('value1', $store1->get('key')); + $this->assertSame('value2', $store2->get('key')); + } + + // ========================================================================= + // OPT_PREFIX SCENARIOS - ACTUAL KEY VERIFICATION + // ========================================================================= + + /** + * Create a store with specific OPT_PREFIX and cache prefix. + */ + private function createStoreWithPrefixes(string $optPrefix, string $cachePrefix): RedisStore + { + $connectionName = $this->createConnectionWithOptPrefix($optPrefix); + $factory = $this->app->get(RedisFactory::class); + $store = new RedisStore($factory, $cachePrefix, $connectionName); + $store->setTagMode(TagMode::Any); + + return $store; + } + + public function testOptPrefixOnlyNoCachePrefix(): void + { + // Create store with OPT_PREFIX only (no cache prefix) + $store = $this->createStoreWithPrefixes('opt:', ''); + + $store->put('test_key', 'test_value', 60); + $this->assertSame('test_value', $store->get('test_key')); + + // Verify actual key structure using raw client + $rawClient = $this->rawRedisClientWithoutPrefix(); + $this->assertTrue($rawClient->exists('opt:test_key') > 0); + $rawClient->close(); + } + + public function testBothOptPrefixAndCachePrefix(): void + { + // Create store with both OPT_PREFIX and cache prefix + $store = $this->createStoreWithPrefixes('opt:', 'cache:'); + + $store->put('test_key', 'test_value', 60); + $this->assertSame('test_value', $store->get('test_key')); + + // Verify actual key structure: OPT_PREFIX + cache prefix + key + $rawClient = $this->rawRedisClientWithoutPrefix(); + $this->assertTrue($rawClient->exists('opt:cache:test_key') > 0); + $rawClient->close(); + } + + public function testNoOptPrefixCachePrefixOnly(): void + { + // Create store with no OPT_PREFIX, only cache prefix + $store = $this->createStoreWithPrefixes('', 'cache:'); + + $store->put('test_key', 'test_value', 60); + $this->assertSame('test_value', $store->get('test_key')); + + // Verify actual key structure: cache prefix + key only + $rawClient = $this->rawRedisClientWithoutPrefix(); + $this->assertTrue($rawClient->exists('cache:test_key') > 0); + $rawClient->close(); + } + + public function testNoPrefixesAtAll(): void + { + // Create store with no prefixes at all + $store = $this->createStoreWithPrefixes('', ''); + + $store->put('test_key', 'test_value', 60); + $this->assertSame('test_value', $store->get('test_key')); + + // Verify actual key structure: just the key + $rawClient = $this->rawRedisClientWithoutPrefix(); + $this->assertTrue($rawClient->exists('test_key') > 0); + $rawClient->close(); + } + + public function testOptPrefixIsolation(): void + { + // Create two stores with different OPT_PREFIX + $store1 = $this->createStoreWithPrefixes('app1:', 'cache:'); + $store2 = $this->createStoreWithPrefixes('app2:', 'cache:'); + + $store1->put('shared_key', 'from_app1', 60); + $store2->put('shared_key', 'from_app2', 60); + + // Each store sees its own value + $this->assertSame('from_app1', $store1->get('shared_key')); + $this->assertSame('from_app2', $store2->get('shared_key')); + + // Verify in Redis: different keys + $rawClient = $this->rawRedisClientWithoutPrefix(); + $this->assertTrue($rawClient->exists('app1:cache:shared_key') > 0); + $this->assertTrue($rawClient->exists('app2:cache:shared_key') > 0); + $rawClient->close(); + } + + public function testOptPrefixWithTaggedOperations(): void + { + $store = $this->createStoreWithPrefixes('opt:', 'cache:'); + $tagged = new AnyTaggedCache($store, new AnyTagSet($store, ['products'])); + + $tagged->put('laptop', 'MacBook', 60); + $this->assertSame('MacBook', $store->get('laptop')); + + // Verify actual keys in Redis + $rawClient = $this->rawRedisClientWithoutPrefix(); + + // Value key: opt: + cache: + key + $this->assertTrue($rawClient->exists('opt:cache:laptop') > 0); + + // Tag hash: opt: + cache: + _any:tag: + tag + :entries + $this->assertTrue($rawClient->exists('opt:cache:_any:tag:products:entries') > 0); + + // Reverse index: opt: + cache: + key + :_any:tags + $this->assertTrue($rawClient->exists('opt:cache:laptop:_any:tags') > 0); + + $rawClient->close(); + } + + public function testOptPrefixWithTagFlush(): void + { + $store = $this->createStoreWithPrefixes('opt:', 'cache:'); + $tagged = new AnyTaggedCache($store, new AnyTagSet($store, ['flush-test'])); + + $tagged->put('item1', 'value1', 60); + $tagged->put('item2', 'value2', 60); + + // Verify items exist + $this->assertSame('value1', $store->get('item1')); + $this->assertSame('value2', $store->get('item2')); + + // Flush the tag + $tagged->flush(); + + // Items should be gone + $this->assertNull($store->get('item1')); + $this->assertNull($store->get('item2')); + + // Verify in Redis + $rawClient = $this->rawRedisClientWithoutPrefix(); + $this->assertFalse($rawClient->exists('opt:cache:item1') > 0); + $this->assertFalse($rawClient->exists('opt:cache:item2') > 0); + $rawClient->close(); + } + + protected function tearDown(): void + { + // Clean up any keys created by OPT_PREFIX tests + $patterns = ['opt:*', 'app1:*', 'app2:*']; + foreach ($patterns as $pattern) { + $this->cleanupKeysWithPattern($pattern); + } + + // Also clean up no-prefix keys + $this->cleanupKeysWithPattern('test_key'); + $this->cleanupKeysWithPattern('cache:*'); + + parent::tearDown(); + } +} diff --git a/tests/Cache/Redis/Integration/PruneIntegrationTest.php b/tests/Cache/Redis/Integration/PruneIntegrationTest.php new file mode 100644 index 000000000..a1cea892e --- /dev/null +++ b/tests/Cache/Redis/Integration/PruneIntegrationTest.php @@ -0,0 +1,388 @@ +setTagMode(TagMode::Any); + + // Store items with multiple tags + Cache::tags(['posts', 'user:1'])->put('post:1', 'data', 60); + Cache::tags(['posts', 'user:2'])->put('post:2', 'data', 60); + Cache::tags(['posts', 'featured'])->put('post:3', 'data', 60); + + // Flush one tag + Cache::tags(['posts'])->flush(); + + // All cache keys should be gone + $this->assertNull(Cache::get('post:1')); + $this->assertNull(Cache::get('post:2')); + $this->assertNull(Cache::get('post:3')); + + // But other tag hashes should still have orphaned fields + $this->assertTrue( + $this->anyModeTagHasEntry('user:1', 'post:1'), + 'user:1 hash should have orphaned field for post:1' + ); + $this->assertTrue( + $this->anyModeTagHasEntry('user:2', 'post:2'), + 'user:2 hash should have orphaned field for post:2' + ); + $this->assertTrue( + $this->anyModeTagHasEntry('featured', 'post:3'), + 'featured hash should have orphaned field for post:3' + ); + } + + public function testAnyModeForgetLeavesOrphanedFields(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'user:1'])->put('post:1', 'data', 60); + + // Forget the item directly + Cache::forget('post:1'); + + // Cache key should be gone + $this->assertNull(Cache::get('post:1')); + + // But tag hash fields remain (orphaned) + $this->assertTrue( + $this->anyModeTagHasEntry('posts', 'post:1'), + 'posts hash should have orphaned field' + ); + $this->assertTrue( + $this->anyModeTagHasEntry('user:1', 'post:1'), + 'user:1 hash should have orphaned field' + ); + } + + // ========================================================================= + // ANY MODE - PRUNE COMMAND + // ========================================================================= + + public function testAnyModePruneRemovesOrphanedFields(): void + { + $this->setTagMode(TagMode::Any); + + // Create orphaned fields + Cache::tags(['posts', 'user:1'])->put('post:1', 'data', 60); + Cache::tags(['posts', 'user:2'])->put('post:2', 'data', 60); + Cache::tags(['posts'])->flush(); // Leaves orphans in user:1 and user:2 + + // Verify orphans exist + $this->assertTrue($this->anyModeTagHasEntry('user:1', 'post:1')); + $this->assertTrue($this->anyModeTagHasEntry('user:2', 'post:2')); + + // Run prune operation + $this->store()->anyTagOps()->prune()->execute(); + + // Orphans should be removed + $this->assertFalse( + $this->anyModeTagHasEntry('user:1', 'post:1'), + 'Orphaned field post:1 should be removed from user:1' + ); + $this->assertFalse( + $this->anyModeTagHasEntry('user:2', 'post:2'), + 'Orphaned field post:2 should be removed from user:2' + ); + } + + public function testAnyModePruneDeletesEmptyTagHashes(): void + { + $this->setTagMode(TagMode::Any); + + // Create item with single tag + Cache::tags(['user:1'])->put('post:1', 'data', 60); + + // Verify hash exists + $this->assertRedisKeyExists($this->anyModeTagKey('user:1')); + + // Forget item (leaves orphaned field) + Cache::forget('post:1'); + + // Orphan exists + $this->assertTrue($this->anyModeTagHasEntry('user:1', 'post:1')); + + // Run prune + $this->store()->anyTagOps()->prune()->execute(); + + // Hash should be deleted (was empty after orphan removal) + $this->assertRedisKeyNotExists($this->anyModeTagKey('user:1')); + } + + public function testAnyModePrunePreservesValidFields(): void + { + $this->setTagMode(TagMode::Any); + + // Create items + Cache::tags(['posts', 'user:1'])->put('post:1', 'data1', 60); + Cache::tags(['posts', 'user:2'])->put('post:2', 'data2', 60); + + // Flush just user:1 (deletes post:1 cache key) + Cache::tags(['user:1'])->flush(); + + // posts hash should have orphaned post:1 and valid post:2 + $this->assertTrue($this->anyModeTagHasEntry('posts', 'post:1')); // Orphaned + $this->assertTrue($this->anyModeTagHasEntry('posts', 'post:2')); // Valid + + // Run prune + $this->store()->anyTagOps()->prune()->execute(); + + // Orphan removed, valid field preserved + $this->assertFalse( + $this->anyModeTagHasEntry('posts', 'post:1'), + 'Orphaned field post:1 should be removed' + ); + $this->assertTrue( + $this->anyModeTagHasEntry('posts', 'post:2'), + 'Valid field post:2 should be preserved' + ); + + // post:2 data should still be accessible + $this->assertSame('data2', Cache::get('post:2')); + } + + public function testAnyModePruneHandlesMultipleTagHashes(): void + { + $this->setTagMode(TagMode::Any); + + // Create items in multiple tags + for ($i = 1; $i <= 5; ++$i) { + Cache::tags(["tag{$i}", 'common'])->put("key{$i}", "data{$i}", 60); + } + + // Flush common tag + Cache::tags(['common'])->flush(); + + // Verify orphans in all tag hashes + for ($i = 1; $i <= 5; ++$i) { + $this->assertTrue( + $this->anyModeTagHasEntry("tag{$i}", "key{$i}"), + "tag{$i} should have orphaned field" + ); + } + + // Run prune + $this->store()->anyTagOps()->prune()->execute(); + + // All orphans should be removed + for ($i = 1; $i <= 5; ++$i) { + $this->assertFalse( + $this->anyModeTagHasEntry("tag{$i}", "key{$i}"), + "Orphan in tag{$i} should be removed" + ); + } + } + + public function testAnyModePruneHandlesLargeNumberOfOrphans(): void + { + $this->setTagMode(TagMode::Any); + + // Create many items + for ($i = 1; $i <= 50; ++$i) { + Cache::tags(['posts', "user:{$i}"])->put("post:{$i}", "data{$i}", 60); + } + + // Flush posts tag + Cache::tags(['posts'])->flush(); + + // Verify some orphans exist + $this->assertTrue($this->anyModeTagHasEntry('user:1', 'post:1')); + $this->assertTrue($this->anyModeTagHasEntry('user:25', 'post:25')); + $this->assertTrue($this->anyModeTagHasEntry('user:50', 'post:50')); + + // Run prune + $this->store()->anyTagOps()->prune()->execute(); + + // All orphans should be removed + for ($i = 1; $i <= 50; ++$i) { + $this->assertFalse( + $this->anyModeTagHasEntry("user:{$i}", "post:{$i}"), + "Orphan in user:{$i} should be removed" + ); + } + } + + public function testAnyModePruneHandlesForeverItems(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'user:1'])->forever('post:1', 'data'); + + // Flush posts tag + Cache::tags(['posts'])->flush(); + + // Cache key should be gone + $this->assertNull(Cache::get('post:1')); + + // Orphaned field in user:1 + $this->assertTrue($this->anyModeTagHasEntry('user:1', 'post:1')); + + // Prune should remove it + $this->store()->anyTagOps()->prune()->execute(); + + $this->assertFalse($this->anyModeTagHasEntry('user:1', 'post:1')); + } + + // ========================================================================= + // ALL MODE - ORPHAN CREATION + // ========================================================================= + + public function testAllModeFlushLeavesOrphanedEntriesInOtherTags(): void + { + $this->setTagMode(TagMode::All); + + // Store items with multiple tags + Cache::tags(['posts', 'user:1'])->put('post:1', 'data', 60); + Cache::tags(['posts', 'user:2'])->put('post:2', 'data', 60); + + // Flush posts tag + Cache::tags(['posts'])->flush(); + + // Cache keys should be gone (posts ZSET deleted) + $this->assertNull(Cache::tags(['posts', 'user:1'])->get('post:1')); + $this->assertNull(Cache::tags(['posts', 'user:2'])->get('post:2')); + + // But other tag ZSETs should still have orphaned entries + // (The namespaced key entries in user:1 and user:2 ZSETs are orphaned) + $this->assertNotEmpty( + $this->getAllModeTagEntries('user:1'), + 'user:1 ZSET should have orphaned entry' + ); + $this->assertNotEmpty( + $this->getAllModeTagEntries('user:2'), + 'user:2 ZSET should have orphaned entry' + ); + } + + // ========================================================================= + // ALL MODE - PRUNE COMMAND + // ========================================================================= + + public function testAllModePruneRemovesOrphanedEntries(): void + { + $this->setTagMode(TagMode::All); + + // Create orphaned entries + Cache::tags(['posts', 'user:1'])->put('post:1', 'data', 60); + Cache::tags(['posts', 'user:2'])->put('post:2', 'data', 60); + Cache::tags(['posts'])->flush(); // Leaves orphans in user:1 and user:2 + + // Verify orphans exist + $this->assertNotEmpty($this->getAllModeTagEntries('user:1')); + $this->assertNotEmpty($this->getAllModeTagEntries('user:2')); + + // Run prune operation (scans all tags) + $this->store()->allTagOps()->prune()->execute(); + + // Orphans should be removed (ZSETs deleted or emptied) + $this->assertEmpty( + $this->getAllModeTagEntries('user:1'), + 'Orphaned entries should be removed from user:1' + ); + $this->assertEmpty( + $this->getAllModeTagEntries('user:2'), + 'Orphaned entries should be removed from user:2' + ); + } + + public function testAllModePrunePreservesValidEntries(): void + { + $this->setTagMode(TagMode::All); + + // Create items + Cache::tags(['posts'])->put('post:1', 'data1', 60); + Cache::tags(['posts'])->put('post:2', 'data2', 60); + + // Forget just post:1 (direct forget doesn't clean tag entries in all mode) + Cache::tags(['posts'])->forget('post:1'); + + // Verify post:1 is gone but post:2 exists + $this->assertNull(Cache::tags(['posts'])->get('post:1')); + $this->assertSame('data2', Cache::tags(['posts'])->get('post:2')); + + // ZSET should still have entries + $entriesBefore = $this->getAllModeTagEntries('posts'); + $this->assertCount(2, $entriesBefore); // Both entries still in ZSET + + // Run prune (will clean stale entries based on TTL) + $this->store()->allTagOps()->prune()->execute(); + + // post:2 should still be accessible + $this->assertSame('data2', Cache::tags(['posts'])->get('post:2')); + } + + public function testAllModePruneHandlesMultipleTags(): void + { + $this->setTagMode(TagMode::All); + + // Create items in multiple tags + for ($i = 1; $i <= 5; ++$i) { + Cache::tags(["tag{$i}"])->put("key{$i}", "data{$i}", 60); + } + + // Verify all ZSETs exist + for ($i = 1; $i <= 5; ++$i) { + $this->assertNotEmpty($this->getAllModeTagEntries("tag{$i}")); + } + + // Flush all tags individually to create state where cache keys are gone + for ($i = 1; $i <= 5; ++$i) { + Cache::tags(["tag{$i}"])->flush(); + } + + // ZSETs should be deleted after flush + for ($i = 1; $i <= 5; ++$i) { + $this->assertEmpty($this->getAllModeTagEntries("tag{$i}")); + } + } + + // ========================================================================= + // REGISTRY CLEANUP (ANY MODE) + // ========================================================================= + + public function testAnyModePruneRemovesStaleTagsFromRegistry(): void + { + $this->setTagMode(TagMode::Any); + + // Create items + Cache::tags(['tag1', 'tag2'])->put('key1', 'value1', 60); + + // Verify tags are in registry + $this->assertTrue($this->anyModeRegistryHasTag('tag1')); + $this->assertTrue($this->anyModeRegistryHasTag('tag2')); + + // Flush both tags + Cache::tags(['tag1', 'tag2'])->flush(); + + // Tags should be removed from registry after flush + $this->assertFalse($this->anyModeRegistryHasTag('tag1')); + $this->assertFalse($this->anyModeRegistryHasTag('tag2')); + } +} diff --git a/tests/Cache/Redis/Integration/RedisCacheIntegrationTestCase.php b/tests/Cache/Redis/Integration/RedisCacheIntegrationTestCase.php new file mode 100644 index 000000000..86a265b89 --- /dev/null +++ b/tests/Cache/Redis/Integration/RedisCacheIntegrationTestCase.php @@ -0,0 +1,313 @@ +app->get(ConfigInterface::class); + + $config->set('cache.default', 'redis'); + } + + /** + * Get the cache repository. + */ + protected function cache(): Repository + { + return Cache::store('redis'); + } + + /** + * Get the underlying RedisStore. + */ + protected function store(): RedisStore + { + $store = $this->cache()->getStore(); + assert($store instanceof RedisStore); + + return $store; + } + + /** + * Get a raw phpredis client for direct Redis verification. + * + * Note: This client has OPT_PREFIX set to testPrefix, so keys + * are automatically prefixed when using this client. + */ + protected function redis(): PhpRedis + { + return Redis::client(); + } + + /** + * Set the tag mode on the store. + */ + protected function setTagMode(TagMode|string $mode): void + { + $this->store()->setTagMode($mode); + } + + /** + * Get the current tag mode. + */ + protected function getTagMode(): TagMode + { + return $this->store()->getTagMode(); + } + + /** + * Get the cache prefix (includes test prefix from parent). + */ + protected function getCachePrefix(): string + { + return $this->store()->getPrefix(); + } + + // ========================================================================= + // ALL MODE HELPERS + // ========================================================================= + + /** + * Get the tag ZSET key for all mode. + * Format: {prefix}_all:tag:{name}:entries. + */ + protected function allModeTagKey(string $tagName): string + { + return $this->getCachePrefix() . '_all:tag:' . $tagName . ':entries'; + } + + /** + * Get all entries from an all-mode tag ZSET. + * + * @return array Key => score mapping + */ + protected function getAllModeTagEntries(string $tagName): array + { + $key = $this->allModeTagKey($tagName); + $result = $this->redis()->zRange($key, 0, -1, ['WITHSCORES' => true]); + + return is_array($result) ? $result : []; + } + + /** + * Check if an entry exists in all-mode tag ZSET. + */ + protected function allModeTagHasEntry(string $tagName, string $cacheKey): bool + { + $key = $this->allModeTagKey($tagName); + + return $this->redis()->zScore($key, $cacheKey) !== false; + } + + // ========================================================================= + // ANY MODE HELPERS + // ========================================================================= + + /** + * Get the tag HASH key for any mode. + * Format: {prefix}_any:tag:{name}:entries. + */ + protected function anyModeTagKey(string $tagName): string + { + return $this->getCachePrefix() . '_any:tag:' . $tagName . ':entries'; + } + + /** + * Get the reverse index SET key for any mode. + * Format: {prefix}{cacheKey}:_any:tags. + */ + protected function anyModeReverseIndexKey(string $cacheKey): string + { + return $this->getCachePrefix() . $cacheKey . ':_any:tags'; + } + + /** + * Get the tag registry ZSET key for any mode. + * Format: {prefix}_any:tag:registry. + */ + protected function anyModeRegistryKey(): string + { + return $this->getCachePrefix() . '_any:tag:registry'; + } + + /** + * Get all fields from an any-mode tag HASH. + * + * @return array Field => value mapping + */ + protected function getAnyModeTagEntries(string $tagName): array + { + $key = $this->anyModeTagKey($tagName); + $result = $this->redis()->hGetAll($key); + + return is_array($result) ? $result : []; + } + + /** + * Check if a field exists in any-mode tag HASH. + */ + protected function anyModeTagHasEntry(string $tagName, string $cacheKey): bool + { + $key = $this->anyModeTagKey($tagName); + + return $this->redis()->hExists($key, $cacheKey); + } + + /** + * Get tags from reverse index SET for any mode. + * + * @return array + */ + protected function getAnyModeReverseIndex(string $cacheKey): array + { + $key = $this->anyModeReverseIndexKey($cacheKey); + $result = $this->redis()->sMembers($key); + + return is_array($result) ? $result : []; + } + + /** + * Get all tags from registry ZSET for any mode. + * + * @return array Tag name => score mapping + */ + protected function getAnyModeRegistry(): array + { + $key = $this->anyModeRegistryKey(); + $result = $this->redis()->zRange($key, 0, -1, ['WITHSCORES' => true]); + + return is_array($result) ? $result : []; + } + + /** + * Check if a tag exists in the any-mode registry. + */ + protected function anyModeRegistryHasTag(string $tagName): bool + { + $key = $this->anyModeRegistryKey(); + + return $this->redis()->zScore($key, $tagName) !== false; + } + + // ========================================================================= + // GENERIC HELPERS + // ========================================================================= + + /** + * Get the tag key based on current mode. + */ + protected function tagKey(string $tagName): string + { + return $this->getTagMode()->isAnyMode() + ? $this->anyModeTagKey($tagName) + : $this->allModeTagKey($tagName); + } + + /** + * Check if a cache key exists in the tag structure for current mode. + */ + protected function tagHasEntry(string $tagName, string $cacheKey): bool + { + return $this->getTagMode()->isAnyMode() + ? $this->anyModeTagHasEntry($tagName, $cacheKey) + : $this->allModeTagHasEntry($tagName, $cacheKey); + } + + /** + * Run a test callback for both tag modes. + * + * This is useful for tests that should verify behavior in both modes. + * The callback receives the current TagMode being tested. + * + * @param callable(TagMode): void $callback + */ + protected function forBothModes(callable $callback): void + { + foreach ([TagMode::All, TagMode::Any] as $mode) { + $this->setTagMode($mode); + + // Flush to clean state between modes + Redis::flushByPattern('*'); + + $callback($mode); + } + } + + /** + * Assert that a Redis key exists. + */ + protected function assertRedisKeyExists(string $key, string $message = ''): void + { + $this->assertTrue( + $this->redis()->exists($key) > 0, + $message ?: "Redis key '{$key}' should exist" + ); + } + + /** + * Assert that a Redis key does not exist. + */ + protected function assertRedisKeyNotExists(string $key, string $message = ''): void + { + $this->assertFalse( + $this->redis()->exists($key) > 0, + $message ?: "Redis key '{$key}' should not exist" + ); + } + + /** + * Assert that a cache key is tracked in its tag structure. + */ + protected function assertKeyTrackedInTag(string $tagName, string $cacheKey, string $message = ''): void + { + $this->assertTrue( + $this->tagHasEntry($tagName, $cacheKey), + $message ?: "Cache key '{$cacheKey}' should be tracked in tag '{$tagName}'" + ); + } + + /** + * Assert that a cache key is NOT tracked in its tag structure. + */ + protected function assertKeyNotTrackedInTag(string $tagName, string $cacheKey, string $message = ''): void + { + $this->assertFalse( + $this->tagHasEntry($tagName, $cacheKey), + $message ?: "Cache key '{$cacheKey}' should not be tracked in tag '{$tagName}'" + ); + } +} diff --git a/tests/Cache/Redis/Integration/RememberIntegrationTest.php b/tests/Cache/Redis/Integration/RememberIntegrationTest.php new file mode 100644 index 000000000..1b577b2a3 --- /dev/null +++ b/tests/Cache/Redis/Integration/RememberIntegrationTest.php @@ -0,0 +1,421 @@ +setTagMode(TagMode::All); + + $result = Cache::tags(['remember_tag'])->remember('remember_key', 60, fn () => 'computed_value'); + + $this->assertSame('computed_value', $result); + $this->assertSame('computed_value', Cache::tags(['remember_tag'])->get('remember_key')); + + // Verify flush works + Cache::tags(['remember_tag'])->flush(); + $this->assertNull(Cache::tags(['remember_tag'])->get('remember_key')); + } + + public function testAllModeReturnsCachedValueOnSecondCall(): void + { + $this->setTagMode(TagMode::All); + + // First call + Cache::tags(['hit_tag'])->remember('hit_key', 60, fn () => 'value_1'); + + // Second call should return cached value, not execute closure + $result = Cache::tags(['hit_tag'])->remember('hit_key', 60, fn () => 'value_2'); + + $this->assertSame('value_1', $result); + } + + public function testAllModeRemembersForever(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::tags(['forever_tag'])->rememberForever('forever_key', fn () => 'forever_value'); + + $this->assertSame('forever_value', $result); + $this->assertSame('forever_value', Cache::tags(['forever_tag'])->get('forever_key')); + + Cache::tags(['forever_tag'])->flush(); + $this->assertNull(Cache::tags(['forever_tag'])->get('forever_key')); + } + + public function testAllModeRemembersWithMultipleTags(): void + { + $this->setTagMode(TagMode::All); + $tags = ['tag1', 'tag2', 'tag3']; + + $result = Cache::tags($tags)->remember('multi_tag_key', 60, fn () => 'multi_tag_value'); + + $this->assertSame('multi_tag_value', $result); + $this->assertSame('multi_tag_value', Cache::tags($tags)->get('multi_tag_key')); + + // Flush one tag should remove it + Cache::tags(['tag2'])->flush(); + $this->assertNull(Cache::tags($tags)->get('multi_tag_key')); + } + + // ========================================================================= + // ANY MODE - REMEMBER OPERATIONS + // ========================================================================= + + public function testAnyModeRemembersValueWithTags(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['remember_tag'])->remember('remember_key', 60, fn () => 'computed_value'); + + $this->assertSame('computed_value', $result); + $this->assertSame('computed_value', Cache::get('remember_key')); + + // Verify flush works + Cache::tags(['remember_tag'])->flush(); + $this->assertNull(Cache::get('remember_key')); + } + + public function testAnyModeReturnsCachedValueOnSecondCall(): void + { + $this->setTagMode(TagMode::Any); + + // First call + Cache::tags(['hit_tag'])->remember('hit_key', 60, fn () => 'value_1'); + + // Second call should return cached value, not execute closure + $result = Cache::tags(['hit_tag'])->remember('hit_key', 60, fn () => 'value_2'); + + $this->assertSame('value_1', $result); + } + + public function testAnyModeRemembersForever(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['forever_tag'])->rememberForever('forever_key', fn () => 'forever_value'); + + $this->assertSame('forever_value', $result); + $this->assertSame('forever_value', Cache::get('forever_key')); + + Cache::tags(['forever_tag'])->flush(); + $this->assertNull(Cache::get('forever_key')); + } + + public function testAnyModeRemembersWithMultipleTags(): void + { + $this->setTagMode(TagMode::Any); + $tags = ['tag1', 'tag2', 'tag3']; + + $result = Cache::tags($tags)->remember('multi_tag_key', 60, fn () => 'multi_tag_value'); + + $this->assertSame('multi_tag_value', $result); + $this->assertSame('multi_tag_value', Cache::get('multi_tag_key')); + + // Flush one tag should remove it (union behavior) + Cache::tags(['tag2'])->flush(); + $this->assertNull(Cache::get('multi_tag_key')); + } + + // ========================================================================= + // CALLBACK NOT CALLED WHEN VALUE EXISTS - BOTH MODES + // ========================================================================= + + public function testAllModeDoesNotCallCallbackWhenValueExists(): void + { + $this->setTagMode(TagMode::All); + + $callCount = 0; + Cache::tags(['existing_tag'])->remember('existing_key', 60, function () use (&$callCount) { + ++$callCount; + + return 'first'; + }); + $this->assertEquals(1, $callCount); + + // Second call should NOT invoke callback + $result = Cache::tags(['existing_tag'])->remember('existing_key', 60, function () use (&$callCount) { + ++$callCount; + + return 'second'; + }); + + $this->assertEquals(1, $callCount); // Still 1 + $this->assertSame('first', $result); + } + + public function testAnyModeDoesNotCallCallbackWhenValueExists(): void + { + $this->setTagMode(TagMode::Any); + + $callCount = 0; + Cache::tags(['existing_tag'])->remember('existing_key', 60, function () use (&$callCount) { + ++$callCount; + + return 'first'; + }); + $this->assertEquals(1, $callCount); + + // Second call should NOT invoke callback + $result = Cache::tags(['existing_tag'])->remember('existing_key', 60, function () use (&$callCount) { + ++$callCount; + + return 'second'; + }); + + $this->assertEquals(1, $callCount); // Still 1 + $this->assertSame('first', $result); + } + + // ========================================================================= + // RE-EXECUTES CLOSURE AFTER FLUSH - BOTH MODES + // ========================================================================= + + public function testAllModeReExecutesClosureAfterFlush(): void + { + $this->setTagMode(TagMode::All); + $tags = ['tag1', 'tag2']; + + // 1. Remember (Miss) + $result = Cache::tags($tags)->remember('lifecycle_key', 60, fn () => 'val_1'); + $this->assertSame('val_1', $result); + + // 2. Remember (Hit) + $result = Cache::tags($tags)->remember('lifecycle_key', 60, fn () => 'val_2'); + $this->assertSame('val_1', $result); + + // 3. Flush tag1 + Cache::tags(['tag1'])->flush(); + + // 4. Remember (Miss - because key was deleted) + $result = Cache::tags($tags)->remember('lifecycle_key', 60, fn () => 'val_3'); + $this->assertSame('val_3', $result); + } + + public function testAnyModeReExecutesClosureAfterFlush(): void + { + $this->setTagMode(TagMode::Any); + $tags = ['tag1', 'tag2']; + + // 1. Remember (Miss) + $result = Cache::tags($tags)->remember('lifecycle_key', 60, fn () => 'val_1'); + $this->assertSame('val_1', $result); + + // 2. Remember (Hit) + $result = Cache::tags($tags)->remember('lifecycle_key', 60, fn () => 'val_2'); + $this->assertSame('val_1', $result); + + // 3. Flush tag1 + Cache::tags(['tag1'])->flush(); + + // 4. Remember (Miss - because key was deleted) + $result = Cache::tags($tags)->remember('lifecycle_key', 60, fn () => 'val_3'); + $this->assertSame('val_3', $result); + } + + // ========================================================================= + // EXCEPTION PROPAGATION - BOTH MODES + // ========================================================================= + + public function testAllModePropagatesExceptionFromRememberCallback(): void + { + $this->setTagMode(TagMode::All); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + Cache::tags(['exception_tag'])->remember('exception_key', 60, function () { + throw new RuntimeException('Callback failed'); + }); + } + + public function testAnyModePropagatesExceptionFromRememberCallback(): void + { + $this->setTagMode(TagMode::Any); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + Cache::tags(['exception_tag'])->remember('exception_key', 60, function () { + throw new RuntimeException('Callback failed'); + }); + } + + public function testAllModePropagatesExceptionFromRememberForeverCallback(): void + { + $this->setTagMode(TagMode::All); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Forever callback failed'); + + Cache::tags(['forever_exception_tag'])->rememberForever('forever_exception_key', function () { + throw new RuntimeException('Forever callback failed'); + }); + } + + public function testAnyModePropagatesExceptionFromRememberForeverCallback(): void + { + $this->setTagMode(TagMode::Any); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Forever callback failed'); + + Cache::tags(['forever_exception_tag'])->rememberForever('forever_exception_key', function () { + throw new RuntimeException('Forever callback failed'); + }); + } + + // ========================================================================= + // EDGE CASE RETURN VALUES - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesFalseReturnFromRemember(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::tags(['false_tag'])->remember('false_key', 60, fn () => false); + + $this->assertFalse($result); + $this->assertFalse(Cache::tags(['false_tag'])->get('false_key')); + } + + public function testAnyModeHandlesFalseReturnFromRemember(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['false_tag'])->remember('false_key', 60, fn () => false); + + $this->assertFalse($result); + $this->assertFalse(Cache::get('false_key')); + } + + public function testAllModeHandlesEmptyStringReturnFromRemember(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::tags(['empty_tag'])->remember('empty_key', 60, fn () => ''); + + $this->assertSame('', $result); + $this->assertSame('', Cache::tags(['empty_tag'])->get('empty_key')); + } + + public function testAnyModeHandlesEmptyStringReturnFromRemember(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['empty_tag'])->remember('empty_key', 60, fn () => ''); + + $this->assertSame('', $result); + $this->assertSame('', Cache::get('empty_key')); + } + + public function testAllModeHandlesZeroReturnFromRemember(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::tags(['zero_tag'])->remember('zero_key', 60, fn () => 0); + + $this->assertEquals(0, $result); + $this->assertEquals(0, Cache::tags(['zero_tag'])->get('zero_key')); + } + + public function testAnyModeHandlesZeroReturnFromRemember(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['zero_tag'])->remember('zero_key', 60, fn () => 0); + + $this->assertEquals(0, $result); + $this->assertEquals(0, Cache::get('zero_key')); + } + + public function testAllModeHandlesEmptyArrayReturnFromRemember(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::tags(['array_tag'])->remember('array_key', 60, fn () => []); + + $this->assertSame([], $result); + $this->assertSame([], Cache::tags(['array_tag'])->get('array_key')); + } + + public function testAnyModeHandlesEmptyArrayReturnFromRemember(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['array_tag'])->remember('array_key', 60, fn () => []); + + $this->assertSame([], $result); + $this->assertSame([], Cache::get('array_key')); + } + + // ========================================================================= + // NON-TAGGED REMEMBER OPERATIONS - BOTH MODES + // ========================================================================= + + public function testNonTaggedRememberInAllMode(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::remember('untagged_remember', 60, fn () => 'untagged_value'); + + $this->assertSame('untagged_value', $result); + $this->assertSame('untagged_value', Cache::get('untagged_remember')); + } + + public function testNonTaggedRememberInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::remember('untagged_remember', 60, fn () => 'untagged_value'); + + $this->assertSame('untagged_value', $result); + $this->assertSame('untagged_value', Cache::get('untagged_remember')); + } + + public function testNonTaggedRememberForeverInAllMode(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::rememberForever('untagged_forever', fn () => 'forever_untagged'); + + $this->assertSame('forever_untagged', $result); + $this->assertSame('forever_untagged', Cache::get('untagged_forever')); + } + + public function testNonTaggedRememberForeverInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::rememberForever('untagged_forever', fn () => 'forever_untagged'); + + $this->assertSame('forever_untagged', $result); + $this->assertSame('forever_untagged', Cache::get('untagged_forever')); + } +} diff --git a/tests/Cache/Redis/Integration/TagConsistencyIntegrationTest.php b/tests/Cache/Redis/Integration/TagConsistencyIntegrationTest.php new file mode 100644 index 000000000..8f21befd5 --- /dev/null +++ b/tests/Cache/Redis/Integration/TagConsistencyIntegrationTest.php @@ -0,0 +1,447 @@ +setTagMode(TagMode::All); + + // Seed data with various configurations + Cache::put('simple-key', 'value', 60); + Cache::tags(['tag1'])->put('tagged-key-1', 'value', 60); + Cache::tags(['tag1', 'tag2'])->put('tagged-key-2', 'value', 60); + Cache::tags(['tag3'])->forever('forever-key', 'value'); + + // Verify data exists + $this->assertTrue(Cache::has('simple-key')); + $this->assertNotNull(Cache::tags(['tag1'])->get('tagged-key-1')); + $this->assertRedisKeyExists($this->allModeTagKey('tag1')); + + // Full flush + Cache::flush(); + + // Verify all keys are gone + $this->assertNull(Cache::get('simple-key')); + $this->assertNull(Cache::tags(['tag1'])->get('tagged-key-1')); + $this->assertNull(Cache::tags(['tag1', 'tag2'])->get('tagged-key-2')); + $this->assertNull(Cache::tags(['tag3'])->get('forever-key')); + } + + public function testAnyModeFullFlushCleansAllKeys(): void + { + $this->setTagMode(TagMode::Any); + + // Seed data with various configurations + Cache::put('simple-key', 'value', 60); + Cache::tags(['tag1'])->put('tagged-key-1', 'value', 60); + Cache::tags(['tag1', 'tag2'])->put('tagged-key-2', 'value', 60); + Cache::tags(['tag3'])->forever('forever-key', 'value'); + + // Verify data exists + $this->assertTrue(Cache::has('simple-key')); + $this->assertSame('value', Cache::get('tagged-key-1')); + $this->assertRedisKeyExists($this->anyModeTagKey('tag1')); + + // Full flush + Cache::flush(); + + // Verify all keys are gone + $this->assertNull(Cache::get('simple-key')); + $this->assertNull(Cache::get('tagged-key-1')); + $this->assertNull(Cache::get('tagged-key-2')); + $this->assertNull(Cache::get('forever-key')); + } + + // ========================================================================= + // TAG REPLACEMENT ON OVERWRITE - ANY MODE ONLY + // ========================================================================= + + public function testAnyModeReverseIndexCleanupOnOverwrite(): void + { + $this->setTagMode(TagMode::Any); + + // Put key with Tag A + Cache::tags(['tag-a'])->put('my-key', 'value', 60); + + // Verify it's in Tag A's hash + $this->assertTrue($this->anyModeTagHasEntry('tag-a', 'my-key')); + $this->assertContains('tag-a', $this->getAnyModeReverseIndex('my-key')); + + // Overwrite same key with Tag B (removing Tag A association) + Cache::tags(['tag-b'])->put('my-key', 'new-value', 60); + + // Verify it's in Tag B's hash + $this->assertTrue($this->anyModeTagHasEntry('tag-b', 'my-key')); + + // Verify it is GONE from Tag A's hash (reverse index cleanup) + $this->assertFalse($this->anyModeTagHasEntry('tag-a', 'my-key')); + + // Verify reverse index updated + $reverseIndex = $this->getAnyModeReverseIndex('my-key'); + $this->assertContains('tag-b', $reverseIndex); + $this->assertNotContains('tag-a', $reverseIndex); + } + + public function testAnyModeTagReplacementWithMultipleTags(): void + { + $this->setTagMode(TagMode::Any); + + // Put key with tags A and B + Cache::tags(['tag-a', 'tag-b'])->put('my-key', 'value', 60); + + // Verify in both tags + $this->assertTrue($this->anyModeTagHasEntry('tag-a', 'my-key')); + $this->assertTrue($this->anyModeTagHasEntry('tag-b', 'my-key')); + + // Overwrite with tags C and D + Cache::tags(['tag-c', 'tag-d'])->put('my-key', 'new-value', 60); + + // Verify removed from A and B + $this->assertFalse($this->anyModeTagHasEntry('tag-a', 'my-key')); + $this->assertFalse($this->anyModeTagHasEntry('tag-b', 'my-key')); + + // Verify added to C and D + $this->assertTrue($this->anyModeTagHasEntry('tag-c', 'my-key')); + $this->assertTrue($this->anyModeTagHasEntry('tag-d', 'my-key')); + } + + public function testAllModeOverwriteCreatesOrphanedEntries(): void + { + $this->setTagMode(TagMode::All); + + // Put key with Tag A + Cache::tags(['tag-a'])->put('my-key', 'value', 60); + + // Get the namespaced key used in all mode + $namespacedKey = Cache::tags(['tag-a'])->taggedItemKey('my-key'); + + // Verify entry exists in tag A + $this->assertTrue($this->allModeTagHasEntry('tag-a', $namespacedKey)); + + // Overwrite with Tag B (different namespace) + Cache::tags(['tag-b'])->put('my-key', 'new-value', 60); + + $newNamespacedKey = Cache::tags(['tag-b'])->taggedItemKey('my-key'); + + // In all mode, there's no reverse index cleanup + // The OLD entry in tag-a remains as an orphan (pointing to non-existent namespaced key) + $this->assertTrue( + $this->allModeTagHasEntry('tag-a', $namespacedKey), + 'All mode should leave orphaned entries (cleaned by prune command)' + ); + + // New entry should exist in tag-b + $this->assertTrue($this->allModeTagHasEntry('tag-b', $newNamespacedKey)); + } + + // ========================================================================= + // LAZY FLUSH BEHAVIOR - ORPHAN CREATION + // ========================================================================= + + public function testAnyModeFlushLeavesOrphansInSharedTags(): void + { + $this->setTagMode(TagMode::Any); + + // Put item with Tag A and Tag B + Cache::tags(['tag-a', 'tag-b'])->put('shared-key', 'value', 60); + + // Verify existence in both + $this->assertTrue($this->anyModeTagHasEntry('tag-a', 'shared-key')); + $this->assertTrue($this->anyModeTagHasEntry('tag-b', 'shared-key')); + + // Flush Tag A + Cache::tags(['tag-a'])->flush(); + + // Item is gone from cache + $this->assertNull(Cache::get('shared-key')); + + // Entry is gone from Tag A (flushed tag) + $this->assertFalse($this->anyModeTagHasEntry('tag-a', 'shared-key')); + + // Orphan remains in Tag B (cleaned up by prune command) + $this->assertTrue( + $this->anyModeTagHasEntry('tag-b', 'shared-key'), + 'Orphaned entry should remain in shared tag until prune' + ); + } + + public function testAllModeFlushLeavesOrphansInSharedTags(): void + { + $this->setTagMode(TagMode::All); + + // Put item with Tag A and Tag B + Cache::tags(['tag-a', 'tag-b'])->put('shared-key', 'value', 60); + + $namespacedKey = Cache::tags(['tag-a', 'tag-b'])->taggedItemKey('shared-key'); + + // Verify existence in both + $this->assertTrue($this->allModeTagHasEntry('tag-a', $namespacedKey)); + $this->assertTrue($this->allModeTagHasEntry('tag-b', $namespacedKey)); + + // Flush Tag A + Cache::tags(['tag-a'])->flush(); + + // Item is gone from cache + $this->assertNull(Cache::tags(['tag-a', 'tag-b'])->get('shared-key')); + + // Entry should be removed from tag-a's ZSET + $this->assertFalse($this->allModeTagHasEntry('tag-a', $namespacedKey)); + + // Orphan remains in Tag B (cleaned up by prune command) + $this->assertTrue( + $this->allModeTagHasEntry('tag-b', $namespacedKey), + 'Orphaned entry should remain in shared tag until prune' + ); + } + + // ========================================================================= + // FORGET CLEANUP - ANY MODE WITH REVERSE INDEX + // ========================================================================= + + public function testAnyModeForgetLeavesOrphanedTagEntries(): void + { + $this->setTagMode(TagMode::Any); + + // Put item with multiple tags + Cache::tags(['tag-x', 'tag-y', 'tag-z'])->put('forget-me', 'value', 60); + + // Verify existence + $this->assertTrue($this->anyModeTagHasEntry('tag-x', 'forget-me')); + $this->assertTrue($this->anyModeTagHasEntry('tag-y', 'forget-me')); + $this->assertTrue($this->anyModeTagHasEntry('tag-z', 'forget-me')); + + // Forget the item by key (non-tagged forget does NOT use reverse index) + Cache::forget('forget-me'); + + // Verify item is gone from cache + $this->assertNull(Cache::get('forget-me')); + + // Orphaned entries remain in tag hashes (cleaned up by prune command) + $this->assertTrue( + $this->anyModeTagHasEntry('tag-x', 'forget-me'), + 'Orphaned entry should remain in tag hash until prune' + ); + $this->assertTrue($this->anyModeTagHasEntry('tag-y', 'forget-me')); + $this->assertTrue($this->anyModeTagHasEntry('tag-z', 'forget-me')); + + // Reverse index also remains (orphaned) + $this->assertNotEmpty($this->getAnyModeReverseIndex('forget-me')); + } + + public function testAllModeForgetLeavesOrphanedTagEntries(): void + { + $this->setTagMode(TagMode::All); + + // Put item with multiple tags + Cache::tags(['tag-x', 'tag-y'])->put('forget-me', 'value', 60); + + $namespacedKey = Cache::tags(['tag-x', 'tag-y'])->taggedItemKey('forget-me'); + + // Verify existence + $this->assertTrue($this->allModeTagHasEntry('tag-x', $namespacedKey)); + $this->assertTrue($this->allModeTagHasEntry('tag-y', $namespacedKey)); + + // Forget via non-tagged facade (no reverse index in all mode) + // This won't clean up tag entries because all mode uses namespaced keys + Cache::forget('forget-me'); + + // The cache key 'forget-me' without namespace should be deleted + // But the namespaced key used by tags is different + // So the tagged item still exists! + $this->assertNotNull( + Cache::tags(['tag-x', 'tag-y'])->get('forget-me'), + 'All mode uses namespaced keys, so Cache::forget("key") does not affect tagged items' + ); + + // To actually forget in all mode, use tags: + Cache::tags(['tag-x', 'tag-y'])->forget('forget-me'); + $this->assertNull(Cache::tags(['tag-x', 'tag-y'])->get('forget-me')); + + // But orphans remain in tag ZSETs (lazy cleanup) + $this->assertTrue($this->allModeTagHasEntry('tag-x', $namespacedKey)); + $this->assertTrue($this->allModeTagHasEntry('tag-y', $namespacedKey)); + } + + // ========================================================================= + // INCREMENT/DECREMENT TAG REPLACEMENT - ANY MODE ONLY + // ========================================================================= + + public function testAnyModeIncrementReplacesTags(): void + { + $this->setTagMode(TagMode::Any); + + // Create item with initial tags + Cache::tags(['tag1', 'tag2'])->put('counter', 10, 60); + + // Verify initial state + $this->assertTrue($this->anyModeTagHasEntry('tag1', 'counter')); + $this->assertTrue($this->anyModeTagHasEntry('tag2', 'counter')); + + // Increment with NEW tags (should replace old ones) + Cache::tags(['tag3'])->increment('counter', 5); + + // Verify value + $this->assertEquals(15, Cache::get('counter')); + + // Verify tags replaced + $this->assertFalse($this->anyModeTagHasEntry('tag1', 'counter')); + $this->assertFalse($this->anyModeTagHasEntry('tag2', 'counter')); + $this->assertTrue($this->anyModeTagHasEntry('tag3', 'counter')); + } + + public function testAnyModeDecrementReplacesTags(): void + { + $this->setTagMode(TagMode::Any); + + // Create item with initial tags + Cache::tags(['tag1', 'tag2'])->put('counter', 20, 60); + + // Decrement with NEW tags + Cache::tags(['tag3', 'tag4'])->decrement('counter', 5); + + // Verify value + $this->assertEquals(15, Cache::get('counter')); + + // Verify tags replaced + $this->assertFalse($this->anyModeTagHasEntry('tag1', 'counter')); + $this->assertFalse($this->anyModeTagHasEntry('tag2', 'counter')); + $this->assertTrue($this->anyModeTagHasEntry('tag3', 'counter')); + $this->assertTrue($this->anyModeTagHasEntry('tag4', 'counter')); + } + + public function testAnyModeIncrementThenDecrementReplacesTags(): void + { + $this->setTagMode(TagMode::Any); + + // Initial state + Cache::tags(['initial'])->put('counter', 10, 60); + $this->assertTrue($this->anyModeTagHasEntry('initial', 'counter')); + + // Increment with tag A + Cache::tags(['tag-a'])->increment('counter', 5); + $this->assertEquals(15, Cache::get('counter')); + $this->assertFalse($this->anyModeTagHasEntry('initial', 'counter')); + $this->assertTrue($this->anyModeTagHasEntry('tag-a', 'counter')); + + // Decrement with tag B + Cache::tags(['tag-b'])->decrement('counter', 3); + $this->assertEquals(12, Cache::get('counter')); + $this->assertFalse($this->anyModeTagHasEntry('tag-a', 'counter')); + $this->assertTrue($this->anyModeTagHasEntry('tag-b', 'counter')); + } + + // ========================================================================= + // REGISTRY CONSISTENCY - ANY MODE ONLY + // ========================================================================= + + public function testAnyModeRegistryTracksActiveTags(): void + { + $this->setTagMode(TagMode::Any); + + // Create items with different tags + Cache::tags(['users'])->put('user:1', 'Alice', 60); + Cache::tags(['posts'])->put('post:1', 'Hello', 60); + Cache::tags(['comments'])->put('comment:1', 'Nice!', 60); + + // Verify registry has all tags + $this->assertTrue($this->anyModeRegistryHasTag('users')); + $this->assertTrue($this->anyModeRegistryHasTag('posts')); + $this->assertTrue($this->anyModeRegistryHasTag('comments')); + } + + public function testAnyModeRegistryScoresUpdateWithTtl(): void + { + $this->setTagMode(TagMode::Any); + + // Create item with short TTL + Cache::tags(['short-ttl'])->put('item1', 'value', 10); + + $registry1 = $this->getAnyModeRegistry(); + $score1 = $registry1['short-ttl'] ?? 0; + + // Create item with longer TTL + Cache::tags(['short-ttl'])->put('item2', 'value', 300); + + $registry2 = $this->getAnyModeRegistry(); + $score2 = $registry2['short-ttl'] ?? 0; + + // Score should have increased (GT flag in ZADD) + $this->assertGreaterThan($score1, $score2); + } + + // ========================================================================= + // EDGE CASES + // ========================================================================= + + public function testAnyModeOverwriteWithSameTagsDoesNotCreateOrphans(): void + { + $this->setTagMode(TagMode::Any); + + // Put item with tags + Cache::tags(['tag-a', 'tag-b'])->put('my-key', 'value1', 60); + + // Overwrite with SAME tags + Cache::tags(['tag-a', 'tag-b'])->put('my-key', 'value2', 60); + + // Verify entries exist once (not duplicated) + $this->assertTrue($this->anyModeTagHasEntry('tag-a', 'my-key')); + $this->assertTrue($this->anyModeTagHasEntry('tag-b', 'my-key')); + + // Value should be updated + $this->assertSame('value2', Cache::get('my-key')); + } + + public function testAnyModeOverwriteWithPartialTagOverlap(): void + { + $this->setTagMode(TagMode::Any); + + // Put item with tags A and B + Cache::tags(['tag-a', 'tag-b'])->put('my-key', 'value1', 60); + + // Overwrite with tags B and C (partial overlap) + Cache::tags(['tag-b', 'tag-c'])->put('my-key', 'value2', 60); + + // A should be removed + $this->assertFalse($this->anyModeTagHasEntry('tag-a', 'my-key')); + + // B should still exist (was in both) + $this->assertTrue($this->anyModeTagHasEntry('tag-b', 'my-key')); + + // C should be added + $this->assertTrue($this->anyModeTagHasEntry('tag-c', 'my-key')); + + // Reverse index should have only B and C + $reverseIndex = $this->getAnyModeReverseIndex('my-key'); + sort($reverseIndex); + $this->assertEquals(['tag-b', 'tag-c'], $reverseIndex); + } +} diff --git a/tests/Cache/Redis/Integration/TagQueryIntegrationTest.php b/tests/Cache/Redis/Integration/TagQueryIntegrationTest.php new file mode 100644 index 000000000..ace54008d --- /dev/null +++ b/tests/Cache/Redis/Integration/TagQueryIntegrationTest.php @@ -0,0 +1,241 @@ +setTagMode(TagMode::Any); + } + + // ========================================================================= + // getTaggedKeys() TESTS + // ========================================================================= + + public function testGetTaggedKeysReturnsEmptyForNonExistentTag(): void + { + $keys = $this->store()->anyTagOps()->getTaggedKeys()->execute('non_existent_tag_xyz'); + $result = iterator_to_array($keys); + + $this->assertSame([], $result); + } + + public function testGetTaggedKeysReturnsAllKeysForTag(): void + { + Cache::tags(['test_tag'])->put('key1', 'value1', 60); + Cache::tags(['test_tag'])->put('key2', 'value2', 60); + Cache::tags(['test_tag'])->put('key3', 'value3', 60); + + $keys = $this->store()->anyTagOps()->getTaggedKeys()->execute('test_tag'); + $result = iterator_to_array($keys); + + $this->assertContains('key1', $result); + $this->assertContains('key2', $result); + $this->assertContains('key3', $result); + $this->assertCount(3, $result); + } + + public function testGetTaggedKeysHandlesSpecialCharacterKeys(): void + { + Cache::tags(['special_tag'])->put('key:with:colons', 'value1', 60); + Cache::tags(['special_tag'])->put('key-with-dashes', 'value2', 60); + Cache::tags(['special_tag'])->put('key_with_underscores', 'value3', 60); + + $keys = $this->store()->anyTagOps()->getTaggedKeys()->execute('special_tag'); + $result = iterator_to_array($keys); + + $this->assertContains('key:with:colons', $result); + $this->assertContains('key-with-dashes', $result); + $this->assertContains('key_with_underscores', $result); + } + + public function testGetTaggedKeysReturnsGeneratorForSmallHashes(): void + { + // Create just a few items (below HSCAN threshold) + for ($i = 0; $i < 5; ++$i) { + Cache::tags(['small_tag'])->put("small_key_{$i}", "value_{$i}", 60); + } + + // Always returns Generator (even for small hashes where HKEYS is used internally) + $keys = $this->store()->anyTagOps()->getTaggedKeys()->execute('small_tag'); + + $this->assertInstanceOf(Generator::class, $keys); + $this->assertCount(5, iterator_to_array($keys)); + } + + // ========================================================================= + // items() TESTS + // ========================================================================= + + public function testItemsRetrievesAllItemsForSingleTag(): void + { + Cache::tags(['users'])->put('user:1', 'Alice', 60); + Cache::tags(['users'])->put('user:2', 'Bob', 60); + Cache::tags(['posts'])->put('post:1', 'Hello', 60); + + $items = iterator_to_array(Cache::tags(['users'])->items()); + + $this->assertCount(2, $items); + $this->assertArrayHasKey('user:1', $items); + $this->assertArrayHasKey('user:2', $items); + $this->assertSame('Alice', $items['user:1']); + $this->assertSame('Bob', $items['user:2']); + $this->assertArrayNotHasKey('post:1', $items); + } + + public function testItemsRetrievesItemsForMultipleTagsUnion(): void + { + Cache::tags(['tag:a'])->put('item:a', 'A', 60); + Cache::tags(['tag:b'])->put('item:b', 'B', 60); + Cache::tags(['tag:c'])->put('item:c', 'C', 60); + + $items = iterator_to_array(Cache::tags(['tag:a', 'tag:b'])->items()); + + $this->assertCount(2, $items); + $this->assertArrayHasKey('item:a', $items); + $this->assertArrayHasKey('item:b', $items); + $this->assertSame('A', $items['item:a']); + $this->assertSame('B', $items['item:b']); + $this->assertArrayNotHasKey('item:c', $items); + } + + public function testItemsDeduplicatesItemsWithMultipleTags(): void + { + // Item has both tags + Cache::tags(['tag:1', 'tag:2'])->put('shared', 'Shared Value', 60); + Cache::tags(['tag:1'])->put('unique:1', 'Unique 1', 60); + + // Retrieve items for both tags + $items = iterator_to_array(Cache::tags(['tag:1', 'tag:2'])->items()); + + $this->assertCount(2, $items); + $this->assertArrayHasKey('shared', $items); + $this->assertArrayHasKey('unique:1', $items); + $this->assertSame('Shared Value', $items['shared']); + $this->assertSame('Unique 1', $items['unique:1']); + } + + public function testItemsHandlesLargeNumberWithChunking(): void + { + // Create 250 items (enough to test chunking) + $data = []; + for ($i = 0; $i < 250; ++$i) { + $data["key:{$i}"] = "value:{$i}"; + } + + Cache::tags(['bulk'])->putMany($data, 60); + + $items = iterator_to_array(Cache::tags(['bulk'])->items()); + + $this->assertCount(250, $items); + $this->assertSame('value:0', $items['key:0']); + $this->assertSame('value:249', $items['key:249']); + } + + public function testItemsIgnoresExpiredOrMissingKeys(): void + { + Cache::tags(['temp'])->put('valid', 'value', 60); + Cache::tags(['temp'])->put('expired', 'value', 60); + + // Manually delete 'expired' key in Redis but leave it in tag hash + // (Simulating lazy cleanup state where tag entry still exists but key is gone) + Cache::forget('expired'); + + $items = iterator_to_array(Cache::tags(['temp'])->items()); + + $this->assertCount(1, $items); + $this->assertArrayHasKey('valid', $items); + $this->assertSame('value', $items['valid']); + $this->assertArrayNotHasKey('expired', $items); + } + + public function testItemsReturnsEmptyForEmptyTag(): void + { + $items = iterator_to_array(Cache::tags(['empty'])->items()); + + $this->assertEmpty($items); + } + + // ========================================================================= + // items() RETURNS GENERATOR + // ========================================================================= + + public function testItemsReturnsGenerator(): void + { + Cache::tags(['gen_tag'])->put('key1', 'value1', 60); + Cache::tags(['gen_tag'])->put('key2', 'value2', 60); + + $items = Cache::tags(['gen_tag'])->items(); + + $this->assertInstanceOf(Generator::class, $items); + } + + // ========================================================================= + // EDGE CASES + // ========================================================================= + + public function testGetTaggedKeysWithForeverItems(): void + { + Cache::tags(['forever_tag'])->forever('forever1', 'value1'); + Cache::tags(['forever_tag'])->forever('forever2', 'value2'); + + $keys = $this->store()->anyTagOps()->getTaggedKeys()->execute('forever_tag'); + $result = iterator_to_array($keys); + + $this->assertContains('forever1', $result); + $this->assertContains('forever2', $result); + $this->assertCount(2, $result); + } + + public function testItemsWithMixedTtlItems(): void + { + Cache::tags(['mixed'])->put('short', 'short_value', 60); + Cache::tags(['mixed'])->forever('forever', 'forever_value'); + + $items = iterator_to_array(Cache::tags(['mixed'])->items()); + + $this->assertCount(2, $items); + $this->assertSame('short_value', $items['short']); + $this->assertSame('forever_value', $items['forever']); + } + + public function testItemsWithDifferentValueTypes(): void + { + Cache::tags(['types'])->put('string', 'hello', 60); + Cache::tags(['types'])->put('int', 42, 60); + Cache::tags(['types'])->put('array', ['a' => 1], 60); + Cache::tags(['types'])->put('bool', true, 60); + + $items = iterator_to_array(Cache::tags(['types'])->items()); + + $this->assertCount(4, $items); + $this->assertSame('hello', $items['string']); + $this->assertEquals(42, $items['int']); + $this->assertEquals(['a' => 1], $items['array']); + $this->assertTrue($items['bool']); + } +} diff --git a/tests/Cache/Redis/Integration/TaggedOperationsIntegrationTest.php b/tests/Cache/Redis/Integration/TaggedOperationsIntegrationTest.php new file mode 100644 index 000000000..c1f3d355b --- /dev/null +++ b/tests/Cache/Redis/Integration/TaggedOperationsIntegrationTest.php @@ -0,0 +1,508 @@ +setTagMode(TagMode::All); + + Cache::tags(['posts'])->put('post:1', 'content', 60); + + // Verify the ZSET exists + $tagKey = $this->allModeTagKey('posts'); + $type = $this->redis()->type($tagKey); + + $this->assertEquals(Redis::REDIS_ZSET, $type, 'Tag structure should be a ZSET in all mode'); + } + + public function testAllModeStoresNamespacedKeyInZset(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts'])->put('post:1', 'content', 60); + + // Get the entries from the ZSET + $entries = $this->getAllModeTagEntries('posts'); + + $this->assertNotEmpty($entries, 'ZSET should contain entries'); + + // The key stored is the namespaced key (sha1 of tag names + key) + // We can't predict the exact key, but we can verify an entry exists + $this->assertCount(1, $entries); + } + + public function testAllModeZsetScoreIsTimestamp(): void + { + $this->setTagMode(TagMode::All); + + $before = time(); + Cache::tags(['posts'])->put('post:1', 'content', 60); + $after = time(); + + $entries = $this->getAllModeTagEntries('posts'); + $score = (int) reset($entries); + + // Score should be the expiration timestamp + $this->assertGreaterThanOrEqual($before + 60, $score); + $this->assertLessThanOrEqual($after + 60 + 1, $score); + } + + public function testAllModeMultipleTagsCreateMultipleZsets(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts', 'featured'])->put('post:1', 'content', 60); + + // Both ZSETs should exist + $this->assertRedisKeyExists($this->allModeTagKey('posts')); + $this->assertRedisKeyExists($this->allModeTagKey('featured')); + + // Both should contain the entry + $this->assertCount(1, $this->getAllModeTagEntries('posts')); + $this->assertCount(1, $this->getAllModeTagEntries('featured')); + } + + public function testAllModeForeverUsesNegativeOneScore(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts'])->forever('eternal', 'content'); + + $entries = $this->getAllModeTagEntries('posts'); + $score = (int) reset($entries); + + // Forever items use score -1 (won't be cleaned by ZREMRANGEBYSCORE) + $this->assertEquals(-1, $score); + } + + public function testAllModeNamespaceIsolatesTagSets(): void + { + $this->setTagMode(TagMode::All); + + // Same key, different tags - should be isolated + Cache::tags(['tag1'])->put('key', 'value1', 60); + Cache::tags(['tag2'])->put('key', 'value2', 60); + + // Both values should be accessible with their respective tags + $this->assertSame('value1', Cache::tags(['tag1'])->get('key')); + $this->assertSame('value2', Cache::tags(['tag2'])->get('key')); + } + + // ========================================================================= + // ANY MODE - TAG STRUCTURE VERIFICATION + // ========================================================================= + + public function testAnyModeCreatesHashForTags(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->put('post:1', 'content', 60); + + // Verify the HASH exists + $tagKey = $this->anyModeTagKey('posts'); + $type = $this->redis()->type($tagKey); + + $this->assertEquals(Redis::REDIS_HASH, $type, 'Tag structure should be a HASH in any mode'); + } + + public function testAnyModeStoresCacheKeyAsHashField(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->put('post:1', 'content', 60); + + // Verify the cache key is stored as a field in the hash + $this->assertTrue( + $this->anyModeTagHasEntry('posts', 'post:1'), + 'Cache key should be stored as hash field' + ); + } + + public function testAnyModeCreatesReverseIndex(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'featured'])->put('post:1', 'content', 60); + + // Verify reverse index SET exists + $reverseKey = $this->anyModeReverseIndexKey('post:1'); + $type = $this->redis()->type($reverseKey); + + $this->assertEquals(Redis::REDIS_SET, $type, 'Reverse index should be a SET'); + + // Verify it contains both tags + $tags = $this->getAnyModeReverseIndex('post:1'); + $this->assertContains('posts', $tags); + $this->assertContains('featured', $tags); + } + + public function testAnyModeUpdatesRegistry(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'featured'])->put('post:1', 'content', 60); + + // Verify registry contains both tags + $this->assertTrue($this->anyModeRegistryHasTag('posts')); + $this->assertTrue($this->anyModeRegistryHasTag('featured')); + } + + public function testAnyModeRegistryScoreIsExpiryTimestamp(): void + { + $this->setTagMode(TagMode::Any); + + $before = time(); + Cache::tags(['posts'])->put('post:1', 'content', 60); + $after = time(); + + $registry = $this->getAnyModeRegistry(); + $this->assertArrayHasKey('posts', $registry); + + $score = (int) $registry['posts']; + + // Score should be the expiry timestamp (current time + TTL) + $this->assertGreaterThanOrEqual($before + 60, $score); + $this->assertLessThanOrEqual($after + 60 + 1, $score); + } + + public function testAnyModeMultipleTagsCreateMultipleHashes(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'featured'])->put('post:1', 'content', 60); + + // Both HASHes should exist + $this->assertRedisKeyExists($this->anyModeTagKey('posts')); + $this->assertRedisKeyExists($this->anyModeTagKey('featured')); + + // Both should contain the cache key + $this->assertTrue($this->anyModeTagHasEntry('posts', 'post:1')); + $this->assertTrue($this->anyModeTagHasEntry('featured', 'post:1')); + } + + public function testAnyModeDirectAccessWithoutTags(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->put('post:1', 'content', 60); + + // In any mode, can access directly without tags + $this->assertSame('content', Cache::get('post:1')); + } + + public function testAnyModeSameKeyDifferentTagsOverwrites(): void + { + $this->setTagMode(TagMode::Any); + + // First put with tag1 + Cache::tags(['tag1'])->put('key', 'value1', 60); + $this->assertSame('value1', Cache::get('key')); + $this->assertTrue($this->anyModeTagHasEntry('tag1', 'key')); + + // Second put with tag2 - should overwrite value AND update tags + Cache::tags(['tag2'])->put('key', 'value2', 60); + $this->assertSame('value2', Cache::get('key')); + + // Reverse index should now contain tag2 + $tags = $this->getAnyModeReverseIndex('key'); + $this->assertContains('tag2', $tags); + } + + // ========================================================================= + // BOTH MODES - MULTIPLE ITEMS SAME TAG + // ========================================================================= + + public function testAllModeMultipleItemsSameTag(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts'])->put('post:1', 'content1', 60); + Cache::tags(['posts'])->put('post:2', 'content2', 60); + Cache::tags(['posts'])->put('post:3', 'content3', 60); + + // All should be accessible + $this->assertSame('content1', Cache::tags(['posts'])->get('post:1')); + $this->assertSame('content2', Cache::tags(['posts'])->get('post:2')); + $this->assertSame('content3', Cache::tags(['posts'])->get('post:3')); + + // ZSET should have 3 entries + $entries = $this->getAllModeTagEntries('posts'); + $this->assertCount(3, $entries); + } + + public function testAnyModeMultipleItemsSameTag(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts'])->put('post:1', 'content1', 60); + Cache::tags(['posts'])->put('post:2', 'content2', 60); + Cache::tags(['posts'])->put('post:3', 'content3', 60); + + // All should be accessible directly + $this->assertSame('content1', Cache::get('post:1')); + $this->assertSame('content2', Cache::get('post:2')); + $this->assertSame('content3', Cache::get('post:3')); + + // HASH should have 3 fields + $entries = $this->getAnyModeTagEntries('posts'); + $this->assertCount(3, $entries); + $this->assertArrayHasKey('post:1', $entries); + $this->assertArrayHasKey('post:2', $entries); + $this->assertArrayHasKey('post:3', $entries); + } + + // ========================================================================= + // BOTH MODES - ITEM WITH MULTIPLE TAGS + // ========================================================================= + + public function testAllModeItemWithMultipleTags(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['posts', 'user:1', 'featured'])->put('post:1', 'content', 60); + + // All tag ZSETs should have the entry + $this->assertCount(1, $this->getAllModeTagEntries('posts')); + $this->assertCount(1, $this->getAllModeTagEntries('user:1')); + $this->assertCount(1, $this->getAllModeTagEntries('featured')); + } + + public function testAnyModeItemWithMultipleTags(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['posts', 'user:1', 'featured'])->put('post:1', 'content', 60); + + // All tag HASHes should have the entry + $this->assertTrue($this->anyModeTagHasEntry('posts', 'post:1')); + $this->assertTrue($this->anyModeTagHasEntry('user:1', 'post:1')); + $this->assertTrue($this->anyModeTagHasEntry('featured', 'post:1')); + + // Reverse index should have all tags + $tags = $this->getAnyModeReverseIndex('post:1'); + $this->assertCount(3, $tags); + $this->assertContains('posts', $tags); + $this->assertContains('user:1', $tags); + $this->assertContains('featured', $tags); + + // Registry should have all tags + $this->assertTrue($this->anyModeRegistryHasTag('posts')); + $this->assertTrue($this->anyModeRegistryHasTag('user:1')); + $this->assertTrue($this->anyModeRegistryHasTag('featured')); + } + + // ========================================================================= + // OPERATIONS THAT UPDATE TAG STRUCTURES + // ========================================================================= + + public function testAllModeIncrementMaintainsTagStructure(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['counters'])->put('views', 10, 60); + $this->assertCount(1, $this->getAllModeTagEntries('counters')); + + Cache::tags(['counters'])->increment('views', 5); + + // Tag structure should still exist + $this->assertCount(1, $this->getAllModeTagEntries('counters')); + $this->assertEquals(15, Cache::tags(['counters'])->get('views')); + } + + public function testAnyModeIncrementMaintainsTagStructure(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['counters'])->put('views', 10, 60); + $this->assertTrue($this->anyModeTagHasEntry('counters', 'views')); + + Cache::tags(['counters'])->increment('views', 5); + + // Tag structure should still exist + $this->assertTrue($this->anyModeTagHasEntry('counters', 'views')); + $this->assertEquals(15, Cache::get('views')); + } + + public function testAllModeAddCreatesTagStructure(): void + { + $this->setTagMode(TagMode::All); + + $result = Cache::tags(['users'])->add('user:1', 'John', 60); + + $this->assertTrue($result); + $this->assertCount(1, $this->getAllModeTagEntries('users')); + } + + public function testAnyModeAddCreatesTagStructure(): void + { + $this->setTagMode(TagMode::Any); + + $result = Cache::tags(['users'])->add('user:1', 'John', 60); + + $this->assertTrue($result); + $this->assertTrue($this->anyModeTagHasEntry('users', 'user:1')); + $this->assertContains('users', $this->getAnyModeReverseIndex('user:1')); + } + + public function testAllModeForeverCreatesTagStructure(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['eternal'])->forever('forever_key', 'forever_value'); + + $this->assertCount(1, $this->getAllModeTagEntries('eternal')); + } + + public function testAnyModeForeverCreatesTagStructure(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['eternal'])->forever('forever_key', 'forever_value'); + + $this->assertTrue($this->anyModeTagHasEntry('eternal', 'forever_key')); + $this->assertContains('eternal', $this->getAnyModeReverseIndex('forever_key')); + } + + // ========================================================================= + // PUTMANY WITH TAGS + // ========================================================================= + + public function testAllModePutManyCreatesTagStructure(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['batch'])->putMany([ + 'item:1' => 'value1', + 'item:2' => 'value2', + 'item:3' => 'value3', + ], 60); + + // Should have 3 entries in the ZSET + $entries = $this->getAllModeTagEntries('batch'); + $this->assertCount(3, $entries); + } + + public function testAnyModePutManyCreatesTagStructure(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['batch'])->putMany([ + 'item:1' => 'value1', + 'item:2' => 'value2', + 'item:3' => 'value3', + ], 60); + + // Should have 3 fields in the HASH + $entries = $this->getAnyModeTagEntries('batch'); + $this->assertCount(3, $entries); + $this->assertArrayHasKey('item:1', $entries); + $this->assertArrayHasKey('item:2', $entries); + $this->assertArrayHasKey('item:3', $entries); + + // Each item should have reverse index + $this->assertContains('batch', $this->getAnyModeReverseIndex('item:1')); + $this->assertContains('batch', $this->getAnyModeReverseIndex('item:2')); + $this->assertContains('batch', $this->getAnyModeReverseIndex('item:3')); + } + + public function testAllModeLargePutManyChunking(): void + { + $this->setTagMode(TagMode::All); + + $values = []; + for ($i = 0; $i < 1500; ++$i) { + $values["large_key_{$i}"] = "value_{$i}"; + } + + $result = Cache::tags(['large_batch'])->putMany($values, 60); + $this->assertTrue($result); + + // Verify first and last items exist + $this->assertSame('value_0', Cache::tags(['large_batch'])->get('large_key_0')); + $this->assertSame('value_1499', Cache::tags(['large_batch'])->get('large_key_1499')); + + // Verify tag structure has all entries + $entries = $this->getAllModeTagEntries('large_batch'); + $this->assertCount(1500, $entries); + + // Flush and verify + Cache::tags(['large_batch'])->flush(); + $this->assertNull(Cache::tags(['large_batch'])->get('large_key_0')); + } + + public function testAnyModeLargePutManyChunking(): void + { + $this->setTagMode(TagMode::Any); + + $values = []; + for ($i = 0; $i < 1500; ++$i) { + $values["large_key_{$i}"] = "value_{$i}"; + } + + $result = Cache::tags(['large_batch'])->putMany($values, 60); + $this->assertTrue($result); + + // Verify first and last items exist + $this->assertSame('value_0', Cache::get('large_key_0')); + $this->assertSame('value_1499', Cache::get('large_key_1499')); + + // Verify tag structure has all entries + $entries = $this->getAnyModeTagEntries('large_batch'); + $this->assertCount(1500, $entries); + + // Flush and verify + Cache::tags(['large_batch'])->flush(); + $this->assertNull(Cache::get('large_key_0')); + } + + public function testAnyModePutManyFlushByOneTag(): void + { + $this->setTagMode(TagMode::Any); + + $items = [ + 'pm_key1' => 'value1', + 'pm_key2' => 'value2', + 'pm_key3' => 'value3', + ]; + + // Store with multiple tags + Cache::tags(['pm_tag1', 'pm_tag2'])->putMany($items, 60); + + // Verify all exist + $this->assertSame('value1', Cache::get('pm_key1')); + $this->assertSame('value2', Cache::get('pm_key2')); + $this->assertSame('value3', Cache::get('pm_key3')); + + // Flush only ONE of the tags - items should still be removed (any mode behavior) + Cache::tags(['pm_tag1'])->flush(); + + // All items should be gone because any mode removes items tagged with ANY of the flushed tags + $this->assertNull(Cache::get('pm_key1')); + $this->assertNull(Cache::get('pm_key2')); + $this->assertNull(Cache::get('pm_key3')); + } +} diff --git a/tests/Cache/Redis/Integration/TtlHandlingIntegrationTest.php b/tests/Cache/Redis/Integration/TtlHandlingIntegrationTest.php new file mode 100644 index 000000000..70bb61f4a --- /dev/null +++ b/tests/Cache/Redis/Integration/TtlHandlingIntegrationTest.php @@ -0,0 +1,353 @@ +setTagMode(TagMode::All); + + Cache::tags(['seconds_ttl'])->put('key', 'value', 60); + + $this->assertSame('value', Cache::tags(['seconds_ttl'])->get('key')); + + // Verify TTL is approximately correct + $prefix = $this->getCachePrefix(); + // In all mode, key is namespaced - but we can check via the tag ZSET score + $entries = $this->getAllModeTagEntries('seconds_ttl'); + $score = (int) reset($entries); + + // Score should be approximately now + 60 seconds + $this->assertGreaterThan(time() + 50, $score); + $this->assertLessThanOrEqual(time() + 61, $score); + } + + public function testAnyModeHandlesIntegerSecondsTtl(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['seconds_ttl'])->put('key', 'value', 60); + + $this->assertSame('value', Cache::get('key')); + + // Verify TTL is approximately correct + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'key'); + + $this->assertGreaterThan(50, $ttl); + $this->assertLessThanOrEqual(60, $ttl); + } + + // ========================================================================= + // DATETIME TTL - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesDateTimeTtl(): void + { + $this->setTagMode(TagMode::All); + + $expires = Carbon::now()->addSeconds(60); + Cache::tags(['datetime_ttl'])->put('datetime_key', 'datetime_value', $expires); + + $this->assertSame('datetime_value', Cache::tags(['datetime_ttl'])->get('datetime_key')); + + // Verify via ZSET score + $entries = $this->getAllModeTagEntries('datetime_ttl'); + $score = (int) reset($entries); + + $this->assertGreaterThan(time() + 50, $score); + $this->assertLessThanOrEqual(time() + 61, $score); + } + + public function testAnyModeHandlesDateTimeTtl(): void + { + $this->setTagMode(TagMode::Any); + + $expires = Carbon::now()->addSeconds(60); + Cache::tags(['datetime_ttl'])->put('datetime_key', 'datetime_value', $expires); + + $this->assertSame('datetime_value', Cache::get('datetime_key')); + + // Verify TTL is approximately correct + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'datetime_key'); + + $this->assertGreaterThan(50, $ttl); + $this->assertLessThanOrEqual(60, $ttl); + } + + // ========================================================================= + // DATEINTERVAL TTL - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesDateIntervalTtl(): void + { + $this->setTagMode(TagMode::All); + + $interval = new DateInterval('PT60S'); // 60 seconds + Cache::tags(['interval_ttl'])->put('interval_key', 'interval_value', $interval); + + $this->assertSame('interval_value', Cache::tags(['interval_ttl'])->get('interval_key')); + + // Verify via ZSET score + $entries = $this->getAllModeTagEntries('interval_ttl'); + $score = (int) reset($entries); + + $this->assertGreaterThan(time() + 50, $score); + $this->assertLessThanOrEqual(time() + 61, $score); + } + + public function testAnyModeHandlesDateIntervalTtl(): void + { + $this->setTagMode(TagMode::Any); + + $interval = new DateInterval('PT60S'); // 60 seconds + Cache::tags(['interval_ttl'])->put('interval_key', 'interval_value', $interval); + + $this->assertSame('interval_value', Cache::get('interval_key')); + + // Verify TTL is approximately correct + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'interval_key'); + + $this->assertGreaterThan(50, $ttl); + $this->assertLessThanOrEqual(60, $ttl); + } + + // ========================================================================= + // VERY SHORT TTL - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesVeryShortTtl(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['short_ttl'])->put('short_key', 'short_value', 1); + + // Should exist immediately + $this->assertSame('short_value', Cache::tags(['short_ttl'])->get('short_key')); + + // Wait for expiration + sleep(2); + + // Should be expired + $this->assertNull(Cache::tags(['short_ttl'])->get('short_key')); + } + + public function testAnyModeHandlesVeryShortTtl(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['short_ttl'])->put('short_key', 'short_value', 1); + + // Should exist immediately + $this->assertSame('short_value', Cache::get('short_key')); + + // Wait for expiration + sleep(2); + + // Should be expired + $this->assertNull(Cache::get('short_key')); + } + + // ========================================================================= + // LARGE TTL - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesLargeTtl(): void + { + $this->setTagMode(TagMode::All); + + $oneYear = 365 * 24 * 60 * 60; + Cache::tags(['large_ttl'])->put('long_key', 'long_value', $oneYear); + + $this->assertSame('long_value', Cache::tags(['large_ttl'])->get('long_key')); + + // Verify via ZSET score + $entries = $this->getAllModeTagEntries('large_ttl'); + $score = (int) reset($entries); + + // Score should be approximately now + 1 year + $this->assertGreaterThan(time() + $oneYear - 10, $score); + $this->assertLessThanOrEqual(time() + $oneYear + 1, $score); + } + + public function testAnyModeHandlesLargeTtl(): void + { + $this->setTagMode(TagMode::Any); + + $oneYear = 365 * 24 * 60 * 60; + Cache::tags(['large_ttl'])->put('long_key', 'long_value', $oneYear); + + $this->assertSame('long_value', Cache::get('long_key')); + + // Verify TTL is close to 1 year + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'long_key'); + + $this->assertGreaterThan($oneYear - 10, $ttl); + $this->assertLessThanOrEqual($oneYear, $ttl); + } + + // ========================================================================= + // FOREVER (NO EXPIRATION) - BOTH MODES + // ========================================================================= + + public function testAllModeHandlesForeverTtl(): void + { + $this->setTagMode(TagMode::All); + + Cache::tags(['forever_test'])->forever('forever_item', 'forever_content'); + + $this->assertSame('forever_content', Cache::tags(['forever_test'])->get('forever_item')); + + // In all mode, forever items have score -1 in ZSET + $entries = $this->getAllModeTagEntries('forever_test'); + $score = (int) reset($entries); + + $this->assertEquals(-1, $score); + } + + public function testAnyModeHandlesForeverTtl(): void + { + $this->setTagMode(TagMode::Any); + + Cache::tags(['forever_test'])->forever('forever_item', 'forever_content'); + + $this->assertSame('forever_content', Cache::get('forever_item')); + + // Verify TTL is -1 (no expiration) + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'forever_item'); + + $this->assertEquals(-1, $ttl); + } + + // ========================================================================= + // TTL UPDATE BEHAVIOR - BOTH MODES + // ========================================================================= + + public function testAllModeUpdatesTtlOnOverwrite(): void + { + $this->setTagMode(TagMode::All); + + // Store with 60 second TTL + Cache::tags(['update_ttl'])->put('update_key', 'original', 60); + + $entriesBefore = $this->getAllModeTagEntries('update_ttl'); + $scoreBefore = (int) reset($entriesBefore); + + // Store again with 30 second TTL + Cache::tags(['update_ttl'])->put('update_key', 'updated', 30); + + $this->assertSame('updated', Cache::tags(['update_ttl'])->get('update_key')); + + // Score should be updated to new TTL (approximately now + 30) + $entriesAfter = $this->getAllModeTagEntries('update_ttl'); + $scoreAfter = (int) reset($entriesAfter); + + $this->assertLessThan($scoreBefore, $scoreAfter); + $this->assertGreaterThan(time() + 20, $scoreAfter); + $this->assertLessThanOrEqual(time() + 31, $scoreAfter); + } + + public function testAnyModeUpdatesTtlOnOverwrite(): void + { + $this->setTagMode(TagMode::Any); + + // Store with 60 second TTL + Cache::tags(['update_ttl'])->put('update_key', 'original', 60); + + // Store again with 30 second TTL + Cache::tags(['update_ttl'])->put('update_key', 'updated', 30); + + $this->assertSame('updated', Cache::get('update_key')); + + // TTL should be updated to 30 seconds + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'update_key'); + + $this->assertLessThanOrEqual(30, $ttl); + $this->assertGreaterThan(20, $ttl); + } + + // ========================================================================= + // NON-TAGGED TTL - BOTH MODES + // ========================================================================= + + public function testNonTaggedTtlInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::put('untagged_key', 'value', 60); + + $this->assertSame('value', Cache::get('untagged_key')); + + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'untagged_key'); + + $this->assertGreaterThan(50, $ttl); + $this->assertLessThanOrEqual(60, $ttl); + } + + public function testNonTaggedTtlInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::put('untagged_key', 'value', 60); + + $this->assertSame('value', Cache::get('untagged_key')); + + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'untagged_key'); + + $this->assertGreaterThan(50, $ttl); + $this->assertLessThanOrEqual(60, $ttl); + } + + public function testNonTaggedForeverInAllMode(): void + { + $this->setTagMode(TagMode::All); + + Cache::forever('untagged_forever', 'eternal'); + + $this->assertSame('eternal', Cache::get('untagged_forever')); + + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'untagged_forever'); + + $this->assertEquals(-1, $ttl); + } + + public function testNonTaggedForeverInAnyMode(): void + { + $this->setTagMode(TagMode::Any); + + Cache::forever('untagged_forever', 'eternal'); + + $this->assertSame('eternal', Cache::get('untagged_forever')); + + $ttl = $this->redis()->ttl($this->getCachePrefix() . 'untagged_forever'); + + $this->assertEquals(-1, $ttl); + } +} diff --git a/tests/Cache/Redis/Operations/AddTest.php b/tests/Cache/Redis/Operations/AddTest.php new file mode 100644 index 000000000..b95d9766c --- /dev/null +++ b/tests/Cache/Redis/Operations/AddTest.php @@ -0,0 +1,118 @@ +mockConnection(); + $client = $connection->_mockClient; + + // SET returns true/OK when key was set + $client->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('bar'), ['EX' => 60, 'NX']) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->add('foo', 'bar', 60); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddReturnsFalseWhenKeyExists(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // SET with NX returns null/false when key already exists + $client->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('bar'), ['EX' => 60, 'NX']) + ->andReturn(null); + + $redis = $this->createStore($connection); + $result = $redis->add('foo', 'bar', 60); + $this->assertFalse($result); + } + + /** + * @test + */ + public function testAddWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Numeric values are NOT serialized (optimization) + $client->shouldReceive('set') + ->once() + ->with('prefix:foo', 42, ['EX' => 60, 'NX']) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->add('foo', 42, 60); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // TTL should be at least 1 + $client->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('bar'), ['EX' => 1, 'NX']) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->add('foo', 'bar', 0); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddWithArrayValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $value = ['key' => 'value', 'nested' => ['a', 'b']]; + + $client->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize($value), ['EX' => 120, 'NX']) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->add('foo', $value, 120); + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/AddEntryTest.php b/tests/Cache/Redis/Operations/AllTag/AddEntryTest.php new file mode 100644 index 000000000..9f8638f67 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/AddEntryTest.php @@ -0,0 +1,336 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 300, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 300, ['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testAddEntryWithZeroTtlStoresNegativeOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', -1, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 0, ['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testAddEntryWithNegativeTtlStoresNegativeOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', -1, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', -5, ['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testAddEntryWithUpdateWhenNxCondition(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 0, ['_all:tag:users:entries'], 'NX'); + } + + /** + * @test + */ + public function testAddEntryWithUpdateWhenXxCondition(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['XX'], -1, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 0, ['_all:tag:users:entries'], 'XX'); + } + + /** + * @test + */ + public function testAddEntryWithUpdateWhenGtCondition(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['GT'], now()->timestamp + 60, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 60, ['_all:tag:users:entries'], 'GT'); + } + + /** + * @test + */ + public function testAddEntryWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->andReturn($client); + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', now()->timestamp + 60, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1]); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 60, ['_all:tag:users:entries', '_all:tag:posts:entries']); + } + + /** + * @test + */ + public function testAddEntryWithEmptyTagsArrayDoesNothing(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // No pipeline or zadd calls should be made + $client->shouldNotReceive('pipeline'); + $client->shouldNotReceive('zadd'); + + $store = $this->createStore($connection); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 60, []); + } + + /** + * @test + */ + public function testAddEntryUsesCorrectPrefix(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('custom_prefix:_all:tag:users:entries', -1, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection, 'custom_prefix:'); + $operation = new AddEntry($store->getContext()); + + $operation->execute('mykey', 0, ['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testAddEntryClusterModeUsesSequentialCommands(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Should use sequential zadd calls directly on client + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 300, 'mykey') + ->andReturn(1); + + $operation = new AddEntry($store->getContext()); + $operation->execute('mykey', 300, ['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testAddEntryClusterModeWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Should use sequential zadd calls for each tag + $expectedScore = now()->timestamp + 60; + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'mykey') + ->andReturn(1); + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', $expectedScore, 'mykey') + ->andReturn(1); + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:comments:entries', $expectedScore, 'mykey') + ->andReturn(1); + + $operation = new AddEntry($store->getContext()); + $operation->execute('mykey', 60, ['_all:tag:users:entries', '_all:tag:posts:entries', '_all:tag:comments:entries']); + } + + /** + * @test + */ + public function testAddEntryClusterModeWithUpdateWhenFlag(): void + { + [$store, $clusterClient] = $this->createClusterStore(); + + // Should use zadd with NX flag as array (phpredis requires array for options) + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'mykey') + ->andReturn(1); + + $operation = new AddEntry($store->getContext()); + $operation->execute('mykey', 0, ['_all:tag:users:entries'], 'NX'); + } + + /** + * @test + */ + public function testAddEntryClusterModeWithZeroTtlStoresNegativeOne(): void + { + [$store, $clusterClient] = $this->createClusterStore(); + + // Score should be -1 for forever items (TTL = 0) + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', -1, 'mykey') + ->andReturn(1); + + $operation = new AddEntry($store->getContext()); + $operation->execute('mykey', 0, ['_all:tag:users:entries']); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/AddTest.php b/tests/Cache/Redis/Operations/AllTag/AddTest.php new file mode 100644 index 000000000..a8cb09c64 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/AddTest.php @@ -0,0 +1,292 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD for tag with TTL score + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + // SET NX EX for atomic add + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue'), ['EX' => 60, 'NX']) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->add()->execute( + 'mykey', + 'myvalue', + 60, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddWithTagsReturnsFalseWhenKeyExists(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('exec')->andReturn([1]); + + // SET NX returns null/false when key already exists + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue'), ['EX' => 60, 'NX']) + ->andReturn(null); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->add()->execute( + 'mykey', + 'myvalue', + 60, + ['_all:tag:users:entries'] + ); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testAddWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $expectedScore = now()->timestamp + 120; + + // ZADD for each tag + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'mykey') + ->andReturn($client); + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', $expectedScore, 'mykey') + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1]); + + // SET NX EX for atomic add + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue'), ['EX' => 120, 'NX']) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->add()->execute( + 'mykey', + 'myvalue', + 120, + ['_all:tag:users:entries', '_all:tag:posts:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddWithEmptyTagsSkipsPipeline(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // No pipeline operations for empty tags + $client->shouldNotReceive('pipeline'); + + // Only SET NX EX for add + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue'), ['EX' => 60, 'NX']) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->add()->execute( + 'mykey', + 'myvalue', + 60, + [] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddInClusterModeUsesSequentialCommands(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Sequential ZADD + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->andReturn(1); + + // SET NX EX for atomic add + $clusterClient->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue'), ['EX' => 60, 'NX']) + ->andReturn(true); + + $result = $store->allTagOps()->add()->execute( + 'mykey', + 'myvalue', + 60, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddInClusterModeReturnsFalseWhenKeyExists(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Sequential ZADD (still happens even if key exists) + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->andReturn(1); + + // SET NX returns false when key exists (RedisCluster return type is string|bool) + $clusterClient->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue'), ['EX' => 60, 'NX']) + ->andReturn(false); + + $result = $store->allTagOps()->add()->execute( + 'mykey', + 'myvalue', + 60, + ['_all:tag:users:entries'] + ); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testAddEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // No pipeline for empty tags + $client->shouldNotReceive('pipeline'); + + // TTL should be at least 1 + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue'), ['EX' => 1, 'NX']) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->add()->execute( + 'mykey', + 'myvalue', + 0, // Zero TTL + [] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddWithNumericValue(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('exec')->andReturn([1]); + + // Numeric values are NOT serialized (optimization) + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', 42, ['EX' => 60, 'NX']) + ->andReturn(true); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->add()->execute( + 'mykey', + 42, + 60, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/DecrementTest.php b/tests/Cache/Redis/Operations/AllTag/DecrementTest.php new file mode 100644 index 000000000..eda492d20 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/DecrementTest.php @@ -0,0 +1,216 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD NX for tag with score -1 (only add if not exists) + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn($client); + + // DECRBY + $client->shouldReceive('decrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 5]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->decrement()->execute( + 'counter', + 1, + ['_all:tag:users:entries'] + ); + + $this->assertSame(5, $result); + } + + /** + * @test + */ + public function testDecrementWithCustomValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn($client); + + $client->shouldReceive('decrby') + ->once() + ->with('prefix:counter', 10) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([0, -5]); // 0 means key already existed (NX condition) + + $store = $this->createStore($connection); + $result = $store->allTagOps()->decrement()->execute( + 'counter', + 10, + ['_all:tag:users:entries'] + ); + + $this->assertSame(-5, $result); + } + + /** + * @test + */ + public function testDecrementWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD NX for each tag + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn($client); + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', ['NX'], -1, 'counter') + ->andReturn($client); + + $client->shouldReceive('decrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1, 9]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->decrement()->execute( + 'counter', + 1, + ['_all:tag:users:entries', '_all:tag:posts:entries'] + ); + + $this->assertSame(9, $result); + } + + /** + * @test + */ + public function testDecrementWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // No ZADD calls expected + $client->shouldReceive('decrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([-1]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->decrement()->execute( + 'counter', + 1, + [] + ); + + $this->assertSame(-1, $result); + } + + /** + * @test + */ + public function testDecrementInClusterModeUsesSequentialCommands(): void + { + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Sequential ZADD NX + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn(1); + + // Sequential DECRBY + $clusterClient->shouldReceive('decrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn(0); + + $result = $store->allTagOps()->decrement()->execute( + 'counter', + 1, + ['_all:tag:users:entries'] + ); + + $this->assertSame(0, $result); + } + + /** + * @test + */ + public function testDecrementReturnsFalseOnPipelineFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('decrby')->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn(false); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->decrement()->execute( + 'counter', + 1, + ['_all:tag:users:entries'] + ); + + $this->assertFalse($result); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/FlushStaleTest.php b/tests/Cache/Redis/Operations/AllTag/FlushStaleTest.php new file mode 100644 index 000000000..ae9de24c1 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/FlushStaleTest.php @@ -0,0 +1,285 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:users:entries', '0', (string) now()->getTimestamp()) + ->andReturn($client); + + $client->shouldReceive('exec')->once(); + + $store = $this->createStore($connection); + $operation = new FlushStale($store->getContext()); + + $operation->execute(['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testFlushStaleEntriesWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // All tags should be processed in a single pipeline + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:users:entries', '0', (string) now()->getTimestamp()) + ->andReturn($client); + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:posts:entries', '0', (string) now()->getTimestamp()) + ->andReturn($client); + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:comments:entries', '0', (string) now()->getTimestamp()) + ->andReturn($client); + + $client->shouldReceive('exec')->once(); + + $store = $this->createStore($connection); + $operation = new FlushStale($store->getContext()); + + $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries', '_all:tag:comments:entries']); + } + + /** + * @test + */ + public function testFlushStaleEntriesWithEmptyTagIdsReturnsEarly(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Should NOT create pipeline or execute any commands for empty array + $client->shouldNotReceive('pipeline'); + + $store = $this->createStore($connection); + $operation = new FlushStale($store->getContext()); + + $operation->execute([]); + } + + /** + * @test + */ + public function testFlushStaleEntriesUsesCorrectPrefix(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('custom_prefix:_all:tag:users:entries', '0', (string) now()->getTimestamp()) + ->andReturn($client); + + $client->shouldReceive('exec')->once(); + + $store = $this->createStore($connection, 'custom_prefix:'); + $operation = new FlushStale($store->getContext()); + + $operation->execute(['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testFlushStaleEntriesUsesCurrentTimestampAsUpperBound(): void + { + // Set a specific time so we can verify the timestamp + Carbon::setTestNow('2025-06-15 12:30:45'); + $expectedTimestamp = (string) Carbon::now()->getTimestamp(); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // Lower bound is '0' (to exclude -1 forever items) + // Upper bound is current timestamp + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:users:entries', '0', $expectedTimestamp) + ->andReturn($client); + + $client->shouldReceive('exec')->once(); + + $store = $this->createStore($connection); + $operation = new FlushStale($store->getContext()); + + $operation->execute(['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testFlushStaleEntriesDoesNotRemoveForeverItems(): void + { + // This test documents that the score range '0' to timestamp + // intentionally excludes items with score -1 (forever items) + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // The lower bound is '0', not '-inf', so -1 scores are excluded + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:users:entries', '0', m::type('string')) + ->andReturnUsing(function ($key, $min, $max) use ($client) { + // Verify lower bound excludes -1 forever items + $this->assertSame('0', $min); + // Verify upper bound is a valid timestamp + $this->assertIsNumeric($max); + + return $client; + }); + + $client->shouldReceive('exec')->once(); + + $store = $this->createStore($connection); + $operation = new FlushStale($store->getContext()); + + $operation->execute(['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testFlushStaleEntriesClusterModeUsesMulti(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Cluster mode uses multi() which handles cross-slot commands + $clusterClient->shouldReceive('multi') + ->once() + ->andReturn($clusterClient); + + $clusterClient->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:users:entries', '0', (string) now()->getTimestamp()) + ->andReturn($clusterClient); + + $clusterClient->shouldReceive('exec') + ->once() + ->andReturn([5]); + + $operation = new FlushStale($store->getContext()); + $operation->execute(['_all:tag:users:entries']); + } + + /** + * @test + */ + public function testFlushStaleEntriesClusterModeWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Cluster mode uses multi() which handles cross-slot commands + $clusterClient->shouldReceive('multi') + ->once() + ->andReturn($clusterClient); + + // All tags processed in single multi block + $timestamp = (string) now()->getTimestamp(); + $clusterClient->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:users:entries', '0', $timestamp) + ->andReturn($clusterClient); + $clusterClient->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:posts:entries', '0', $timestamp) + ->andReturn($clusterClient); + $clusterClient->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_all:tag:comments:entries', '0', $timestamp) + ->andReturn($clusterClient); + + $clusterClient->shouldReceive('exec') + ->once() + ->andReturn([3, 2, 0]); + + $operation = new FlushStale($store->getContext()); + $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries', '_all:tag:comments:entries']); + } + + /** + * @test + */ + public function testFlushStaleEntriesClusterModeUsesCorrectPrefix(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(prefix: 'custom_prefix:'); + + // Cluster mode uses multi() + $clusterClient->shouldReceive('multi') + ->once() + ->andReturn($clusterClient); + + // Should use custom prefix + $clusterClient->shouldReceive('zRemRangeByScore') + ->once() + ->with('custom_prefix:_all:tag:users:entries', '0', (string) now()->getTimestamp()) + ->andReturn($clusterClient); + + $clusterClient->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $operation = new FlushStale($store->getContext()); + $operation->execute(['_all:tag:users:entries']); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/FlushTest.php b/tests/Cache/Redis/Operations/AllTag/FlushTest.php new file mode 100644 index 000000000..e6eefbbed --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/FlushTest.php @@ -0,0 +1,401 @@ +mockConnection(); + $client = $connection->_mockClient; + + // Mock GetEntries to return cache keys + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection(['key1', 'key2'])); + + // Should delete the cache entries (with prefix) via pipeline + $client->shouldReceive('del') + ->once() + ->with('prefix:key1', 'prefix:key2') + ->andReturn(2); + + // Should delete the tag sorted set + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $operation = new Flush($store->getContext(), $getEntries); + + $operation->execute(['_all:tag:users:entries'], ['users']); + } + + /** + * @test + */ + public function testFlushWithMultipleTagsDeletesAllEntriesAndTagSets(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Mock GetEntries to return cache keys from multiple tags + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries', '_all:tag:posts:entries']) + ->andReturn(new LazyCollection(['user_key1', 'user_key2', 'post_key1'])); + + // Should delete all cache entries (with prefix) via pipeline + $client->shouldReceive('del') + ->once() + ->with('prefix:user_key1', 'prefix:user_key2', 'prefix:post_key1') + ->andReturn(3); + + // Should delete both tag sorted sets in a single batched call + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries', 'prefix:_all:tag:posts:entries') + ->andReturn(2); + + $store = $this->createStore($connection); + $operation = new Flush($store->getContext(), $getEntries); + + $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries'], ['users', 'posts']); + } + + /** + * @test + */ + public function testFlushWithNoEntriesStillDeletesTagSets(): void + { + $connection = $this->mockConnection(); + + // Mock GetEntries to return empty collection + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection([])); + + // No cache entries to delete + $connection->shouldNotReceive('del')->with(m::pattern('/^prefix:(?!tag:)/')); + + // Should still delete the tag sorted set + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $operation = new Flush($store->getContext(), $getEntries); + + $operation->execute(['_all:tag:users:entries'], ['users']); + } + + /** + * @test + */ + public function testFlushChunksLargeEntrySets(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Create more than CHUNK_SIZE (1000) entries + $entries = []; + for ($i = 1; $i <= 1500; ++$i) { + $entries[] = "key{$i}"; + } + + // Mock GetEntries to return many cache keys + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection($entries)); + + // First chunk: 1000 entries (via pipeline on client) + $firstChunkArgs = []; + for ($i = 1; $i <= 1000; ++$i) { + $firstChunkArgs[] = "prefix:key{$i}"; + } + $client->shouldReceive('del') + ->once() + ->with(...$firstChunkArgs) + ->andReturn(1000); + + // Second chunk: 500 entries (via pipeline on client) + $secondChunkArgs = []; + for ($i = 1001; $i <= 1500; ++$i) { + $secondChunkArgs[] = "prefix:key{$i}"; + } + $client->shouldReceive('del') + ->once() + ->with(...$secondChunkArgs) + ->andReturn(500); + + // Should delete the tag sorted set + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $operation = new Flush($store->getContext(), $getEntries); + + $operation->execute(['_all:tag:users:entries'], ['users']); + } + + /** + * @test + */ + public function testFlushUsesCorrectPrefix(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Mock GetEntries to return cache keys + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection(['mykey'])); + + // Should use custom prefix for cache entries (via pipeline on client) + $client->shouldReceive('del') + ->once() + ->with('custom_prefix:mykey') + ->andReturn(1); + + // Should use custom prefix for tag sorted set + $connection->shouldReceive('del') + ->once() + ->with('custom_prefix:_all:tag:users:entries') + ->andReturn(1); + + $store = $this->createStore($connection, 'custom_prefix:'); + $operation = new Flush($store->getContext(), $getEntries); + + $operation->execute(['_all:tag:users:entries'], ['users']); + } + + /** + * @test + */ + public function testFlushWithEmptyTagIdsAndTagNames(): void + { + $connection = $this->mockConnection(); + + // Mock GetEntries - will be called with empty array + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with([]) + ->andReturn(new LazyCollection([])); + + // No del calls should be made for entries or tags + $connection->shouldNotReceive('del'); + + $store = $this->createStore($connection); + $operation = new Flush($store->getContext(), $getEntries); + + $operation->execute([], []); + } + + /** + * @test + */ + public function testFlushTagKeyFormat(): void + { + $connection = $this->mockConnection(); + + // Mock GetEntries + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->andReturn(new LazyCollection([])); + + // Verify the tag key format: "tag:{name}:entries" + $connection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:my-special-tag:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $operation = new Flush($store->getContext(), $getEntries); + + $operation->execute(['_all:tag:my-special-tag:entries'], ['my-special-tag']); + } + + /** + * @test + */ + public function testFlushInClusterModeUsesSequentialDel(): void + { + [$store, $clusterClient, $clusterConnection] = $this->createClusterStore(); + + // Mock GetEntries to return cache keys + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection(['key1', 'key2'])); + + // Cluster mode should NOT use pipeline + $clusterClient->shouldNotReceive('pipeline'); + + // Should delete cache entries directly (sequential DEL) + $clusterClient->shouldReceive('del') + ->once() + ->with('prefix:key1', 'prefix:key2') + ->andReturn(2); + + // Should delete the tag sorted set + $clusterConnection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $operation = new Flush($store->getContext(), $getEntries); + $operation->execute(['_all:tag:users:entries'], ['users']); + } + + /** + * @test + */ + public function testFlushInClusterModeChunksLargeSets(): void + { + [$store, $clusterClient, $clusterConnection] = $this->createClusterStore(); + + // Create more than CHUNK_SIZE (1000) entries + $entries = []; + for ($i = 1; $i <= 1500; ++$i) { + $entries[] = "key{$i}"; + } + + // Mock GetEntries to return many cache keys + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection($entries)); + + // Cluster mode should NOT use pipeline + $clusterClient->shouldNotReceive('pipeline'); + + // First chunk: 1000 entries (sequential DEL) + $firstChunkArgs = []; + for ($i = 1; $i <= 1000; ++$i) { + $firstChunkArgs[] = "prefix:key{$i}"; + } + $clusterClient->shouldReceive('del') + ->once() + ->with(...$firstChunkArgs) + ->andReturn(1000); + + // Second chunk: 500 entries (sequential DEL) + $secondChunkArgs = []; + for ($i = 1001; $i <= 1500; ++$i) { + $secondChunkArgs[] = "prefix:key{$i}"; + } + $clusterClient->shouldReceive('del') + ->once() + ->with(...$secondChunkArgs) + ->andReturn(500); + + // Should delete the tag sorted set + $clusterConnection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $operation = new Flush($store->getContext(), $getEntries); + $operation->execute(['_all:tag:users:entries'], ['users']); + } + + /** + * @test + */ + public function testFlushInClusterModeWithMultipleTags(): void + { + [$store, $clusterClient, $clusterConnection] = $this->createClusterStore(); + + // Mock GetEntries to return cache keys from multiple tags + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries', '_all:tag:posts:entries']) + ->andReturn(new LazyCollection(['user_key1', 'user_key2', 'post_key1'])); + + // Cluster mode should NOT use pipeline + $clusterClient->shouldNotReceive('pipeline'); + + // Should delete all cache entries (sequential DEL) + $clusterClient->shouldReceive('del') + ->once() + ->with('prefix:user_key1', 'prefix:user_key2', 'prefix:post_key1') + ->andReturn(3); + + // Should delete both tag sorted sets + $clusterConnection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries', 'prefix:_all:tag:posts:entries') + ->andReturn(2); + + $operation = new Flush($store->getContext(), $getEntries); + $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries'], ['users', 'posts']); + } + + /** + * @test + */ + public function testFlushInClusterModeWithNoEntries(): void + { + [$store, $clusterClient, $clusterConnection] = $this->createClusterStore(); + + // Mock GetEntries to return empty collection + $getEntries = m::mock(GetEntries::class); + $getEntries->shouldReceive('execute') + ->once() + ->with(['_all:tag:users:entries']) + ->andReturn(new LazyCollection([])); + + // Cluster mode should NOT use pipeline + $clusterClient->shouldNotReceive('pipeline'); + + // No cache entries to delete - del should NOT be called on cluster client + $clusterClient->shouldNotReceive('del'); + + // Should still delete the tag sorted set + $clusterConnection->shouldReceive('del') + ->once() + ->with('prefix:_all:tag:users:entries') + ->andReturn(1); + + $operation = new Flush($store->getContext(), $getEntries); + $operation->execute(['_all:tag:users:entries'], ['users']); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/ForeverTest.php b/tests/Cache/Redis/Operations/AllTag/ForeverTest.php new file mode 100644 index 000000000..a926eaa75 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/ForeverTest.php @@ -0,0 +1,250 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD for tag with score -1 (forever) + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', -1, 'mykey') + ->andReturn($client); + + // SET for cache value (no expiration) + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->forever()->execute( + 'mykey', + 'myvalue', + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testForeverWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD for each tag with score -1 + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', -1, 'mykey') + ->andReturn($client); + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', -1, 'mykey') + ->andReturn($client); + + // SET for cache value + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->forever()->execute( + 'mykey', + 'myvalue', + ['_all:tag:users:entries', '_all:tag:posts:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testForeverWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // SET for cache value only + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->forever()->execute( + 'mykey', + 'myvalue', + [] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testForeverInClusterModeUsesSequentialCommands(): void + { + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Sequential ZADD with score -1 + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', -1, 'mykey') + ->andReturn(1); + + // Sequential SET + $clusterClient->shouldReceive('set') + ->once() + ->with('prefix:mykey', serialize('myvalue')) + ->andReturn(true); + + $result = $store->allTagOps()->forever()->execute( + 'mykey', + 'myvalue', + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testForeverReturnsFalseOnFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('set')->andReturn($client); + + // SET returns false (failure) + $client->shouldReceive('exec') + ->once() + ->andReturn([1, false]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->forever()->execute( + 'mykey', + 'myvalue', + ['_all:tag:users:entries'] + ); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testForeverUsesCorrectPrefix(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('custom:_all:tag:users:entries', -1, 'mykey') + ->andReturn($client); + + $client->shouldReceive('set') + ->once() + ->with('custom:mykey', serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection, 'custom:'); + $result = $store->allTagOps()->forever()->execute( + 'mykey', + 'myvalue', + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testForeverWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + + // Numeric values are NOT serialized (optimization) + $client->shouldReceive('set') + ->once() + ->with('prefix:mykey', 42) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->forever()->execute( + 'mykey', + 42, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php b/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php new file mode 100644 index 000000000..cdc482550 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/GetEntriesTest.php @@ -0,0 +1,323 @@ +mockConnection(); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['key1' => 1, 'key2' => 2]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', 0, '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries']); + + $this->assertInstanceOf(LazyCollection::class, $entries); + $this->assertSame(['key1', 'key2'], $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesWithEmptyTagReturnsEmptyCollection(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return []; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', 0, '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries']); + + $this->assertSame([], $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesWithMultipleTags(): void + { + $connection = $this->mockConnection(); + + // First tag + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['user_key1' => 1, 'user_key2' => 2]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', 0, '*', 1000) + ->andReturnNull(); + + // Second tag + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:posts:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['post_key1' => 1]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:posts:entries', 0, '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries']); + + // Should combine entries from both tags + $this->assertSame(['user_key1', 'user_key2', 'post_key1'], $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesDeduplicatesWithinTag(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['key1' => 1, 'key2' => 2]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', 0, '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries']); + + // array_unique is applied within each tag + $this->assertCount(2, $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesHandlesNullScanResult(): void + { + $connection = $this->mockConnection(); + // zScan returns null/false when done or empty + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries']); + + $this->assertSame([], $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesHandlesFalseScanResult(): void + { + $connection = $this->mockConnection(); + // zScan can return false in some cases + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturn(false); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries']); + + $this->assertSame([], $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesWithEmptyTagIdsArrayReturnsEmptyCollection(): void + { + $connection = $this->mockConnection(); + // No zScan calls should be made + $connection->shouldNotReceive('zScan'); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute([]); + + $this->assertSame([], $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesUsesCorrectPrefix(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('zScan') + ->once() + ->with('custom_prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['key1' => 1]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('custom_prefix:_all:tag:users:entries', 0, '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection, 'custom_prefix:'); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries']); + + $this->assertSame(['key1'], $entries->all()); + } + + /** + * @test + */ + public function testGetEntriesHandlesPaginatedResults(): void + { + $connection = $this->mockConnection(); + + // First page + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 123; // Non-zero cursor indicates more data + + return ['key1' => 1, 'key2' => 2]; + }); + + // Second page (returns remaining entries) + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', 123, '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; // Zero cursor indicates end + + return ['key3' => 3]; + }); + + // Final call with cursor 0 returns null (phpredis behavior) + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', 0, '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries']); + + $this->assertSame(['key1', 'key2', 'key3'], $entries->all()); + } + + /** + * @test + * + * Documents that deduplication is per-tag, not global. If the same key + * exists in multiple tags, it will appear multiple times in the result. + * This is intentional - the Flush operation handles this gracefully + * (deleting a non-existent key is a no-op). + */ + public function testGetEntriesDoesNotDeduplicateAcrossTags(): void + { + $connection = $this->mockConnection(); + + // First tag has 'shared_key' + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['shared_key' => 1, 'user_only' => 2]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:users:entries', 0, '*', 1000) + ->andReturnNull(); + + // Second tag also has 'shared_key' + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:posts:entries', m::any(), '*', 1000) + ->andReturnUsing(function ($key, &$cursor) { + $cursor = 0; + + return ['shared_key' => 1, 'post_only' => 2]; + }); + $connection->shouldReceive('zScan') + ->once() + ->with('prefix:_all:tag:posts:entries', 0, '*', 1000) + ->andReturnNull(); + + $store = $this->createStore($connection); + $operation = new GetEntries($store->getContext()); + + $entries = $operation->execute(['_all:tag:users:entries', '_all:tag:posts:entries']); + + // 'shared_key' appears twice - once from each tag + $this->assertSame(['shared_key', 'user_only', 'shared_key', 'post_only'], $entries->all()); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/IncrementTest.php b/tests/Cache/Redis/Operations/AllTag/IncrementTest.php new file mode 100644 index 000000000..f1f71337d --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/IncrementTest.php @@ -0,0 +1,216 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD NX for tag with score -1 (only add if not exists) + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn($client); + + // INCRBY + $client->shouldReceive('incrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 5]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->increment()->execute( + 'counter', + 1, + ['_all:tag:users:entries'] + ); + + $this->assertSame(5, $result); + } + + /** + * @test + */ + public function testIncrementWithCustomValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn($client); + + $client->shouldReceive('incrby') + ->once() + ->with('prefix:counter', 10) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([0, 15]); // 0 means key already existed (NX condition) + + $store = $this->createStore($connection); + $result = $store->allTagOps()->increment()->execute( + 'counter', + 10, + ['_all:tag:users:entries'] + ); + + $this->assertSame(15, $result); + } + + /** + * @test + */ + public function testIncrementWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD NX for each tag + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn($client); + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', ['NX'], -1, 'counter') + ->andReturn($client); + + $client->shouldReceive('incrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1, 1]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->increment()->execute( + 'counter', + 1, + ['_all:tag:users:entries', '_all:tag:posts:entries'] + ); + + $this->assertSame(1, $result); + } + + /** + * @test + */ + public function testIncrementWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // No ZADD calls expected + $client->shouldReceive('incrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->increment()->execute( + 'counter', + 1, + [] + ); + + $this->assertSame(1, $result); + } + + /** + * @test + */ + public function testIncrementInClusterModeUsesSequentialCommands(): void + { + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Sequential ZADD NX + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', ['NX'], -1, 'counter') + ->andReturn(1); + + // Sequential INCRBY + $clusterClient->shouldReceive('incrby') + ->once() + ->with('prefix:counter', 1) + ->andReturn(10); + + $result = $store->allTagOps()->increment()->execute( + 'counter', + 1, + ['_all:tag:users:entries'] + ); + + $this->assertSame(10, $result); + } + + /** + * @test + */ + public function testIncrementReturnsFalseOnPipelineFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('incrby')->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn(false); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->increment()->execute( + 'counter', + 1, + ['_all:tag:users:entries'] + ); + + $this->assertFalse($result); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/PruneTest.php b/tests/Cache/Redis/Operations/AllTag/PruneTest.php new file mode 100644 index 000000000..11b7551bb --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/PruneTest.php @@ -0,0 +1,397 @@ + [], 'iterator' => 0], + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(0, $result['tags_scanned']); + $this->assertSame(0, $result['stale_entries_removed']); + $this->assertSame(0, $result['entries_checked']); + $this->assertSame(0, $result['orphans_removed']); + $this->assertSame(0, $result['empty_sets_deleted']); + } + + /** + * @test + */ + public function testPruneRemovesStaleEntriesFromSingleTag(): void + { + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => ['_all:tag:users:entries'], 'iterator' => 0], + ], + zRemRangeByScoreResults: [ + '_all:tag:users:entries' => 5, // 5 stale entries removed + ], + zScanResults: [ + '_all:tag:users:entries' => [ + ['members' => [], 'iterator' => 0], // No members to check for orphans + ], + ], + zCardResults: [ + '_all:tag:users:entries' => 3, // 3 remaining entries (not empty) + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(1, $result['tags_scanned']); + $this->assertSame(5, $result['stale_entries_removed']); + $this->assertSame(0, $result['empty_sets_deleted']); + } + + /** + * @test + */ + public function testPruneDeletesEmptySortedSets(): void + { + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => ['_all:tag:users:entries'], 'iterator' => 0], + ], + zRemRangeByScoreResults: [ + '_all:tag:users:entries' => 10, // All entries removed + ], + zScanResults: [ + '_all:tag:users:entries' => [ + ['members' => [], 'iterator' => 0], + ], + ], + zCardResults: [ + '_all:tag:users:entries' => 0, // Empty after removal + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(1, $result['tags_scanned']); + $this->assertSame(10, $result['stale_entries_removed']); + $this->assertSame(1, $result['empty_sets_deleted']); + } + + /** + * @test + */ + public function testPruneHandlesMultipleTags(): void + { + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => ['_all:tag:users:entries', '_all:tag:posts:entries', '_all:tag:comments:entries'], 'iterator' => 0], + ], + zRemRangeByScoreResults: [ + '_all:tag:users:entries' => 2, + '_all:tag:posts:entries' => 3, + '_all:tag:comments:entries' => 0, + ], + zScanResults: [ + '_all:tag:users:entries' => [['members' => [], 'iterator' => 0]], + '_all:tag:posts:entries' => [['members' => [], 'iterator' => 0]], + '_all:tag:comments:entries' => [['members' => [], 'iterator' => 0]], + ], + zCardResults: [ + '_all:tag:users:entries' => 5, + '_all:tag:posts:entries' => 0, // Empty - should be deleted + '_all:tag:comments:entries' => 10, + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(3, $result['tags_scanned']); + $this->assertSame(5, $result['stale_entries_removed']); // 2 + 3 + 0 + $this->assertSame(1, $result['empty_sets_deleted']); // Only posts was empty + } + + /** + * @test + */ + public function testPruneDeduplicatesScanResults(): void + { + // SafeScan iterates multiple times, returning duplicates + $fakeClient = new FakeRedisClient( + scanResults: [ + // First scan: returns 2 keys, iterator = 100 (continue) + ['keys' => ['_all:tag:users:entries', '_all:tag:posts:entries'], 'iterator' => 100], + // Second scan: returns 1 duplicate + 1 new, iterator = 0 (done) + ['keys' => ['_all:tag:users:entries', '_all:tag:comments:entries'], 'iterator' => 0], + ], + zRemRangeByScoreResults: [ + '_all:tag:users:entries' => 1, + '_all:tag:posts:entries' => 1, + '_all:tag:comments:entries' => 1, + ], + zScanResults: [ + '_all:tag:users:entries' => [['members' => [], 'iterator' => 0]], + '_all:tag:posts:entries' => [['members' => [], 'iterator' => 0]], + '_all:tag:comments:entries' => [['members' => [], 'iterator' => 0]], + ], + zCardResults: [ + '_all:tag:users:entries' => 5, + '_all:tag:posts:entries' => 5, + '_all:tag:comments:entries' => 5, + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + // Verify scan was called twice (multi-iteration) + $this->assertSame(2, $fakeClient->getScanCallCount()); + + // SafeScan yields each key as encountered (no deduplication in SafeScan itself), + // but Prune processes each unique tag once via the generator + // Actually, SafeScan is a generator - it yields duplicates if SCAN returns them + // The 4 keys scanned means duplicate 'users' was yielded twice + $this->assertSame(4, $result['tags_scanned']); + } + + /** + * @test + */ + public function testPruneUsesCorrectScanPattern(): void + { + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => [], 'iterator' => 0], + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient, prefix: 'custom_prefix:'); + $operation = new Prune($store->getContext()); + + $operation->execute(); + + // Verify SCAN was called with correct pattern + $this->assertSame(1, $fakeClient->getScanCallCount()); + $this->assertSame('custom_prefix:_all:tag:*:entries', $fakeClient->getScanCalls()[0]['pattern']); + } + + /** + * @test + */ + public function testPrunePreservesForeverItems(): void + { + // Forever items have score -1, ZREMRANGEBYSCORE uses '0' as lower bound + // This test verifies the behavior documentation + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => ['_all:tag:users:entries'], 'iterator' => 0], + ], + zRemRangeByScoreResults: [ + // 0 entries removed because all are forever items (score -1) + '_all:tag:users:entries' => 0, + ], + zScanResults: [ + '_all:tag:users:entries' => [['members' => [], 'iterator' => 0]], + ], + zCardResults: [ + '_all:tag:users:entries' => 5, // 5 forever items remain + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(0, $result['stale_entries_removed']); + $this->assertSame(0, $result['empty_sets_deleted']); + } + + /** + * @test + */ + public function testPruneUsesCustomScanCount(): void + { + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => [], 'iterator' => 0], + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $operation->execute(500); + + // Verify SCAN was called with custom count + $this->assertSame(500, $fakeClient->getScanCalls()[0]['count']); + } + + /** + * @test + */ + public function testPruneViaStoreOperationsContainer(): void + { + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => [], 'iterator' => 0], + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + + // Access via the operations container + $result = $store->allTagOps()->prune()->execute(); + + $this->assertSame(0, $result['tags_scanned']); + } + + /** + * @test + */ + public function testPruneRemovesOrphanedEntries(): void + { + // Set up: tag has 3 members, but 2 cache keys don't exist (orphans) + $fakeClient = new FakeRedisClient( + scanResults: [ + ['keys' => ['_all:tag:users:entries'], 'iterator' => 0], + ], + zRemRangeByScoreResults: [ + '_all:tag:users:entries' => 0, // No stale entries + ], + zScanResults: [ + '_all:tag:users:entries' => [ + // ZSCAN returns [member => score, ...] + ['members' => ['key1' => 1234567890.0, 'key2' => 1234567891.0, 'key3' => 1234567892.0], 'iterator' => 0], + ], + ], + // EXISTS results: key1 exists (1), key2 doesn't (0), key3 exists (1) + execResults: [ + [1, 0, 1], // Pipeline results for EXISTS calls + ], + zCardResults: [ + '_all:tag:users:entries' => 2, // 2 remaining after orphan removal + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(3, $result['entries_checked']); + $this->assertSame(1, $result['orphans_removed']); // key2 was orphaned + + // Verify zRem was called to remove orphan + $zRemCalls = $fakeClient->getZRemCalls(); + $this->assertCount(1, $zRemCalls); + $this->assertSame('_all:tag:users:entries', $zRemCalls[0]['key']); + $this->assertContains('key2', $zRemCalls[0]['members']); + } + + /** + * @test + */ + public function testPruneHandlesOptPrefixCorrectly(): void + { + // When OPT_PREFIX is set, SCAN pattern needs prefix, but returned keys have it stripped + $fakeClient = new FakeRedisClient( + scanResults: [ + // SafeScan strips the OPT_PREFIX from returned keys + ['keys' => ['myapp:_all:tag:users:entries'], 'iterator' => 0], + ], + optPrefix: 'myapp:', + zRemRangeByScoreResults: [ + '_all:tag:users:entries' => 1, + ], + zScanResults: [ + '_all:tag:users:entries' => [['members' => [], 'iterator' => 0]], + ], + zCardResults: [ + '_all:tag:users:entries' => 5, + ], + ); + + $store = $this->createStoreWithFakeClient($fakeClient, prefix: 'cache:'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + // Verify SCAN pattern included OPT_PREFIX + $this->assertSame('myapp:cache:_all:tag:*:entries', $fakeClient->getScanCalls()[0]['pattern']); + + $this->assertSame(1, $result['tags_scanned']); + } + + /** + * Create a RedisStore with a FakeRedisClient. + * + * This follows the pattern from FlushByPatternTest - mock the connection + * to return the FakeRedisClient, mock the pool infrastructure. + */ + private function createStoreWithFakeClient( + FakeRedisClient $fakeClient, + string $prefix = 'prefix:', + string $connectionName = 'default', + ): RedisStore { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('serialized')->andReturn(false); + $connection->shouldReceive('client')->andReturn($fakeClient); + + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->andReturn($connection); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with($connectionName)->andReturn($pool); + + return new RedisStore( + m::mock(RedisFactory::class), + $prefix, + $connectionName, + $poolFactory + ); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/PutManyTest.php b/tests/Cache/Redis/Operations/AllTag/PutManyTest.php new file mode 100644 index 000000000..57d1aaa3f --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/PutManyTest.php @@ -0,0 +1,565 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $expectedScore = now()->timestamp + 60; + + // Variadic ZADD: one command with all members for the tag + // Format: key, score1, member1, score2, member2, ... + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'ns:foo', $expectedScore, 'ns:baz') + ->andReturn($client); + + // SETEX for each key + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 60, serialize('bar')) + ->andReturn($client); + + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:baz', 60, serialize('qux')) + ->andReturn($client); + + // Results: 1 ZADD (returns count of new members) + 2 SETEX (return true) + $client->shouldReceive('exec') + ->once() + ->andReturn([2, true, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar', 'baz' => 'qux'], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $expectedScore = now()->timestamp + 120; + + // Variadic ZADD for each tag (one command per tag, all keys as members) + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'ns:foo') + ->andReturn($client); + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', $expectedScore, 'ns:foo') + ->andReturn($client); + + // SETEX for the key + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 120, serialize('bar')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar'], + 120, + ['_all:tag:users:entries', '_all:tag:posts:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // Only SETEX, no ZADD + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 60, serialize('bar')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar'], + 60, + [], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyWithEmptyValuesReturnsTrue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // No pipeline operations for empty values + $client->shouldNotReceive('pipeline'); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + [], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyInClusterModeUsesVariadicZadd(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + $expectedScore = now()->timestamp + 60; + + // Variadic ZADD: one command with all members for the tag + // This works in cluster because all members go to ONE sorted set (one slot) + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'ns:foo', $expectedScore, 'ns:baz') + ->andReturn(2); + + // Sequential SETEX for each key + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 60, serialize('bar')) + ->andReturn(true); + + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:ns:baz', 60, serialize('qux')) + ->andReturn(true); + + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar', 'baz' => 'qux'], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyReturnsFalseOnFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('setex')->andReturn($client); + + // One SETEX fails + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true, 1, false]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar', 'baz' => 'qux'], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutManyReturnsFalseOnPipelineFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('setex')->andReturn($client); + + // Pipeline fails entirely + $client->shouldReceive('exec') + ->once() + ->andReturn(false); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar'], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutManyEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + + // TTL should be at least 1 + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 1, serialize('bar')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar'], + 0, // Zero TTL + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyWithNumericValues(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + + // Numeric values are NOT serialized (optimization) + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:count', 60, 42) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['count' => 42], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyUsesCorrectPrefix(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $expectedScore = now()->timestamp + 30; + + // Custom prefix should be used + $client->shouldReceive('zadd') + ->once() + ->with('custom:_all:tag:users:entries', $expectedScore, 'ns:foo') + ->andReturn($client); + + $client->shouldReceive('setex') + ->once() + ->with('custom:ns:foo', 30, serialize('bar')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection, 'custom:'); + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar'], + 30, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + * + * Tests the maximum optimization benefit: multiple keys × multiple tags. + * Before: O(keys × tags) ZADD commands + * After: O(tags) ZADD commands (each with all keys) + */ + public function testPutManyWithMultipleTagsAndMultipleKeys(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $expectedScore = now()->timestamp + 60; + + // Variadic ZADD for first tag with all keys + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'ns:a', $expectedScore, 'ns:b', $expectedScore, 'ns:c') + ->andReturn($client); + + // Variadic ZADD for second tag with all keys + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', $expectedScore, 'ns:a', $expectedScore, 'ns:b', $expectedScore, 'ns:c') + ->andReturn($client); + + // SETEX for each key + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:a', 60, serialize('val-a')) + ->andReturn($client); + + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:b', 60, serialize('val-b')) + ->andReturn($client); + + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:c', 60, serialize('val-c')) + ->andReturn($client); + + // Results: 2 ZADDs + 3 SETEXs + $client->shouldReceive('exec') + ->once() + ->andReturn([3, 3, true, true, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->putMany()->execute( + ['a' => 'val-a', 'b' => 'val-b', 'c' => 'val-c'], + 60, + ['_all:tag:users:entries', '_all:tag:posts:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyInClusterModeWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + $expectedScore = now()->timestamp + 60; + + // Variadic ZADD for each tag (different slots, separate commands) + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'ns:foo', $expectedScore, 'ns:bar') + ->andReturn(2); + + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', $expectedScore, 'ns:foo', $expectedScore, 'ns:bar') + ->andReturn(2); + + // SETEXs for each key + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 60, serialize('value1')) + ->andReturn(true); + + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:ns:bar', 60, serialize('value2')) + ->andReturn(true); + + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'value1', 'bar' => 'value2'], + 60, + ['_all:tag:users:entries', '_all:tag:posts:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyInClusterModeWithEmptyTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // No ZADD calls for empty tags + $clusterClient->shouldNotReceive('zadd'); + + // Only SETEXs + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 60, serialize('bar')) + ->andReturn(true); + + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'bar'], + 60, + [], + 'ns:' + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyInClusterModeReturnsFalseOnSetexFailure(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + $expectedScore = now()->timestamp + 60; + + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'ns:foo', $expectedScore, 'ns:bar') + ->andReturn(2); + + // First SETEX succeeds, second fails + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 60, serialize('value1')) + ->andReturn(true); + + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:ns:bar', 60, serialize('value2')) + ->andReturn(false); + + $result = $store->allTagOps()->putMany()->execute( + ['foo' => 'value1', 'bar' => 'value2'], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutManyInClusterModeWithEmptyValuesReturnsTrue(): void + { + [$store, $clusterClient] = $this->createClusterStore(); + + // No operations for empty values + $clusterClient->shouldNotReceive('zadd'); + $clusterClient->shouldNotReceive('setex'); + + $result = $store->allTagOps()->putMany()->execute( + [], + 60, + ['_all:tag:users:entries'], + 'ns:' + ); + + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/PutTest.php b/tests/Cache/Redis/Operations/AllTag/PutTest.php new file mode 100644 index 000000000..27ffe82d2 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/PutTest.php @@ -0,0 +1,304 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // ZADD for tag + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->andReturn($client); + + // SETEX for cache value + $client->shouldReceive('setex') + ->once() + ->with('prefix:mykey', 60, serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->put()->execute( + 'mykey', + 'myvalue', + 60, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithMultipleTags(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $expectedScore = now()->timestamp + 120; + + // ZADD for each tag + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', $expectedScore, 'mykey') + ->andReturn($client); + $client->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:posts:entries', $expectedScore, 'mykey') + ->andReturn($client); + + // SETEX for cache value + $client->shouldReceive('setex') + ->once() + ->with('prefix:mykey', 120, serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->put()->execute( + 'mykey', + 'myvalue', + 120, + ['_all:tag:users:entries', '_all:tag:posts:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithEmptyTagsStillStoresValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + // No ZADD calls expected + // SETEX for cache value + $client->shouldReceive('setex') + ->once() + ->with('prefix:mykey', 60, serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->put()->execute( + 'mykey', + 'myvalue', + 60, + [] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutUsesCorrectPrefix(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd') + ->once() + ->with('custom:_all:tag:users:entries', now()->timestamp + 30, 'mykey') + ->andReturn($client); + + $client->shouldReceive('setex') + ->once() + ->with('custom:mykey', 30, serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection, 'custom:'); + $result = $store->allTagOps()->put()->execute( + 'mykey', + 'myvalue', + 30, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutReturnsFalseOnFailure(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + $client->shouldReceive('setex')->andReturn($client); + + // SETEX returns false (failure) + $client->shouldReceive('exec') + ->once() + ->andReturn([1, false]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->put()->execute( + 'mykey', + 'myvalue', + 60, + ['_all:tag:users:entries'] + ); + + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutInClusterModeUsesSequentialCommands(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + [$store, $clusterClient] = $this->createClusterStore(); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + // Sequential ZADD + $clusterClient->shouldReceive('zadd') + ->once() + ->with('prefix:_all:tag:users:entries', now()->timestamp + 60, 'mykey') + ->andReturn(1); + + // Sequential SETEX + $clusterClient->shouldReceive('setex') + ->once() + ->with('prefix:mykey', 60, serialize('myvalue')) + ->andReturn(true); + + $result = $store->allTagOps()->put()->execute( + 'mykey', + 'myvalue', + 60, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + + // TTL should be at least 1 + $client->shouldReceive('setex') + ->once() + ->with('prefix:mykey', 1, serialize('myvalue')) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->put()->execute( + 'mykey', + 'myvalue', + 0, // Zero TTL + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithNumericValue(): void + { + Carbon::setTestNow('2000-01-01 00:00:00'); + + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('pipeline')->once()->andReturn($client); + + $client->shouldReceive('zadd')->andReturn($client); + + // Numeric values are NOT serialized (optimization) + $client->shouldReceive('setex') + ->once() + ->with('prefix:mykey', 60, 42) + ->andReturn($client); + + $client->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $store = $this->createStore($connection); + $result = $store->allTagOps()->put()->execute( + 'mykey', + 42, + 60, + ['_all:tag:users:entries'] + ); + + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/RememberForeverTest.php b/tests/Cache/Redis/Operations/AllTag/RememberForeverTest.php new file mode 100644 index 000000000..275324ff7 --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/RememberForeverTest.php @@ -0,0 +1,359 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturn(serialize('cached_value')); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 'new_value', ['tag1:entries']); + + $this->assertSame('cached_value', $value); + $this->assertTrue($wasHit); + } + + /** + * @test + */ + public function testRememberForeverCallsCallbackOnCacheMissUsingPipeline(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturnNull(); + + // Pipeline mode for non-cluster + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + // ZADD for each tag with score -1 (forever marker) + $pipeline->shouldReceive('zadd') + ->once() + ->withArgs(function ($key, $score, $member) { + $this->assertSame('prefix:tag1:entries', $key); + $this->assertSame(self::FOREVER_SCORE, $score); + $this->assertSame('ns:foo', $member); + + return true; + }); + + // SET (not SETEX) for forever items + $pipeline->shouldReceive('set') + ->once() + ->withArgs(function ($key, $value) { + $this->assertSame('prefix:ns:foo', $key); + $this->assertSame(serialize('computed_value'), $value); + + return true; + }); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $callCount = 0; + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }, ['tag1:entries']); + + $this->assertSame('computed_value', $value); + $this->assertFalse($wasHit); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberForeverDoesNotCallCallbackOnCacheHit(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturn(serialize('existing_value')); + + $callCount = 0; + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }, ['tag1:entries']); + + $this->assertSame('existing_value', $value); + $this->assertTrue($wasHit); + $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); + } + + /** + * @test + */ + public function testRememberForeverWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + // Should ZADD to each tag's sorted set with score -1 + $pipeline->shouldReceive('zadd') + ->times(3) + ->withArgs(function ($key, $score, $member) { + $this->assertSame(self::FOREVER_SCORE, $score); + + return true; + }) + ->andReturn(1); + + $pipeline->shouldReceive('set') + ->once() + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, 1, 1, true]); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute( + 'ns:foo', + fn () => 'value', + ['tag1:entries', 'tag2:entries', 'tag3:entries'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $redis = $this->createStore($connection); + $redis->allTagOps()->rememberForever()->execute('ns:foo', function () { + throw new RuntimeException('Callback failed'); + }, ['tag1:entries']); + } + + /** + * @test + */ + public function testRememberForeverUsesSequentialCommandsInClusterMode(): void + { + $connection = $this->mockClusterConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturnNull(); + + // In cluster mode, should use sequential zadd calls (not pipeline) + $client->shouldReceive('zadd') + ->twice() + ->withArgs(function ($key, $score, $member) { + // Score may be float or int depending on implementation + $this->assertEquals(self::FOREVER_SCORE, $score); + + return true; + }) + ->andReturn(1); + + // SET without TTL + $client->shouldReceive('set') + ->once() + ->with('prefix:ns:foo', serialize('value')) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute( + 'ns:foo', + fn () => 'value', + ['tag1:entries', 'tag2:entries'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + $pipeline->shouldReceive('zadd') + ->once() + ->andReturn(1); + + // Numeric values are NOT serialized + $pipeline->shouldReceive('set') + ->once() + ->withArgs(function ($key, $value) { + $this->assertSame(42, $value); + + return true; + }) + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 42, ['tag1:entries']); + + $this->assertSame(42, $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + // No ZADD calls when tags are empty + $pipeline->shouldReceive('zadd')->never(); + + $pipeline->shouldReceive('set') + ->once() + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([true]); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 'bar', []); + + $this->assertSame('bar', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverUsesNegativeOneScoreForForeverMarker(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + // Verify score is -1 (the "forever" marker that prevents cleanup) + $capturedScore = null; + $pipeline->shouldReceive('zadd') + ->once() + ->withArgs(function ($key, $score, $member) use (&$capturedScore) { + $capturedScore = $score; + + return true; + }) + ->andReturn(1); + + $pipeline->shouldReceive('set') + ->once() + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $redis = $this->createStore($connection); + $redis->allTagOps()->rememberForever()->execute('ns:foo', fn () => 'bar', ['tag1:entries']); + + $this->assertSame(-1, $capturedScore, 'Forever items should use score -1'); + } +} diff --git a/tests/Cache/Redis/Operations/AllTag/RememberTest.php b/tests/Cache/Redis/Operations/AllTag/RememberTest.php new file mode 100644 index 000000000..735fc909a --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTag/RememberTest.php @@ -0,0 +1,343 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturn(serialize('cached_value')); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, fn () => 'new_value', ['tag1:entries']); + + $this->assertSame('cached_value', $value); + $this->assertTrue($wasHit); + } + + /** + * @test + */ + public function testRememberCallsCallbackOnCacheMissUsingPipeline(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturnNull(); + + // Pipeline mode for non-cluster + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + // ZADD for each tag + $pipeline->shouldReceive('zadd') + ->once() + ->withArgs(function ($key, $score, $member) { + $this->assertSame('prefix:tag1:entries', $key); + $this->assertIsInt($score); + $this->assertSame('ns:foo', $member); + + return true; + }); + + // SETEX for the value + $pipeline->shouldReceive('setex') + ->once() + ->withArgs(function ($key, $ttl, $value) { + $this->assertSame('prefix:ns:foo', $key); + $this->assertSame(60, $ttl); + $this->assertSame(serialize('computed_value'), $value); + + return true; + }); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $callCount = 0; + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }, ['tag1:entries']); + + $this->assertSame('computed_value', $value); + $this->assertFalse($wasHit); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberDoesNotCallCallbackOnCacheHit(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturn(serialize('existing_value')); + + $callCount = 0; + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }, ['tag1:entries']); + + $this->assertSame('existing_value', $value); + $this->assertTrue($wasHit); + $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); + } + + /** + * @test + */ + public function testRememberWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + // Should ZADD to each tag's sorted set + $pipeline->shouldReceive('zadd') + ->times(3) + ->andReturn(1); + + $pipeline->shouldReceive('setex') + ->once() + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, 1, 1, true]); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->remember()->execute( + 'ns:foo', + 60, + fn () => 'value', + ['tag1:entries', 'tag2:entries', 'tag3:entries'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $redis = $this->createStore($connection); + $redis->allTagOps()->remember()->execute('ns:foo', 60, function () { + throw new RuntimeException('Callback failed'); + }, ['tag1:entries']); + } + + /** + * @test + */ + public function testRememberEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + $pipeline->shouldReceive('zadd') + ->once() + ->andReturn(1); + + // TTL should be at least 1 + $pipeline->shouldReceive('setex') + ->once() + ->withArgs(function ($key, $ttl, $value) { + $this->assertSame(1, $ttl); + + return true; + }) + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $redis = $this->createStore($connection); + $redis->allTagOps()->remember()->execute('ns:foo', 0, fn () => 'bar', ['tag1:entries']); + } + + /** + * @test + */ + public function testRememberUsesSequentialCommandsInClusterMode(): void + { + $connection = $this->mockClusterConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:ns:foo') + ->andReturnNull(); + + // In cluster mode, should use sequential zadd calls (not pipeline) + $client->shouldReceive('zadd') + ->twice() + ->andReturn(1); + + $client->shouldReceive('setex') + ->once() + ->with('prefix:ns:foo', 60, serialize('value')) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->remember()->execute( + 'ns:foo', + 60, + fn () => 'value', + ['tag1:entries', 'tag2:entries'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + $pipeline->shouldReceive('zadd') + ->once() + ->andReturn(1); + + // Numeric values are NOT serialized + $pipeline->shouldReceive('setex') + ->once() + ->withArgs(function ($key, $ttl, $value) { + $this->assertSame(42, $value); + + return true; + }) + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([1, true]); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, fn () => 42, ['tag1:entries']); + + $this->assertSame(42, $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $pipeline = m::mock(); + $client->shouldReceive('pipeline') + ->once() + ->andReturn($pipeline); + + // No ZADD calls when tags are empty + $pipeline->shouldReceive('zadd')->never(); + + $pipeline->shouldReceive('setex') + ->once() + ->andReturn(true); + + $pipeline->shouldReceive('exec') + ->once() + ->andReturn([true]); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->allTagOps()->remember()->execute('ns:foo', 60, fn () => 'bar', []); + + $this->assertSame('bar', $value); + $this->assertFalse($wasHit); + } +} diff --git a/tests/Cache/Redis/Operations/AllTagOperationsTest.php b/tests/Cache/Redis/Operations/AllTagOperationsTest.php new file mode 100644 index 000000000..62aa6001b --- /dev/null +++ b/tests/Cache/Redis/Operations/AllTagOperationsTest.php @@ -0,0 +1,92 @@ +mockConnection(); + $store = $this->createStore($connection); + $ops = $store->allTagOps(); + + $this->assertInstanceOf(Put::class, $ops->put()); + $this->assertInstanceOf(PutMany::class, $ops->putMany()); + $this->assertInstanceOf(Add::class, $ops->add()); + $this->assertInstanceOf(Forever::class, $ops->forever()); + $this->assertInstanceOf(Increment::class, $ops->increment()); + $this->assertInstanceOf(Decrement::class, $ops->decrement()); + $this->assertInstanceOf(AddEntry::class, $ops->addEntry()); + $this->assertInstanceOf(GetEntries::class, $ops->getEntries()); + $this->assertInstanceOf(FlushStale::class, $ops->flushStale()); + $this->assertInstanceOf(Flush::class, $ops->flush()); + $this->assertInstanceOf(Prune::class, $ops->prune()); + $this->assertInstanceOf(Remember::class, $ops->remember()); + $this->assertInstanceOf(RememberForever::class, $ops->rememberForever()); + } + + /** + * @test + */ + public function testOperationInstancesAreCached(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $ops = $store->allTagOps(); + + // Same instance returned on repeated calls + $this->assertSame($ops->put(), $ops->put()); + $this->assertSame($ops->remember(), $ops->remember()); + $this->assertSame($ops->getEntries(), $ops->getEntries()); + } + + /** + * @test + */ + public function testClearResetsAllCachedInstances(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $ops = $store->allTagOps(); + + // Get instances before clear + $putBefore = $ops->put(); + $rememberBefore = $ops->remember(); + + // Clear + $ops->clear(); + + // Instances should be new after clear + $this->assertNotSame($putBefore, $ops->put()); + $this->assertNotSame($rememberBefore, $ops->remember()); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/AddTest.php b/tests/Cache/Redis/Operations/AnyTag/AddTest.php new file mode 100644 index 000000000..6fea00a35 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/AddTest.php @@ -0,0 +1,71 @@ +mockConnection(); + $client = $connection->_mockClient; + + // evalSha returns false (script not cached), eval returns true (key added) + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + $this->assertStringContainsString('SET', $script); + $this->assertStringContainsString('NX', $script); + $this->assertStringContainsString('HSETEX', $script); + $this->assertSame(2, $numKeys); + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->add()->execute('foo', 'bar', 60, ['users']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testAddWithTagsReturnsFalseWhenKeyExists(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Lua script returns false when key already exists (SET NX fails) + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(false); // Key exists + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->add()->execute('foo', 'bar', 60, ['users']); + $this->assertFalse($result); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/DecrementTest.php b/tests/Cache/Redis/Operations/AnyTag/DecrementTest.php new file mode 100644 index 000000000..28fed5480 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/DecrementTest.php @@ -0,0 +1,47 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + $this->assertStringContainsString('DECRBY', $script); + $this->assertStringContainsString('TTL', $script); + $this->assertSame(2, $numKeys); + + return true; + }) + ->andReturn(5); // New value after decrement + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->decrement()->execute('counter', 5, ['stats']); + $this->assertSame(5, $result); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/FlushTest.php b/tests/Cache/Redis/Operations/AnyTag/FlushTest.php new file mode 100644 index 000000000..72227782e --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/FlushTest.php @@ -0,0 +1,341 @@ +mockConnection(); + $client = $connection->_mockClient; + + // Mock GetTaggedKeys to return cache keys + $getTaggedKeys = m::mock(GetTaggedKeys::class); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('users') + ->andReturn($this->arrayToGenerator(['key1', 'key2'])); + + // Pipeline mode expectations + $client->shouldReceive('pipeline')->andReturn($client); + + // Should delete reverse indexes via pipeline + $client->shouldReceive('del') + ->once() + ->with('prefix:key1:_any:tags', 'prefix:key2:_any:tags') + ->andReturn($client); + + // Should unlink cache entries via pipeline + $client->shouldReceive('unlink') + ->once() + ->with('prefix:key1', 'prefix:key2') + ->andReturn($client); + + // First exec for chunk processing + $client->shouldReceive('exec')->andReturn([2, 2]); + + // Should delete the tag hash and remove from registry via pipeline + $client->shouldReceive('del') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn($client); + $client->shouldReceive('zrem') + ->once() + ->with('prefix:_any:tag:registry', 'users') + ->andReturn($client); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Flush($store->getContext(), $getTaggedKeys); + + $result = $operation->execute(['users']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlushWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Mock GetTaggedKeys to return keys from multiple tags + $getTaggedKeys = m::mock(GetTaggedKeys::class); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('users') + ->andReturn($this->arrayToGenerator(['user_key1'])); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('posts') + ->andReturn($this->arrayToGenerator(['post_key1'])); + + // Pipeline mode expectations + $client->shouldReceive('pipeline')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('unlink')->andReturn($client); + $client->shouldReceive('zrem')->andReturn($client); + $client->shouldReceive('exec')->andReturn([]); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Flush($store->getContext(), $getTaggedKeys); + + $result = $operation->execute(['users', 'posts']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlushWithNoEntriesStillDeletesTagHashes(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Mock GetTaggedKeys to return empty + $getTaggedKeys = m::mock(GetTaggedKeys::class); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('users') + ->andReturn($this->arrayToGenerator([])); + + // Pipeline mode - only tag hash deletion, no chunk processing + $client->shouldReceive('pipeline')->andReturn($client); + $client->shouldReceive('del') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn($client); + $client->shouldReceive('zrem') + ->once() + ->with('prefix:_any:tag:registry', 'users') + ->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 1]); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Flush($store->getContext(), $getTaggedKeys); + + $result = $operation->execute(['users']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlushDeduplicatesKeysAcrossTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Mock GetTaggedKeys - both tags have 'shared_key' + $getTaggedKeys = m::mock(GetTaggedKeys::class); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('users') + ->andReturn($this->arrayToGenerator(['shared_key', 'user_only'])); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('posts') + ->andReturn($this->arrayToGenerator(['shared_key', 'post_only'])); + + // Pipeline mode - shared_key should only appear once due to buffer deduplication + $client->shouldReceive('pipeline')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('unlink')->andReturn($client); + $client->shouldReceive('zrem')->andReturn($client); + $client->shouldReceive('exec')->andReturn([]); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Flush($store->getContext(), $getTaggedKeys); + + $result = $operation->execute(['users', 'posts']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlushUsesCorrectPrefix(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $getTaggedKeys = m::mock(GetTaggedKeys::class); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('users') + ->andReturn($this->arrayToGenerator(['mykey'])); + + $client->shouldReceive('pipeline')->andReturn($client); + + // Should use custom prefix for reverse index + $client->shouldReceive('del') + ->once() + ->with('custom_prefix:mykey:_any:tags') + ->andReturn($client); + + // Should use custom prefix for cache key + $client->shouldReceive('unlink') + ->once() + ->with('custom_prefix:mykey') + ->andReturn($client); + + $client->shouldReceive('exec')->andReturn([1, 1]); + + // Should use custom prefix for tag hash + $client->shouldReceive('del') + ->once() + ->with('custom_prefix:_any:tag:users:entries') + ->andReturn($client); + + // Should use custom prefix for registry + $client->shouldReceive('zrem') + ->once() + ->with('custom_prefix:_any:tag:registry', 'users') + ->andReturn($client); + + $store = $this->createStore($connection, 'custom_prefix:'); + $store->setTagMode('any'); + $operation = new Flush($store->getContext(), $getTaggedKeys); + + $result = $operation->execute(['users']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlushClusterModeUsesSequentialCommands(): void + { + [$store, $clusterClient] = $this->createClusterStore(tagMode: 'any'); + + $getTaggedKeys = m::mock(GetTaggedKeys::class); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('users') + ->andReturn($this->arrayToGenerator(['key1', 'key2'])); + + // Cluster mode: NO pipeline calls + $clusterClient->shouldNotReceive('pipeline'); + + // Sequential del for reverse indexes + $clusterClient->shouldReceive('del') + ->once() + ->with('prefix:key1:_any:tags', 'prefix:key2:_any:tags') + ->andReturn(2); + + // Sequential unlink for cache keys + $clusterClient->shouldReceive('unlink') + ->once() + ->with('prefix:key1', 'prefix:key2') + ->andReturn(2); + + // Sequential del for tag hash + $clusterClient->shouldReceive('del') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(1); + + // Sequential zrem for registry + $clusterClient->shouldReceive('zrem') + ->once() + ->with('prefix:_any:tag:registry', 'users') + ->andReturn(1); + + $operation = new Flush($store->getContext(), $getTaggedKeys); + $result = $operation->execute(['users']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlushClusterModeWithMultipleTags(): void + { + [$store, $clusterClient] = $this->createClusterStore(tagMode: 'any'); + + $getTaggedKeys = m::mock(GetTaggedKeys::class); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('users') + ->andReturn($this->arrayToGenerator(['user_key'])); + $getTaggedKeys->shouldReceive('execute') + ->once() + ->with('posts') + ->andReturn($this->arrayToGenerator(['post_key'])); + + // Sequential commands for chunks + $clusterClient->shouldReceive('del')->andReturn(1); + $clusterClient->shouldReceive('unlink')->andReturn(1); + $clusterClient->shouldReceive('zrem')->andReturn(1); + + $operation = new Flush($store->getContext(), $getTaggedKeys); + $result = $operation->execute(['users', 'posts']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testFlushViaRedisStoreMethod(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Mock hlen/hkeys for GetTaggedKeys internal calls + $client->shouldReceive('hlen') + ->with('prefix:_any:tag:users:entries') + ->andReturn(1); + $client->shouldReceive('hkeys') + ->with('prefix:_any:tag:users:entries') + ->andReturn(['mykey']); + + // Pipeline mode + $client->shouldReceive('pipeline')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('unlink')->andReturn($client); + $client->shouldReceive('zrem')->andReturn($client); + $client->shouldReceive('exec')->andReturn([]); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $result = $store->anyTagOps()->flush()->execute(['users']); + $this->assertTrue($result); + } + + /** + * Helper to convert array to generator. + * + * @param array $items + * @return Generator + */ + private function arrayToGenerator(array $items): Generator + { + foreach ($items as $item) { + yield $item; + } + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/ForeverTest.php b/tests/Cache/Redis/Operations/AnyTag/ForeverTest.php new file mode 100644 index 000000000..e290f369d --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/ForeverTest.php @@ -0,0 +1,49 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // Forever uses SET (no TTL), HSET (no expiration), ZADD with max expiry + $this->assertStringContainsString("redis.call('SET'", $script); + $this->assertStringContainsString('HSET', $script); + $this->assertStringContainsString('253402300799', $script); // MAX_EXPIRY + $this->assertSame(2, $numKeys); + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->forever()->execute('foo', 'bar', ['users']); + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/GetTagItemsTest.php b/tests/Cache/Redis/Operations/AnyTag/GetTagItemsTest.php new file mode 100644 index 000000000..c00321273 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/GetTagItemsTest.php @@ -0,0 +1,129 @@ +mockConnection(); + $client = $connection->_mockClient; + + // GetTaggedKeys mock + $client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(2); + $client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['foo', 'bar']); + + // MGET to fetch values + $client->shouldReceive('mget') + ->once() + ->with(['prefix:foo', 'prefix:bar']) + ->andReturn([serialize('value1'), serialize('value2')]); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $items = iterator_to_array($redis->anyTagOps()->getTagItems()->execute(['users'])); + + $this->assertSame(['foo' => 'value1', 'bar' => 'value2'], $items); + } + + /** + * @test + */ + public function testTagItemsSkipsNonExistentKeys(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(3); + $client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['foo', 'bar', 'baz']); + + // bar doesn't exist (returns null) + $client->shouldReceive('mget') + ->once() + ->with(['prefix:foo', 'prefix:bar', 'prefix:baz']) + ->andReturn([serialize('value1'), null, serialize('value3')]); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $items = iterator_to_array($redis->anyTagOps()->getTagItems()->execute(['users'])); + + $this->assertSame(['foo' => 'value1', 'baz' => 'value3'], $items); + } + + /** + * @test + */ + public function testTagItemsDeduplicatesAcrossTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // First tag 'users' has keys foo, bar + $client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(2); + $client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['foo', 'bar']); + + // Second tag 'posts' has keys bar, baz (bar is duplicate) + $client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:posts:entries') + ->andReturn(2); + $client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:posts:entries') + ->andReturn(['bar', 'baz']); + + // MGET called twice (batches of keys from each tag) + $client->shouldReceive('mget') + ->once() + ->with(['prefix:foo', 'prefix:bar']) + ->andReturn([serialize('v1'), serialize('v2')]); + $client->shouldReceive('mget') + ->once() + ->with(['prefix:baz']) // bar already seen, only baz + ->andReturn([serialize('v3')]); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $items = iterator_to_array($redis->anyTagOps()->getTagItems()->execute(['users', 'posts'])); + + // bar should only appear once + $this->assertCount(3, $items); + $this->assertSame('v1', $items['foo']); + $this->assertSame('v2', $items['bar']); + $this->assertSame('v3', $items['baz']); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/GetTaggedKeysTest.php b/tests/Cache/Redis/Operations/AnyTag/GetTaggedKeysTest.php new file mode 100644 index 000000000..fd4513976 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/GetTaggedKeysTest.php @@ -0,0 +1,162 @@ +mockConnection(); + $client = $connection->_mockClient; + + // Small hash (below threshold) uses HKEYS + $client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(5); + $client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(['key1', 'key2', 'key3']); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $keys = iterator_to_array($redis->anyTagOps()->getTaggedKeys()->execute('users')); + + $this->assertSame(['key1', 'key2', 'key3'], $keys); + } + + /** + * @test + */ + public function testGetTaggedKeysUsesHscanForLargeHashes(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Large hash (above threshold of 1000) uses HSCAN + $client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(5000); + + // HSCAN returns key-value pairs, iterator updates by reference + $client->shouldReceive('hscan') + ->once() + ->withArgs(function ($key, &$iterator, $pattern, $count) { + $iterator = 0; // Done after first iteration + return true; + }) + ->andReturn(['key1' => '1', 'key2' => '1', 'key3' => '1']); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $keys = iterator_to_array($redis->anyTagOps()->getTaggedKeys()->execute('users')); + + $this->assertSame(['key1', 'key2', 'key3'], $keys); + } + + /** + * @test + */ + public function testGetTaggedKeysReturnsEmptyForNonExistentTag(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('hlen') + ->once() + ->with('prefix:_any:tag:nonexistent:entries') + ->andReturn(0); + $client->shouldReceive('hkeys') + ->once() + ->with('prefix:_any:tag:nonexistent:entries') + ->andReturn([]); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $keys = iterator_to_array($redis->anyTagOps()->getTaggedKeys()->execute('nonexistent')); + + $this->assertSame([], $keys); + } + + /** + * @test + * + * Verifies that HSCAN correctly handles multiple batches with per-batch connection checkout. + * The iterator must be passed by reference correctly across withConnection() calls. + * + * Uses FakeRedisClient instead of Mockery because Mockery doesn't properly propagate + * modifications to reference parameters (like &$iterator) back to the caller. + */ + public function testGetTaggedKeysHandlesMultipleHscanBatches(): void + { + $tagKey = 'prefix:_any:tag:users:entries'; + + // FakeRedisClient properly handles &$iterator reference parameter + $fakeClient = new FakeRedisClient( + hLenResults: [$tagKey => 5000], // Large hash triggers HSCAN path + hScanResults: [ + $tagKey => [ + // First batch: iterator -> 100 (more to come) + ['fields' => ['key1' => '1', 'key2' => '1'], 'iterator' => 100], + // Second batch: iterator -> 200 (more to come) + ['fields' => ['key3' => '1', 'key4' => '1'], 'iterator' => 200], + // Third batch: iterator -> 0 (done) + ['fields' => ['key5' => '1'], 'iterator' => 0], + ], + ], + ); + + // Create a mock connection that returns the fake client + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('serialized')->andReturn(false)->byDefault(); + $connection->shouldReceive('client')->andReturn($fakeClient)->byDefault(); + + // Create pool factory that returns our connection + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + + $store = new RedisStore( + m::mock(RedisFactory::class), + 'prefix:', + 'default', + $poolFactory + ); + $store->setTagMode('any'); + + $keys = iterator_to_array($store->anyTagOps()->getTaggedKeys()->execute('users')); + + // Should have all keys from all 3 batches + $this->assertSame(['key1', 'key2', 'key3', 'key4', 'key5'], $keys); + + // Verify all 3 HSCAN batches were called + $this->assertSame(3, $fakeClient->getHScanCallCount()); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/IncrementTest.php b/tests/Cache/Redis/Operations/AnyTag/IncrementTest.php new file mode 100644 index 000000000..af9bee46d --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/IncrementTest.php @@ -0,0 +1,48 @@ +mockConnection(); + $client = $connection->_mockClient; + + // Lua script returns the incremented value + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + $this->assertStringContainsString('INCRBY', $script); + $this->assertStringContainsString('TTL', $script); + $this->assertSame(2, $numKeys); + + return true; + }) + ->andReturn(15); // New value after increment + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->increment()->execute('counter', 5, ['stats']); + $this->assertSame(15, $result); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/PruneTest.php b/tests/Cache/Redis/Operations/AnyTag/PruneTest.php new file mode 100644 index 000000000..153ade212 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/PruneTest.php @@ -0,0 +1,616 @@ +mockConnection(); + $client = $connection->_mockClient; + + // ZREMRANGEBYSCORE on registry removes expired tags + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_any:tag:registry', '-inf', m::type('string')) + ->andReturn(2); // 2 expired tags removed + + // ZRANGE returns empty (no active tags) + $client->shouldReceive('zRange') + ->once() + ->with('prefix:_any:tag:registry', 0, -1) + ->andReturn([]); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(0, $result['hashes_scanned']); + $this->assertSame(0, $result['fields_checked']); + $this->assertSame(0, $result['orphans_removed']); + $this->assertSame(0, $result['empty_hashes_deleted']); + $this->assertSame(2, $result['expired_tags_removed']); + } + + /** + * @test + */ + public function testPruneRemovesOrphanedFieldsFromTagHash(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Step 1: Remove expired tags from registry + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_any:tag:registry', '-inf', m::type('string')) + ->andReturn(0); + + // Step 2: Get active tags + $client->shouldReceive('zRange') + ->once() + ->with('prefix:_any:tag:registry', 0, -1) + ->andReturn(['users']); + + // Step 3: HSCAN the tag hash + $client->shouldReceive('hScan') + ->once() + ->andReturnUsing(function ($tagHash, &$iterator, $match, $count) { + $iterator = 0; + return [ + 'key1' => '1', + 'key2' => '1', + 'key3' => '1', + ]; + }); + + // Pipeline for EXISTS checks + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('exists') + ->times(3) + ->andReturn($client); + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 0, 1]); // key2 doesn't exist (orphaned) + + // HDEL orphaned key2 + $client->shouldReceive('hDel') + ->once() + ->with('prefix:_any:tag:users:entries', 'key2') + ->andReturn(1); + + // HLEN to check if hash is empty + $client->shouldReceive('hLen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(2); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(1, $result['hashes_scanned']); + $this->assertSame(3, $result['fields_checked']); + $this->assertSame(1, $result['orphans_removed']); + $this->assertSame(0, $result['empty_hashes_deleted']); + $this->assertSame(0, $result['expired_tags_removed']); + } + + /** + * @test + */ + public function testPruneDeletesEmptyHashAfterRemovingOrphans(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->andReturn(0); + + $client->shouldReceive('zRange') + ->once() + ->andReturn(['users']); + + $client->shouldReceive('hScan') + ->once() + ->andReturnUsing(function ($tagHash, &$iterator, $match, $count) { + $iterator = 0; + return ['key1' => '1']; + }); + + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('exists')->once()->andReturn($client); + $client->shouldReceive('exec') + ->once() + ->andReturn([0]); // key1 doesn't exist (orphaned) + + $client->shouldReceive('hDel') + ->once() + ->with('prefix:_any:tag:users:entries', 'key1') + ->andReturn(1); + + // Hash is now empty + $client->shouldReceive('hLen') + ->once() + ->andReturn(0); + + // Delete empty hash + $client->shouldReceive('del') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(1, $result['hashes_scanned']); + $this->assertSame(1, $result['fields_checked']); + $this->assertSame(1, $result['orphans_removed']); + $this->assertSame(1, $result['empty_hashes_deleted']); + } + + /** + * @test + */ + public function testPruneHandlesMultipleTagHashes(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->andReturn(1); // 1 expired tag removed + + $client->shouldReceive('zRange') + ->once() + ->andReturn(['users', 'posts', 'comments']); + + // First tag: users - 2 fields, 1 orphan + $client->shouldReceive('hScan') + ->once() + ->with('prefix:_any:tag:users:entries', m::any(), '*', m::any()) + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return ['u1' => '1', 'u2' => '1']; + }); + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('exists')->twice()->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1, 0]); + $client->shouldReceive('hDel') + ->once() + ->with('prefix:_any:tag:users:entries', 'u2') + ->andReturn(1); + $client->shouldReceive('hLen') + ->once() + ->with('prefix:_any:tag:users:entries') + ->andReturn(1); + + // Second tag: posts - 1 field, 0 orphans + $client->shouldReceive('hScan') + ->once() + ->with('prefix:_any:tag:posts:entries', m::any(), '*', m::any()) + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return ['p1' => '1']; + }); + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('exists')->once()->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([1]); + $client->shouldReceive('hLen') + ->once() + ->with('prefix:_any:tag:posts:entries') + ->andReturn(1); + + // Third tag: comments - 3 fields, all orphans (hash becomes empty) + $client->shouldReceive('hScan') + ->once() + ->with('prefix:_any:tag:comments:entries', m::any(), '*', m::any()) + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return ['c1' => '1', 'c2' => '1', 'c3' => '1']; + }); + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('exists')->times(3)->andReturn($client); + $client->shouldReceive('exec')->once()->andReturn([0, 0, 0]); + $client->shouldReceive('hDel') + ->once() + ->with('prefix:_any:tag:comments:entries', 'c1', 'c2', 'c3') + ->andReturn(3); + $client->shouldReceive('hLen') + ->once() + ->with('prefix:_any:tag:comments:entries') + ->andReturn(0); + $client->shouldReceive('del') + ->once() + ->with('prefix:_any:tag:comments:entries') + ->andReturn(1); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(3, $result['hashes_scanned']); + $this->assertSame(6, $result['fields_checked']); // 2 + 1 + 3 + $this->assertSame(4, $result['orphans_removed']); // 1 + 0 + 3 + $this->assertSame(1, $result['empty_hashes_deleted']); + $this->assertSame(1, $result['expired_tags_removed']); + } + + /** + * @test + */ + public function testPruneUsesCorrectTagHashKeyFormat(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('custom:_any:tag:registry', '-inf', m::type('string')) + ->andReturn(0); + + $client->shouldReceive('zRange') + ->once() + ->with('custom:_any:tag:registry', 0, -1) + ->andReturn(['users']); + + // Verify correct tag hash key format + $client->shouldReceive('hScan') + ->once() + ->with('custom:_any:tag:users:entries', m::any(), '*', m::any()) + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return []; + }); + + $client->shouldReceive('hLen') + ->once() + ->with('custom:_any:tag:users:entries') + ->andReturn(0); + + $client->shouldReceive('del') + ->once() + ->with('custom:_any:tag:users:entries') + ->andReturn(1); + + $store = $this->createStore($connection, 'custom:'); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $operation->execute(); + } + + /** + * @test + */ + public function testPruneClusterModeUsesSequentialExistsChecks(): void + { + [$store, $clusterClient] = $this->createClusterStore(tagMode: 'any'); + + // Should NOT use pipeline in cluster mode + $clusterClient->shouldNotReceive('pipeline'); + + $clusterClient->shouldReceive('zRemRangeByScore') + ->once() + ->andReturn(0); + + $clusterClient->shouldReceive('zRange') + ->once() + ->andReturn(['users']); + + $clusterClient->shouldReceive('hScan') + ->once() + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return ['key1' => '1', 'key2' => '1']; + }); + + // Sequential EXISTS checks in cluster mode + $clusterClient->shouldReceive('exists') + ->once() + ->with('prefix:key1') + ->andReturn(1); + $clusterClient->shouldReceive('exists') + ->once() + ->with('prefix:key2') + ->andReturn(0); + + $clusterClient->shouldReceive('hDel') + ->once() + ->with('prefix:_any:tag:users:entries', 'key2') + ->andReturn(1); + + $clusterClient->shouldReceive('hLen') + ->once() + ->andReturn(1); + + $operation = new Prune($store->getContext()); + $result = $operation->execute(); + + $this->assertSame(1, $result['hashes_scanned']); + $this->assertSame(2, $result['fields_checked']); + $this->assertSame(1, $result['orphans_removed']); + } + + /** + * @test + */ + public function testPruneHandlesEmptyHscanResult(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->andReturn(0); + + $client->shouldReceive('zRange') + ->once() + ->andReturn(['users']); + + // HSCAN returns empty (no fields in hash) + $client->shouldReceive('hScan') + ->once() + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return []; + }); + + // Should still check HLEN + $client->shouldReceive('hLen') + ->once() + ->andReturn(0); + + $client->shouldReceive('del') + ->once() + ->andReturn(1); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(1, $result['hashes_scanned']); + $this->assertSame(0, $result['fields_checked']); + $this->assertSame(0, $result['orphans_removed']); + $this->assertSame(1, $result['empty_hashes_deleted']); + } + + /** + * @test + */ + public function testPruneHandlesHscanWithMultipleIterations(): void + { + // Use FakeRedisClient stub for proper reference parameter handling + // (Mockery's andReturnUsing doesn't propagate &$iterator modifications) + $registryKey = 'prefix:_any:tag:registry'; + $tagHashKey = 'prefix:_any:tag:users:entries'; + + $fakeClient = new FakeRedisClient( + scanResults: [], + execResults: [ + [1, 0], // First EXISTS batch: key1 exists, key2 orphaned + [0], // Second EXISTS batch: key3 orphaned + ], + hScanResults: [ + $tagHashKey => [ + // First hScan: returns 2 fields, iterator = 100 (continue) + ['fields' => ['key1' => '1', 'key2' => '1'], 'iterator' => 100], + // Second hScan: returns 1 field, iterator = 0 (done) + ['fields' => ['key3' => '1'], 'iterator' => 0], + ], + ], + zRangeResults: [ + $registryKey => ['users'], + ], + hLenResults: [ + $tagHashKey => 1, // 1 field remaining after cleanup + ], + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('release')->zeroOrMoreTimes(); + $connection->shouldReceive('serialized')->andReturn(false); + $connection->shouldReceive('client')->andReturn($fakeClient); + + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->andReturn($connection); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + + $store = new RedisStore( + m::mock(RedisFactory::class), + 'prefix:', + 'default', + $poolFactory + ); + $store->setTagMode('any'); + + $operation = new Prune($store->getContext()); + $result = $operation->execute(); + + // Verify hScan was called twice (multi-iteration) + $this->assertSame(2, $fakeClient->getHScanCallCount()); + + // Verify stats + $this->assertSame(1, $result['hashes_scanned']); + $this->assertSame(3, $result['fields_checked']); // 2 + 1 fields + $this->assertSame(2, $result['orphans_removed']); // key2 + key3 + } + + /** + * @test + */ + public function testPruneUsesCustomScanCount(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->andReturn(0); + + $client->shouldReceive('zRange') + ->once() + ->andReturn(['users']); + + // HSCAN should use custom count + $client->shouldReceive('hScan') + ->once() + ->with(m::any(), m::any(), '*', 500) + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return []; + }); + + $client->shouldReceive('hLen') + ->once() + ->andReturn(0); + + $client->shouldReceive('del') + ->once() + ->andReturn(1); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $operation->execute(500); + } + + /** + * @test + */ + public function testPruneViaStoreOperationsContainer(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->andReturn(0); + + $client->shouldReceive('zRange') + ->once() + ->andReturn([]); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + + // Access via the operations container + $result = $store->anyTagOps()->prune()->execute(); + + $this->assertSame(0, $result['hashes_scanned']); + } + + /** + * @test + */ + public function testPruneRemovesExpiredTagsFromRegistry(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // 5 expired tags removed + $client->shouldReceive('zRemRangeByScore') + ->once() + ->with('prefix:_any:tag:registry', '-inf', m::type('string')) + ->andReturn(5); + + $client->shouldReceive('zRange') + ->once() + ->andReturn([]); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(5, $result['expired_tags_removed']); + } + + /** + * @test + */ + public function testPruneDoesNotRemoveNonOrphanedFields(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('zRemRangeByScore') + ->once() + ->andReturn(0); + + $client->shouldReceive('zRange') + ->once() + ->andReturn(['users']); + + $client->shouldReceive('hScan') + ->once() + ->andReturnUsing(function ($tagHash, &$iterator) { + $iterator = 0; + return ['key1' => '1', 'key2' => '1', 'key3' => '1']; + }); + + $client->shouldReceive('pipeline')->once()->andReturn($client); + $client->shouldReceive('exists')->times(3)->andReturn($client); + $client->shouldReceive('exec') + ->once() + ->andReturn([1, 1, 1]); // All keys exist + + // Should NOT call hDel since no orphans + $client->shouldNotReceive('hDel'); + + $client->shouldReceive('hLen') + ->once() + ->andReturn(3); + + $store = $this->createStore($connection); + $store->setTagMode('any'); + $operation = new Prune($store->getContext()); + + $result = $operation->execute(); + + $this->assertSame(1, $result['hashes_scanned']); + $this->assertSame(3, $result['fields_checked']); + $this->assertSame(0, $result['orphans_removed']); + $this->assertSame(0, $result['empty_hashes_deleted']); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php b/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php new file mode 100644 index 000000000..79bfa3322 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/PutManyTest.php @@ -0,0 +1,56 @@ +mockConnection(); + $client = $connection->_mockClient; + + // Standard mode uses pipeline() not multi() + $client->shouldReceive('pipeline')->andReturn($client); + + // First pipeline for getting old tags (smembers) + $client->shouldReceive('smembers')->twice()->andReturn($client); + $client->shouldReceive('exec')->andReturn([[], []]); // No old tags for first pipeline + + // Second pipeline for setex, reverse index updates, and tag hashes + $client->shouldReceive('setex')->twice()->andReturn($client); + $client->shouldReceive('del')->twice()->andReturn($client); + $client->shouldReceive('sadd')->twice()->andReturn($client); + $client->shouldReceive('expire')->twice()->andReturn($client); + + // hSet and hexpire for tag hashes (batch operation) + $client->shouldReceive('hSet')->andReturn($client); + $client->shouldReceive('hexpire')->andReturn($client); + + // zadd for registry + $client->shouldReceive('zadd')->andReturn($client); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->putMany()->execute([ + 'foo' => 'bar', + 'baz' => 'qux', + ], 60, ['users']); + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/PutTest.php b/tests/Cache/Redis/Operations/AnyTag/PutTest.php new file mode 100644 index 000000000..eb4a1ed6c --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/PutTest.php @@ -0,0 +1,128 @@ +mockConnection(); + $client = $connection->_mockClient; + + // Standard mode uses Lua script with evalSha + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); // Script not cached + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // Verify Lua script contains expected commands + $this->assertStringContainsString('SETEX', $script); + $this->assertStringContainsString('HSETEX', $script); + $this->assertStringContainsString('ZADD', $script); + $this->assertStringContainsString('SMEMBERS', $script); + // 2 keys: cache key + reverse index key + $this->assertSame(2, $numKeys); + + return true; + }) + ->andReturn(true); + + // Mock smembers for old tags lookup (Lua script uses this internally but we mock the full execution) + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->put()->execute('foo', 'bar', 60, ['users', 'posts']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithTagsUsesSequentialCommandsInClusterMode(): void + { + [$redis, $clusterClient] = $this->createClusterStore(tagMode: 'any'); + + // Cluster mode expectations + $clusterClient->shouldReceive('smembers')->once()->andReturn([]); + $clusterClient->shouldReceive('setex')->once()->with('prefix:foo', 60, serialize('bar'))->andReturn(true); + + // Multi for reverse index + $clusterClient->shouldReceive('multi')->andReturn($clusterClient); + $clusterClient->shouldReceive('del')->andReturn($clusterClient); + $clusterClient->shouldReceive('sadd')->andReturn($clusterClient); + $clusterClient->shouldReceive('expire')->andReturn($clusterClient); + $clusterClient->shouldReceive('exec')->andReturn([true, true, true]); + + // HSETEX for tag hashes (2 tags) - use withAnyArgs to bypass type checking + $clusterClient->shouldReceive('hsetex')->withAnyArgs()->twice()->andReturn(true); + + // ZADD for registry - use withAnyArgs to handle variable args + $clusterClient->shouldReceive('zadd')->withAnyArgs()->once()->andReturn(2); + + $result = $redis->anyTagOps()->put()->execute('foo', 'bar', 60, ['users', 'posts']); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithTagsHandlesEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->put()->execute('foo', 'bar', 60, []); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutWithTagsWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // Numeric values should be passed as strings in ARGV + $this->assertIsString($args[2]); // Serialized value position + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $result = $redis->anyTagOps()->put()->execute('foo', 42, 60, ['numbers']); + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/RememberForeverTest.php b/tests/Cache/Redis/Operations/AnyTag/RememberForeverTest.php new file mode 100644 index 000000000..a582e1e5b --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/RememberForeverTest.php @@ -0,0 +1,490 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('cached_value')); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'new_value', ['users']); + + $this->assertSame('cached_value', $value); + $this->assertTrue($wasHit); + } + + /** + * @test + */ + public function testRememberForeverCallsCallbackOnCacheMissUsingLua(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturnNull(); + + // First tries evalSha, then falls back to eval + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // Verify script uses SET (not SETEX) and HSET (not HSETEX) + $this->assertStringContainsString("redis.call('SET'", $script); + $this->assertStringContainsString("redis.call('HSET'", $script); + $this->assertStringContainsString('ZADD', $script); + // Should NOT contain SETEX or HSETEX for forever items + // Note: The word "HEXPIRE" appears in comments but not as a redis.call + $this->assertStringNotContainsString('SETEX', $script); + $this->assertStringNotContainsString('HSETEX', $script); + // Verify no redis.call('HEXPIRE' - the word may appear in comments but not as actual command + $this->assertStringNotContainsString("redis.call('HEXPIRE", $script); + $this->assertSame(2, $numKeys); + + return true; + }) + ->andReturn(true); + + $callCount = 0; + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }, ['users']); + + $this->assertSame('computed_value', $value); + $this->assertFalse($wasHit); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberForeverUsesEvalShaWhenScriptCached(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // evalSha succeeds (script is cached) + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + // eval should NOT be called + $client->shouldReceive('eval')->never(); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'value', ['users']); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverDoesNotCallCallbackOnCacheHit(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('existing_value')); + + $callCount = 0; + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }, ['users']); + + $this->assertSame('existing_value', $value); + $this->assertTrue($wasHit); + $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); + } + + /** + * @test + */ + public function testRememberForeverWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Verify multiple tags are passed in the Lua script args + $client->shouldReceive('evalSha') + ->once() + ->withArgs(function ($hash, $args, $numKeys) { + // Args: 2 KEYS + 5 ARGV (value, tagPrefix, registryKey, rawKey, tagHashSuffix) = 7 + // Tags start at index 7 (ARGV[6...]) + $tags = array_slice($args, 7); + $this->assertContains('users', $tags); + $this->assertContains('posts', $tags); + $this->assertContains('comments', $tags); + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute( + 'foo', + fn () => 'value', + ['users', 'posts', 'comments'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $redis->anyTagOps()->rememberForever()->execute('foo', function () { + throw new RuntimeException('Callback failed'); + }, ['users']); + } + + /** + * @test + */ + public function testRememberForeverUsesSequentialCommandsInClusterMode(): void + { + $connection = $this->mockClusterConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturnNull(); + + // In cluster mode, uses sequential commands instead of Lua + + // Get old tags from reverse index + $client->shouldReceive('smembers') + ->once() + ->andReturn([]); + + // SET without TTL (not SETEX) + $client->shouldReceive('set') + ->once() + ->andReturn(true); + + // Multi for reverse index update (no expire call for forever) - return same client for chaining + $client->shouldReceive('multi')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('sadd')->andReturn($client); + // No expire() call for forever items + $client->shouldReceive('exec')->andReturn([1, 1]); + + // HSET for each tag (not HSETEX, no HEXPIRE) + $client->shouldReceive('hset') + ->twice() + ->andReturn(true); + + // ZADD for registry with MAX_EXPIRY + $client->shouldReceive('zadd') + ->once() + ->withArgs(function ($key, $options, ...$rest) { + $this->assertSame(['GT'], $options); + // First score should be MAX_EXPIRY (253402300799) + $this->assertSame(253402300799, $rest[0]); + + return true; + }) + ->andReturn(2); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute( + 'foo', + fn () => 'value', + ['users', 'posts'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 42, ['users']); + + $this->assertSame(42, $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverHandlesFalseReturnFromGet(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Redis returns false for non-existent keys + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(false); + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'computed', ['users']); + + $this->assertSame('computed', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // With empty tags, should still use Lua script but with no tags in args + $client->shouldReceive('evalSha') + ->once() + ->withArgs(function ($hash, $args, $numKeys) { + // Args: 2 KEYS + 5 ARGV = 7 fixed, tags start at index 7 (ARGV[6...]) + $tags = array_slice($args, 7); + $this->assertEmpty($tags); + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', []); + + $this->assertSame('bar', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverDoesNotSetExpirationOnReverseIndex(): void + { + $connection = $this->mockClusterConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $client->shouldReceive('smembers') + ->once() + ->andReturn([]); + + $client->shouldReceive('set') + ->once() + ->andReturn(true); + + // Multi for reverse index - should NOT have expire call + // Return same client for chaining (required for RedisCluster type constraints) + $client->shouldReceive('multi')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('sadd')->andReturn($client); + // Note: We can't easily test that expire is never called with this pattern + // because the client mock is reused. The absence of expire in the code is + // verified by reading the implementation. + $client->shouldReceive('exec')->andReturn([1, 1]); + + $client->shouldReceive('hset') + ->once() + ->andReturn(true); + + $client->shouldReceive('zadd') + ->once() + ->andReturn(1); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', ['users']); + } + + /** + * @test + */ + public function testRememberForeverUsesMaxExpiryForRegistry(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Verify Lua script contains MAX_EXPIRY constant + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // MAX_EXPIRY = 253402300799 (Year 9999) + $this->assertStringContainsString('253402300799', $script); + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', ['users']); + } + + /** + * @test + */ + public function testRememberForeverRemovesItemFromOldTagsInClusterMode(): void + { + $connection = $this->mockClusterConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Return old tags that should be cleaned up + $client->shouldReceive('smembers') + ->once() + ->andReturn(['old_tag', 'users']); + + $client->shouldReceive('set') + ->once() + ->andReturn(true); + + // Multi for reverse index - return same client for chaining + $client->shouldReceive('multi')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('sadd')->andReturn($client); + $client->shouldReceive('exec')->andReturn([1, 1]); + + // Should HDEL from old_tag since it's not in new tags + $client->shouldReceive('hdel') + ->once() + ->withArgs(function ($hashKey, $key) { + $this->assertStringContainsString('old_tag', $hashKey); + $this->assertSame('foo', $key); + + return true; + }) + ->andReturn(1); + + // HSET only for new tag 'users' + $client->shouldReceive('hset') + ->once() + ->andReturn(true); + + $client->shouldReceive('zadd') + ->once() + ->andReturn(1); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $redis->anyTagOps()->rememberForever()->execute('foo', fn () => 'bar', ['users']); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTag/RememberTest.php b/tests/Cache/Redis/Operations/AnyTag/RememberTest.php new file mode 100644 index 000000000..96c31aae2 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTag/RememberTest.php @@ -0,0 +1,349 @@ +mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('cached_value')); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'new_value', ['users']); + + $this->assertSame('cached_value', $value); + $this->assertTrue($wasHit); + } + + /** + * @test + */ + public function testRememberCallsCallbackOnCacheMissUsingLua(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturnNull(); + + // First tries evalSha, then falls back to eval + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // Verify script contains expected commands + $this->assertStringContainsString('SETEX', $script); + $this->assertStringContainsString('HSETEX', $script); + $this->assertStringContainsString('ZADD', $script); + $this->assertSame(2, $numKeys); + + return true; + }) + ->andReturn(true); + + $callCount = 0; + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }, ['users']); + + $this->assertSame('computed_value', $value); + $this->assertFalse($wasHit); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberUsesEvalShaWhenScriptCached(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // evalSha succeeds (script is cached) + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + // eval should NOT be called + $client->shouldReceive('eval')->never(); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'value', ['users']); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberDoesNotCallCallbackOnCacheHit(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('existing_value')); + + $callCount = 0; + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }, ['users']); + + $this->assertSame('existing_value', $value); + $this->assertTrue($wasHit); + $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); + } + + /** + * @test + */ + public function testRememberWithMultipleTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Verify multiple tags are passed in the Lua script args + $client->shouldReceive('evalSha') + ->once() + ->withArgs(function ($hash, $args, $numKeys) { + // Args: 2 KEYS + 7 ARGV = 9 fixed, tags start at index 9 (ARGV[8...]) + $tags = array_slice($args, 9); + $this->assertContains('users', $tags); + $this->assertContains('posts', $tags); + $this->assertContains('comments', $tags); + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute( + 'foo', + 60, + fn () => 'value', + ['users', 'posts', 'comments'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + $redis->anyTagOps()->remember()->execute('foo', 60, function () { + throw new RuntimeException('Callback failed'); + }, ['users']); + } + + /** + * @test + */ + public function testRememberUsesSequentialCommandsInClusterMode(): void + { + $connection = $this->mockClusterConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturnNull(); + + // In cluster mode, uses sequential commands instead of Lua + + // Get old tags from reverse index + $client->shouldReceive('smembers') + ->once() + ->andReturn([]); + + // SETEX for the value + $client->shouldReceive('setex') + ->once() + ->andReturn(true); + + // Multi for reverse index update - return same client for chaining + $client->shouldReceive('multi')->andReturn($client); + $client->shouldReceive('del')->andReturn($client); + $client->shouldReceive('sadd')->andReturn($client); + $client->shouldReceive('expire')->andReturn($client); + $client->shouldReceive('exec')->andReturn([1, 1, 1]); + + // HSETEX for each tag + $client->shouldReceive('hsetex') + ->twice() + ->andReturn(true); + + // ZADD for registry + $client->shouldReceive('zadd') + ->once() + ->andReturn(2); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute( + 'foo', + 60, + fn () => 'value', + ['users', 'posts'] + ); + + $this->assertSame('value', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberWithNumericValue(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 42, ['users']); + + $this->assertSame(42, $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberHandlesFalseReturnFromGet(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // Redis returns false for non-existent keys + $client->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(false); + + $client->shouldReceive('evalSha') + ->once() + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'computed', ['users']); + + $this->assertSame('computed', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberWithEmptyTags(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('get') + ->once() + ->andReturnNull(); + + // With empty tags, should still use Lua script but with no tags in args + $client->shouldReceive('evalSha') + ->once() + ->withArgs(function ($hash, $args, $numKeys) { + // Args: 2 KEYS + 7 ARGV (value, ttl, tagPrefix, registryKey, time, rawKey, tagHashSuffix) = 9 + // Tags start at index 9 (ARGV[8...]) + $tags = array_slice($args, 9); + $this->assertEmpty($tags); + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + [$value, $wasHit] = $redis->anyTagOps()->remember()->execute('foo', 60, fn () => 'bar', []); + + $this->assertSame('bar', $value); + $this->assertFalse($wasHit); + } +} diff --git a/tests/Cache/Redis/Operations/AnyTagOperationsTest.php b/tests/Cache/Redis/Operations/AnyTagOperationsTest.php new file mode 100644 index 000000000..c635310c5 --- /dev/null +++ b/tests/Cache/Redis/Operations/AnyTagOperationsTest.php @@ -0,0 +1,90 @@ +mockConnection(); + $store = $this->createStore($connection); + $ops = $store->anyTagOps(); + + $this->assertInstanceOf(Put::class, $ops->put()); + $this->assertInstanceOf(PutMany::class, $ops->putMany()); + $this->assertInstanceOf(Add::class, $ops->add()); + $this->assertInstanceOf(Forever::class, $ops->forever()); + $this->assertInstanceOf(Increment::class, $ops->increment()); + $this->assertInstanceOf(Decrement::class, $ops->decrement()); + $this->assertInstanceOf(GetTaggedKeys::class, $ops->getTaggedKeys()); + $this->assertInstanceOf(GetTagItems::class, $ops->getTagItems()); + $this->assertInstanceOf(Flush::class, $ops->flush()); + $this->assertInstanceOf(Prune::class, $ops->prune()); + $this->assertInstanceOf(Remember::class, $ops->remember()); + $this->assertInstanceOf(RememberForever::class, $ops->rememberForever()); + } + + /** + * @test + */ + public function testOperationInstancesAreCached(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $ops = $store->anyTagOps(); + + // Same instance returned on repeated calls + $this->assertSame($ops->put(), $ops->put()); + $this->assertSame($ops->remember(), $ops->remember()); + $this->assertSame($ops->getTaggedKeys(), $ops->getTaggedKeys()); + } + + /** + * @test + */ + public function testClearResetsAllCachedInstances(): void + { + $connection = $this->mockConnection(); + $store = $this->createStore($connection); + $ops = $store->anyTagOps(); + + // Get instances before clear + $putBefore = $ops->put(); + $rememberBefore = $ops->remember(); + + // Clear + $ops->clear(); + + // Instances should be new after clear + $this->assertNotSame($putBefore, $ops->put()); + $this->assertNotSame($rememberBefore, $ops->remember()); + } +} diff --git a/tests/Cache/Redis/Operations/DecrementTest.php b/tests/Cache/Redis/Operations/DecrementTest.php new file mode 100644 index 000000000..a64e0fc32 --- /dev/null +++ b/tests/Cache/Redis/Operations/DecrementTest.php @@ -0,0 +1,45 @@ +mockConnection(); + $connection->shouldReceive('decrby')->once()->with('prefix:foo', 5)->andReturn(4); + + $redis = $this->createStore($connection); + $result = $redis->decrement('foo', 5); + $this->assertEquals(4, $result); + } + + /** + * @test + */ + public function testDecrementOnNonExistentKeyReturnsDecrementedValue(): void + { + // Redis DECRBY on non-existent key initializes to 0, then decrements + $connection = $this->mockConnection(); + $connection->shouldReceive('decrby')->once()->with('prefix:counter', 1)->andReturn(-1); + + $redis = $this->createStore($connection); + $this->assertSame(-1, $redis->decrement('counter')); + } +} diff --git a/tests/Cache/Redis/Operations/FlushTest.php b/tests/Cache/Redis/Operations/FlushTest.php new file mode 100644 index 000000000..4364430ff --- /dev/null +++ b/tests/Cache/Redis/Operations/FlushTest.php @@ -0,0 +1,32 @@ +mockConnection(); + $connection->shouldReceive('flushdb')->once()->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->flush(); + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/ForeverTest.php b/tests/Cache/Redis/Operations/ForeverTest.php new file mode 100644 index 000000000..92933ef95 --- /dev/null +++ b/tests/Cache/Redis/Operations/ForeverTest.php @@ -0,0 +1,50 @@ +mockConnection(); + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('foo')) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->forever('foo', 'foo'); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testForeverWithNumericValue(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', 99) + ->andReturn(true); + + $redis = $this->createStore($connection); + $this->assertTrue($redis->forever('foo', 99)); + } +} diff --git a/tests/Cache/Redis/Operations/ForgetTest.php b/tests/Cache/Redis/Operations/ForgetTest.php new file mode 100644 index 000000000..3f4c0cf2b --- /dev/null +++ b/tests/Cache/Redis/Operations/ForgetTest.php @@ -0,0 +1,44 @@ +mockConnection(); + $connection->shouldReceive('del')->once()->with('prefix:foo')->andReturn(1); + + $redis = $this->createStore($connection); + $this->assertTrue($redis->forget('foo')); + } + + /** + * @test + */ + public function testForgetReturnsFalseWhenKeyDoesNotExist(): void + { + // Redis del() returns 0 when key doesn't exist, cast to bool = false + $connection = $this->mockConnection(); + $connection->shouldReceive('del')->once()->with('prefix:nonexistent')->andReturn(0); + + $redis = $this->createStore($connection); + $this->assertFalse($redis->forget('nonexistent')); + } +} diff --git a/tests/Cache/Redis/Operations/GetTest.php b/tests/Cache/Redis/Operations/GetTest.php new file mode 100644 index 000000000..3704b7405 --- /dev/null +++ b/tests/Cache/Redis/Operations/GetTest.php @@ -0,0 +1,116 @@ +mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(null); + + $redis = $this->createStore($connection); + $this->assertNull($redis->get('foo')); + } + + /** + * @test + */ + public function testRedisValueIsReturned(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(serialize('foo')); + + $redis = $this->createStore($connection); + $this->assertSame('foo', $redis->get('foo')); + } + + /** + * @test + */ + public function testRedisValueIsReturnedForNumerics(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(1); + + $redis = $this->createStore($connection); + $this->assertEquals(1, $redis->get('foo')); + } + + /** + * @test + */ + public function testGetReturnsFalseValueAsNull(): void + { + // Redis returns false for non-existent keys + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(false); + + $redis = $this->createStore($connection); + $this->assertNull($redis->get('foo')); + } + + /** + * @test + */ + public function testGetReturnsEmptyStringCorrectly(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(serialize('')); + + $redis = $this->createStore($connection); + $this->assertSame('', $redis->get('foo')); + } + + /** + * @test + */ + public function testGetReturnsZeroCorrectly(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(0); + + $redis = $this->createStore($connection); + $this->assertSame(0, $redis->get('foo')); + } + + /** + * @test + */ + public function testGetReturnsFloatCorrectly(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(3.14); + + $redis = $this->createStore($connection); + $this->assertSame(3.14, $redis->get('foo')); + } + + /** + * @test + */ + public function testGetReturnsNegativeNumberCorrectly(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(-42); + + $redis = $this->createStore($connection); + $this->assertSame(-42, $redis->get('foo')); + } +} diff --git a/tests/Cache/Redis/Operations/IncrementTest.php b/tests/Cache/Redis/Operations/IncrementTest.php new file mode 100644 index 000000000..b30e75c24 --- /dev/null +++ b/tests/Cache/Redis/Operations/IncrementTest.php @@ -0,0 +1,57 @@ +mockConnection(); + $connection->shouldReceive('incrby')->once()->with('prefix:foo', 5)->andReturn(6); + + $redis = $this->createStore($connection); + $result = $redis->increment('foo', 5); + $this->assertEquals(6, $result); + } + + /** + * @test + */ + public function testIncrementOnNonExistentKeyReturnsIncrementedValue(): void + { + // Redis INCRBY on non-existent key initializes to 0, then increments + $connection = $this->mockConnection(); + $connection->shouldReceive('incrby')->once()->with('prefix:counter', 1)->andReturn(1); + + $redis = $this->createStore($connection); + $this->assertSame(1, $redis->increment('counter')); + } + + /** + * @test + */ + public function testIncrementWithLargeValue(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('incrby')->once()->with('prefix:foo', 1000000)->andReturn(1000005); + + $redis = $this->createStore($connection); + $this->assertSame(1000005, $redis->increment('foo', 1000000)); + } +} diff --git a/tests/Cache/Redis/Operations/ManyTest.php b/tests/Cache/Redis/Operations/ManyTest.php new file mode 100644 index 000000000..1beca43f6 --- /dev/null +++ b/tests/Cache/Redis/Operations/ManyTest.php @@ -0,0 +1,83 @@ +mockConnection(); + $connection->shouldReceive('mget') + ->once() + ->with(['prefix:foo', 'prefix:fizz', 'prefix:norf', 'prefix:null']) + ->andReturn([ + serialize('bar'), + serialize('buzz'), + serialize('quz'), + null, + ]); + + $redis = $this->createStore($connection); + $results = $redis->many(['foo', 'fizz', 'norf', 'null']); + + $this->assertSame('bar', $results['foo']); + $this->assertSame('buzz', $results['fizz']); + $this->assertSame('quz', $results['norf']); + $this->assertNull($results['null']); + } + + /** + * @test + */ + public function testManyReturnsEmptyArrayForEmptyKeys(): void + { + $connection = $this->mockConnection(); + + $redis = $this->createStore($connection); + $results = $redis->many([]); + + $this->assertSame([], $results); + } + + /** + * @test + */ + public function testManyMaintainsKeyIndexMapping(): void + { + $connection = $this->mockConnection(); + // Return values in same order as requested + $connection->shouldReceive('mget') + ->once() + ->with(['prefix:a', 'prefix:b', 'prefix:c']) + ->andReturn([ + serialize('value_a'), + null, + serialize('value_c'), + ]); + + $redis = $this->createStore($connection); + $results = $redis->many(['a', 'b', 'c']); + + // Verify correct mapping + $this->assertSame('value_a', $results['a']); + $this->assertNull($results['b']); + $this->assertSame('value_c', $results['c']); + $this->assertCount(3, $results); + } +} diff --git a/tests/Cache/Redis/Operations/PutManyTest.php b/tests/Cache/Redis/Operations/PutManyTest.php new file mode 100644 index 000000000..3563f99fe --- /dev/null +++ b/tests/Cache/Redis/Operations/PutManyTest.php @@ -0,0 +1,156 @@ +mockConnection(); + $client = $connection->_mockClient; + + // Standard mode (not cluster) uses Lua script with evalSha + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); // Script not cached + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // Verify Lua script structure + $this->assertStringContainsString('SETEX', $script); + // Keys: prefix:foo, prefix:baz, prefix:bar + $this->assertSame(3, $numKeys); + // Args: [key1, key2, key3, ttl, val1, val2, val3] + $this->assertSame('prefix:foo', $args[0]); + $this->assertSame('prefix:baz', $args[1]); + $this->assertSame('prefix:bar', $args[2]); + $this->assertSame(60, $args[3]); // TTL + + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->putMany([ + 'foo' => 'bar', + 'baz' => 'qux', + 'bar' => 'norf', + ], 60); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyUsesMultiInClusterMode(): void + { + [$redis, $clusterClient] = $this->createClusterStore(); + + // RedisCluster::multi() returns $this (fluent interface) + $clusterClient->shouldReceive('multi')->once()->andReturn($clusterClient); + $clusterClient->shouldReceive('setex')->once()->with('prefix:foo', 60, serialize('bar'))->andReturn($clusterClient); + $clusterClient->shouldReceive('setex')->once()->with('prefix:baz', 60, serialize('qux'))->andReturn($clusterClient); + $clusterClient->shouldReceive('exec')->once()->andReturn([true, true]); + + $result = $redis->putMany([ + 'foo' => 'bar', + 'baz' => 'qux', + ], 60); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyClusterModeReturnsFalseOnFailure(): void + { + [$redis, $clusterClient] = $this->createClusterStore(); + + // RedisCluster::multi() returns $this (fluent interface) + $clusterClient->shouldReceive('multi')->once()->andReturn($clusterClient); + $clusterClient->shouldReceive('setex')->twice()->andReturn($clusterClient); + $clusterClient->shouldReceive('exec')->once()->andReturn([true, false]); // One failed + + $result = $redis->putMany([ + 'foo' => 'bar', + 'baz' => 'qux', + ], 60); + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutManyReturnsTrueForEmptyValues(): void + { + $connection = $this->mockConnection(); + + $redis = $this->createStore($connection); + $result = $redis->putMany([], 60); + + $this->assertTrue($result); + } + + /** + * @test + */ + public function testPutManyLuaFailureReturnsFalse(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + // In standard mode (Lua), if both evalSha and eval fail, return false + $client->shouldReceive('evalSha') + ->once() + ->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->andReturn(false); // Lua script failed + + $redis = $this->createStore($connection); + $result = $redis->putMany([ + 'foo' => 'bar', + 'baz' => 'qux', + ], 60); + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutManyEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + $client = $connection->_mockClient; + + $client->shouldReceive('evalSha')->once()->andReturn(false); + $client->shouldReceive('eval') + ->once() + ->withArgs(function ($script, $args, $numKeys) { + // TTL should be 1, not 0 + $this->assertSame(1, $args[$numKeys]); // TTL is at args[numKeys] + return true; + }) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->putMany(['foo' => 'bar'], 0); + $this->assertTrue($result); + } +} diff --git a/tests/Cache/Redis/Operations/PutTest.php b/tests/Cache/Redis/Operations/PutTest.php new file mode 100644 index 000000000..0a8615c8f --- /dev/null +++ b/tests/Cache/Redis/Operations/PutTest.php @@ -0,0 +1,83 @@ +mockConnection(); + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, serialize('foo')) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->put('foo', 'foo', 60); + $this->assertTrue($result); + } + + /** + * @test + */ + public function testSetMethodProperlyCallsRedisForNumerics(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, 1) + ->andReturn(false); + + $redis = $this->createStore($connection); + $result = $redis->put('foo', 1, 60); + $this->assertFalse($result); + } + + /** + * @test + */ + public function testPutPreservesArrayValues(): void + { + $connection = $this->mockConnection(); + $array = ['nested' => ['data' => 'value']]; + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, serialize($array)) + ->andReturn(true); + + $redis = $this->createStore($connection); + $this->assertTrue($redis->put('foo', $array, 60)); + } + + /** + * @test + */ + public function testPutEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + // TTL of 0 should become 1 (Redis requires positive TTL for SETEX) + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 1, serialize('bar')) + ->andReturn(true); + + $redis = $this->createStore($connection); + $this->assertTrue($redis->put('foo', 'bar', 0)); + } +} diff --git a/tests/Cache/Redis/Operations/RememberForeverTest.php b/tests/Cache/Redis/Operations/RememberForeverTest.php new file mode 100644 index 000000000..131ede7f3 --- /dev/null +++ b/tests/Cache/Redis/Operations/RememberForeverTest.php @@ -0,0 +1,261 @@ +mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('cached_value')); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', fn () => 'new_value'); + + $this->assertSame('cached_value', $value); + $this->assertTrue($wasHit); + } + + /** + * @test + */ + public function testRememberForeverCallsCallbackOnCacheMiss(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturnNull(); + + // Uses SET without TTL (not SETEX) + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('computed_value')) + ->andReturn(true); + + $callCount = 0; + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }); + + $this->assertSame('computed_value', $value); + $this->assertFalse($wasHit); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberForeverDoesNotCallCallbackOnCacheHit(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('existing_value')); + + $callCount = 0; + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }); + + $this->assertSame('existing_value', $value); + $this->assertTrue($wasHit); + $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); + } + + /** + * @test + */ + public function testRememberForeverWithNumericValue(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Numeric values are NOT serialized (optimization) + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', 42) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', fn () => 42); + + $this->assertSame(42, $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithArrayValue(): void + { + $connection = $this->mockConnection(); + $arrayValue = ['key' => 'value', 'nested' => ['a', 'b']]; + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize($arrayValue)) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', fn () => $arrayValue); + + $this->assertSame($arrayValue, $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $redis = $this->createStore($connection); + $redis->rememberForever('foo', function () { + throw new RuntimeException('Callback failed'); + }); + } + + /** + * @test + */ + public function testRememberForeverHandlesFalseReturnFromGet(): void + { + $connection = $this->mockConnection(); + + // Redis returns false for non-existent keys + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(false); + + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('computed')) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', fn () => 'computed'); + + $this->assertSame('computed', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithEmptyStringValue(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize('')) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', fn () => ''); + + $this->assertSame('', $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithZeroValue(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Zero is numeric, not serialized + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', 0) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', fn () => 0); + + $this->assertSame(0, $value); + $this->assertFalse($wasHit); + } + + /** + * @test + */ + public function testRememberForeverWithNullReturnedFromCallback(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $connection->shouldReceive('set') + ->once() + ->with('prefix:foo', serialize(null)) + ->andReturn(true); + + $redis = $this->createStore($connection); + [$value, $wasHit] = $redis->rememberForever('foo', fn () => null); + + $this->assertNull($value); + $this->assertFalse($wasHit); + } +} diff --git a/tests/Cache/Redis/Operations/RememberTest.php b/tests/Cache/Redis/Operations/RememberTest.php new file mode 100644 index 000000000..ce3aa222b --- /dev/null +++ b/tests/Cache/Redis/Operations/RememberTest.php @@ -0,0 +1,273 @@ +mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('cached_value')); + + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, fn () => 'new_value'); + + $this->assertSame('cached_value', $result); + } + + /** + * @test + */ + public function testRememberCallsCallbackOnCacheMiss(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturnNull(); + + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, serialize('computed_value')) + ->andReturn(true); + + $callCount = 0; + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, function () use (&$callCount) { + ++$callCount; + + return 'computed_value'; + }); + + $this->assertSame('computed_value', $result); + $this->assertSame(1, $callCount); + } + + /** + * @test + */ + public function testRememberDoesNotCallCallbackOnCacheHit(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(serialize('existing_value')); + + $callCount = 0; + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, function () use (&$callCount) { + ++$callCount; + + return 'new_value'; + }); + + $this->assertSame('existing_value', $result); + $this->assertSame(0, $callCount, 'Callback should not be called on cache hit'); + } + + /** + * @test + */ + public function testRememberEnforcesMinimumTtlOfOne(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + // TTL should be at least 1 even when 0 is passed + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 1, serialize('bar')) + ->andReturn(true); + + $redis = $this->createStore($connection); + $redis->remember('foo', 0, fn () => 'bar'); + } + + /** + * @test + */ + public function testRememberWithNumericValue(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Numeric values are NOT serialized (optimization) + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, 42) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, fn () => 42); + + $this->assertSame(42, $result); + } + + /** + * @test + */ + public function testRememberWithArrayValue(): void + { + $connection = $this->mockConnection(); + $value = ['key' => 'value', 'nested' => ['a', 'b']]; + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 120, serialize($value)) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 120, fn () => $value); + + $this->assertSame($value, $result); + } + + /** + * @test + */ + public function testRememberWithObjectValue(): void + { + $connection = $this->mockConnection(); + $value = (object) ['name' => 'test']; + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, serialize($value)) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, fn () => $value); + + $this->assertEquals($value, $result); + } + + /** + * @test + */ + public function testRememberPropagatesExceptionFromCallback(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Callback failed'); + + $redis = $this->createStore($connection); + $redis->remember('foo', 60, function () { + throw new RuntimeException('Callback failed'); + }); + } + + /** + * @test + */ + public function testRememberHandlesFalseReturnFromGet(): void + { + $connection = $this->mockConnection(); + + // Redis returns false for non-existent keys + $connection->shouldReceive('get') + ->once() + ->with('prefix:foo') + ->andReturn(false); + + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, serialize('computed')) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, fn () => 'computed'); + + $this->assertSame('computed', $result); + } + + /** + * @test + */ + public function testRememberWithEmptyStringValue(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, serialize('')) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, fn () => ''); + + $this->assertSame('', $result); + } + + /** + * @test + */ + public function testRememberWithZeroValue(): void + { + $connection = $this->mockConnection(); + + $connection->shouldReceive('get') + ->once() + ->andReturnNull(); + + // Zero is numeric, not serialized + $connection->shouldReceive('setex') + ->once() + ->with('prefix:foo', 60, 0) + ->andReturn(true); + + $redis = $this->createStore($connection); + $result = $redis->remember('foo', 60, fn () => 0); + + $this->assertSame(0, $result); + } +} diff --git a/tests/Cache/Redis/RedisStoreTest.php b/tests/Cache/Redis/RedisStoreTest.php new file mode 100644 index 000000000..3ff84a243 --- /dev/null +++ b/tests/Cache/Redis/RedisStoreTest.php @@ -0,0 +1,349 @@ +mockConnection(); + $redis = $this->createStore($connection); + + $this->assertSame('prefix:', $redis->getPrefix()); + $redis->setPrefix('foo:'); + $this->assertSame('foo:', $redis->getPrefix()); + $redis->setPrefix(''); + $this->assertEmpty($redis->getPrefix()); + } + + /** + * @test + */ + public function testSetConnectionClearsCachedInstances(): void + { + $connection1 = $this->mockConnection(); + $connection1->shouldReceive('get')->once()->with('prefix:foo')->andReturn(serialize('value1')); + + $connection2 = $this->mockConnection(); + $connection2->shouldReceive('get')->once()->with('prefix:foo')->andReturn(serialize('value2')); + + // Create store with first connection + $poolFactory1 = $this->createPoolFactory($connection1, 'conn1'); + $redis = new RedisStore( + m::mock(RedisFactory::class), + 'prefix:', + 'conn1', + $poolFactory1 + ); + + $this->assertSame('value1', $redis->get('foo')); + + // Change connection - this should clear cached operation instances + $poolFactory2 = $this->createPoolFactory($connection2, 'conn2'); + + // We need to inject the new pool factory. Since we can't directly, + // we verify that setConnection clears the context by checking + // that a new store with different connection gets different values. + $redis2 = new RedisStore( + m::mock(RedisFactory::class), + 'prefix:', + 'conn2', + $poolFactory2 + ); + + $this->assertSame('value2', $redis2->get('foo')); + } + + /** + * @test + */ + public function testSetPrefixClearsCachedOperations(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->once()->with('prefix:foo')->andReturn(serialize('old')); + $connection->shouldReceive('get')->once()->with('newprefix:foo')->andReturn(serialize('new')); + + $redis = $this->createStore($connection); + + // First get with original prefix + $this->assertSame('old', $redis->get('foo')); + + // Change prefix (include colon since setPrefix stores as-is) + $redis->setPrefix('newprefix:'); + + // Second get should use new prefix + $this->assertSame('new', $redis->get('foo')); + } + + /** + * @test + */ + public function testTagsReturnsAllTaggedCache(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + + $tagged = $redis->tags(['users', 'posts']); + + $this->assertInstanceOf(\Hypervel\Cache\Redis\AllTaggedCache::class, $tagged); + } + + /** + * @test + */ + public function testTagsWithSingleTagAsString(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + + $tagged = $redis->tags('users'); + + $this->assertInstanceOf(\Hypervel\Cache\Redis\AllTaggedCache::class, $tagged); + } + + /** + * @test + */ + public function testTagsWithVariadicArguments(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + + $tagged = $redis->tags('users', 'posts', 'comments'); + + $this->assertInstanceOf(\Hypervel\Cache\Redis\AllTaggedCache::class, $tagged); + } + + /** + * @test + */ + public function testDefaultTagModeIsAll(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + + $this->assertSame(TagMode::All, $redis->getTagMode()); + } + + /** + * @test + */ + public function testSetTagModeReturnsStoreInstance(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + + $result = $redis->setTagMode('any'); + + $this->assertSame($redis, $result); + $this->assertSame(TagMode::Any, $redis->getTagMode()); + } + + /** + * @test + */ + public function testTagsReturnsAnyTaggedCacheWhenInAnyMode(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + $redis->setTagMode('any'); + + $tagged = $redis->tags(['users', 'posts']); + + $this->assertInstanceOf(\Hypervel\Cache\Redis\AnyTaggedCache::class, $tagged); + } + + /** + * @test + */ + public function testTagsReturnsAllTaggedCacheWhenInAllMode(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + $redis->setTagMode('all'); + + $tagged = $redis->tags(['users', 'posts']); + + $this->assertInstanceOf(\Hypervel\Cache\Redis\AllTaggedCache::class, $tagged); + } + + /** + * @test + */ + public function testSetTagModeFallsBackToAllForInvalidMode(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + + $redis->setTagMode('invalid'); + + $this->assertSame(TagMode::All, $redis->getTagMode()); + } + + /** + * @test + */ + public function testLockReturnsRedisLockInstance(): void + { + $connection = $this->mockConnection(); + $redisProxy = m::mock(RedisProxy::class); + $redisFactory = m::mock(RedisFactory::class); + $redisFactory->shouldReceive('get')->with('default')->andReturn($redisProxy); + + $redis = new RedisStore( + $redisFactory, + 'prefix:', + 'default', + $this->createPoolFactory($connection) + ); + + $lock = $redis->lock('mylock', 10); + + $this->assertInstanceOf(RedisLock::class, $lock); + } + + /** + * @test + */ + public function testLockWithOwner(): void + { + $connection = $this->mockConnection(); + $redisProxy = m::mock(RedisProxy::class); + $redisFactory = m::mock(RedisFactory::class); + $redisFactory->shouldReceive('get')->with('default')->andReturn($redisProxy); + + $redis = new RedisStore( + $redisFactory, + 'prefix:', + 'default', + $this->createPoolFactory($connection) + ); + + $lock = $redis->lock('mylock', 10, 'custom-owner'); + + $this->assertInstanceOf(RedisLock::class, $lock); + } + + /** + * @test + */ + public function testRestoreLockReturnsRedisLockInstance(): void + { + $connection = $this->mockConnection(); + $redisProxy = m::mock(RedisProxy::class); + $redisFactory = m::mock(RedisFactory::class); + $redisFactory->shouldReceive('get')->with('default')->andReturn($redisProxy); + + $redis = new RedisStore( + $redisFactory, + 'prefix:', + 'default', + $this->createPoolFactory($connection) + ); + + $lock = $redis->restoreLock('mylock', 'owner-123'); + + $this->assertInstanceOf(RedisLock::class, $lock); + } + + /** + * @test + */ + public function testSetLockConnectionReturnsSelf(): void + { + $connection = $this->mockConnection(); + $redis = $this->createStore($connection); + + $result = $redis->setLockConnection('locks'); + + $this->assertSame($redis, $result); + } + + /** + * @test + */ + public function testLockUsesLockConnectionWhenSet(): void + { + $connection = $this->mockConnection(); + $redisProxy = m::mock(RedisProxy::class); + $lockProxy = m::mock(RedisProxy::class); + $redisFactory = m::mock(RedisFactory::class); + $redisFactory->shouldReceive('get')->with('default')->andReturn($redisProxy); + $redisFactory->shouldReceive('get')->with('locks')->andReturn($lockProxy); + + $redis = new RedisStore( + $redisFactory, + 'prefix:', + 'default', + $this->createPoolFactory($connection) + ); + + $redis->setLockConnection('locks'); + $lock = $redis->lock('mylock', 10); + + $this->assertInstanceOf(RedisLock::class, $lock); + } + + /** + * @test + */ + public function testGetRedisReturnsRedisFactory(): void + { + $connection = $this->mockConnection(); + $redisFactory = m::mock(RedisFactory::class); + + $redis = new RedisStore( + $redisFactory, + 'prefix:', + 'default', + $this->createPoolFactory($connection) + ); + + $this->assertSame($redisFactory, $redis->getRedis()); + } + + /** + * @test + */ + public function testConnectionReturnsRedisProxy(): void + { + $connection = $this->mockConnection(); + $redisProxy = m::mock(RedisProxy::class); + $redisFactory = m::mock(RedisFactory::class); + $redisFactory->shouldReceive('get')->with('default')->andReturn($redisProxy); + + $redis = new RedisStore( + $redisFactory, + 'prefix:', + 'default', + $this->createPoolFactory($connection) + ); + + $this->assertSame($redisProxy, $redis->connection()); + } +} diff --git a/tests/Cache/Redis/Support/SerializationTest.php b/tests/Cache/Redis/Support/SerializationTest.php new file mode 100644 index 000000000..6ef884f61 --- /dev/null +++ b/tests/Cache/Redis/Support/SerializationTest.php @@ -0,0 +1,203 @@ +serialization = new Serialization(); + } + + public function testSerializeReturnsRawValueWhenSerializerConfigured(): void + { + $connection = $this->createConnection(serialized: true); + + $this->assertSame('test-value', $this->serialization->serialize($connection, 'test-value')); + $this->assertSame(123, $this->serialization->serialize($connection, 123)); + $this->assertSame(['foo' => 'bar'], $this->serialization->serialize($connection, ['foo' => 'bar'])); + } + + public function testSerializePhpSerializesWhenNoSerializerConfigured(): void + { + $connection = $this->createConnection(serialized: false); + + $this->assertSame(serialize('test-value'), $this->serialization->serialize($connection, 'test-value')); + $this->assertSame(serialize(['foo' => 'bar']), $this->serialization->serialize($connection, ['foo' => 'bar'])); + } + + public function testSerializeReturnsRawNumericValues(): void + { + $connection = $this->createConnection(serialized: false); + + // Numeric values are returned raw for performance optimization + $this->assertSame(123, $this->serialization->serialize($connection, 123)); + $this->assertSame(45.67, $this->serialization->serialize($connection, 45.67)); + $this->assertSame(0, $this->serialization->serialize($connection, 0)); + $this->assertSame(-99, $this->serialization->serialize($connection, -99)); + } + + public function testSerializeHandlesSpecialFloatValues(): void + { + $connection = $this->createConnection(serialized: false); + + // INF, -INF, and NaN should be serialized, not returned raw + $this->assertSame(serialize(INF), $this->serialization->serialize($connection, INF)); + $this->assertSame(serialize(-INF), $this->serialization->serialize($connection, -INF)); + // NaN comparison is tricky - it serializes to a special representation + $result = $this->serialization->serialize($connection, NAN); + $this->assertIsString($result); + $this->assertStringContainsString('NAN', $result); + } + + public function testUnserializeReturnsNullForNullInput(): void + { + $connection = $this->createConnection(serialized: false); + + $this->assertNull($this->serialization->unserialize($connection, null)); + } + + public function testUnserializeReturnsNullForFalseInput(): void + { + $connection = $this->createConnection(serialized: false); + + $this->assertNull($this->serialization->unserialize($connection, false)); + } + + public function testUnserializeReturnsRawValueWhenSerializerConfigured(): void + { + $connection = $this->createConnection(serialized: true); + + $this->assertSame('test-value', $this->serialization->unserialize($connection, 'test-value')); + $this->assertSame(['foo' => 'bar'], $this->serialization->unserialize($connection, ['foo' => 'bar'])); + } + + public function testUnserializePhpUnserializesWhenNoSerializerConfigured(): void + { + $connection = $this->createConnection(serialized: false); + + $this->assertSame('test-value', $this->serialization->unserialize($connection, serialize('test-value'))); + $this->assertSame(['foo' => 'bar'], $this->serialization->unserialize($connection, serialize(['foo' => 'bar']))); + } + + public function testUnserializeReturnsNumericValuesRaw(): void + { + $connection = $this->createConnection(serialized: false); + + $this->assertSame(123, $this->serialization->unserialize($connection, 123)); + $this->assertSame(45.67, $this->serialization->unserialize($connection, 45.67)); + // Numeric strings are also returned raw + $this->assertSame('123', $this->serialization->unserialize($connection, '123')); + $this->assertSame('45.67', $this->serialization->unserialize($connection, '45.67')); + } + + public function testSerializeForLuaUsesPackWhenSerializerConfigured(): void + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('serialized')->andReturn(true); + $connection->shouldReceive('pack') + ->with(['test-value']) + ->andReturn(['packed-value']); + + $this->assertSame('packed-value', $this->serialization->serializeForLua($connection, 'test-value')); + } + + public function testSerializeForLuaAppliesCompressionWhenEnabled(): void + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis::COMPRESSION_LZF not available (phpredis compiled without LZF support)'); + } + + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $connection->shouldReceive('serialized')->andReturn(false); + $connection->shouldReceive('client')->andReturn($client); + $client->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_LZF); + $client->shouldReceive('_serialize') + ->with(serialize('test-value')) + ->andReturn('compressed-value'); + + $this->assertSame('compressed-value', $this->serialization->serializeForLua($connection, 'test-value')); + } + + public function testSerializeForLuaReturnsPhpSerializedWhenNoSerializerOrCompression(): void + { + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $connection->shouldReceive('serialized')->andReturn(false); + $connection->shouldReceive('client')->andReturn($client); + $client->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_NONE); + + $this->assertSame(serialize('test-value'), $this->serialization->serializeForLua($connection, 'test-value')); + } + + public function testSerializeForLuaCastsNumericValuesToString(): void + { + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $connection->shouldReceive('serialized')->andReturn(false); + $connection->shouldReceive('client')->andReturn($client); + $client->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_NONE); + + // Numeric values should be cast to string for Lua ARGV + $this->assertSame('123', $this->serialization->serializeForLua($connection, 123)); + $this->assertSame('45.67', $this->serialization->serializeForLua($connection, 45.67)); + } + + public function testSerializeForLuaCastsNumericToStringWithCompression(): void + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis::COMPRESSION_LZF not available (phpredis compiled without LZF support)'); + } + + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $connection->shouldReceive('serialized')->andReturn(false); + $connection->shouldReceive('client')->andReturn($client); + $client->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_LZF); + // When compression is enabled, numeric strings get passed through _serialize + $client->shouldReceive('_serialize') + ->with('123') + ->andReturn('compressed-123'); + + $this->assertSame('compressed-123', $this->serialization->serializeForLua($connection, 123)); + } + + /** + * Create a mock RedisConnection with the given serialized flag. + */ + private function createConnection(bool $serialized = false): RedisConnection + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('serialized')->andReturn($serialized); + + return $connection; + } +} diff --git a/tests/Cache/Redis/Support/StoreContextTest.php b/tests/Cache/Redis/Support/StoreContextTest.php new file mode 100644 index 000000000..a9a1cc508 --- /dev/null +++ b/tests/Cache/Redis/Support/StoreContextTest.php @@ -0,0 +1,280 @@ +createContext(prefix: 'myapp:'); + + $this->assertSame('myapp:', $context->prefix()); + } + + public function testConnectionNameReturnsConfiguredConnectionName(): void + { + $context = $this->createContext(connectionName: 'cache'); + + $this->assertSame('cache', $context->connectionName()); + } + + public function testTagScanPatternCombinesPrefixWithTagSegment(): void + { + $context = $this->createContext(prefix: 'myapp:'); + + $this->assertSame('myapp:_any:tag:*:entries', $context->tagScanPattern()); + } + + public function testTagHashKeyBuildsCorrectFormat(): void + { + $context = $this->createContext(prefix: 'myapp:'); + + $this->assertSame('myapp:_any:tag:users:entries', $context->tagHashKey('users')); + $this->assertSame('myapp:_any:tag:posts:entries', $context->tagHashKey('posts')); + } + + public function testTagHashSuffixReturnsConstant(): void + { + $context = $this->createContext(); + + $this->assertSame(':entries', $context->tagHashSuffix()); + } + + public function testReverseIndexKeyBuildsCorrectFormat(): void + { + $context = $this->createContext(prefix: 'myapp:'); + + $this->assertSame('myapp:user:1:_any:tags', $context->reverseIndexKey('user:1')); + $this->assertSame('myapp:post:42:_any:tags', $context->reverseIndexKey('post:42')); + } + + public function testRegistryKeyBuildsCorrectFormat(): void + { + $context = $this->createContext(prefix: 'myapp:'); + + $this->assertSame('myapp:_any:tag:registry', $context->registryKey()); + } + + public function testWithConnectionGetsConnectionFromPoolAndReleasesIt(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + + $poolFactory->shouldReceive('getPool') + ->once() + ->with('default') + ->andReturn($pool); + + $pool->shouldReceive('get') + ->once() + ->andReturn($connection); + + $connection->shouldReceive('release') + ->once(); + + $context = new StoreContext($poolFactory, 'default', 'prefix:', TagMode::Any); + + $result = $context->withConnection(function ($conn) use ($connection) { + $this->assertSame($connection, $conn); + return 'callback-result'; + }); + + $this->assertSame('callback-result', $result); + } + + public function testWithConnectionReleasesConnectionOnException(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + + $poolFactory->shouldReceive('getPool') + ->once() + ->with('default') + ->andReturn($pool); + + $pool->shouldReceive('get') + ->once() + ->andReturn($connection); + + $connection->shouldReceive('release') + ->once(); + + $context = new StoreContext($poolFactory, 'default', 'prefix:', TagMode::Any); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Test exception'); + + $context->withConnection(function () { + throw new RuntimeException('Test exception'); + }); + } + + public function testIsClusterReturnsTrueForRedisCluster(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + $client = m::mock(RedisCluster::class); + + $poolFactory->shouldReceive('getPool')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('release'); + + $context = new StoreContext($poolFactory, 'default', 'prefix:', TagMode::Any); + + $this->assertTrue($context->isCluster()); + } + + public function testIsClusterReturnsFalseForRegularRedis(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $poolFactory->shouldReceive('getPool')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('release'); + + $context = new StoreContext($poolFactory, 'default', 'prefix:', TagMode::Any); + + $this->assertFalse($context->isCluster()); + } + + public function testOptPrefixReturnsRedisOptionPrefix(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $poolFactory->shouldReceive('getPool')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('release'); + $client->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn('redis_prefix:'); + + $context = new StoreContext($poolFactory, 'default', 'cache:', TagMode::Any); + + $this->assertSame('redis_prefix:', $context->optPrefix()); + } + + public function testOptPrefixReturnsEmptyStringWhenNotSet(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $poolFactory->shouldReceive('getPool')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('release'); + $client->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn(null); + + $context = new StoreContext($poolFactory, 'default', 'cache:', TagMode::Any); + + $this->assertSame('', $context->optPrefix()); + } + + public function testFullTagPrefixIncludesOptPrefix(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $poolFactory->shouldReceive('getPool')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('release'); + $client->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn('redis:'); + + $context = new StoreContext($poolFactory, 'default', 'cache:', TagMode::Any); + + $this->assertSame('redis:cache:_any:tag:', $context->fullTagPrefix()); + } + + public function testFullReverseIndexKeyIncludesOptPrefix(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $poolFactory->shouldReceive('getPool')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('release'); + $client->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn('redis:'); + + $context = new StoreContext($poolFactory, 'default', 'cache:', TagMode::Any); + + $this->assertSame('redis:cache:user:1:_any:tags', $context->fullReverseIndexKey('user:1')); + } + + public function testFullRegistryKeyIncludesOptPrefix(): void + { + $poolFactory = m::mock(PoolFactory::class); + $pool = m::mock(RedisPool::class); + $connection = m::mock(RedisConnection::class); + $client = m::mock(Redis::class); + + $poolFactory->shouldReceive('getPool')->andReturn($pool); + $pool->shouldReceive('get')->andReturn($connection); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('release'); + $client->shouldReceive('getOption') + ->with(Redis::OPT_PREFIX) + ->andReturn('redis:'); + + $context = new StoreContext($poolFactory, 'default', 'cache:', TagMode::Any); + + $this->assertSame('redis:cache:_any:tag:registry', $context->fullRegistryKey()); + } + + public function testConstantsHaveExpectedValues(): void + { + $this->assertSame(253402300799, StoreContext::MAX_EXPIRY); + $this->assertSame('1', StoreContext::TAG_FIELD_VALUE); + } + + private function createContext( + string $connectionName = 'default', + string $prefix = 'prefix:', + TagMode $tagMode = TagMode::Any + ): StoreContext { + $poolFactory = m::mock(PoolFactory::class); + + return new StoreContext($poolFactory, $connectionName, $prefix, $tagMode); + } +} diff --git a/tests/Cache/RedisLockTest.php b/tests/Cache/RedisLockTest.php new file mode 100644 index 000000000..a55401857 --- /dev/null +++ b/tests/Cache/RedisLockTest.php @@ -0,0 +1,186 @@ +shouldReceive('set') + ->once() + ->with('lock:foo', m::type('string'), ['EX' => 60, 'NX']) + ->andReturn(true); + + $lock = new RedisLock($redis, 'lock:foo', 60); + + $this->assertTrue($lock->acquire()); + } + + public function testAcquireWithExpirationReturnsFalseWhenLockExists(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('set') + ->once() + ->with('lock:foo', m::type('string'), ['EX' => 60, 'NX']) + ->andReturn(false); + + $lock = new RedisLock($redis, 'lock:foo', 60); + + $this->assertFalse($lock->acquire()); + } + + public function testAcquireWithoutExpirationUsesSETNX(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('setnx') + ->once() + ->with('lock:foo', m::type('string')) + ->andReturn(true); + + $lock = new RedisLock($redis, 'lock:foo', 0); + + $this->assertTrue($lock->acquire()); + } + + public function testAcquireWithoutExpirationReturnsFalseWhenLockExists(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('setnx') + ->once() + ->with('lock:foo', m::type('string')) + ->andReturn(false); + + $lock = new RedisLock($redis, 'lock:foo', 0); + + $this->assertFalse($lock->acquire()); + } + + public function testReleaseUsesLuaScriptToAtomicallyCheckOwnership(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('eval') + ->once() + ->with(m::type('string'), ['lock:foo', 'owner123'], 1) + ->andReturn(1); + + $lock = new RedisLock($redis, 'lock:foo', 60, 'owner123'); + + $this->assertTrue($lock->release()); + } + + public function testReleaseReturnsFalseWhenNotOwner(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('eval') + ->once() + ->with(m::type('string'), ['lock:foo', 'owner123'], 1) + ->andReturn(0); + + $lock = new RedisLock($redis, 'lock:foo', 60, 'owner123'); + + $this->assertFalse($lock->release()); + } + + public function testForceReleaseDeletesKeyRegardlessOfOwnership(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('del') + ->once() + ->with('lock:foo'); + + $lock = new RedisLock($redis, 'lock:foo', 60); + + $lock->forceRelease(); + } + + public function testOwnerReturnsTheOwnerIdentifier(): void + { + $redis = m::mock(Redis::class); + + $lock = new RedisLock($redis, 'lock:foo', 60, 'my-owner-id'); + + $this->assertSame('my-owner-id', $lock->owner()); + } + + public function testOwnerIsAutoGeneratedWhenNotProvided(): void + { + $redis = m::mock(Redis::class); + + $lock = new RedisLock($redis, 'lock:foo', 60); + + $this->assertNotEmpty($lock->owner()); + $this->assertIsString($lock->owner()); + } + + public function testGetCallsAcquireAndExecutesCallbackOnSuccess(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('set') + ->once() + ->with('lock:foo', m::type('string'), ['EX' => 60, 'NX']) + ->andReturn(true); + $redis->shouldReceive('eval') + ->once() + ->andReturn(1); + + $lock = new RedisLock($redis, 'lock:foo', 60); + + $result = $lock->get(fn () => 'callback-result'); + + $this->assertSame('callback-result', $result); + } + + public function testGetReturnsFalseWhenLockNotAcquired(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('set') + ->once() + ->with('lock:foo', m::type('string'), ['EX' => 60, 'NX']) + ->andReturn(false); + + $lock = new RedisLock($redis, 'lock:foo', 60); + + $result = $lock->get(fn () => 'callback-result'); + + $this->assertFalse($result); + } + + public function testGetReleasesLockAfterCallbackEvenOnException(): void + { + $redis = m::mock(Redis::class); + $redis->shouldReceive('set') + ->once() + ->andReturn(true); + $redis->shouldReceive('eval') + ->once() + ->andReturn(1); + + $lock = new RedisLock($redis, 'lock:foo', 60); + + $this->expectException(RuntimeException::class); + + $lock->get(function () { + throw new RuntimeException('test exception'); + }); + } +} diff --git a/tests/Core/Database/Eloquent/Factories/FactoryTest.php b/tests/Core/Database/Eloquent/Factories/FactoryTest.php index d63b66e42..8fd40aadf 100644 --- a/tests/Core/Database/Eloquent/Factories/FactoryTest.php +++ b/tests/Core/Database/Eloquent/Factories/FactoryTest.php @@ -24,7 +24,7 @@ * @internal * @coversNothing */ -class DatabaseEloquentFactoryTest extends TestCase +class FactoryTest extends TestCase { use RefreshDatabase; diff --git a/tests/Redis/DurationLimiterTest.php b/tests/Redis/DurationLimiterTest.php new file mode 100644 index 000000000..406894c24 --- /dev/null +++ b/tests/Redis/DurationLimiterTest.php @@ -0,0 +1,194 @@ +mockRedis(); + // Lua script returns: [acquired (1=success), decaysAt, remaining] + $redis->shouldReceive('eval') + ->once() + ->andReturn([1, time() + 60, 4]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $result = $limiter->acquire(); + + $this->assertTrue($result); + $this->assertSame(4, $limiter->remaining); + } + + public function testAcquireFailsWhenAtLimit(): void + { + $redis = $this->mockRedis(); + // Lua script returns: [acquired (0=failed), decaysAt, remaining] + $redis->shouldReceive('eval') + ->once() + ->andReturn([0, time() + 30, 0]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $result = $limiter->acquire(); + + $this->assertFalse($result); + $this->assertSame(0, $limiter->remaining); + } + + public function testRemainingIsNeverNegative(): void + { + $redis = $this->mockRedis(); + // Even if script returns negative, remaining should be 0 + $redis->shouldReceive('eval') + ->once() + ->andReturn([0, time() + 60, -2]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $limiter->acquire(); + + $this->assertSame(0, $limiter->remaining); + } + + public function testTooManyAttemptsReturnsTrueWhenNoRemaining(): void + { + $redis = $this->mockRedis(); + $redis->shouldReceive('eval') + ->once() + ->andReturn([time() + 60, 0]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $result = $limiter->tooManyAttempts(); + + $this->assertTrue($result); + $this->assertSame(0, $limiter->remaining); + } + + public function testTooManyAttemptsReturnsFalseWhenHasRemaining(): void + { + $redis = $this->mockRedis(); + $redis->shouldReceive('eval') + ->once() + ->andReturn([time() + 60, 3]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $result = $limiter->tooManyAttempts(); + + $this->assertFalse($result); + $this->assertSame(3, $limiter->remaining); + } + + public function testClearDeletesKey(): void + { + $redis = $this->mockRedis(); + $redis->shouldReceive('del') + ->once() + ->with('test-key') + ->andReturn(1); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $limiter->clear(); + + // Mockery verifies del() was called + } + + public function testBlockExecutesCallbackOnSuccess(): void + { + $redis = $this->mockRedis(); + $redis->shouldReceive('eval') + ->once() + ->andReturn([1, time() + 60, 4]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $callbackExecuted = false; + $result = $limiter->block(5, function () use (&$callbackExecuted) { + $callbackExecuted = true; + return 'callback-result'; + }); + + $this->assertTrue($callbackExecuted); + $this->assertSame('callback-result', $result); + } + + public function testBlockThrowsExceptionAfterTimeout(): void + { + $redis = $this->mockRedis(); + // Always fail to acquire + $redis->shouldReceive('eval') + ->andReturn([0, time() + 60, 0]); + + $factory = $this->createFactory($redis); + $limiter = new DurationLimiter($factory, 'default', 'test-key', 5, 60); + + $this->expectException(LimiterTimeoutException::class); + + // Timeout of 0 means it should fail immediately on first retry + $limiter->block(0, null, 1); // 1ms sleep between retries + } + + public function testUsesSpecifiedConnectionName(): void + { + $cacheRedis = $this->mockRedis(); + $cacheRedis->shouldReceive('eval') + ->once() + ->andReturn([1, time() + 60, 4]); + + $factory = m::mock(RedisFactory::class); + // Expect 'cache' connection, not 'default' + $factory->shouldReceive('get')->with('cache')->andReturn($cacheRedis); + + $limiter = new DurationLimiter($factory, 'cache', 'test-key', 5, 60); + + $limiter->acquire(); + + // Mockery verifies get('cache') was called + } + + /** + * Create a mock RedisProxy. + */ + private function mockRedis(): m\MockInterface|RedisProxy + { + return m::mock(RedisProxy::class); + } + + /** + * Create a RedisFactory that returns the given RedisProxy. + */ + private function createFactory(m\MockInterface|RedisProxy $redis): RedisFactory + { + $factory = m::mock(RedisFactory::class); + $factory->shouldReceive('get')->with('default')->andReturn($redis); + + return $factory; + } +} diff --git a/tests/Redis/MultiExecTest.php b/tests/Redis/MultiExecTest.php new file mode 100644 index 000000000..6614407fd --- /dev/null +++ b/tests/Redis/MultiExecTest.php @@ -0,0 +1,194 @@ +shouldReceive('pipeline')->once()->andReturn($pipelineInstance); + + $connection = $this->createMockConnection($phpRedis); + // Connection is stored in context and released via defer() at end of coroutine + $connection->shouldReceive('release')->once(); + $redis = $this->createRedis($connection); + + $result = $redis->pipeline(); + + // Without callback, returns the pipeline instance for chaining + $this->assertSame($pipelineInstance, $result); + } + + public function testPipelineWithCallbackExecutesAndReturnsResults(): void + { + $execResults = ['OK', 'OK', 'value']; + + $pipelineInstance = m::mock(PhpRedis::class); + $pipelineInstance->shouldReceive('set')->twice()->andReturnSelf(); + $pipelineInstance->shouldReceive('get')->once()->andReturnSelf(); + $pipelineInstance->shouldReceive('exec')->once()->andReturn($execResults); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('pipeline')->once()->andReturn($pipelineInstance); + + $connection = $this->createMockConnection($phpRedis); + $connection->shouldReceive('release')->once(); + $redis = $this->createRedis($connection); + + $result = $redis->pipeline(function ($pipe) { + $pipe->set('key1', 'value1'); + $pipe->set('key2', 'value2'); + $pipe->get('key1'); + }); + + $this->assertSame($execResults, $result); + } + + public function testTransactionWithoutCallbackReturnsInstanceForChaining(): void + { + $multiInstance = m::mock(PhpRedis::class); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('multi')->once()->andReturn($multiInstance); + + $connection = $this->createMockConnection($phpRedis); + // Connection is stored in context and released via defer() at end of coroutine + $connection->shouldReceive('release')->once(); + $redis = $this->createRedis($connection); + + $result = $redis->transaction(); + + // Without callback, returns the multi instance for chaining + $this->assertSame($multiInstance, $result); + } + + public function testTransactionWithCallbackExecutesAndReturnsResults(): void + { + $execResults = ['OK', 5]; + + $multiInstance = m::mock(PhpRedis::class); + $multiInstance->shouldReceive('set')->once()->andReturnSelf(); + $multiInstance->shouldReceive('incr')->once()->andReturnSelf(); + $multiInstance->shouldReceive('exec')->once()->andReturn($execResults); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('multi')->once()->andReturn($multiInstance); + + $connection = $this->createMockConnection($phpRedis); + $connection->shouldReceive('release')->once(); + $redis = $this->createRedis($connection); + + $result = $redis->transaction(function ($tx) { + $tx->set('key', 'value'); + $tx->incr('counter'); + }); + + $this->assertSame($execResults, $result); + } + + public function testPipelineWithCallbackDoesNotReleaseExistingContextConnection(): void + { + $pipelineInstance = m::mock(PhpRedis::class); + $pipelineInstance->shouldReceive('exec')->once()->andReturn([]); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('pipeline')->once()->andReturn($pipelineInstance); + + $connection = $this->createMockConnection($phpRedis); + // Set up existing connection in context BEFORE the pipeline call + Context::set('redis.connection.default', $connection); + + // Connection is NOT released during the test (it already existed in context), + // but allow release() call for test cleanup + $connection->shouldReceive('release')->zeroOrMoreTimes(); + + $redis = $this->createRedis($connection); + + $redis->pipeline(function ($pipe) { + // empty callback + }); + } + + public function testPipelineWithCallbackReleasesOnException(): void + { + $pipelineInstance = m::mock(PhpRedis::class); + // exec throws exception + $pipelineInstance->shouldReceive('exec')->once()->andThrow(new RuntimeException('Redis error')); + + $phpRedis = m::mock(PhpRedis::class); + $phpRedis->shouldReceive('pipeline')->once()->andReturn($pipelineInstance); + + $connection = $this->createMockConnection($phpRedis); + // Connection should still be released even on exception + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Redis error'); + + $redis->pipeline(function ($pipe) { + // callback runs, but exec will throw + }); + } + + /** + * Create a mock RedisConnection. + */ + private function createMockConnection(m\MockInterface $phpRedis): m\MockInterface|RedisConnection + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('getConnection')->andReturn($connection); + $connection->shouldReceive('getEventDispatcher')->andReturnNull(); + $connection->shouldReceive('setDatabase')->andReturnNull(); + $connection->shouldReceive('shouldTransform')->andReturnSelf(); + + // Forward method calls to the phpRedis mock + $connection->shouldReceive('pipeline')->andReturnUsing(fn () => $phpRedis->pipeline()); + $connection->shouldReceive('multi')->andReturnUsing(fn () => $phpRedis->multi()); + + return $connection; + } + + /** + * Create a Redis instance with the given mock connection. + */ + private function createRedis(m\MockInterface|RedisConnection $connection): Redis + { + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->andReturn($connection); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + + return new Redis($poolFactory); + } +} diff --git a/tests/Redis/Operations/FlushByPatternTest.php b/tests/Redis/Operations/FlushByPatternTest.php new file mode 100644 index 000000000..91e37f3b5 --- /dev/null +++ b/tests/Redis/Operations/FlushByPatternTest.php @@ -0,0 +1,200 @@ + ['cache:test:key1', 'cache:test:key2', 'cache:test:key3'], 'iterator' => 0], + ], + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('client')->andReturn($client); + $connection->shouldReceive('unlink') + ->once() + ->with('cache:test:key1', 'cache:test:key2', 'cache:test:key3') + ->andReturn(3); + + $flushByPattern = new FlushByPattern($connection); + + $deletedCount = $flushByPattern->execute('cache:test:*'); + + $this->assertSame(3, $deletedCount); + } + + public function testFlushReturnsZeroWhenNoKeysMatch(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => [], 'iterator' => 0], + ], + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('client')->andReturn($client); + // unlink should NOT be called when no keys found + $connection->shouldNotReceive('unlink'); + + $flushByPattern = new FlushByPattern($connection); + + $deletedCount = $flushByPattern->execute('cache:nonexistent:*'); + + $this->assertSame(0, $deletedCount); + } + + public function testFlushHandlesOptPrefixCorrectly(): void + { + // Client has OPT_PREFIX set - SafeScan should handle this + $client = new FakeRedisClient( + scanResults: [ + // Redis returns keys WITH the OPT_PREFIX + ['keys' => ['myapp:cache:test:key1', 'myapp:cache:test:key2'], 'iterator' => 0], + ], + optPrefix: 'myapp:', + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('client')->andReturn($client); + // Keys passed to unlink should have OPT_PREFIX stripped + // (phpredis will auto-add it back) + $connection->shouldReceive('unlink') + ->once() + ->with('cache:test:key1', 'cache:test:key2') + ->andReturn(2); + + $flushByPattern = new FlushByPattern($connection); + + $deletedCount = $flushByPattern->execute('cache:test:*'); + + $this->assertSame(2, $deletedCount); + } + + public function testFlushDeletesInBatches(): void + { + // Generate 2500 keys to test batching (BUFFER_SIZE is 1000) + $batch1Keys = []; + $batch2Keys = []; + $batch3Keys = []; + + for ($i = 0; $i < 1000; ++$i) { + $batch1Keys[] = "cache:test:key{$i}"; + } + for ($i = 1000; $i < 2000; ++$i) { + $batch2Keys[] = "cache:test:key{$i}"; + } + for ($i = 2000; $i < 2500; ++$i) { + $batch3Keys[] = "cache:test:key{$i}"; + } + + $client = new FakeRedisClient( + scanResults: [ + // Return all keys in one scan result to simplify test + ['keys' => array_merge($batch1Keys, $batch2Keys, $batch3Keys), 'iterator' => 0], + ], + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('client')->andReturn($client); + + // Should be called 3 times (1000 + 1000 + 500) + $connection->shouldReceive('unlink') + ->times(3) + ->andReturn(1000, 1000, 500); + + $flushByPattern = new FlushByPattern($connection); + + $deletedCount = $flushByPattern->execute('cache:test:*'); + + $this->assertSame(2500, $deletedCount); + } + + public function testFlushHandlesMultipleScanIterations(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['cache:test:key1', 'cache:test:key2'], 'iterator' => 42], // More to scan + ['keys' => ['cache:test:key3'], 'iterator' => 0], // Done + ], + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('client')->andReturn($client); + // All keys should be collected and deleted together (under buffer size) + $connection->shouldReceive('unlink') + ->once() + ->with('cache:test:key1', 'cache:test:key2', 'cache:test:key3') + ->andReturn(3); + + $flushByPattern = new FlushByPattern($connection); + + $deletedCount = $flushByPattern->execute('cache:test:*'); + + $this->assertSame(3, $deletedCount); + } + + public function testFlushHandlesUnlinkReturningNonInteger(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['cache:test:key1'], 'iterator' => 0], + ], + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('client')->andReturn($client); + // unlink might return false on error + $connection->shouldReceive('unlink') + ->once() + ->andReturn(false); + + $flushByPattern = new FlushByPattern($connection); + + $deletedCount = $flushByPattern->execute('cache:test:*'); + + $this->assertSame(0, $deletedCount); + } + + public function testFlushPassesPatternToSafeScan(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => [], 'iterator' => 0], + ], + ); + + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('client')->andReturn($client); + + $flushByPattern = new FlushByPattern($connection); + + $flushByPattern->execute('cache:users:*'); + + // Verify the pattern was passed to scan + $this->assertSame(1, $client->getScanCallCount()); + $this->assertSame('cache:users:*', $client->getScanCalls()[0]['pattern']); + } +} diff --git a/tests/Redis/Operations/SafeScanTest.php b/tests/Redis/Operations/SafeScanTest.php new file mode 100644 index 000000000..4093b8f89 --- /dev/null +++ b/tests/Redis/Operations/SafeScanTest.php @@ -0,0 +1,213 @@ + ['cache:users:1', 'cache:users:2'], 'iterator' => 0], + ], + ); + + $safeScan = new SafeScan($client, ''); + $keys = iterator_to_array($safeScan->execute('cache:users:*')); + + $this->assertSame(['cache:users:1', 'cache:users:2'], $keys); + + // Verify scan was called with correct pattern + $this->assertSame(1, $client->getScanCallCount()); + $this->assertSame('cache:users:*', $client->getScanCalls()[0]['pattern']); + } + + public function testScanPrependsOptPrefixToPattern(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['myapp:cache:users:1'], 'iterator' => 0], + ], + optPrefix: 'myapp:', + ); + + $safeScan = new SafeScan($client, 'myapp:'); + $keys = iterator_to_array($safeScan->execute('cache:users:*')); + + // Returned keys should have prefix stripped + $this->assertSame(['cache:users:1'], $keys); + + // Verify scan was called with OPT_PREFIX prepended to pattern + $this->assertSame('myapp:cache:users:*', $client->getScanCalls()[0]['pattern']); + } + + public function testScanStripsOptPrefixFromReturnedKeys(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['prefix:cache:key1', 'prefix:cache:key2', 'prefix:cache:key3'], 'iterator' => 0], + ], + optPrefix: 'prefix:', + ); + + $safeScan = new SafeScan($client, 'prefix:'); + $keys = iterator_to_array($safeScan->execute('cache:*')); + + // Keys should have prefix stripped so they work with other phpredis commands + $this->assertSame(['cache:key1', 'cache:key2', 'cache:key3'], $keys); + } + + public function testScanHandlesEmptyResults(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => [], 'iterator' => 0], + ], + ); + + $safeScan = new SafeScan($client, ''); + $keys = iterator_to_array($safeScan->execute('cache:nonexistent:*')); + + $this->assertSame([], $keys); + } + + public function testScanHandlesFalseResult(): void + { + // FakeRedisClient returns false when no more results configured + $client = new FakeRedisClient( + scanResults: [], // No results configured + ); + + $safeScan = new SafeScan($client, ''); + $keys = iterator_to_array($safeScan->execute('cache:*')); + + $this->assertSame([], $keys); + } + + public function testScanIteratesMultipleBatches(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['cache:key1', 'cache:key2'], 'iterator' => 42], // More to scan + ['keys' => ['cache:key3'], 'iterator' => 0], // Done + ], + ); + + $safeScan = new SafeScan($client, ''); + $keys = iterator_to_array($safeScan->execute('cache:*')); + + $this->assertSame(['cache:key1', 'cache:key2', 'cache:key3'], $keys); + $this->assertSame(2, $client->getScanCallCount()); + } + + public function testScanDoesNotDoublePrefixWhenPatternAlreadyHasPrefix(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['myapp:cache:key1'], 'iterator' => 0], + ], + optPrefix: 'myapp:', + ); + + $safeScan = new SafeScan($client, 'myapp:'); + + // Pattern already has prefix - should NOT add it again + $keys = iterator_to_array($safeScan->execute('myapp:cache:*')); + + // Should strip prefix from result + $this->assertSame(['cache:key1'], $keys); + + // Pattern should NOT be double-prefixed + $this->assertSame('myapp:cache:*', $client->getScanCalls()[0]['pattern']); + } + + public function testScanReturnsKeyAsIsWhenItDoesNotHavePrefix(): void + { + $client = new FakeRedisClient( + scanResults: [ + // Edge case: Redis somehow returns key without expected prefix + ['keys' => ['other:key1'], 'iterator' => 0], + ], + optPrefix: 'myapp:', + ); + + $safeScan = new SafeScan($client, 'myapp:'); + $keys = iterator_to_array($safeScan->execute('cache:*')); + + // Key should be returned as-is since it doesn't have the prefix + $this->assertSame(['other:key1'], $keys); + } + + public function testScanUsesCustomCount(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['cache:key1'], 'iterator' => 0], + ], + ); + + $safeScan = new SafeScan($client, ''); + $keys = iterator_to_array($safeScan->execute('cache:*', 500)); + + $this->assertSame(['cache:key1'], $keys); + $this->assertSame(500, $client->getScanCalls()[0]['count']); + } + + public function testScanWorksWithEmptyOptPrefix(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['cache:key1', 'cache:key2'], 'iterator' => 0], + ], + optPrefix: '', // No prefix configured + ); + + $safeScan = new SafeScan($client, ''); + $keys = iterator_to_array($safeScan->execute('cache:*')); + + // No stripping needed when no prefix + $this->assertSame(['cache:key1', 'cache:key2'], $keys); + } + + public function testScanHandlesMixedPrefixedAndUnprefixedKeys(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => ['myapp:cache:key1', 'other:key2', 'myapp:cache:key3'], 'iterator' => 0], + ], + optPrefix: 'myapp:', + ); + + $safeScan = new SafeScan($client, 'myapp:'); + $keys = iterator_to_array($safeScan->execute('cache:*')); + + // Prefixed keys stripped, unprefixed returned as-is + $this->assertSame(['cache:key1', 'other:key2', 'cache:key3'], $keys); + } + + public function testScanDefaultCountIs1000(): void + { + $client = new FakeRedisClient( + scanResults: [ + ['keys' => [], 'iterator' => 0], + ], + ); + + $safeScan = new SafeScan($client, ''); + iterator_to_array($safeScan->execute('cache:*')); + + $this->assertSame(1000, $client->getScanCalls()[0]['count']); + } +} diff --git a/tests/Redis/RedisConnectionTest.php b/tests/Redis/RedisConnectionTest.php index 4fcdda757..176bd2fdb 100644 --- a/tests/Redis/RedisConnectionTest.php +++ b/tests/Redis/RedisConnectionTest.php @@ -13,6 +13,7 @@ use Hypervel\Tests\TestCase; use Mockery; use Psr\Container\ContainerInterface; +use Redis; /** * @internal @@ -491,6 +492,248 @@ public function testZunionstoreSimple(): void $this->assertEquals(5, $result); } + public function testGetTransformsFalseToNull(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('get') + ->with('key') + ->once() + ->andReturn(false); + + $result = $connection->__call('get', ['key']); + + $this->assertNull($result); + } + + public function testSetWithoutOptions(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('set') + ->with('key', 'value', null) + ->once() + ->andReturn(true); + + $result = $connection->__call('set', ['key', 'value']); + + $this->assertTrue($result); + } + + public function testSetnxReturnsZeroOnFailure(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('setNx') + ->with('key', 'value') + ->once() + ->andReturn(false); + + $result = $connection->__call('setnx', ['key', 'value']); + + $this->assertEquals(0, $result); + } + + public function testHmsetWithAlternatingKeyValuePairs(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('hMSet') + ->with('hash', ['field1' => 'value1', 'field2' => 'value2']) + ->once() + ->andReturn(true); + + // Laravel style: key, value, key, value + $result = $connection->__call('hmset', ['hash', 'field1', 'value1', 'field2', 'value2']); + + $this->assertTrue($result); + } + + public function testZaddWithScoreMemberPairs(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zAdd') + ->with('zset', [], 1.0, 'member1', 2.0, 'member2') + ->once() + ->andReturn(2); + + $result = $connection->__call('zadd', ['zset', 1.0, 'member1', 2.0, 'member2']); + + $this->assertEquals(2, $result); + } + + public function testZrangebyscoreWithListLimitPassesThrough(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zRangeByScore') + ->with('zset', '-inf', '+inf', ['limit' => [5, 20]]) + ->once() + ->andReturn(['member1']); + + // Already in list format - passes through + $result = $connection->__call('zrangebyscore', ['zset', '-inf', '+inf', ['limit' => [5, 20]]]); + + $this->assertEquals(['member1'], $result); + } + + public function testZrevrangebyscoreWithLimitOption(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zRevRangeByScore') + ->with('zset', '+inf', '-inf', ['limit' => [0, 5]]) + ->once() + ->andReturn(['member2', 'member1']); + + $result = $connection->__call('zrevrangebyscore', ['zset', '+inf', '-inf', ['limit' => ['offset' => 0, 'count' => 5]]]); + + $this->assertEquals(['member2', 'member1'], $result); + } + + public function testZinterstoreDefaultsAggregate(): void + { + $connection = $this->mockRedisConnection(transform: true); + + $connection->getConnection() + ->shouldReceive('zinterstore') + ->with('output', ['set1', 'set2'], null, 'sum') + ->once() + ->andReturn(2); + + $result = $connection->__call('zinterstore', ['output', ['set1', 'set2']]); + + $this->assertEquals(2, $result); + } + + public function testCallWithoutTransformPassesDirectly(): void + { + $connection = $this->mockRedisConnection(transform: false); + + // Without transform, get() returns false (not null) + $connection->getConnection() + ->shouldReceive('get') + ->with('key') + ->once() + ->andReturn(false); + + $result = $connection->__call('get', ['key']); + + $this->assertFalse($result); + } + + public function testSerializedReturnsTrueWhenSerializerConfigured(): void + { + $connection = $this->mockRedisConnection(); + + $connection->getConnection() + ->shouldReceive('getOption') + ->with(Redis::OPT_SERIALIZER) + ->andReturn(Redis::SERIALIZER_PHP); + + $this->assertTrue($connection->serialized()); + } + + public function testSerializedReturnsFalseWhenNoSerializer(): void + { + $connection = $this->mockRedisConnection(); + + $connection->getConnection() + ->shouldReceive('getOption') + ->with(Redis::OPT_SERIALIZER) + ->andReturn(Redis::SERIALIZER_NONE); + + $this->assertFalse($connection->serialized()); + } + + public function testCompressedReturnsTrueWhenCompressionConfigured(): void + { + if (! defined('Redis::COMPRESSION_LZF')) { + $this->markTestSkipped('Redis::COMPRESSION_LZF is not defined.'); + } + + $connection = $this->mockRedisConnection(); + + $connection->getConnection() + ->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_LZF); + + $this->assertTrue($connection->compressed()); + } + + public function testCompressedReturnsFalseWhenNoCompression(): void + { + $connection = $this->mockRedisConnection(); + + $connection->getConnection() + ->shouldReceive('getOption') + ->with(Redis::OPT_COMPRESSION) + ->andReturn(Redis::COMPRESSION_NONE); + + $this->assertFalse($connection->compressed()); + } + + public function testPackReturnsEmptyArrayForEmptyInput(): void + { + $connection = $this->mockRedisConnection(); + + $result = $connection->pack([]); + + $this->assertSame([], $result); + } + + public function testPackUsesNativePackMethod(): void + { + $connection = $this->mockRedisConnection(); + + $connection->getConnection() + ->shouldReceive('_pack') + ->with('value1') + ->once() + ->andReturn('packed1'); + $connection->getConnection() + ->shouldReceive('_pack') + ->with('value2') + ->once() + ->andReturn('packed2'); + + $result = $connection->pack(['value1', 'value2']); + + $this->assertSame(['packed1', 'packed2'], $result); + } + + public function testPackPreservesArrayKeys(): void + { + $connection = $this->mockRedisConnection(); + + $connection->getConnection() + ->shouldReceive('_pack') + ->with('value1') + ->once() + ->andReturn('packed1'); + $connection->getConnection() + ->shouldReceive('_pack') + ->with('value2') + ->once() + ->andReturn('packed2'); + + $result = $connection->pack(['key1' => 'value1', 'key2' => 'value2']); + + $this->assertSame([ + 'key1' => 'packed1', + 'key2' => 'packed2', + ], $result); + } + protected function mockRedisConnection(?ContainerInterface $container = null, ?PoolInterface $pool = null, array $options = [], bool $transform = false): RedisConnection { $connection = new RedisConnectionStub( diff --git a/tests/Redis/RedisFactoryTest.php b/tests/Redis/RedisFactoryTest.php new file mode 100644 index 000000000..39b1974ad --- /dev/null +++ b/tests/Redis/RedisFactoryTest.php @@ -0,0 +1,99 @@ +createFactoryWithProxies([ + 'default' => m::mock(RedisProxy::class), + 'cache' => m::mock(RedisProxy::class), + ]); + + $proxy = $factory->get('default'); + + $this->assertInstanceOf(RedisProxy::class, $proxy); + } + + public function testGetReturnsDifferentProxiesForDifferentPools(): void + { + $defaultProxy = m::mock(RedisProxy::class); + $cacheProxy = m::mock(RedisProxy::class); + + $factory = $this->createFactoryWithProxies([ + 'default' => $defaultProxy, + 'cache' => $cacheProxy, + ]); + + $this->assertSame($defaultProxy, $factory->get('default')); + $this->assertSame($cacheProxy, $factory->get('cache')); + } + + public function testGetThrowsExceptionForUnconfiguredPool(): void + { + $factory = $this->createFactoryWithProxies([ + 'default' => m::mock(RedisProxy::class), + ]); + + $this->expectException(InvalidRedisProxyException::class); + $this->expectExceptionMessage('Invalid Redis proxy.'); + + $factory->get('nonexistent'); + } + + public function testGetReturnsSameProxyInstanceOnMultipleCalls(): void + { + $proxy = m::mock(RedisProxy::class); + + $factory = $this->createFactoryWithProxies([ + 'default' => $proxy, + ]); + + $first = $factory->get('default'); + $second = $factory->get('default'); + + $this->assertSame($first, $second); + } + + /** + * Create a RedisFactory with pre-configured proxies (bypassing constructor). + * + * @param array $proxies + */ + private function createFactoryWithProxies(array $proxies): RedisFactory + { + // Create factory with empty config (no pools created) + $config = m::mock(ConfigInterface::class); + $config->shouldReceive('get')->with('redis')->andReturn([]); + + $factory = new RedisFactory($config); + + // Inject proxies via reflection + $reflection = new ReflectionClass($factory); + $property = $reflection->getProperty('proxies'); + $property->setAccessible(true); + $property->setValue($factory, $proxies); + + return $factory; + } +} diff --git a/tests/Redis/RedisProxyTest.php b/tests/Redis/RedisProxyTest.php new file mode 100644 index 000000000..c8f617fd7 --- /dev/null +++ b/tests/Redis/RedisProxyTest.php @@ -0,0 +1,91 @@ +mockConnection(); + $cacheConnection->shouldReceive('get')->once()->with('key')->andReturn('cached'); + $cacheConnection->shouldReceive('release')->once(); + + $cachePool = m::mock(RedisPool::class); + $cachePool->shouldReceive('get')->andReturn($cacheConnection); + + $poolFactory = m::mock(PoolFactory::class); + // Expect 'cache' pool to be requested, not 'default' + $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($cachePool); + + $proxy = new RedisProxy($poolFactory, 'cache'); + + $result = $proxy->get('key'); + + $this->assertSame('cached', $result); + } + + public function testProxyContextKeyUsesPoolName(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('pipeline')->once()->andReturn(m::mock(Redis::class)); + // Connection is released via defer() at end of coroutine + $connection->shouldReceive('release')->once(); + + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->andReturn($connection); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('cache')->andReturn($pool); + + $proxy = new RedisProxy($poolFactory, 'cache'); + + $proxy->pipeline(); + + // Context key should use the pool name + $this->assertTrue(Context::has('redis.connection.cache')); + $this->assertFalse(Context::has('redis.connection.default')); + } + + /** + * Create a mock RedisConnection with standard expectations. + */ + private function mockConnection(): m\MockInterface|RedisConnection + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('getConnection')->andReturn($connection); + $connection->shouldReceive('getEventDispatcher')->andReturnNull(); + $connection->shouldReceive('shouldTransform')->andReturnSelf(); + + return $connection; + } +} diff --git a/tests/Redis/RedisTest.php b/tests/Redis/RedisTest.php new file mode 100644 index 000000000..9a3d67050 --- /dev/null +++ b/tests/Redis/RedisTest.php @@ -0,0 +1,186 @@ +mockConnection(); + $connection->shouldReceive('get')->once()->with('foo')->andReturn('bar'); + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $result = $redis->get('foo'); + + $this->assertSame('bar', $result); + } + + public function testConnectionIsStoredInContextForMulti(): void + { + $multiInstance = m::mock(PhpRedis::class); + + $connection = $this->mockConnection(); + $connection->shouldReceive('multi')->once()->andReturn($multiInstance); + // Connection is released via defer() at end of coroutine + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $result = $redis->multi(); + + $this->assertSame($multiInstance, $result); + // Connection should be stored in context + $this->assertTrue(Context::has('redis.connection.default')); + } + + public function testConnectionIsStoredInContextForPipeline(): void + { + $pipelineInstance = m::mock(PhpRedis::class); + + $connection = $this->mockConnection(); + $connection->shouldReceive('pipeline')->once()->andReturn($pipelineInstance); + // Connection is released via defer() at end of coroutine + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $result = $redis->pipeline(); + + $this->assertSame($pipelineInstance, $result); + $this->assertTrue(Context::has('redis.connection.default')); + } + + public function testConnectionIsStoredInContextForSelect(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('select')->once()->with(1)->andReturn(true); + $connection->shouldReceive('setDatabase')->once()->with(1); + // Connection is released via defer() at end of coroutine + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $result = $redis->select(1); + + $this->assertTrue($result); + $this->assertTrue(Context::has('redis.connection.default')); + } + + public function testExistingContextConnectionIsReused(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get')->twice()->andReturn('value1', 'value2'); + // Connection is NOT released during the test (it already existed in context), + // but allow release() call for test cleanup + $connection->shouldReceive('release')->zeroOrMoreTimes(); + + // Pre-set connection in context + Context::set('redis.connection.default', $connection); + + $redis = $this->createRedis($connection); + + // Both calls should use the same connection from context + $result1 = $redis->get('key1'); + $result2 = $redis->get('key2'); + + $this->assertSame('value1', $result1); + $this->assertSame('value2', $result2); + } + + public function testExceptionIsPropagated(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get') + ->once() + ->andThrow(new RuntimeException('Redis error')); + $connection->shouldReceive('release')->once(); + + $redis = $this->createRedis($connection); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Redis error'); + + $redis->get('key'); + } + + public function testNullReturnedOnExceptionWhenContextConnectionExists(): void + { + $connection = $this->mockConnection(); + $connection->shouldReceive('get') + ->once() + ->andThrow(new RuntimeException('Error')); + // Connection is NOT released during the test (it already existed in context), + // but allow release() call for test cleanup + $connection->shouldReceive('release')->zeroOrMoreTimes(); + + // Pre-set connection in context + Context::set('redis.connection.default', $connection); + + $redis = $this->createRedis($connection); + + // When context connection exists and error occurs, null is returned + // (the return in finally supersedes the throw in catch) + $result = $redis->get('key'); + + $this->assertNull($result); + } + + /** + * Create a mock RedisConnection with standard expectations. + */ + private function mockConnection(): m\MockInterface|RedisConnection + { + $connection = m::mock(RedisConnection::class); + $connection->shouldReceive('getConnection')->andReturn($connection); + $connection->shouldReceive('getEventDispatcher')->andReturnNull(); + $connection->shouldReceive('shouldTransform')->andReturnSelf(); + + return $connection; + } + + /** + * Create a Redis instance with the given mock connection. + */ + private function createRedis(m\MockInterface|RedisConnection $connection): Redis + { + $pool = m::mock(RedisPool::class); + $pool->shouldReceive('get')->andReturn($connection); + + $poolFactory = m::mock(PoolFactory::class); + $poolFactory->shouldReceive('getPool')->with('default')->andReturn($pool); + + return new Redis($poolFactory); + } +} diff --git a/tests/Redis/Stub/FakeRedisClient.php b/tests/Redis/Stub/FakeRedisClient.php new file mode 100644 index 000000000..4b89bba83 --- /dev/null +++ b/tests/Redis/Stub/FakeRedisClient.php @@ -0,0 +1,515 @@ + ['key1', 'key2'], 'iterator' => 100], // First scan: continue + * ['keys' => ['key3'], 'iterator' => 0], // Second scan: done + * ] + * ); + * ``` + * + * Usage for HSCAN: + * ```php + * $client = new FakeRedisClient( + * hScanResults: [ + * 'hash:key' => [ + * ['fields' => ['f1' => 'v1', 'f2' => 'v2'], 'iterator' => 100], + * ['fields' => ['f3' => 'v3'], 'iterator' => 0], + * ], + * ] + * ); + * ``` + * + * @internal For testing only - does not connect to Redis + */ +class FakeRedisClient extends Redis +{ + /** + * Configured scan results: array of ['keys' => [...], 'iterator' => int]. + * + * @var array, iterator: int}> + */ + private array $scanResults; + + /** + * Current scan call index. + */ + private int $scanCallIndex = 0; + + /** + * Recorded scan calls for assertions. + * + * @var array + */ + private array $scanCalls = []; + + /** + * Configured hScan results per hash key. + * + * @var array, iterator: int}>> + */ + private array $hScanResults; + + /** + * Current hScan call index per hash key. + * + * @var array + */ + private array $hScanCallIndex = []; + + /** + * Recorded hScan calls for assertions. + * + * @var array + */ + private array $hScanCalls = []; + + /** + * Pipeline mode flag. + */ + private bool $inPipeline = false; + + /** + * Queued pipeline commands. + * + * @var array + */ + private array $pipelineQueue = []; + + /** + * Configured exec() results for pipeline operations. + * + * @var array> + */ + private array $execResults = []; + + /** + * Current exec call index. + */ + private int $execCallIndex = 0; + + /** + * Configured zRange results per key. + * + * @var array> + */ + private array $zRangeResults = []; + + /** + * Configured hLen results per key. + * + * @var array + */ + private array $hLenResults = []; + + /** + * Configured OPT_PREFIX value for getOption(). + */ + private string $optPrefix = ''; + + /** + * Configured zScan results per key. + * + * @var array, iterator: int}>> + */ + private array $zScanResults = []; + + /** + * Current zScan call index per key. + * + * @var array + */ + private array $zScanCallIndex = []; + + /** + * Recorded zScan calls for assertions. + * + * @var array + */ + private array $zScanCalls = []; + + /** + * Recorded zRem calls for assertions. + * + * @var array}> + */ + private array $zRemCalls = []; + + /** + * Configured zCard results per key. + * + * @var array + */ + private array $zCardResults = []; + + /** + * Configured zRemRangeByScore results per key (for sequential execution). + * + * @var array + */ + private array $zRemRangeByScoreResults = []; + + /** + * Create a new fake Redis client. + * + * @param array, iterator: int}> $scanResults Configured scan results + * @param array> $execResults Configured exec() results for pipelines + * @param array, iterator: int}>> $hScanResults Configured hScan results + * @param array> $zRangeResults Configured zRange results + * @param array $hLenResults Configured hLen results + * @param string $optPrefix Configured OPT_PREFIX value + * @param array, iterator: int}>> $zScanResults Configured zScan results + * @param array $zCardResults Configured zCard results per key + * @param array $zRemRangeByScoreResults Configured zRemRangeByScore results per key + */ + public function __construct( + array $scanResults = [], + array $execResults = [], + array $hScanResults = [], + array $zRangeResults = [], + array $hLenResults = [], + string $optPrefix = '', + array $zScanResults = [], + array $zCardResults = [], + array $zRemRangeByScoreResults = [], + ) { + // Note: We intentionally do NOT call parent::__construct() to avoid + // any connection attempts. This fake client never connects to Redis. + $this->scanResults = $scanResults; + $this->execResults = $execResults; + $this->hScanResults = $hScanResults; + $this->zRangeResults = $zRangeResults; + $this->hLenResults = $hLenResults; + $this->optPrefix = $optPrefix; + $this->zScanResults = $zScanResults; + $this->zCardResults = $zCardResults; + $this->zRemRangeByScoreResults = $zRemRangeByScoreResults; + } + + /** + * Simulate Redis SCAN with proper reference parameter handling. + * + * @param null|int|string $iterator Cursor (modified by reference) + * @param null|string $pattern Optional pattern to match + * @param int $count Optional count hint + * @param null|string $type Optional type filter + * @return array|false + */ + public function scan(int|string|null &$iterator, ?string $pattern = null, int $count = 0, ?string $type = null): array|false + { + // Record the call for assertions + $this->scanCalls[] = ['pattern' => $pattern, 'count' => $count]; + + if (! isset($this->scanResults[$this->scanCallIndex])) { + $iterator = 0; + return false; + } + + $result = $this->scanResults[$this->scanCallIndex]; + $iterator = $result['iterator']; + ++$this->scanCallIndex; + + return $result['keys']; + } + + /** + * Get recorded scan calls for test assertions. + * + * @return array + */ + public function getScanCalls(): array + { + return $this->scanCalls; + } + + /** + * Get the number of scan() calls made. + */ + public function getScanCallCount(): int + { + return count($this->scanCalls); + } + + /** + * Simulate Redis HSCAN with proper reference parameter handling. + * + * @param string $key Hash key + * @param null|int|string $iterator Cursor (modified by reference) + * @param null|string $pattern Optional pattern to match + * @param int $count Optional count hint + * @return array|bool|Redis + */ + public function hscan(string $key, int|string|null &$iterator, ?string $pattern = null, int $count = 0): Redis|array|bool + { + // Record the call for assertions + $this->hScanCalls[] = ['key' => $key, 'pattern' => $pattern, 'count' => $count]; + + // Initialize call index for this key if not set + if (! isset($this->hScanCallIndex[$key])) { + $this->hScanCallIndex[$key] = 0; + } + + if (! isset($this->hScanResults[$key][$this->hScanCallIndex[$key]])) { + $iterator = 0; + return false; + } + + $result = $this->hScanResults[$key][$this->hScanCallIndex[$key]]; + $iterator = $result['iterator']; + ++$this->hScanCallIndex[$key]; + + return $result['fields']; + } + + /** + * Get recorded hScan calls for test assertions. + * + * @return array + */ + public function getHScanCalls(): array + { + return $this->hScanCalls; + } + + /** + * Get the number of hScan() calls made. + */ + public function getHScanCallCount(): int + { + return count($this->hScanCalls); + } + + /** + * Simulate getOption() for compression and prefix checks. + */ + public function getOption(int $option): mixed + { + return match ($option) { + Redis::OPT_COMPRESSION => Redis::COMPRESSION_NONE, + Redis::OPT_PREFIX => $this->optPrefix, + default => null, + }; + } + + /** + * Simulate zRange to get sorted set members. + * + * @return array|false|Redis + */ + public function zRange(string $key, string|int $start, string|int $end, array|bool|null $options = null): Redis|array|false + { + return $this->zRangeResults[$key] ?? []; + } + + /** + * Simulate hLen to get hash length. + */ + public function hLen(string $key): Redis|int|false + { + return $this->hLenResults[$key] ?? 0; + } + + /** + * Queue exists in pipeline or execute directly. + * + * @return $this|bool|int + */ + public function exists(mixed $key, mixed ...$other_keys): Redis|int|bool + { + $keys = is_array($key) ? $key : array_merge([$key], $other_keys); + + if ($this->inPipeline) { + $this->pipelineQueue[] = ['method' => 'exists', 'args' => $keys]; + return $this; + } + return 0; + } + + /** + * Queue hDel in pipeline or execute directly. + * + * @return $this|false|int + */ + public function hDel(string $key, string ...$fields): Redis|int|false + { + if ($this->inPipeline) { + $this->pipelineQueue[] = ['method' => 'hDel', 'args' => [$key, ...$fields]]; + return $this; + } + return count($fields); + } + + /** + * Enter pipeline mode. + * + * @return $this + */ + public function pipeline(): Redis + { + $this->inPipeline = true; + $this->pipelineQueue = []; + return $this; + } + + /** + * Execute pipeline and return results. + * + * @return array|false + */ + public function exec(): array|false + { + $this->inPipeline = false; + + if (isset($this->execResults[$this->execCallIndex])) { + $result = $this->execResults[$this->execCallIndex]; + ++$this->execCallIndex; + return $result; + } + + // Return empty array if no more configured results + return []; + } + + /** + * Queue zRemRangeByScore in pipeline or execute directly. + * + * @return $this|false|int + */ + public function zRemRangeByScore(string $key, string $min, string $max): Redis|int|false + { + if ($this->inPipeline) { + $this->pipelineQueue[] = ['method' => 'zRemRangeByScore', 'args' => [$key, $min, $max]]; + return $this; + } + return $this->zRemRangeByScoreResults[$key] ?? 0; + } + + /** + * Queue zCard in pipeline or execute directly. + * + * @return $this|false|int + */ + public function zCard(string $key): Redis|int|false + { + if ($this->inPipeline) { + $this->pipelineQueue[] = ['method' => 'zCard', 'args' => [$key]]; + return $this; + } + return $this->zCardResults[$key] ?? 0; + } + + /** + * Queue del in pipeline or execute directly. + * + * @return $this|false|int + */ + public function del(array|string $key, string ...$other_keys): Redis|int|false + { + $keys = is_array($key) ? $key : array_merge([$key], $other_keys); + + if ($this->inPipeline) { + $this->pipelineQueue[] = ['method' => 'del', 'args' => $keys]; + return $this; + } + return count($keys); + } + + /** + * Simulate Redis ZSCAN with proper reference parameter handling. + * + * @param string $key Sorted set key + * @param null|int|string $iterator Cursor (modified by reference) + * @param null|string $pattern Optional pattern to match + * @param int $count Optional count hint + * @return array|false|Redis + */ + public function zscan(string $key, int|string|null &$iterator, ?string $pattern = null, int $count = 0): Redis|array|false + { + // Record the call for assertions + $this->zScanCalls[] = ['key' => $key, 'pattern' => $pattern, 'count' => $count]; + + // Initialize call index for this key if not set + if (! isset($this->zScanCallIndex[$key])) { + $this->zScanCallIndex[$key] = 0; + } + + if (! isset($this->zScanResults[$key][$this->zScanCallIndex[$key]])) { + $iterator = 0; + return false; + } + + $result = $this->zScanResults[$key][$this->zScanCallIndex[$key]]; + $iterator = $result['iterator']; + ++$this->zScanCallIndex[$key]; + + return $result['members']; + } + + /** + * Get recorded zScan calls for test assertions. + * + * @return array + */ + public function getZScanCalls(): array + { + return $this->zScanCalls; + } + + /** + * Simulate Redis ZREM. + * + * @return false|int Number of members removed + */ + public function zRem(mixed $key, mixed $member, mixed ...$other_members): Redis|int|false + { + $allMembers = array_merge([$member], $other_members); + $this->zRemCalls[] = ['key' => $key, 'members' => $allMembers]; + return count($allMembers); + } + + /** + * Get recorded zRem calls for test assertions. + * + * @return array}> + */ + public function getZRemCalls(): array + { + return $this->zRemCalls; + } + + /** + * Reset the client state for reuse in tests. + * Note: This is a test helper, not the Redis::reset() connection reset. + */ + public function resetFakeState(): void + { + $this->scanCallIndex = 0; + $this->scanCalls = []; + $this->hScanCallIndex = []; + $this->hScanCalls = []; + $this->zScanCallIndex = []; + $this->zScanCalls = []; + $this->zRemCalls = []; + $this->execCallIndex = 0; + $this->inPipeline = false; + $this->pipelineQueue = []; + } +} diff --git a/tests/Redis/Stubs/RedisConnectionStub.php b/tests/Redis/Stubs/RedisConnectionStub.php index c3143d1dd..34a69494c 100644 --- a/tests/Redis/Stubs/RedisConnectionStub.php +++ b/tests/Redis/Stubs/RedisConnectionStub.php @@ -25,6 +25,10 @@ public function check(): bool public function getActiveConnection(): Redis { + if ($this->connection !== null) { + return $this->connection; + } + $connection = $this->redisConnection ?? Mockery::mock(Redis::class); diff --git a/tests/Support/RedisIntegrationTestCase.php b/tests/Support/RedisIntegrationTestCase.php new file mode 100644 index 000000000..e9100d09d --- /dev/null +++ b/tests/Support/RedisIntegrationTestCase.php @@ -0,0 +1,249 @@ +markTestSkipped( + 'Redis integration tests are disabled. Set RUN_REDIS_INTEGRATION_TESTS=true in .env to enable.' + ); + } + + $this->computeTestPrefix(); + + parent::setUp(); + + $this->configureRedis(); + $this->configurePackage(); + $this->flushTestKeys(); + } + + protected function tearDown(): void + { + if (env('RUN_REDIS_INTEGRATION_TESTS', false)) { + $this->flushTestKeys(); + } + + parent::tearDown(); + } + + /** + * Compute parallel-safe prefix based on TEST_TOKEN from paratest. + * + * Each worker gets a unique prefix (e.g., int_test_1:, int_test_2:). + * This provides isolation without needing separate databases. + */ + protected function computeTestPrefix(): void + { + $testToken = env('TEST_TOKEN', ''); + + if ($testToken !== '') { + $this->testPrefix = "{$this->basePrefix}_{$testToken}:"; + } else { + $this->testPrefix = "{$this->basePrefix}:"; + } + } + + /** + * Configure Redis connection settings from environment variables. + */ + protected function configureRedis(): void + { + $config = $this->app->get(ConfigInterface::class); + + $connectionConfig = [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'auth' => env('REDIS_AUTH', null) ?: null, + 'port' => (int) env('REDIS_PORT', 6379), + 'db' => (int) env('REDIS_DB', $this->redisDatabase), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + 'options' => [ + 'prefix' => $this->testPrefix, + ], + ]; + + // Set both locations - database.redis.* (source) and redis.* (runtime) + // FoundationServiceProvider copies database.redis.* to redis.* at boot, + // but we run AFTER boot, so we must set redis.* directly + $config->set('database.redis.default', $connectionConfig); + $config->set('redis.default', $connectionConfig); + } + + /** + * Configure package-specific settings. + * + * Override this method in subclasses to add package-specific configuration + * (e.g., cache.default, cache.prefix for cache tests). + */ + protected function configurePackage(): void + { + // Override in subclasses + } + + /** + * Flush all keys matching the test prefix. + * + * Uses flushByPattern('*') which, combined with OPT_PREFIX, only deletes + * keys belonging to this test. Safer than flushdb() for parallel tests. + */ + protected function flushTestKeys(): void + { + try { + Redis::flushByPattern('*'); + } catch (Throwable) { + // Ignore errors during cleanup + } + } + + // ========================================================================= + // CUSTOM CONNECTION HELPERS (for OPT_PREFIX testing) + // ========================================================================= + + /** + * Track custom connections created during tests for cleanup. + * + * @var array + */ + private array $customConnections = []; + + /** + * Create a Redis connection with a specific OPT_PREFIX. + * + * This allows testing different prefix configurations: + * - Empty string for no OPT_PREFIX + * - Custom string for specific OPT_PREFIX + * + * The connection is registered in config and can be used to create stores. + * + * @param string $optPrefix The OPT_PREFIX to set (empty string for none) + * @return string The connection name to use with RedisStore + */ + protected function createConnectionWithOptPrefix(string $optPrefix): string + { + $connectionName = 'test_opt_' . ($optPrefix === '' ? 'none' : md5($optPrefix)); + + // Don't recreate if already exists + if (in_array($connectionName, $this->customConnections, true)) { + return $connectionName; + } + + $config = $this->app->get(ConfigInterface::class); + + // Build connection config with correct test database + // Note: We can't rely on redis.default because FoundationServiceProvider + // copies database.redis.* to redis.* at boot (before test's setUp runs) + $connectionConfig = [ + 'host' => env('REDIS_HOST', '127.0.0.1'), + 'auth' => env('REDIS_AUTH', null) ?: null, + 'port' => (int) env('REDIS_PORT', 6379), + 'db' => (int) env('REDIS_DB', $this->redisDatabase), + 'pool' => [ + 'min_connections' => 1, + 'max_connections' => 10, + 'connect_timeout' => 10.0, + 'wait_timeout' => 3.0, + 'heartbeat' => -1, + 'max_idle_time' => 60.0, + ], + 'options' => [ + 'prefix' => $optPrefix, + ], + ]; + + // Register the new connection directly to redis.* (runtime config location) + $config->set("redis.{$connectionName}", $connectionConfig); + + $this->customConnections[] = $connectionName; + + return $connectionName; + } + + /** + * Get a raw phpredis client without any OPT_PREFIX. + * + * Useful for verifying actual key names in Redis. + */ + protected function rawRedisClientWithoutPrefix(): \Redis + { + $client = new \Redis(); + $client->connect( + env('REDIS_HOST', '127.0.0.1'), + (int) env('REDIS_PORT', 6379) + ); + + $auth = env('REDIS_AUTH'); + if ($auth) { + $client->auth($auth); + } + + $client->select((int) env('REDIS_DB', $this->redisDatabase)); + + return $client; + } + + /** + * Clean up keys matching a pattern using raw client. + */ + protected function cleanupKeysWithPattern(string $pattern): void + { + $client = $this->rawRedisClientWithoutPrefix(); + $keys = $client->keys($pattern); + if (! empty($keys)) { + $client->del(...$keys); + } + $client->close(); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 000000000..b041b853c --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,22 @@ +load(); +}