From 8670336a95fbb2028aa0e8430d78b2b5c9ade240 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sat, 11 Apr 2026 21:47:42 +0600 Subject: [PATCH] init --- .editorconfig | 15 + .gitattributes | 21 + .github/CODEOWNERS | 1 + .github/dependabot.yml | 6 + .github/scripts/composer-audit-guard.php | 85 ++ .github/scripts/phpstan-sarif.php | 178 +++ .github/scripts/syntax.php | 109 ++ .github/workflows/build.yml | 191 +++ .gitignore | 18 + .readthedocs.yaml | 18 + README.md | 62 +- benchmarks/CacheFileBench.php | 84 ++ benchmarks/MemoizeBench.php | 42 + benchmarks/SerializerBench.php | 53 + captainhook.json | 51 + composer.json | 132 ++ pest.xml | 22 + phpbench.json | 26 + phpcs.xml.dist | 52 + phpstan.neon.dist | 14 + phpunit.xml | 22 + pint.json | 73 + psalm.xml | 41 + rector.php | 14 + src/Cache/Adapter/AbstractCacheAdapter.php | 82 ++ src/Cache/Adapter/ApcuCacheAdapter.php | 173 +++ src/Cache/Adapter/ArrayCacheAdapter.php | 121 ++ src/Cache/Adapter/CachePayloadCodec.php | 170 +++ src/Cache/Adapter/ChainCacheAdapter.php | 129 ++ src/Cache/Adapter/DynamoDbCacheAdapter.php | 246 ++++ src/Cache/Adapter/FileCacheAdapter.php | 197 +++ .../Adapter/InternalCachePoolInterface.php | 19 + src/Cache/Adapter/MemCacheAdapter.php | 275 ++++ src/Cache/Adapter/MongoDbCacheAdapter.php | 194 +++ src/Cache/Adapter/NullCacheAdapter.php | 62 + src/Cache/Adapter/PhpFilesCacheAdapter.php | 194 +++ src/Cache/Adapter/PostgresCacheAdapter.php | 177 +++ src/Cache/Adapter/RedisCacheAdapter.php | 200 +++ .../Adapter/RedisClusterCacheAdapter.php | 173 +++ src/Cache/Adapter/S3CacheAdapter.php | 256 ++++ .../Adapter/SharedMemoryCacheAdapter.php | 175 +++ src/Cache/Adapter/SqliteCacheAdapter.php | 235 ++++ src/Cache/Adapter/WeakMapCacheAdapter.php | 201 +++ src/Cache/Cache.php | 1219 +++++++++++++++++ src/Cache/CacheInterface.php | 65 + src/Cache/Item/AbstractCacheItem.php | 123 ++ src/Cache/Item/ApcuCacheItem.php | 7 + src/Cache/Item/FileCacheItem.php | 15 + src/Cache/Item/GenericCacheItem.php | 7 + src/Cache/Item/MemCacheItem.php | 7 + src/Cache/Item/RedisCacheItem.php | 7 + src/Cache/Item/SqliteCacheItem.php | 7 + src/Cache/Lock/FileLockProvider.php | 80 ++ src/Cache/Lock/LockHandle.php | 14 + src/Cache/Lock/LockProviderInterface.php | 12 + src/Cache/Lock/MemcachedLockProvider.php | 61 + src/Cache/Lock/RedisLockProvider.php | 66 + .../CacheMetricsCollectorInterface.php | 14 + .../Metrics/InMemoryCacheMetricsCollector.php | 23 + .../CacheInvalidArgumentException.php | 11 + src/Memoize/MemoizeTrait.php | 30 + src/Memoize/Memoizer.php | 147 ++ src/Memoize/OnceMemoizer.php | 133 ++ src/Serializer/ValueSerializer.php | 282 ++++ src/functions.php | 60 + tests/Cache/ApcuCachePoolTest.php | 174 +++ tests/Cache/ArrayCachePoolTest.php | 41 + tests/Cache/CacheFeaturesTest.php | 205 +++ tests/Cache/ChainCachePoolTest.php | 27 + tests/Cache/DynamoDbCachePoolTest.php | 103 ++ tests/Cache/FileCachePoolTest.php | 236 ++++ tests/Cache/MemCachePoolTest.php | 180 +++ tests/Cache/MongoDbCachePoolTest.php | 79 ++ tests/Cache/NullCachePoolTest.php | 30 + tests/Cache/PhpFilesCachePoolTest.php | 42 + tests/Cache/PostgresCachePoolTest.php | 54 + tests/Cache/RedisCachePoolTest.php | 177 +++ tests/Cache/RedisClusterCachePoolTest.php | 120 ++ tests/Cache/S3CachePoolTest.php | 79 ++ tests/Cache/SharedMemoryCachePoolTest.php | 19 + tests/Cache/SqliteCachePoolTest.php | 163 +++ tests/Cache/WeakMapCachePoolTest.php | 25 + tests/Memoize/MemoizeTest.php | 87 ++ tests/Memoize/OnceMemoizerTest.php | 107 ++ tests/Serializer/ValueSerializerTest.php | 74 + 85 files changed, 9020 insertions(+), 1 deletion(-) create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .github/CODEOWNERS create mode 100644 .github/dependabot.yml create mode 100644 .github/scripts/composer-audit-guard.php create mode 100644 .github/scripts/phpstan-sarif.php create mode 100644 .github/scripts/syntax.php create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 .readthedocs.yaml create mode 100644 benchmarks/CacheFileBench.php create mode 100644 benchmarks/MemoizeBench.php create mode 100644 benchmarks/SerializerBench.php create mode 100644 captainhook.json create mode 100644 composer.json create mode 100644 pest.xml create mode 100644 phpbench.json create mode 100644 phpcs.xml.dist create mode 100644 phpstan.neon.dist create mode 100644 phpunit.xml create mode 100644 pint.json create mode 100644 psalm.xml create mode 100644 rector.php create mode 100644 src/Cache/Adapter/AbstractCacheAdapter.php create mode 100644 src/Cache/Adapter/ApcuCacheAdapter.php create mode 100644 src/Cache/Adapter/ArrayCacheAdapter.php create mode 100644 src/Cache/Adapter/CachePayloadCodec.php create mode 100644 src/Cache/Adapter/ChainCacheAdapter.php create mode 100644 src/Cache/Adapter/DynamoDbCacheAdapter.php create mode 100644 src/Cache/Adapter/FileCacheAdapter.php create mode 100644 src/Cache/Adapter/InternalCachePoolInterface.php create mode 100644 src/Cache/Adapter/MemCacheAdapter.php create mode 100644 src/Cache/Adapter/MongoDbCacheAdapter.php create mode 100644 src/Cache/Adapter/NullCacheAdapter.php create mode 100644 src/Cache/Adapter/PhpFilesCacheAdapter.php create mode 100644 src/Cache/Adapter/PostgresCacheAdapter.php create mode 100644 src/Cache/Adapter/RedisCacheAdapter.php create mode 100644 src/Cache/Adapter/RedisClusterCacheAdapter.php create mode 100644 src/Cache/Adapter/S3CacheAdapter.php create mode 100644 src/Cache/Adapter/SharedMemoryCacheAdapter.php create mode 100644 src/Cache/Adapter/SqliteCacheAdapter.php create mode 100644 src/Cache/Adapter/WeakMapCacheAdapter.php create mode 100644 src/Cache/Cache.php create mode 100644 src/Cache/CacheInterface.php create mode 100644 src/Cache/Item/AbstractCacheItem.php create mode 100644 src/Cache/Item/ApcuCacheItem.php create mode 100644 src/Cache/Item/FileCacheItem.php create mode 100644 src/Cache/Item/GenericCacheItem.php create mode 100644 src/Cache/Item/MemCacheItem.php create mode 100644 src/Cache/Item/RedisCacheItem.php create mode 100644 src/Cache/Item/SqliteCacheItem.php create mode 100644 src/Cache/Lock/FileLockProvider.php create mode 100644 src/Cache/Lock/LockHandle.php create mode 100644 src/Cache/Lock/LockProviderInterface.php create mode 100644 src/Cache/Lock/MemcachedLockProvider.php create mode 100644 src/Cache/Lock/RedisLockProvider.php create mode 100644 src/Cache/Metrics/CacheMetricsCollectorInterface.php create mode 100644 src/Cache/Metrics/InMemoryCacheMetricsCollector.php create mode 100644 src/Exceptions/CacheInvalidArgumentException.php create mode 100644 src/Memoize/MemoizeTrait.php create mode 100644 src/Memoize/Memoizer.php create mode 100644 src/Memoize/OnceMemoizer.php create mode 100644 src/Serializer/ValueSerializer.php create mode 100644 src/functions.php create mode 100644 tests/Cache/ApcuCachePoolTest.php create mode 100644 tests/Cache/ArrayCachePoolTest.php create mode 100644 tests/Cache/CacheFeaturesTest.php create mode 100644 tests/Cache/ChainCachePoolTest.php create mode 100644 tests/Cache/DynamoDbCachePoolTest.php create mode 100644 tests/Cache/FileCachePoolTest.php create mode 100644 tests/Cache/MemCachePoolTest.php create mode 100644 tests/Cache/MongoDbCachePoolTest.php create mode 100644 tests/Cache/NullCachePoolTest.php create mode 100644 tests/Cache/PhpFilesCachePoolTest.php create mode 100644 tests/Cache/PostgresCachePoolTest.php create mode 100644 tests/Cache/RedisCachePoolTest.php create mode 100644 tests/Cache/RedisClusterCachePoolTest.php create mode 100644 tests/Cache/S3CachePoolTest.php create mode 100644 tests/Cache/SharedMemoryCachePoolTest.php create mode 100644 tests/Cache/SqliteCachePoolTest.php create mode 100644 tests/Cache/WeakMapCachePoolTest.php create mode 100644 tests/Memoize/MemoizeTest.php create mode 100644 tests/Memoize/OnceMemoizerTest.php create mode 100644 tests/Serializer/ValueSerializerTest.php diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6537ca4 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,15 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true + +[*.md] +trim_trailing_whitespace = false + +[*.{yml,yaml}] +indent_size = 2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2755315 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,21 @@ +.github export-ignore +benchmarks export-ignore +docs export-ignore +examples export-ignore +tests export-ignore + +.editorconfig export-ignore +.gitattributes export-ignore +.gitignore export-ignore +.readthedocs.yaml export-ignore +captainhook.json export-ignore +pest.xml export-ignore +phpbench.json export-ignore +phpcs.xml.dist export-ignore +phpstan.neon.dist export-ignore +phpunit.xml export-ignore +pint.json export-ignore +psalm.xml export-ignore +rector.php export-ignore + +* text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..931aef9 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1 @@ +* @abmmhasan diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..e9d271d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,6 @@ +version: 2 +updates: + - package-ecosystem: "composer" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php new file mode 100644 index 0000000..a1b1cdb --- /dev/null +++ b/.github/scripts/composer-audit-guard.php @@ -0,0 +1,85 @@ + ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], +]; + +$process = proc_open($command, $descriptorSpec, $pipes); + +if (! \is_resource($process)) { + fwrite(STDERR, "Failed to start composer audit process.\n"); + exit(1); +} + +fclose($pipes[0]); +$stdout = stream_get_contents($pipes[1]) ?: ''; +$stderr = stream_get_contents($pipes[2]) ?: ''; +fclose($pipes[1]); +fclose($pipes[2]); + +$exitCode = proc_close($process); + +/** @var array|null $decoded */ +$decoded = json_decode($stdout, true); + +if (! \is_array($decoded)) { + fwrite(STDERR, "Unable to parse composer audit JSON output.\n"); + if (trim($stdout) !== '') { + fwrite(STDERR, $stdout . "\n"); + } + if (trim($stderr) !== '') { + fwrite(STDERR, $stderr . "\n"); + } + + exit($exitCode !== 0 ? $exitCode : 1); +} + +$advisories = $decoded['advisories'] ?? []; +$abandoned = $decoded['abandoned'] ?? []; + +$advisoryCount = 0; + +if (\is_array($advisories)) { + foreach ($advisories as $entries) { + if (\is_array($entries)) { + $advisoryCount += \count($entries); + } + } +} + +$abandonedPackages = []; + +if (\is_array($abandoned)) { + foreach ($abandoned as $package => $replacement) { + if (\is_string($package) && $package !== '') { + $abandonedPackages[$package] = $replacement; + } + } +} + +echo sprintf( + "Composer audit summary: %d advisories, %d abandoned packages.\n", + $advisoryCount, + \count($abandonedPackages), +); + +if ($abandonedPackages !== []) { + fwrite(STDERR, "Warning: abandoned packages detected (non-blocking):\n"); + foreach ($abandonedPackages as $package => $replacement) { + $target = \is_string($replacement) && $replacement !== '' ? $replacement : 'none'; + fwrite(STDERR, sprintf(" - %s (replacement: %s)\n", $package, $target)); + } +} + +if ($advisoryCount > 0) { + fwrite(STDERR, "Security vulnerabilities detected by composer audit.\n"); + exit(1); +} + +exit(0); diff --git a/.github/scripts/phpstan-sarif.php b/.github/scripts/phpstan-sarif.php new file mode 100644 index 0000000..2b01b26 --- /dev/null +++ b/.github/scripts/phpstan-sarif.php @@ -0,0 +1,178 @@ + [sarif-output] + */ + +$argv = $_SERVER['argv'] ?? []; +$input = $argv[1] ?? ''; +$output = $argv[2] ?? 'phpstan-results.sarif'; + +if (! is_string($input) || $input === '') { + fwrite(STDERR, "Error: missing input file.\n"); + fwrite(STDERR, "Usage: php .github/scripts/phpstan-sarif.php [sarif-output]\n"); + exit(2); +} + +if (! is_file($input) || ! is_readable($input)) { + fwrite(STDERR, "Error: input file not found or unreadable: {$input}\n"); + exit(2); +} + +$raw = file_get_contents($input); +if ($raw === false) { + fwrite(STDERR, "Error: failed to read input file: {$input}\n"); + exit(2); +} + +$decoded = json_decode($raw, true); +if (! is_array($decoded)) { + fwrite(STDERR, "Error: input is not valid JSON.\n"); + exit(2); +} + +/** + * @return non-empty-string + */ +function normalizeUri(string $path): string +{ + $normalized = str_replace('\\', '/', $path); + $cwd = getcwd(); + + if (is_string($cwd) && $cwd !== '') { + $cwd = rtrim(str_replace('\\', '/', $cwd), '/'); + + if (preg_match('/^[A-Za-z]:\//', $normalized) === 1) { + if (stripos($normalized, $cwd . '/') === 0) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } elseif (str_starts_with($normalized, '/')) { + if (str_starts_with($normalized, $cwd . '/')) { + $normalized = substr($normalized, strlen($cwd) + 1); + } + } + } + + $normalized = ltrim($normalized, './'); + + return $normalized === '' ? 'unknown.php' : $normalized; +} + +$results = []; +$rules = []; + +$globalErrors = $decoded['errors'] ?? []; +if (is_array($globalErrors)) { + foreach ($globalErrors as $error) { + if (! is_string($error) || $error === '') { + continue; + } + + $ruleId = 'phpstan.internal'; + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $error, + ], + ]; + } +} + +$files = $decoded['files'] ?? []; +if (is_array($files)) { + foreach ($files as $filePath => $fileData) { + if (! is_string($filePath) || ! is_array($fileData)) { + continue; + } + + $messages = $fileData['messages'] ?? []; + if (! is_array($messages)) { + continue; + } + + foreach ($messages as $messageData) { + if (! is_array($messageData)) { + continue; + } + + $messageText = (string) ($messageData['message'] ?? 'PHPStan issue'); + $line = (int) ($messageData['line'] ?? 1); + $identifier = (string) ($messageData['identifier'] ?? ''); + $ruleId = $identifier !== '' ? $identifier : 'phpstan.issue'; + + if ($line < 1) { + $line = 1; + } + + $rules[$ruleId] = true; + $results[] = [ + 'ruleId' => $ruleId, + 'level' => 'error', + 'message' => [ + 'text' => $messageText, + ], + 'locations' => [[ + 'physicalLocation' => [ + 'artifactLocation' => [ + 'uri' => normalizeUri($filePath), + ], + 'region' => [ + 'startLine' => $line, + ], + ], + ]], + ]; + } + } +} + +$ruleDescriptors = []; +$ruleIds = array_keys($rules); +sort($ruleIds); + +foreach ($ruleIds as $ruleId) { + $ruleDescriptors[] = [ + 'id' => $ruleId, + 'name' => $ruleId, + 'shortDescription' => [ + 'text' => $ruleId, + ], + ]; +} + +$sarif = [ + '$schema' => 'https://json.schemastore.org/sarif-2.1.0.json', + 'version' => '2.1.0', + 'runs' => [[ + 'tool' => [ + 'driver' => [ + 'name' => 'PHPStan', + 'informationUri' => 'https://phpstan.org/', + 'rules' => $ruleDescriptors, + ], + ], + 'results' => $results, + ]], +]; + +$encoded = json_encode($sarif, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +if (! is_string($encoded)) { + fwrite(STDERR, "Error: failed to encode SARIF JSON.\n"); + exit(2); +} + +$written = file_put_contents($output, $encoded . PHP_EOL); +if ($written === false) { + fwrite(STDERR, "Error: failed to write output file: {$output}\n"); + exit(2); +} + +fwrite(STDOUT, sprintf("SARIF generated: %s (%d findings)\n", $output, count($results))); +exit(0); diff --git a/.github/scripts/syntax.php b/.github/scripts/syntax.php new file mode 100644 index 0000000..043bf53 --- /dev/null +++ b/.github/scripts/syntax.php @@ -0,0 +1,109 @@ +isFile()) { + continue; + } + + $filename = $entry->getFilename(); + if (! str_ends_with($filename, '.php')) { + continue; + } + + $files[] = $entry->getPathname(); + } +} + +$files = array_values(array_unique($files)); +sort($files); + +if ($files === []) { + fwrite(STDOUT, "No PHP files found.\n"); + exit(0); +} + +$failed = []; + +foreach ($files as $file) { + $command = [PHP_BINARY, '-d', 'display_errors=1', '-l', $file]; + $descriptorSpec = [ + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ]; + + $process = proc_open($command, $descriptorSpec, $pipes); + + if (! is_resource($process)) { + $failed[] = [$file, 'Could not start PHP lint process']; + continue; + } + + $stdout = stream_get_contents($pipes[1]); + fclose($pipes[1]); + + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[2]); + + $exitCode = proc_close($process); + + if ($exitCode !== 0) { + $output = trim((string) $stdout . "\n" . (string) $stderr); + $failed[] = [$file, $output !== '' ? $output : 'Unknown lint failure']; + } +} + +if ($failed === []) { + fwrite(STDOUT, sprintf("Syntax OK: %d PHP files checked.\n", count($files))); + exit(0); +} + +fwrite(STDERR, sprintf("Syntax errors in %d file(s):\n", count($failed))); + +foreach ($failed as [$file, $error]) { + fwrite(STDERR, "- {$file}\n{$error}\n"); +} + +exit(1); diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..2bc2e77 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,191 @@ +name: "Security & Standards" + +on: + schedule: + - cron: '0 0 * * 0' + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + prepare: + name: Prepare CI matrix + runs-on: ubuntu-latest + outputs: + php_versions: ${{ steps.matrix.outputs.php_versions }} + dependency_versions: ${{ steps.matrix.outputs.dependency_versions }} + steps: + - name: Define shared matrix values + id: matrix + run: | + echo 'php_versions=["8.4","8.5"]' >> "$GITHUB_OUTPUT" + echo 'dependency_versions=["prefer-lowest","prefer-stable"]' >> "$GITHUB_OUTPUT" + + run: + needs: prepare + runs-on: ${{ matrix.operating-system }} + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + memcached: + image: memcached:1.6-alpine + ports: + - 11211:11211 + postgres: + image: postgres:16-alpine + ports: + - 5432:5432 + env: + POSTGRES_DB: cachelayer + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + options: >- + --health-cmd "pg_isready -U postgres -d cachelayer" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + strategy: + matrix: + operating-system: [ ubuntu-latest ] + php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} + dependency-version: ${{ fromJson(needs.prepare.outputs.dependency_versions) }} + + name: Code Analysis - PHP ${{ matrix.php-versions }} - ${{ matrix.dependency-version }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + extensions: mbstring, redis, memcached, apcu, pdo_pgsql, pdo_sqlite, sqlite3, sysvshm + ini-values: apc.enable_cli=1, apcu.enable_cli=1 + coverage: xdebug + + - name: Check PHP Version + run: php -v + + - name: Verify cache-driver extensions + run: | + php -r "foreach (['apcu','redis','memcached','pdo_pgsql','pdo_sqlite','sqlite3','sysvshm'] as \$ext) { if (!extension_loaded(\$ext)) { fwrite(STDERR, \"Missing extension: {\$ext}\" . PHP_EOL); exit(1); } }" + php -r "if (!apcu_enabled()) { fwrite(STDERR, 'APCu is not enabled for CLI' . PHP_EOL); exit(1); } if (!function_exists('shm_attach')) { fwrite(STDERR, 'shm_attach() is unavailable' . PHP_EOL); exit(1); }" + + - name: Wait for cache services + env: + CACHELAYER_PG_DSN: pgsql:host=127.0.0.1;port=5432;dbname=cachelayer + CACHELAYER_PG_USER: postgres + CACHELAYER_PG_PASS: postgres + run: | + for i in {1..30}; do + php -r '$r = new Redis(); try { if ($r->connect("127.0.0.1", 6379, 0.5)) { $pong = $r->ping(); if ($pong === true || stripos((string) $pong, "pong") !== false) { exit(0); } } } catch (Throwable) {} exit(1);' && break + sleep 1 + if [ "$i" -eq 30 ]; then echo "Redis service not ready"; exit 1; fi + done + + for i in {1..30}; do + php -r '$m = new Memcached(); $m->addServer("127.0.0.1", 11211); $m->set("ci_probe", "ok", 5); exit($m->getResultCode() === Memcached::RES_SUCCESS ? 0 : 1);' && break + sleep 1 + if [ "$i" -eq 30 ]; then echo "Memcached service not ready"; exit 1; fi + done + + for i in {1..30}; do + php -r '$dsn = getenv("CACHELAYER_PG_DSN"); $user = getenv("CACHELAYER_PG_USER"); $pass = getenv("CACHELAYER_PG_PASS"); try { $pdo = new PDO($dsn, $user, $pass); $pdo->query("SELECT 1"); exit(0); } catch (Throwable) { exit(1); }' && break + sleep 1 + if [ "$i" -eq 30 ]; then echo "PostgreSQL service not ready"; exit 1; fi + done + + - name: Validate Composer + run: composer validate --strict + + - name: Resolve dependencies (${{ matrix.dependency-version }}) + run: composer update --no-interaction --prefer-dist --no-progress --${{ matrix.dependency-version }} + + - name: Test + env: + CACHELAYER_PG_DSN: pgsql:host=127.0.0.1;port=5432;dbname=cachelayer + CACHELAYER_PG_USER: postgres + CACHELAYER_PG_PASS: postgres + run: | + find src tests -name '*.php' -print0 | xargs -0 -n1 php -l > /dev/null + composer test:code + composer test:lint + composer test:sniff + composer test:refactor + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer test:static + fi + if [ "${{ matrix.dependency-version }}" != "prefer-lowest" ]; then + composer test:security + fi + + analyze: + needs: prepare + name: Security Analysis - PHP ${{ matrix.php-versions }} + runs-on: ubuntu-latest + strategy: + matrix: + php-versions: ${{ fromJson(needs.prepare.outputs.php_versions) }} + permissions: + security-events: write + actions: read + contents: read + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-versions }} + tools: composer:v2 + extensions: mbstring, redis, memcached, apcu, pdo_pgsql, pdo_sqlite, sqlite3, sysvshm + ini-values: apc.enable_cli=1, apcu.enable_cli=1 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist --no-progress + + - name: Composer Audit (Release Guard) + run: composer audit --abandoned=ignore + + - name: Quality Gate (PHPStan) + run: composer test:static + + - name: Security Gate (Psalm) + run: composer test:security + + - name: Run PHPStan (Code Scanning) + run: | + php ./vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress --error-format=json > phpstan-results.json || true + php .github/scripts/phpstan-sarif.php phpstan-results.json phpstan-results.sarif + continue-on-error: true + + - name: Upload PHPStan Results + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: phpstan-results.sarif + category: "phpstan-${{ matrix.php-versions }}" + if: always() && hashFiles('phpstan-results.sarif') != '' + + # Run Psalm (Deep Taint Analysis) + - name: Run Psalm Security Scan + run: | + php ./vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --report=psalm-results.sarif || true + continue-on-error: true + + - name: Upload Psalm Results + uses: github/codeql-action/upload-sarif@v4 + with: + sarif_file: psalm-results.sarif + category: "psalm-${{ matrix.php-versions }}" + if: always() && hashFiles('psalm-results.sarif') != '' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..961285c --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +.idea +.psalm-cache +.phpunit.cache +.vscode +.windsurf +*~ +*.patch +*.txt +!docs/requirements.txt +AI_CONTEXT.md +composer.lock +example +example.php +git-story_media +patch.php +test.php +var +vendor diff --git a/.readthedocs.yaml b/.readthedocs.yaml new file mode 100644 index 0000000..8111fe2 --- /dev/null +++ b/.readthedocs.yaml @@ -0,0 +1,18 @@ +version: 2 + +build: + os: ubuntu-24.04 + tools: + python: "3.13" + +python: + install: + - requirements: docs/requirements.txt + +sphinx: + configuration: docs/conf.py + +formats: + - pdf + - epub + diff --git a/README.md b/README.md index 5f47d3a..0a5801a 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# CacheLayer \ No newline at end of file +# CacheLayer + +CacheLayer is a standalone cache toolkit for modern PHP applications. + +It provides: + +- PSR-6 and PSR-16 compatible `Cache` facade +- Adapters for `APCu`, `File`, `Memcached`, `Redis`, `Redis Cluster`, `SQLite`, `PostgreSQL` +- In-process adapters: `memory` (array), `weakMap`, `nullStore`, `chain` +- Filesystem/Opcode adapter: `phpFiles` +- Shared-memory adapter: `sharedMemory` (sysvshm) +- Cloud adapters: `mongodb`, `dynamoDb`, `s3` (SDK/client injected or auto-created when SDK is installed) +- Tag-version invalidation (`setTagged()`, `invalidateTag()`, `invalidateTags()`) without full key scans +- Stampede-safe `remember()` with pluggable lock providers (file/redis/memcached) +- Per-adapter metrics counters with export hooks +- Optional payload compression via `configurePayloadCompression()` +- Serializer helpers for closures/resources used by cache payloads +- Memoization primitives: `Memoizer`, `MemoizeTrait`, and helpers `memoize()`, `remember()`, `once()` + +Quick usage: + +```php +use Infocyph\CacheLayer\Cache\Cache; + +$cache = Cache::memory('app'); +$cache->setTagged('user:1', ['name' => 'A'], ['users'], 300); + +$cache->invalidateTag('users'); // all entries tagged with "users" become stale + +$value = $cache->remember('expensive', fn () => compute(), 60); +$metrics = $cache->exportMetrics(); +``` + +Factory overview: + +```php +Cache::apcu('ns'); +Cache::file('ns', __DIR__ . '/storage/cache'); +Cache::phpFiles('ns', __DIR__ . '/storage/cache'); +Cache::memcache('ns'); +Cache::redis('ns'); +Cache::redisCluster('ns', ['127.0.0.1:6379']); +Cache::sqlite('ns'); +Cache::postgres('ns'); +Cache::memory('ns'); +Cache::weakMap('ns'); +Cache::sharedMemory('ns'); +Cache::nullStore(); +Cache::chain([ + new Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter('l1'), + new Infocyph\CacheLayer\Cache\Adapter\RedisCacheAdapter('l2'), +]); +``` + +Namespace: + +- `Infocyph\CacheLayer\Cache\...` +- `Infocyph\CacheLayer\Cache\Lock\...` +- `Infocyph\CacheLayer\Cache\Metrics\...` +- `Infocyph\CacheLayer\Serializer\...` +- `Infocyph\CacheLayer\Exceptions\...` diff --git a/benchmarks/CacheFileBench.php b/benchmarks/CacheFileBench.php new file mode 100644 index 0000000..63181ab --- /dev/null +++ b/benchmarks/CacheFileBench.php @@ -0,0 +1,84 @@ +dir = sys_get_temp_dir() . '/cachelayer_bench_' . uniqid(); + $this->cache = Cache::file('bench', $this->dir); + $this->cache->set('hot', 1, 60); + } + + public function tearDown(): void + { + if (!is_dir($this->dir)) { + return; + } + + $it = new \RecursiveDirectoryIterator($this->dir, \FilesystemIterator::SKIP_DOTS); + $rim = new \RecursiveIteratorIterator($it, \RecursiveIteratorIterator::CHILD_FIRST); + foreach ($rim as $file) { + $path = $file->getRealPath(); + if ($path === false) { + continue; + } + + $file->isDir() ? @rmdir($path) : @unlink($path); + } + + @rmdir($this->dir); + } + + #[Bench\BeforeMethods(['setUp'])] + #[Bench\AfterMethods(['tearDown'])] + public function benchRememberMissThenHit(): int + { + $sum = 0; + for ($i = 0; $i < 100; $i++) { + $sum += (int) $this->cache->remember('remember-key', fn() => 42, 30); + } + + return $sum; + } + + #[Bench\BeforeMethods(['setUp'])] + #[Bench\AfterMethods(['tearDown'])] + public function benchSingletonGetHotPath(): int + { + $sum = 0; + for ($i = 0; $i < 100; $i++) { + $sum += (int) $this->cache->get('hot', 0); + } + + return $sum; + } + + #[Bench\BeforeMethods(['setUp'])] + #[Bench\AfterMethods(['tearDown'])] + public function benchTaggedSetAndInvalidate(): int + { + $checksum = 0; + for ($i = 0; $i < 50; $i++) { + $key = 'tagged-' . $i; + $this->cache->setTagged($key, $i, ['bench-tag']); + $checksum += (int) ($this->cache->get($key, 0)); + } + + $this->cache->invalidateTag('bench-tag'); + $checksum += $this->cache->get('tagged-0') === null ? 1 : 0; + + return $checksum; + } +} diff --git a/benchmarks/MemoizeBench.php b/benchmarks/MemoizeBench.php new file mode 100644 index 0000000..c6c5bf5 --- /dev/null +++ b/benchmarks/MemoizeBench.php @@ -0,0 +1,42 @@ +flush(); + } + #[Bench\BeforeMethods(['setUp'])] + public function benchGlobalMemoizeHit(): int + { + $fn = static fn(int $v): int => $v * 2; + $sum = 0; + for ($i = 0; $i < 100; $i++) { + $sum += memoize($fn, [7]); + } + + return $sum; + } + + #[Bench\BeforeMethods(['setUp'])] + public function benchObjectMemoizeHit(): int + { + $obj = new \stdClass(); + $fn = static fn(): int => 21; + $sum = 0; + for ($i = 0; $i < 100; $i++) { + $sum += remember($obj, $fn); + } + + return $sum; + } +} diff --git a/benchmarks/SerializerBench.php b/benchmarks/SerializerBench.php new file mode 100644 index 0000000..78d3bef --- /dev/null +++ b/benchmarks/SerializerBench.php @@ -0,0 +1,53 @@ + + */ + private array $payload; + + public function __construct() + { + $this->payload = [ + 'id' => 123, + 'name' => 'cache-layer', + 'flags' => [true, false, true], + 'meta' => ['release' => 2, 'enabled' => true], + ]; + } + + public function benchEncodeDecodeArray(): int + { + $blob = ValueSerializer::encode($this->payload); + $decoded = ValueSerializer::decode($blob); + + return (int) ($decoded['id'] ?? 0); + } + + public function benchSerializeUnserializeArray(): int + { + $blob = ValueSerializer::serialize($this->payload); + $decoded = ValueSerializer::unserialize($blob); + + return (int) ($decoded['id'] ?? 0); + } + + public function benchSerializeUnserializeClosure(): int + { + $fn = static fn(int $v): int => $v + 5; + $blob = ValueSerializer::serialize($fn); + $restored = ValueSerializer::unserialize($blob); + + return $restored(10); + } +} diff --git a/captainhook.json b/captainhook.json new file mode 100644 index 0000000..bc08ef3 --- /dev/null +++ b/captainhook.json @@ -0,0 +1,51 @@ +{ + "commit-msg": { + "enabled": false, + "actions": [] + }, + "pre-push": { + "enabled": false, + "actions": [] + }, + "pre-commit": { + "enabled": true, + "actions": [ + { + "action": "composer validate --strict", + "options": [] + }, + { + "action": "composer audit --no-interaction --abandoned=ignore", + "options": [] + }, + { + "action": "composer tests", + "options": [] + } + ] + }, + "prepare-commit-msg": { + "enabled": false, + "actions": [] + }, + "post-commit": { + "enabled": false, + "actions": [] + }, + "post-merge": { + "enabled": false, + "actions": [] + }, + "post-checkout": { + "enabled": false, + "actions": [] + }, + "post-rewrite": { + "enabled": false, + "actions": [] + }, + "post-change": { + "enabled": false, + "actions": [] + } +} diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..c6ab843 --- /dev/null +++ b/composer.json @@ -0,0 +1,132 @@ +{ + "name": "infocyph/cachelayer", + "description": "PSR-6/PSR-16 cache layer with local, distributed, and cloud adapters.", + "type": "library", + "license": "MIT", + "keywords": [ + "cache", + "memoize", + "memoization", + "psr-6", + "psr-16", + "apcu", + "redis", + "memcached", + "sqlite", + "postgres", + "mongodb", + "dynamodb", + "s3", + "weakmap", + "chain-cache" + ], + "authors": [ + { + "name": "abmmhasan", + "email": "abmmhasan@gmail.com" + } + ], + "suggest": { + "ext-apcu": "For APCu-based caching (in-memory, per-process)", + "ext-redis": "For Redis-based caching (persistent, networked)", + "ext-memcached": "For Memcached-based caching (distributed, RAM)", + "ext-sqlite3": "For SQLite-based caching (file-based, portable)", + "ext-pdo": "For SQLite-based caching (file-based, portable)", + "ext-pdo_pgsql": "For PostgreSQL-based caching via PostgresCacheAdapter", + "ext-sysvshm": "For shared-memory caching via SharedMemoryCacheAdapter", + "mongodb/mongodb": "For MongoDB caching via MongoDbCacheAdapter", + "aws/aws-sdk-php": "For DynamoDB and S3 adapters", + "ext-mbstring": "Recommended for development tools output formatting (Pest/Termwind)" + }, + "autoload": { + "files": [ + "src/functions.php" + ], + "psr-4": { + "Infocyph\\CacheLayer\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "Infocyph\\CacheLayer\\Tests\\": "tests/" + } + }, + "require": { + "php": ">=8.3", + "opis/closure": "^4.5", + "psr/cache": "^3.0", + "psr/simple-cache": "^3.0" + }, + "require-dev": { + "captainhook/captainhook": "^5.29.2", + "laravel/pint": "^1.29", + "pestphp/pest": "^4.4.5", + "pestphp/pest-plugin-drift": "^4.1", + "phpbench/phpbench": "^1.6", + "phpstan/phpstan": "^2.1", + "rector/rector": "^2.4.1", + "squizlabs/php_codesniffer": "^4.0.1", + "symfony/var-dumper": "^7.3 || ^8.0.8", + "tomasvotruba/cognitive-complexity": "^1.1", + "vimeo/psalm": "^6.16.1" + }, + "scripts": { + "test:syntax": "@php .github/scripts/syntax.php src tests benchmarks examples", + "test:code": "@php vendor/bin/pest", + "test:lint": "@php vendor/bin/pint --test", + "test:sniff": "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=full", + "test:static": "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G", + "test:security": "@php vendor/bin/psalm --config=psalm.xml --security-analysis --threads=1 --no-cache", + "test:refactor": "@php vendor/bin/rector process --dry-run --debug", + "test:bench": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "test:details": [ + "@test:syntax", + "@test:code", + "@test:lint", + "@test:sniff", + "@test:static", + "@test:security", + "@test:refactor" + ], + "test:all": [ + "@test:syntax", + "@php vendor/bin/pest --parallel --processes=10", + "@php vendor/bin/pint --test", + "@php vendor/bin/phpcs --standard=phpcs.xml.dist --report=summary", + "@php vendor/bin/phpstan analyse --configuration=phpstan.neon.dist --memory-limit=1G --no-progress", + "@php vendor/bin/psalm --config=psalm.xml --show-info=false --security-analysis --no-progress --no-cache", + "@php vendor/bin/rector process --dry-run" + ], + "release:audit": "@php .github/scripts/composer-audit-guard.php", + "release:guard": [ + "@composer validate --strict", + "@release:audit", + "@tests" + ], + "process:lint": "@php vendor/bin/pint", + "process:sniff:fix": "@php vendor/bin/phpcbf --standard=phpcs.xml.dist --runtime-set ignore_errors_on_exit 1", + "process:refactor": "@php vendor/bin/rector process", + "process:all": [ + "@process:refactor", + "@process:lint", + "@process:sniff:fix" + ], + "bench:run": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate", + "bench:quick": "@php vendor/bin/phpbench run --config=phpbench.json --report=aggregate --revs=10 --iterations=3 --warmup=1", + "bench:chart": "@php vendor/bin/phpbench run --config=phpbench.json --report=chart", + "tests": "@test:all", + "process": "@process:all", + "benchmark": "@bench:run", + "post-autoload-dump": "captainhook install --only-enabled -nf" + }, + "minimum-stability": "stable", + "prefer-stable": true, + "config": { + "sort-packages": true, + "optimize-autoloader": true, + "classmap-authoritative": true, + "allow-plugins": { + "pestphp/pest-plugin": true + } + } +} diff --git a/pest.xml b/pest.xml new file mode 100644 index 0000000..d5d12d8 --- /dev/null +++ b/pest.xml @@ -0,0 +1,22 @@ + + + + + ./tests + + + + + ./src + + + + + + + diff --git a/phpbench.json b/phpbench.json new file mode 100644 index 0000000..fff7e05 --- /dev/null +++ b/phpbench.json @@ -0,0 +1,26 @@ +{ + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "benchmarks", + "runner.file_pattern": "*Bench.php", + "runner.attributes": true, + "runner.annotations": false, + "runner.progress": "dots", + "runner.retry_threshold": 8, + "report.generators": { + "chart": { + "title": "Benchmark Chart", + "description": "Console bar chart grouped by benchmark subject", + "generator": "component", + "components": [ + { + "component": "bar_chart_aggregate", + "x_partition": ["subject_name"], + "bar_partition": ["benchmark_name"], + "y_expr": "mode(partition['result_time_avg'])", + "y_axes_label": "yValue as time precision 1" + } + ] + } + } +} diff --git a/phpcs.xml.dist b/phpcs.xml.dist new file mode 100644 index 0000000..666c061 --- /dev/null +++ b/phpcs.xml.dist @@ -0,0 +1,52 @@ + + + Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. + + + + + + + ./src + ./tests + + */vendor/* + */.git/* + */.idea/* + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/phpstan.neon.dist b/phpstan.neon.dist new file mode 100644 index 0000000..0adc1df --- /dev/null +++ b/phpstan.neon.dist @@ -0,0 +1,14 @@ +includes: + - vendor/tomasvotruba/cognitive-complexity/config/extension.neon + +parameters: + customRulesetUsed: true + paths: + - src + parallel: + maximumNumberOfProcesses: 1 + cognitive_complexity: + class: 150 + function: 14 + dependency_tree: 150 + dependency_tree_types: [] diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..d5d12d8 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,22 @@ + + + + + ./tests + + + + + ./src + + + + + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..46529c3 --- /dev/null +++ b/pint.json @@ -0,0 +1,73 @@ +{ + "preset": "per", + "exclude": [ + "tests" + ], + "notPath": [ + "rector.php" + ], + "rules": { + "ordered_imports": { + "imports_order": ["class", "function", "const"], + "sort_algorithm": "alpha" + }, + "no_unused_imports": true, + + "ordered_class_elements": { + "order": [ + "use_trait", + + "case", + + "constant_public", + "constant_protected", + "constant_private", + "constant", + + "property_public_static", + "property_protected_static", + "property_private_static", + "property_static", + + "property_public_readonly", + "property_protected_readonly", + "property_private_readonly", + + "property_public_abstract", + "property_protected_abstract", + + "property_public", + "property_protected", + "property_private", + "property", + + "construct", + "destruct", + "magic", + "phpunit", + + "method_public_abstract_static", + "method_protected_abstract_static", + "method_private_abstract_static", + + "method_public_abstract", + "method_protected_abstract", + "method_private_abstract", + "method_abstract", + + "method_public_static", + "method_public", + + "method_protected_static", + "method_protected", + + "method_private_static", + "method_private", + + "method_static", + "method" + ], + "sort_algorithm": "alpha" + } + } +} diff --git a/psalm.xml b/psalm.xml new file mode 100644 index 0000000..bb0401c --- /dev/null +++ b/psalm.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rector.php b/rector.php new file mode 100644 index 0000000..e30ddf8 --- /dev/null +++ b/rector.php @@ -0,0 +1,14 @@ +withPaths([__DIR__ . '/src']) + ->withPreparedSets(deadCode: true) + ->withPhpVersion( + constant(PhpVersion::class . '::PHP_' . PHP_MAJOR_VERSION . PHP_MINOR_VERSION), + ) + ->withPhpSets(); diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php new file mode 100644 index 0000000..e9b3e51 --- /dev/null +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -0,0 +1,82 @@ + */ + protected array $deferred = []; + + /** + * Determines if this adapter supports the given cache item. + * + * @param CacheItemInterface $item The cache item to check. + * @return bool True if the adapter supports this item type. + */ + abstract protected function supportsItem(CacheItemInterface $item): bool; + + public function commit(): bool + { + $ok = true; + foreach ($this->deferred as $key => $item) { + $ok = $ok && $this->save($item); + unset($this->deferred[$key]); + } + return $ok; + } + + public function get(string $key): mixed + { + $item = $this->getItem($key); + return $item->isHit() ? $item->get() : null; + } + + public function getItems(array $keys = []): iterable + { + foreach ($keys as $key) { + yield $key => $this->getItem($key); + } + } + + public function internalPersist(CacheItemInterface $item): bool + { + return $this->save($item); + } + + public function internalQueue(CacheItemInterface $item): bool + { + return $this->saveDeferred($item); + } + + public function saveDeferred(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + $this->deferred[$item->getKey()] = $item; + return true; + } + + public function set(string $key, mixed $value, ?int $ttl = null): bool + { + $item = $this->getItem($key); + $item->set($value)->expiresAfter($ttl); + return $this->save($item); + } +} diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php new file mode 100644 index 0000000..2dce444 --- /dev/null +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -0,0 +1,173 @@ +ns = sanitize_cache_ns($namespace); + } + + public function clear(): bool + { + foreach ($this->listKeys() as $apcuKey) { + apcu_delete($apcuKey); + } + $this->deferred = []; + return true; + } + + public function count(): int + { + return count($this->listKeys()); + } + + public function deleteItem(string $key): bool + { + $mapped = $this->map($key); + if (!apcu_exists($mapped)) { + return true; + } + + return apcu_delete($mapped); + } + + public function deleteItems(array $keys): bool + { + $ok = true; + foreach ($keys as $k) { + $ok = $ok && $this->deleteItem($k); + } + return $ok; + } + + public function getItem(string $key): ApcuCacheItem + { + $apcuKey = $this->map($key); + $success = false; + $raw = apcu_fetch($apcuKey, $success); + + if ($success && is_string($raw)) { + $record = CachePayloadCodec::decode($raw); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + return new ApcuCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + } + apcu_delete($apcuKey); + } + return new ApcuCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return apcu_exists($this->map($key)); + } + + public function multiFetch(array $keys): array + { + if ($keys === []) { + return []; + } + $prefixed = array_map($this->map(...), $keys); + $raw = apcu_fetch($prefixed); + + $items = []; + $stale = []; + foreach ($keys as $k) { + $p = $this->map($k); + if (array_key_exists($p, $raw)) { + $record = CachePayloadCodec::decode((string) $raw[$p]); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + $items[$k] = new ApcuCacheItem( + $this, + $k, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + continue; + } + $stale[] = $p; + } + $items[$k] = new ApcuCacheItem($this, $k); + } + + if ($stale !== []) { + apcu_delete($stale); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + throw new CacheInvalidArgumentException('Wrong item type for ApcuCacheAdapter'); + } + $expires = CachePayloadCodec::expirationFromItem($item); + $ttl = $expires['ttl']; + if ($ttl === 0) { + apcu_delete($this->map($item->getKey())); + return true; + } + + $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + return apcu_store($this->map($item->getKey()), $blob, $ttl ?? 0); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof ApcuCacheItem; + } + + private function listKeys(): array + { + $iter = new \APCUIterator( + '/^' . preg_quote($this->ns . ':', '/') . '/', + APC_ITER_KEY, + ); + $out = []; + foreach ($iter as $k => $unused) { + $out[] = $k; + } + return $out; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } +} diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php new file mode 100644 index 0000000..3508e18 --- /dev/null +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -0,0 +1,121 @@ + */ + private array $store = []; + + public function __construct(string $namespace = 'default') + { + $this->ns = sanitize_cache_ns($namespace); + } + + public function clear(): bool + { + $this->store = []; + $this->deferred = []; + return true; + } + + public function count(): int + { + $this->pruneExpired(); + return count($this->store); + } + + public function deleteItem(string $key): bool + { + unset($this->store[$this->map($key)]); + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->store[$this->map((string) $key)]); + } + + return true; + } + + public function getItem(string $key): GenericCacheItem + { + $mapped = $this->map($key); + $blob = $this->store[$mapped] ?? null; + if (!is_string($blob)) { + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + unset($this->store[$mapped]); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $this->store[$this->map($item->getKey())] = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + return true; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } + + private function pruneExpired(): void + { + foreach ($this->store as $mapped => $blob) { + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + unset($this->store[$mapped]); + } + } + } +} diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php new file mode 100644 index 0000000..eae6c53 --- /dev/null +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -0,0 +1,170 @@ + self::FORMAT, + 'value' => $value, + 'expires' => $expiresAt, + ]); + + if (self::$compressionThresholdBytes === null || self::$compressionThresholdBytes < 1) { + return $encoded; + } + + if (strlen($encoded) < self::$compressionThresholdBytes || !function_exists('gzencode')) { + return $encoded; + } + + $compressed = gzencode($encoded, self::$compressionLevel); + if (!is_string($compressed) || strlen($compressed) >= strlen($encoded)) { + return $encoded; + } + + return self::COMPRESSED_PREFIX . base64_encode($compressed); + } + + /** + * @return array{ttl:int|null,expiresAt:int|null} + */ + public static function expirationFromItem(CacheItemInterface $item): array + { + $ttl = method_exists($item, 'ttlSeconds') ? $item->ttlSeconds() : null; + $expiresAt = $ttl === null ? null : time() + $ttl; + + return ['ttl' => $ttl, 'expiresAt' => $expiresAt]; + } + + public static function isExpired(?int $expiresAt, ?int $now = null): bool + { + return $expiresAt !== null && $expiresAt <= ($now ?? time()); + } + + public static function toDateTime(?int $expiresAt): ?DateTimeInterface + { + return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); + } + + /** + * @return array{value:mixed,expires:int|null}|null + */ + private static function decodeArrayPayload(mixed $decoded): ?array + { + if (!is_array($decoded)) { + return null; + } + + $fromFormatted = self::decodeFormattedPayload($decoded); + if ($fromFormatted !== null) { + return $fromFormatted; + } + + if (array_key_exists('value', $decoded) && array_key_exists('expires', $decoded)) { + return [ + 'value' => $decoded['value'], + 'expires' => self::normalizeExpires($decoded['expires']), + ]; + } + + return null; + } + + /** + * @return array{value:mixed,expires:int|null}|null + */ + private static function decodeCacheItem(mixed $decoded): ?array + { + if (!$decoded instanceof CacheItemInterface) { + return null; + } + + return ['value' => $decoded->get(), 'expires' => null]; + } + + /** + * @param array $decoded + * @return array{value:mixed,expires:int|null}|null + */ + private static function decodeFormattedPayload(array $decoded): ?array + { + if (($decoded['__imx_cache'] ?? null) !== self::FORMAT || !array_key_exists('value', $decoded)) { + return null; + } + + return [ + 'value' => $decoded['value'], + 'expires' => self::normalizeExpires($decoded['expires'] ?? null), + ]; + } + + private static function expandIfCompressed(string $blob): string + { + if (!str_starts_with($blob, self::COMPRESSED_PREFIX)) { + return $blob; + } + + $payload = substr($blob, strlen(self::COMPRESSED_PREFIX)); + $raw = base64_decode($payload, true); + if ($raw === false || !function_exists('gzdecode')) { + return $blob; + } + + $decoded = gzdecode($raw); + return is_string($decoded) ? $decoded : $blob; + } + + private static function normalizeExpires(mixed $expires): ?int + { + return is_int($expires) ? $expires : null; + } + + private static function tryUnserialize(string $blob): mixed + { + try { + return ValueSerializer::unserialize($blob); + } catch (Throwable) { + return null; + } + } +} diff --git a/src/Cache/Adapter/ChainCacheAdapter.php b/src/Cache/Adapter/ChainCacheAdapter.php new file mode 100644 index 0000000..e3d65de --- /dev/null +++ b/src/Cache/Adapter/ChainCacheAdapter.php @@ -0,0 +1,129 @@ + $pools + */ + public function __construct(private readonly array $pools) + { + if ($pools === []) { + throw new InvalidArgumentException('ChainCacheAdapter requires at least one pool.'); + } + } + + public function clear(): bool + { + $ok = true; + foreach ($this->pools as $pool) { + $ok = $pool->clear() && $ok; + } + + $this->deferred = []; + return $ok; + } + + public function count(): int + { + $first = $this->pools[0]; + return $first instanceof \Countable ? count($first) : 0; + } + + public function deleteItem(string $key): bool + { + $ok = true; + foreach ($this->pools as $pool) { + $ok = $pool->deleteItem($key) && $ok; + } + + return $ok; + } + + public function deleteItems(array $keys): bool + { + $ok = true; + foreach ($this->pools as $pool) { + $ok = $pool->deleteItems($keys) && $ok; + } + + return $ok; + } + + public function getItem(string $key): GenericCacheItem + { + foreach ($this->pools as $idx => $pool) { + $item = $pool->getItem($key); + if (!$item->isHit()) { + continue; + } + + $value = $item->get(); + $ttl = method_exists($item, 'ttlSeconds') ? $item->ttlSeconds() : null; + + for ($i = 0; $i < $idx; $i++) { + $promote = $this->pools[$i]->getItem($key); + $promote->set($value); + $promote->expiresAfter($ttl); + $this->pools[$i]->save($promote); + } + + $out = new GenericCacheItem($this, $key); + $out->set($value); + $out->expiresAfter($ttl); + return $out; + } + + return new GenericCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $ok = true; + foreach ($this->pools as $pool) { + $target = $pool->getItem($item->getKey()); + $target->set($item->get()); + $target->expiresAfter($expires['ttl']); + $ok = $pool->save($target) && $ok; + } + + return $ok; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } +} diff --git a/src/Cache/Adapter/DynamoDbCacheAdapter.php b/src/Cache/Adapter/DynamoDbCacheAdapter.php new file mode 100644 index 0000000..78fa03f --- /dev/null +++ b/src/Cache/Adapter/DynamoDbCacheAdapter.php @@ -0,0 +1,246 @@ +ns = sanitize_cache_ns($namespace); + + foreach (['getItem', 'putItem', 'deleteItem', 'scan', 'batchWriteItem'] as $method) { + if (!method_exists($this->client, $method)) { + throw new RuntimeException( + sprintf('DynamoDbCacheAdapter requires client method `%s()`.', $method), + ); + } + } + } + + public function clear(): bool + { + $keys = []; + $lastKey = null; + + do { + $params = [ + 'TableName' => $this->table, + 'FilterExpression' => '#ns = :ns', + 'ProjectionExpression' => '#k', + 'ExpressionAttributeNames' => [ + '#ns' => 'ns', + '#k' => 'ckey', + ], + 'ExpressionAttributeValues' => [ + ':ns' => ['S' => $this->ns], + ], + ]; + + if (is_array($lastKey)) { + $params['ExclusiveStartKey'] = $lastKey; + } + + $result = $this->toArray($this->client->scan($params)) ?? []; + foreach ($result['Items'] ?? [] as $item) { + if (is_array($item['ckey'] ?? null) && is_string($item['ckey']['S'] ?? null)) { + $keys[] = $item['ckey']['S']; + } + } + + $lastKey = isset($result['LastEvaluatedKey']) && is_array($result['LastEvaluatedKey']) + ? $result['LastEvaluatedKey'] + : null; + } while ($lastKey !== null); + + foreach (array_chunk($keys, 25) as $batch) { + $requests = array_map( + fn(string $key): array => ['DeleteRequest' => ['Key' => ['ckey' => ['S' => $key]]]], + $batch, + ); + $this->client->batchWriteItem(['RequestItems' => [$this->table => $requests]]); + } + + $this->deferred = []; + return true; + } + + public function count(): int + { + $result = $this->toArray($this->client->scan([ + 'TableName' => $this->table, + 'FilterExpression' => '#ns = :ns AND (attribute_not_exists(#exp) OR #exp > :now)', + 'Select' => 'COUNT', + 'ExpressionAttributeNames' => [ + '#ns' => 'ns', + '#exp' => 'expires', + ], + 'ExpressionAttributeValues' => [ + ':ns' => ['S' => $this->ns], + ':now' => ['N' => (string) time()], + ], + ])); + + return (int) ($result['Count'] ?? 0); + } + + public function deleteItem(string $key): bool + { + $this->client->deleteItem([ + 'TableName' => $this->table, + 'Key' => ['ckey' => ['S' => $this->map($key)]], + ]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem((string) $key); + } + + return true; + } + + public function getItem(string $key): GenericCacheItem + { + $result = $this->toArray($this->client->getItem([ + 'TableName' => $this->table, + 'Key' => ['ckey' => ['S' => $this->map($key)]], + 'ConsistentRead' => true, + ])); + + $row = isset($result['Item']) && is_array($result['Item']) ? $result['Item'] : null; + if ($row === null) { + return new GenericCacheItem($this, $key); + } + + $payload = $row['payload']['S'] ?? null; + if (!is_string($payload)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $expiresAt = is_array($row['expires'] ?? null) && is_string($row['expires']['N'] ?? null) + ? (int) $row['expires']['N'] + : null; + if (CachePayloadCodec::isExpired($expiresAt)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $blob = base64_decode($payload, true); + if (!is_string($blob)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $itemMap = [ + 'ckey' => ['S' => $this->map($item->getKey())], + 'ns' => ['S' => $this->ns], + 'payload' => ['S' => base64_encode(CachePayloadCodec::encode($item->get(), $expires['expiresAt']))], + ]; + if ($expires['expiresAt'] !== null) { + $itemMap['expires'] = ['N' => (string) $expires['expiresAt']]; + } + + $this->client->putItem([ + 'TableName' => $this->table, + 'Item' => $itemMap, + ]); + + return true; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } + + /** + * @return array|null + */ + private function toArray(mixed $value): ?array + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + return $value; + } + + if ($value instanceof \ArrayAccess && $value instanceof \Traversable) { + $out = []; + foreach ($value as $k => $v) { + $out[(string) $k] = $v; + } + + return $out; + } + + if (method_exists($value, 'toArray')) { + $arr = $value->toArray(); + return is_array($arr) ? $arr : null; + } + + return null; + } +} diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php new file mode 100644 index 0000000..aace694 --- /dev/null +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -0,0 +1,197 @@ +createDirectory($namespace, $baseDir); + } + + public function clear(): bool + { + $ok = true; + foreach (glob("$this->dir*.cache") as $f) { + $ok = $ok && @unlink($f); + } + $this->deferred = []; + return $ok; + } + + public function count(): int + { + return iterator_count(new \FilesystemIterator($this->dir, \FilesystemIterator::SKIP_DOTS)); + } + + public function deleteItem(string $key): bool + { + $file = $this->fileFor($key); + + return !is_file($file) || @unlink($file); + } + + public function deleteItems(array $keys): bool + { + $ok = true; + foreach ($keys as $k) { + $ok = $ok && $this->deleteItem($k); + } + return $ok; + } + + public function getItem(string $key): FileCacheItem + { + $file = $this->fileFor($key); + + if (is_file($file)) { + $raw = file_get_contents($file); + if (is_string($raw)) { + $record = CachePayloadCodec::decode($raw); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + return new FileCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + } + } + @unlink($file); + } + + return new FileCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + throw new CacheInvalidArgumentException('Invalid item type for FileCacheAdapter'); + } + + $expires = CachePayloadCodec::expirationFromItem($item); + $ttl = $expires['ttl']; + if ($ttl === 0) { + return $this->deleteItem($item->getKey()); + } + + $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + $tmp = tempnam($this->dir, 'c_'); + if ($tmp === false) { + return false; + } + + if (file_put_contents($tmp, $blob) === false) { + @unlink($tmp); + return false; + } + + if (!@rename($tmp, $this->fileFor($item->getKey()))) { + @unlink($tmp); + return false; + } + + return true; + } + + public function setNamespaceAndDirectory(string $namespace, ?string $baseDir = null): void + { + $this->createDirectory($namespace, $baseDir); + $this->deferred = []; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof FileCacheItem; + } + + private function assertWritableDirectory(string $path, string $message): void + { + if (!is_writable($path)) { + throw new RuntimeException($message); + } + } + + private function createDirectory(string $ns, ?string $baseDir): void + { + $baseDir = rtrim($baseDir ?? sys_get_temp_dir(), DIRECTORY_SEPARATOR); + $ns = sanitize_cache_ns($ns); + $this->dir = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; + + if (is_dir($this->dir)) { + $this->assertWritableDirectory($this->dir, "Cache directory '$this->dir' exists but is not writable"); + return; + } + + $this->ensureBaseDirectoryExists($baseDir); + $this->ensureCacheDirectoryExists($this->dir); + $this->assertWritableDirectory($this->dir, 'Cache directory ' . $this->dir . ' is not writable'); + } + + private function ensureBaseDirectoryExists(string $baseDir): void + { + if (file_exists($baseDir) && !is_dir($baseDir)) { + throw new RuntimeException( + 'Cache base path ' . realpath($baseDir) . ' exists and is *not* a directory', + ); + } + + if (!is_dir($baseDir) && !@mkdir($baseDir, 0770, true) && !is_dir($baseDir)) { + $this->throwCreationError('Failed to create base directory ' . $baseDir); + } + } + + private function ensureCacheDirectoryExists(string $cacheDir): void + { + if (file_exists($cacheDir) && !is_dir($cacheDir)) { + throw new RuntimeException( + realpath($cacheDir) . ' exists and is not a directory', + ); + } + + if (!@mkdir($cacheDir, 0770, true) && !is_dir($cacheDir)) { + $this->throwCreationError('Failed to create cache directory ' . $cacheDir); + } + } + + private function fileFor(string $key): string + { + return $this->dir . hash('xxh128', $key) . '.cache'; + } + + private function throwCreationError(string $prefix): void + { + $err = error_get_last()['message'] ?? 'unknown error'; + throw new RuntimeException($prefix . ": $err"); + } +} diff --git a/src/Cache/Adapter/InternalCachePoolInterface.php b/src/Cache/Adapter/InternalCachePoolInterface.php new file mode 100644 index 0000000..1cee275 --- /dev/null +++ b/src/Cache/Adapter/InternalCachePoolInterface.php @@ -0,0 +1,19 @@ +ns = sanitize_cache_ns($namespace); + $this->mc = $client ?? new Memcached(); + if (!$client) { + $this->mc->addServers($servers); + } + } + + public function clear(): bool + { + $this->mc->flush(); + $this->deferred = []; + $this->knownKeys = []; + return true; + } + + public function count(): int + { + return count($this->fetchKeys()); + } + + public function deleteItem(string $key): bool + { + $this->mc->delete($this->map($key)); + unset($this->knownKeys[$key]); + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $k) { + $this->deleteItem($k); + } + return true; + } + + public function getClient(): Memcached + { + return $this->mc; + } + + public function getItem(string $key): MemCacheItem + { + $raw = $this->mc->get($this->map($key)); + if ($this->mc->getResultCode() === Memcached::RES_SUCCESS && is_string($raw)) { + $record = CachePayloadCodec::decode($raw); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + return new MemCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + } + $this->mc->delete($this->map($key)); + unset($this->knownKeys[$key]); + } + return new MemCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + $this->mc->get($this->map($key)); + return $this->mc->getResultCode() === \Memcached::RES_SUCCESS; + } + + public function multiFetch(array $keys): array + { + if ($keys === []) { + return []; + } + + $prefixed = array_map($this->map(...), $keys); + $raw = $this->mc->getMulti($prefixed, Memcached::GET_PRESERVE_ORDER) ?: []; + + $items = []; + $stale = []; + $staleLogicalKeys = []; + foreach ($keys as $k) { + $p = $this->map($k); + if (isset($raw[$p])) { + $record = CachePayloadCodec::decode((string) $raw[$p]); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + $items[$k] = new MemCacheItem( + $this, + $k, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + continue; + } + $stale[] = $p; + $staleLogicalKeys[] = $k; + } + $items[$k] = new MemCacheItem($this, $k); + } + + if ($stale !== []) { + $this->mc->deleteMulti($stale); + foreach ($staleLogicalKeys as $key) { + unset($this->knownKeys[$key]); + } + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + throw new CacheInvalidArgumentException('Wrong item class'); + } + + $expires = CachePayloadCodec::expirationFromItem($item); + $ttl = $expires['ttl']; + if ($ttl === 0) { + $this->mc->delete($this->map($item->getKey())); + unset($this->knownKeys[$item->getKey()]); + return true; + } + + $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + $ok = $this->mc->set($this->map($item->getKey()), $blob, $ttl ?? 0); + if ($ok) { + $this->knownKeys[$item->getKey()] = true; + } + return $ok; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof MemCacheItem; + } + + /** + * @param array $seen + * @param list $out + */ + private function collectDumpedKeys( + string $server, + int $slabId, + string $pref, + array &$seen, + array &$out, + ): void { + $dump = $this->mc->getStats("cachedump $slabId 0"); + + if (!isset($dump[$server]) || !is_array($dump[$server])) { + return; + } + + $keys = $this->stripNamespace(array_keys($dump[$server]), $pref); + + foreach ($keys as $key) { + if (isset($seen[$key])) { + continue; + } + + $seen[$key] = true; + $out[] = $key; + } + } + + /** + * @param array $items + * + * @return list + */ + private function extractSlabIds(array $items): array + { + $ids = []; + + foreach ($items as $name => $value) { + if (!preg_match('/items:(\d+):number/', (string) $name, $m)) { + continue; + } + + $ids[] = (int) $m[1]; + } + + return array_values(array_unique($ids)); + } + + private function fastKnownKeys(): array + { + return $this->knownKeys ? array_keys($this->knownKeys) : []; + } + + private function fetchKeys(): array + { + if ($quick = $this->fastKnownKeys()) { + return $quick; + } + + $pref = $this->ns . ':'; + if ($keys = $this->keysFromGetAll($pref)) { + return $keys; + } + return $this->keysFromSlabDump($pref); + } + + private function keysFromGetAll(string $pref): array + { + $all = $this->mc->getAllKeys(); + if (!is_array($all)) { + return []; + } + return $this->stripNamespace($all, $pref); + } + + private function keysFromSlabDump(string $pref): array + { + $out = []; + $seen = []; + + foreach ($this->slabIdsByServer() as $server => $slabIds) { + foreach ($slabIds as $slabId) { + $this->collectDumpedKeys($server, $slabId, $pref, $seen, $out); + } + } + + return $out; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } + + /** + * @return array> + */ + private function slabIdsByServer(): array + { + $stats = $this->mc->getStats('items'); + + return array_map($this->extractSlabIds(...), $stats); + } + + private function stripNamespace(array $fullKeys, string $pref): array + { + return array_values(array_map( + fn(string $k) => substr($k, strlen($pref)), + array_filter($fullKeys, fn(string $k) => str_starts_with($k, $pref)), + )); + } +} diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php new file mode 100644 index 0000000..46a3346 --- /dev/null +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -0,0 +1,194 @@ +ns = sanitize_cache_ns($namespace); + + foreach (['findOne', 'updateOne', 'deleteOne', 'deleteMany', 'countDocuments'] as $method) { + if (!method_exists($this->collection, $method)) { + throw new RuntimeException( + sprintf('MongoDbCacheAdapter requires collection method `%s()`.', $method), + ); + } + } + } + + public static function fromClient( + object $client, + string $database = 'cachelayer', + string $collection = 'entries', + string $namespace = 'default', + ): self { + if (!method_exists($client, 'selectCollection')) { + throw new RuntimeException('Mongo client must expose selectCollection().'); + } + + /** @var object $selected */ + $selected = $client->selectCollection($database, $collection); + return new self($selected, $namespace); + } + + public function clear(): bool + { + $this->collection->deleteMany(['ns' => $this->ns]); + $this->deferred = []; + return true; + } + + public function count(): int + { + return (int) $this->collection->countDocuments([ + 'ns' => $this->ns, + '$or' => [ + ['expires' => null], + ['expires' => ['$gt' => time()]], + ], + ]); + } + + public function deleteItem(string $key): bool + { + $this->collection->deleteOne(['_id' => $this->map($key)]); + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem((string) $key); + } + + return true; + } + + public function getItem(string $key): GenericCacheItem + { + $doc = $this->collection->findOne(['_id' => $this->map($key)]); + $row = $this->toArray($doc); + + if ($row === null || !is_string($row['payload'] ?? null)) { + return new GenericCacheItem($this, $key); + } + + $expiresAt = is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null; + if (CachePayloadCodec::isExpired($expiresAt)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $blob = base64_decode($row['payload'], true); + if (!is_string($blob)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $this->collection->updateOne( + ['_id' => $this->map($item->getKey())], + [ + '$set' => [ + 'ns' => $this->ns, + 'payload' => base64_encode(CachePayloadCodec::encode($item->get(), $expires['expiresAt'])), + 'expires' => $expires['expiresAt'], + ], + ], + ['upsert' => true], + ); + + return true; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } + + /** + * @return array|null + */ + private function toArray(mixed $value): ?array + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + return $value; + } + + if ($value instanceof \JsonSerializable) { + $json = $value->jsonSerialize(); + return is_array($json) ? $json : null; + } + + if ($value instanceof \ArrayAccess && $value instanceof \Traversable) { + $out = []; + foreach ($value as $k => $v) { + $out[(string) $k] = $v; + } + + return $out; + } + + return null; + } +} diff --git a/src/Cache/Adapter/NullCacheAdapter.php b/src/Cache/Adapter/NullCacheAdapter.php new file mode 100644 index 0000000..d9ef7fd --- /dev/null +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -0,0 +1,62 @@ +deferred = []; + return true; + } + + public function count(): int + { + return 0; + } + + public function deleteItem(string $key): bool + { + return true; + } + + public function deleteItems(array $keys): bool + { + return true; + } + + public function getItem(string $key): GenericCacheItem + { + return new GenericCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return false; + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = new GenericCacheItem($this, (string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + return $this->supportsItem($item); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } +} diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php new file mode 100644 index 0000000..34a50af --- /dev/null +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -0,0 +1,194 @@ +createDirectory($namespace, $baseDir); + } + + public function clear(): bool + { + $ok = true; + foreach (glob($this->dir . '*.php') ?: [] as $file) { + $ok = (@unlink($file) || !is_file($file)) && $ok; + $this->invalidateOpcache($file); + } + + $this->deferred = []; + return $ok; + } + + public function count(): int + { + $count = 0; + foreach (glob($this->dir . '*.php') ?: [] as $file) { + $row = @require $file; + if (!is_array($row) || !isset($row['p']) || !is_string($row['p'])) { + continue; + } + + $blob = base64_decode($row['p'], true); + if (!is_string($blob)) { + continue; + } + + $record = CachePayloadCodec::decode($blob); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + $count++; + } + } + + return $count; + } + + public function deleteItem(string $key): bool + { + $file = $this->fileFor($key); + $ok = !is_file($file) || @unlink($file); + $this->invalidateOpcache($file); + return $ok; + } + + public function deleteItems(array $keys): bool + { + $ok = true; + foreach ($keys as $key) { + $ok = $this->deleteItem((string) $key) && $ok; + } + + return $ok; + } + + public function getItem(string $key): GenericCacheItem + { + $file = $this->fileFor($key); + if (!is_file($file)) { + return new GenericCacheItem($this, $key); + } + + $row = @require $file; + if (!is_array($row) || !isset($row['p']) || !is_string($row['p'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $blob = base64_decode($row['p'], true); + if (!is_string($blob)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + $payload = var_export(base64_encode($blob), true); + $code = " {$payload}];\n"; + + $file = $this->fileFor($item->getKey()); + $tmp = tempnam($this->dir, 'pc_'); + if ($tmp === false) { + return false; + } + + if (file_put_contents($tmp, $code) === false) { + @unlink($tmp); + return false; + } + + if (!@rename($tmp, $file)) { + @unlink($tmp); + return false; + } + + $this->invalidateOpcache($file); + return true; + } + + public function setNamespaceAndDirectory(string $namespace, ?string $baseDir = null): void + { + $this->createDirectory($namespace, $baseDir); + $this->deferred = []; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function createDirectory(string $ns, ?string $baseDir): void + { + $baseDir = rtrim($baseDir ?? sys_get_temp_dir(), DIRECTORY_SEPARATOR); + $ns = sanitize_cache_ns($ns); + $this->dir = $baseDir . DIRECTORY_SEPARATOR . 'phpcache_' . $ns . DIRECTORY_SEPARATOR; + + if (!is_dir($this->dir) && !@mkdir($this->dir, 0770, true) && !is_dir($this->dir)) { + throw new RuntimeException("Unable to create PHP cache directory: {$this->dir}"); + } + + if (!is_writable($this->dir)) { + throw new RuntimeException("PHP cache directory is not writable: {$this->dir}"); + } + } + + private function fileFor(string $key): string + { + return $this->dir . hash('xxh128', $key) . '.php'; + } + + private function invalidateOpcache(string $file): void + { + if (function_exists('opcache_invalidate')) { + @opcache_invalidate($file, true); + } + } +} diff --git a/src/Cache/Adapter/PostgresCacheAdapter.php b/src/Cache/Adapter/PostgresCacheAdapter.php new file mode 100644 index 0000000..f67e19e --- /dev/null +++ b/src/Cache/Adapter/PostgresCacheAdapter.php @@ -0,0 +1,177 @@ +ns = sanitize_cache_ns($namespace); + $this->table = $table; + $this->pdo = $pdo ?? new PDO( + $dsn ?? 'pgsql:host=127.0.0.1;port=5432;dbname=postgres', + $username, + $password, + ); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->pdo->exec( + "CREATE TABLE IF NOT EXISTS {$this->table} ( + ckey TEXT PRIMARY KEY, + payload TEXT NOT NULL, + expires BIGINT NULL + )", + ); + $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$this->table}_expires_idx ON {$this->table}(expires)"); + } + + public function clear(): bool + { + $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey LIKE :prefix"); + $ok = $stmt->execute([':prefix' => $this->ns . ':%']); + $this->deferred = []; + return $ok; + } + + public function count(): int + { + $stmt = $this->pdo->prepare( + "SELECT COUNT(*) FROM {$this->table} + WHERE ckey LIKE :prefix + AND (expires IS NULL OR expires > :now)", + ); + $stmt->execute([ + ':prefix' => $this->ns . ':%', + ':now' => time(), + ]); + + return (int) $stmt->fetchColumn(); + } + + public function deleteItem(string $key): bool + { + $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey = :k"); + return $stmt->execute([':k' => $this->map($key)]); + } + + public function deleteItems(array $keys): bool + { + $ok = true; + foreach ($keys as $key) { + $ok = $this->deleteItem((string) $key) && $ok; + } + + return $ok; + } + + public function getItem(string $key): GenericCacheItem + { + $stmt = $this->pdo->prepare( + "SELECT payload, expires FROM {$this->table} WHERE ckey = :k LIMIT 1", + ); + $stmt->execute([':k' => $this->map($key)]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + if (!is_array($row)) { + return new GenericCacheItem($this, $key); + } + + $expiresAt = is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null; + if (CachePayloadCodec::isExpired($expiresAt)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $blob = base64_decode((string) ($row['payload'] ?? ''), true); + if (!is_string($blob)) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $blob = base64_encode(CachePayloadCodec::encode($item->get(), $expires['expiresAt'])); + $stmt = $this->pdo->prepare( + "INSERT INTO {$this->table} (ckey, payload, expires) + VALUES (:k, :p, :e) + ON CONFLICT (ckey) + DO UPDATE SET payload = EXCLUDED.payload, expires = EXCLUDED.expires", + ); + + return $stmt->execute([ + ':k' => $this->map($item->getKey()), + ':p' => $blob, + ':e' => $expires['expiresAt'], + ]); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } +} diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php new file mode 100644 index 0000000..cb8690c --- /dev/null +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -0,0 +1,200 @@ +ns = sanitize_cache_ns($namespace); + $this->redis = $client ?? $this->connect($dsn); + } + + public function clear(): bool + { + $cursor = null; + do { + $keys = $this->redis->scan($cursor, $this->ns . ':*', 1000); + if ($keys) { + $this->redis->del($keys); + } + } while ($cursor); + $this->deferred = []; + return true; + } + + public function count(): int + { + $iter = null; + $count = 0; + while ($keys = $this->redis->scan($iter, $this->ns . ':*', 1000)) { + $count += count($keys); + } + return $count; + } + + public function deleteItem(string $key): bool + { + return $this->redis->del($this->map($key)) !== false; + } + + public function deleteItems(array $keys): bool + { + if ($keys === []) { + return true; + } + + $full = array_map($this->map(...), $keys); + return $this->redis->del($full) !== false; + } + + public function getClient(): Redis + { + return $this->redis; + } + + public function getItem(string $key): RedisCacheItem + { + $raw = $this->redis->get($this->map($key)); + if (is_string($raw)) { + $record = CachePayloadCodec::decode($raw); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + return new RedisCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + } + $this->redis->del($this->map($key)); + } + return new RedisCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return $this->redis->exists($this->map($key)) === 1; + } + + public function multiFetch(array $keys): array + { + if ($keys === []) { + return []; + } + + $prefixed = array_map($this->map(...), $keys); + $rawVals = $this->redis->mget($prefixed); + + $items = []; + $stale = []; + foreach ($keys as $idx => $k) { + $v = $rawVals[$idx]; + if ($v !== null && $v !== false) { + $record = CachePayloadCodec::decode((string) $v); + if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + $items[$k] = new RedisCacheItem( + $this, + $k, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + continue; + } + $stale[] = $this->map($k); + } + $items[$k] = new RedisCacheItem($this, $k); + } + + if ($stale !== []) { + $this->redis->del($stale); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + throw new CacheInvalidArgumentException('RedisCacheAdapter expects RedisCacheItem'); + } + + $expires = CachePayloadCodec::expirationFromItem($item); + $ttl = $expires['ttl']; + if ($ttl === 0) { + $this->redis->del($this->map($item->getKey())); + return true; + } + + $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + return $ttl === null + ? $this->redis->set($this->map($item->getKey()), $blob) + : $this->redis->setex($this->map($item->getKey()), max(1, $ttl), $blob); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof RedisCacheItem; + } + + private function connect(string $dsn): Redis + { + $r = new Redis(); + $parts = parse_url($dsn); + if (!$parts) { + throw new RuntimeException("Invalid Redis DSN: $dsn"); + } + $host = $parts['host'] ?? '127.0.0.1'; + $port = $parts['port'] ?? 6379; + $r->connect($host, (int) $port); + if (isset($parts['pass'])) { + $r->auth($parts['pass']); + } + if (isset($parts['path']) && $parts['path'] !== '/') { + $db = (int) ltrim($parts['path'], '/'); + $r->select($db); + } + return $r; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } +} diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php new file mode 100644 index 0000000..5006278 --- /dev/null +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -0,0 +1,173 @@ + $seeds + */ + public function __construct( + string $namespace = 'default', + array $seeds = ['127.0.0.1:6379'], + float $timeout = 1.0, + float $readTimeout = 1.0, + bool $persistent = false, + ?object $client = null, + ) { + if ($client === null) { + if (!class_exists(\RedisCluster::class)) { + throw new RuntimeException('phpredis RedisCluster support is not loaded'); + } + + $client = new \RedisCluster( + null, + $seeds, + $timeout, + $readTimeout, + $persistent, + ); + } + + $this->ns = sanitize_cache_ns($namespace); + $this->assertClientShape($client); + $this->cluster = $client; + } + + public function clear(): bool + { + $keys = $this->cluster->sMembers($this->indexKey()); + if (is_array($keys) && $keys !== []) { + foreach ($keys as $key) { + $this->cluster->del((string) $key); + } + } + $this->cluster->del($this->indexKey()); + $this->deferred = []; + return true; + } + + public function count(): int + { + return (int) $this->cluster->sCard($this->indexKey()); + } + + public function deleteItem(string $key): bool + { + $mapped = $this->map($key); + $this->cluster->sRem($this->indexKey(), $mapped); + return $this->cluster->del($mapped) !== false; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem((string) $key); + } + + return true; + } + + public function getClient(): object + { + return $this->cluster; + } + + public function getItem(string $key): GenericCacheItem + { + $mapped = $this->map($key); + $raw = $this->cluster->get($mapped); + if (!is_string($raw)) { + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($raw); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->cluster->exists($this->map($key)) > 0; + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $mapped = $this->map($item->getKey()); + $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + + $ok = $expires['ttl'] === null + ? $this->cluster->set($mapped, $blob) + : $this->cluster->setex($mapped, max(1, $expires['ttl']), $blob); + + if ($ok) { + $this->cluster->sAdd($this->indexKey(), $mapped); + } + + return (bool) $ok; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function assertClientShape(object $client): void + { + foreach (['sMembers', 'del', 'sCard', 'get', 'exists', 'set', 'setex', 'sAdd', 'sRem'] as $method) { + if (!method_exists($client, $method)) { + throw new RuntimeException( + sprintf('RedisClusterCacheAdapter client must expose `%s()`.', $method), + ); + } + } + } + + private function indexKey(): string + { + return $this->ns . ':__keys'; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } +} diff --git a/src/Cache/Adapter/S3CacheAdapter.php b/src/Cache/Adapter/S3CacheAdapter.php new file mode 100644 index 0000000..18859bb --- /dev/null +++ b/src/Cache/Adapter/S3CacheAdapter.php @@ -0,0 +1,256 @@ +ns = sanitize_cache_ns($namespace); + $this->keyPrefix = trim($prefix, '/'); + + foreach (['putObject', 'getObject', 'deleteObject', 'listObjectsV2', 'deleteObjects'] as $method) { + if (!method_exists($this->client, $method)) { + throw new RuntimeException( + sprintf('S3CacheAdapter requires client method `%s()`.', $method), + ); + } + } + } + + public function clear(): bool + { + $keys = $this->listNamespaceKeys(); + foreach (array_chunk($keys, 1000) as $chunk) { + $objects = array_map(fn(string $key): array => ['Key' => $key], $chunk); + $this->client->deleteObjects([ + 'Bucket' => $this->bucket, + 'Delete' => ['Objects' => $objects, 'Quiet' => true], + ]); + } + + $this->deferred = []; + return true; + } + + public function count(): int + { + $count = 0; + foreach ($this->listNamespaceKeys() as $key) { + $logicalKey = $this->logicalKeyFromObjectKey($key); + if ($logicalKey === null) { + continue; + } + + if ($this->getItem($logicalKey)->isHit()) { + $count++; + } + } + + return $count; + } + + public function deleteItem(string $key): bool + { + $this->client->deleteObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->map($key), + ]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem((string) $key); + } + + return true; + } + + public function getItem(string $key): GenericCacheItem + { + try { + $result = $this->client->getObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->map($key), + ]); + } catch (\Throwable) { + return new GenericCacheItem($this, $key); + } + + $row = $this->toArray($result) ?? []; + $body = $row['Body'] ?? null; + if ($body instanceof \Stringable) { + $body = (string) $body; + } + + if (!is_string($body)) { + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($body); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $this->deleteItem($key); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $this->client->putObject([ + 'Bucket' => $this->bucket, + 'Key' => $this->map($item->getKey()), + 'Body' => CachePayloadCodec::encode($item->get(), $expires['expiresAt']), + 'ContentType' => 'application/octet-stream', + ]); + + return true; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + /** + * @return list + */ + private function listNamespaceKeys(): array + { + $prefix = $this->namespacePrefix(); + $out = []; + $token = null; + + do { + $params = [ + 'Bucket' => $this->bucket, + 'Prefix' => $prefix, + 'MaxKeys' => 1000, + ]; + if (is_string($token) && $token !== '') { + $params['ContinuationToken'] = $token; + } + + $result = $this->toArray($this->client->listObjectsV2($params)) ?? []; + foreach ($result['Contents'] ?? [] as $row) { + if (is_array($row) && is_string($row['Key'] ?? null)) { + $out[] = $row['Key']; + } + } + + $token = is_string($result['NextContinuationToken'] ?? null) + ? $result['NextContinuationToken'] + : null; + } while ($token !== null); + + return $out; + } + + private function logicalKeyFromObjectKey(string $objectKey): ?string + { + $prefix = $this->namespacePrefix(); + if (!str_starts_with($objectKey, $prefix)) { + return null; + } + + $name = substr($objectKey, strlen($prefix)); + $parts = explode('_', $name, 2); + if (count($parts) !== 2) { + return null; + } + + $encoded = substr($parts[1], 0, -6); + if ($encoded === '' || !str_ends_with($name, '.cache')) { + return null; + } + + return rawurldecode($encoded); + } + + private function map(string $key): string + { + return $this->namespacePrefix() . hash('xxh128', $key) . '_' . rawurlencode($key) . '.cache'; + } + + private function namespacePrefix(): string + { + return $this->keyPrefix . '/' . $this->ns . '/'; + } + + /** + * @return array|null + */ + private function toArray(mixed $value): ?array + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + return $value; + } + + if ($value instanceof \ArrayAccess && $value instanceof \Traversable) { + $out = []; + foreach ($value as $k => $v) { + $out[(string) $k] = $v; + } + + return $out; + } + + if (method_exists($value, 'toArray')) { + $arr = $value->toArray(); + return is_array($arr) ? $arr : null; + } + + return null; + } +} diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php new file mode 100644 index 0000000..9733aa4 --- /dev/null +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -0,0 +1,175 @@ +ns = sanitize_cache_ns($namespace); + $this->tokenFile = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cachelayer_shm_' . $this->ns . '.tok'; + if (!is_file($this->tokenFile)) { + @touch($this->tokenFile); + } + + $projectId = function_exists('ftok') ? ftok($this->tokenFile, 'C') : false; + $shmKey = is_int($projectId) && $projectId > 0 + ? $projectId + : abs(crc32('cachelayer:' . $this->ns)); + + $segment = @shm_attach($shmKey, max(1_048_576, $segmentSize), 0666); + if ($segment === false) { + throw new RuntimeException('Unable to attach shared-memory segment'); + } + + $this->segment = $segment; + if (!shm_has_var($this->segment, self::VAR_ID)) { + shm_put_var($this->segment, self::VAR_ID, []); + } + } + + public function clear(): bool + { + $this->deferred = []; + return shm_put_var($this->segment, self::VAR_ID, []); + } + + public function count(): int + { + $store = $this->loadStore(); + $changed = false; + $count = 0; + + foreach ($store as $key => $blob) { + $record = CachePayloadCodec::decode((string) $blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + unset($store[$key]); + $changed = true; + continue; + } + + $count++; + } + + if ($changed) { + $this->store($store); + } + + return $count; + } + + public function deleteItem(string $key): bool + { + $store = $this->loadStore(); + unset($store[$this->map($key)]); + return $this->store($store); + } + + public function deleteItems(array $keys): bool + { + $store = $this->loadStore(); + foreach ($keys as $key) { + unset($store[$this->map((string) $key)]); + } + + return $this->store($store); + } + + public function getItem(string $key): GenericCacheItem + { + $mapped = $this->map($key); + $store = $this->loadStore(); + $blob = $store[$mapped] ?? null; + if (!is_string($blob)) { + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + unset($store[$mapped]); + $this->store($store); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $store = $this->loadStore(); + $store[$this->map($item->getKey())] = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + return $this->store($store); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function loadStore(): array + { + if (!shm_has_var($this->segment, self::VAR_ID)) { + return []; + } + + $store = shm_get_var($this->segment, self::VAR_ID); + return is_array($store) ? $store : []; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } + + private function store(array $store): bool + { + return shm_put_var($this->segment, self::VAR_ID, $store); + } +} diff --git a/src/Cache/Adapter/SqliteCacheAdapter.php b/src/Cache/Adapter/SqliteCacheAdapter.php new file mode 100644 index 0000000..4c203f8 --- /dev/null +++ b/src/Cache/Adapter/SqliteCacheAdapter.php @@ -0,0 +1,235 @@ +ns = sanitize_cache_ns($namespace); + $file = $dbPath ?: sys_get_temp_dir() . "/cache_$this->ns.sqlite"; + + $this->pdo = new PDO('sqlite:' . $file); + $this->pdo->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;'); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + + $this->pdo->exec( + 'CREATE TABLE IF NOT EXISTS cache ( + key TEXT PRIMARY KEY, + value BLOB NOT NULL, + expires INTEGER + )', + ); + $this->pdo->exec('CREATE INDEX IF NOT EXISTS exp_idx ON cache(expires)'); + } + + public function clear(): bool + { + $this->pdo->exec('DELETE FROM cache'); + $this->deferred = []; + return true; + } + + public function count(): int + { + return (int) $this->pdo->query( + 'SELECT COUNT(*) FROM cache WHERE expires IS NULL OR expires > ' . time(), + )->fetchColumn(); + } + + public function deleteItem(string $key): bool + { + $this->pdo->prepare('DELETE FROM cache WHERE key = :k')->execute([':k' => $key]); + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $k) { + $this->deleteItem($k); + } + return true; + } + + public function getItem(string $key): SqliteCacheItem + { + $stmt = $this->pdo->prepare( + 'SELECT value, expires FROM cache WHERE key = :k LIMIT 1', + ); + $stmt->execute([':k' => $key]); + $row = $stmt->fetch(PDO::FETCH_ASSOC); + + $now = time(); + if ($row && (!$row['expires'] || $row['expires'] > $now)) { + $record = CachePayloadCodec::decode($row['value']); + $expiresAt = is_numeric($row['expires']) ? (int) $row['expires'] : null; + if ($record !== null) { + $expiresAt = $record['expires'] ?? $expiresAt; + if (!CachePayloadCodec::isExpired($expiresAt, $now)) { + return new SqliteCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($expiresAt), + ); + } + } + } + + $this->pdo->prepare('DELETE FROM cache WHERE key = :k')->execute([':k' => $key]); + return new SqliteCacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + if ($keys === []) { + return []; + } + + $rows = $this->fetchRowsByKeys($keys); + $items = []; + $now = time(); + $staleKeys = []; + + foreach ($keys as $k) { + $items[$k] = $this->buildFetchedItem($k, $rows, $now, $staleKeys); + } + + if ($staleKeys !== []) { + $this->deleteMany($staleKeys); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + throw new CacheInvalidArgumentException('Wrong item class'); + } + + $expires = CachePayloadCodec::expirationFromItem($item); + $ttl = $expires['ttl']; + if ($ttl === 0) { + return $this->deleteItem($item->getKey()); + } + + $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + + $stmt = $this->pdo->prepare( + 'REPLACE INTO cache(key, value, expires) VALUES(:k, :v, :e)', + ); + return $stmt->execute([ + ':k' => $item->getKey(), + ':v' => $blob, + ':e' => $expires['expiresAt'], + ]); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof SqliteCacheItem; + } + + private function buildFetchedItem( + string $key, + array $rows, + int $now, + array &$staleKeys, + ): SqliteCacheItem { + if (isset($rows[$key])) { + $item = $this->hydrateFetchedItem($key, $rows[$key], $now); + if ($item !== null) { + return $item; + } + + $staleKeys[] = $key; + } + + return new SqliteCacheItem($this, $key); + } + + /** + * @param array $keys + */ + private function deleteMany(array $keys): void + { + if ($keys === []) { + return; + } + + $marks = implode(',', array_fill(0, count($keys), '?')); + $stmt = $this->pdo->prepare("DELETE FROM cache WHERE key IN ($marks)"); + $stmt->execute($keys); + } + + /** + * @return array + */ + private function fetchRowsByKeys(array $keys): array + { + $marks = implode(',', array_fill(0, count($keys), '?')); + $stmt = $this->pdo->prepare( + "SELECT key, value, expires + FROM cache + WHERE key IN ($marks)", + ); + $stmt->execute($keys); + + /** @var array $rows */ + $rows = []; + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $r) { + $rows[$r['key']] = ['value' => $r['value'], 'expires' => $r['expires']]; + } + + return $rows; + } + + private function hydrateFetchedItem( + string $key, + array $row, + int $now, + ): ?SqliteCacheItem { + if ($row['expires'] !== null && $row['expires'] <= $now) { + return null; + } + + $record = CachePayloadCodec::decode($row['value']); + $expiresAt = $row['expires']; + + if ($record === null) { + return null; + } + + $expiresAt = $record['expires'] ?? $expiresAt; + if (CachePayloadCodec::isExpired($expiresAt, $now)) { + return null; + } + + return new SqliteCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($expiresAt), + ); + } +} diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php new file mode 100644 index 0000000..f87233e --- /dev/null +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -0,0 +1,201 @@ + */ + private array $scalarStore = []; + /** @var array */ + private array $weakExpires = []; + private WeakMap $weakObjects; + /** @var array */ + private array $weakRefs = []; + + public function __construct(string $namespace = 'default') + { + $this->ns = sanitize_cache_ns($namespace); + $this->weakObjects = new WeakMap(); + } + + public function clear(): bool + { + $this->scalarStore = []; + $this->weakRefs = []; + $this->weakExpires = []; + $this->weakObjects = new WeakMap(); + $this->deferred = []; + return true; + } + + public function count(): int + { + $this->pruneCollected(); + $this->pruneExpiredScalar(); + + $count = count($this->scalarStore); + foreach ($this->weakRefs as $mapped => $ref) { + $obj = $ref->get(); + if (!is_object($obj)) { + continue; + } + + if (CachePayloadCodec::isExpired($this->weakExpires[$mapped] ?? null)) { + continue; + } + + $count++; + } + + return $count; + } + + public function deleteItem(string $key): bool + { + $mapped = $this->map($key); + unset($this->scalarStore[$mapped], $this->weakExpires[$mapped]); + + $ref = $this->weakRefs[$mapped] ?? null; + if ($ref instanceof WeakReference) { + $obj = $ref->get(); + if (is_object($obj) && isset($this->weakObjects[$obj])) { + unset($this->weakObjects[$obj]); + } + } + + unset($this->weakRefs[$mapped]); + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem((string) $key); + } + + return true; + } + + public function getItem(string $key): GenericCacheItem + { + $this->pruneCollected(); + $mapped = $this->map($key); + + if (isset($this->weakRefs[$mapped])) { + $ref = $this->weakRefs[$mapped]; + $obj = $ref->get(); + $exp = $this->weakExpires[$mapped] ?? null; + + if (is_object($obj) && !CachePayloadCodec::isExpired($exp)) { + $item = new GenericCacheItem($this, $key); + $item->set($obj); + if ($exp !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($exp)); + } + + return $item; + } + + $this->deleteItem($key); + } + + if (!isset($this->scalarStore[$mapped])) { + return new GenericCacheItem($this, $key); + } + + $record = CachePayloadCodec::decode($this->scalarStore[$mapped]); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + unset($this->scalarStore[$mapped]); + return new GenericCacheItem($this, $key); + } + + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[(string) $key] = $this->getItem((string) $key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + $mapped = $this->map($item->getKey()); + $value = $item->get(); + + if (is_object($value)) { + $ref = WeakReference::create($value); + $this->weakRefs[$mapped] = $ref; + $this->weakExpires[$mapped] = $expires['expiresAt']; + $this->weakObjects[$value] = ['key' => $mapped, 'expires' => $expires['expiresAt']]; + unset($this->scalarStore[$mapped]); + return true; + } + + unset($this->weakRefs[$mapped], $this->weakExpires[$mapped]); + $this->scalarStore[$mapped] = CachePayloadCodec::encode($value, $expires['expiresAt']); + return true; + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private function map(string $key): string + { + return $this->ns . ':' . $key; + } + + private function pruneCollected(): void + { + foreach ($this->weakRefs as $mapped => $ref) { + $obj = $ref->get(); + if (!is_object($obj) || CachePayloadCodec::isExpired($this->weakExpires[$mapped] ?? null)) { + unset($this->weakRefs[$mapped], $this->weakExpires[$mapped]); + } + } + } + + private function pruneExpiredScalar(): void + { + foreach ($this->scalarStore as $mapped => $blob) { + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + unset($this->scalarStore[$mapped]); + } + } + } +} diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php new file mode 100644 index 0000000..d841144 --- /dev/null +++ b/src/Cache/Cache.php @@ -0,0 +1,1219 @@ +lockProvider = $lockProvider ?? new FileLockProvider(); + $this->metrics = $metrics ?? new InMemoryCacheMetricsCollector(); + } + + /** + * Retrieves a value from the cache using magic property access. + * + * This method allows accessing cached values using property syntax. + * It is equivalent to calling the `get()` method with the property name. + * + * @param string $name The key for which to retrieve the value. + * @return mixed The value associated with the given key. + * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if the key is invalid. + */ + public function __get(string $name): mixed + { + return $this->get($name); + } + + /** + * Whether the given key is set in the cache. + * + * @throws Psr6InvalidArgumentException + */ + public function __isset(string $name): bool + { + return $this->has($name); + } + + /** + * Sets a value in the cache. + * + * Magic property setter, equivalent to calling `set($name, $value, null)`. + * + * + * @throws SimpleCacheInvalidArgument if the key is invalid + */ + public function __set(string $name, mixed $value): void + { + $this->set($name, $value); + } + + /** + * Magic method to unset an item in the cache. + * + * This method deletes the cache entry associated with the given name. + * + * @param string $name The name of the cache item to unset. + * + * @throws SimpleCacheInvalidArgument + */ + public function __unset(string $name): void + { + $this->delete($name); + } + + /** + * Static factory for APCu-based cache. + * + * @param string $namespace Cache prefix. Will be suffixed to each key. + */ + public static function apcu(string $namespace = 'default'): self + { + return new self(new Adapter\ApcuCacheAdapter($namespace)); + } + + /** + * @param array $pools + */ + public static function chain(array $pools): self + { + return new self(new Adapter\ChainCacheAdapter($pools)); + } + + public static function dynamoDb( + string $namespace = 'default', + string $table = 'cachelayer_entries', + ?object $client = null, + array $config = [], + ): self { + if ($client === null) { + if (!class_exists(\Aws\DynamoDb\DynamoDbClient::class)) { + throw new CacheInvalidArgumentException( + 'aws/aws-sdk-php is required unless a DynamoDB client is provided.', + ); + } + + $client = new \Aws\DynamoDb\DynamoDbClient($config + [ + 'version' => 'latest', + 'region' => 'us-east-1', + ]); + } + + return new self(new Adapter\DynamoDbCacheAdapter($client, $table, $namespace)); + } + + /** + * Static factory for file-based cache. + * + * @param string $namespace Cache prefix. Will be suffixed to each key. + * @param string|null $dir Directory to store cache files (or null → sys temp dir). + */ + public static function file(string $namespace = 'default', ?string $dir = null): self + { + return new self(new Adapter\FileCacheAdapter($namespace, $dir)); + } + + + /** + * Static factory for local cache selection. + * + * Determines the appropriate caching mechanism based on the availability of the APCu extension. + * If APCu is enabled, it returns an APCu-based cache; otherwise, it defaults to a file-based cache. + * + * @param string $namespace Cache prefix. Will be suffixed to each key. + * @param string|null $dir Directory to store cache files (or null → sys temp dir), used if APCu is not enabled. + * @return static An instance of the cache using the selected adapter. + */ + public static function local( + string $namespace = 'default', + ?string $dir = null, + ): self { + if (extension_loaded('apcu') && apcu_enabled()) { + return self::apcu($namespace); + } + + return self::file($namespace, $dir); + } + + /** + * Static factory for Memcached-based cache. + * + * @param string $namespace Cache prefix. Will be suffixed to each key. + * @param array $servers Memcached servers as an array of `[host, port, weight]`. + * The `weight` is a float between 0 and 1, and defaults to 0. + * @param \Memcached|null $client Optional preconfigured Memcached instance. + */ + public static function memcache( + string $namespace = 'default', + array $servers = [['127.0.0.1', 11211, 0]], + ?\Memcached $client = null, + ): self { + $adapter = new Adapter\MemCacheAdapter($namespace, $servers, $client); + + return (new self($adapter))->setLockProvider( + new MemcachedLockProvider($adapter->getClient()), + ); + } + + public static function memory(string $namespace = 'default'): self + { + return new self(new Adapter\ArrayCacheAdapter($namespace)); + } + + public static function mongodb( + string $namespace = 'default', + ?object $collection = null, + ?object $client = null, + string $database = 'cachelayer', + string $collectionName = 'entries', + string $uri = 'mongodb://127.0.0.1:27017', + ): self { + if ($collection === null) { + if ($client === null) { + if (!class_exists(\MongoDB\Client::class)) { + throw new CacheInvalidArgumentException( + 'mongodb/mongodb is required unless a collection/client is provided.', + ); + } + + $client = new \MongoDB\Client($uri); + } + + $adapter = Adapter\MongoDbCacheAdapter::fromClient( + $client, + $database, + $collectionName, + $namespace, + ); + + return new self($adapter); + } + + return new self(new Adapter\MongoDbCacheAdapter($collection, $namespace)); + } + + public static function nullStore(): self + { + return new self(new Adapter\NullCacheAdapter()); + } + + public static function phpFiles(string $namespace = 'default', ?string $dir = null): self + { + return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir)); + } + + public static function postgres( + string $namespace = 'default', + ?string $dsn = null, + ?string $username = null, + ?string $password = null, + ?\PDO $pdo = null, + string $table = 'cachelayer_entries', + ): self { + return new self(new Adapter\PostgresCacheAdapter($namespace, $dsn, $username, $password, $pdo, $table)); + } + + /** + * Static factory for Redis cache. + * + * @param string $namespace Cache prefix. + * @param string $dsn DSN for Redis connection (e.g. 'redis://127.0.0.1:6379'), + * or null to use the default ('redis://127.0.0.1:6379'). + * @param \Redis|null $client Optional preconfigured Redis instance. + */ + public static function redis( + string $namespace = 'default', + string $dsn = 'redis://127.0.0.1:6379', + ?\Redis $client = null, + ): self { + $adapter = new Adapter\RedisCacheAdapter($namespace, $dsn, $client); + + return (new self($adapter))->setLockProvider( + new RedisLockProvider($adapter->getClient()), + ); + } + + public static function redisCluster( + string $namespace = 'default', + array $seeds = ['127.0.0.1:6379'], + float $timeout = 1.0, + float $readTimeout = 1.0, + bool $persistent = false, + ?object $client = null, + ): self { + return new self( + new Adapter\RedisClusterCacheAdapter( + $namespace, + $seeds, + $timeout, + $readTimeout, + $persistent, + $client, + ), + ); + } + + public static function s3( + string $namespace = 'default', + string $bucket = 'cachelayer', + ?object $client = null, + array $config = [], + string $prefix = 'cachelayer', + ): self { + if ($client === null) { + if (!class_exists(\Aws\S3\S3Client::class)) { + throw new CacheInvalidArgumentException( + 'aws/aws-sdk-php is required unless an S3 client is provided.', + ); + } + + $client = new \Aws\S3\S3Client($config + [ + 'version' => 'latest', + 'region' => 'us-east-1', + ]); + } + + return new self(new Adapter\S3CacheAdapter($client, $bucket, $prefix, $namespace)); + } + + public static function sharedMemory(string $namespace = 'default', int $segmentSize = 16_777_216): self + { + return new self(new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize)); + } + + /** + * Static factory for SQLite-based cache. + * + * @param string $namespace Cache prefix. Will be suffixed to each key. + * @param string|null $file Path to SQLite file (or null → sys temp dir). + */ + public static function sqlite(string $namespace = 'default', ?string $file = null): self + { + return new self(new Adapter\SqliteCacheAdapter($namespace, $file)); + } + + public static function weakMap(string $namespace = 'default'): self + { + return new self(new Adapter\WeakMapCacheAdapter($namespace)); + } + + /** + * Removes all items from the cache. + * + * @return bool + * True if the operation was successful, false otherwise. + */ + public function clear(): bool + { + return $this->adapter->clear(); + } + + /** + * Wipes out the entire cache. + */ + public function clearCache(): bool + { + return $this->clear(); + } + + /** + * Commits any deferred cache items. + * + * If the underlying adapter supports deferred cache items, this + * method will persist all items that have been added to the deferred + * queue. If the adapter does not support deferred cache items, this + * method is a no-op. + * + * @return bool True if all deferred items were successfully saved, false otherwise. + */ + public function commit(): bool + { + return $this->adapter->commit(); + } + + public function configurePayloadCompression(?int $thresholdBytes = null, int $level = 6): self + { + Adapter\CachePayloadCodec::configureCompression($thresholdBytes, $level); + return $this; + } + + /** + * Returns the number of items in the cache. + * + * If the adapter implements the {@see Countable} interface, it will be + * used to retrieve the count. Otherwise, this method will use the + * {@see iterable} interface to count the items. + * + * @throws Psr6InvalidArgumentException + */ + public function count(): int + { + return $this->adapter instanceof Countable + ? count($this->adapter) + : iterator_count($this->adapter->getItems([])); + } + + /** + * Delete an item from the cache. + * + * @throws SimpleCacheInvalidArgument if the key is invalid + */ + public function delete(string $key): bool + { + $this->validateKey($key); + + try { + $deleted = $this->adapter->deleteItem($key); + } catch (Psr6InvalidArgumentException $e) { + throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); + } + + $this->clearTagMeta($key); + $this->metric('delete'); + + return $deleted; + } + + /** + * Deletes a single item from the cache. + * + * This method deletes the item from the cache if it exists. If the item does + * not exist, it is silently ignored. + * + * @param string $key + * The key of the item to delete. + * + * @return bool + * True if the item was successfully deleted, false otherwise. + * @throws Psr6InvalidArgumentException + */ + public function deleteItem(string $key): bool + { + $this->validateKey($key); + $deleted = $this->adapter->deleteItem($key); + $this->clearTagMeta($key); + $this->metric('delete'); + return $deleted; + } + + /** + * Deletes multiple items from the cache. + * + * @param string[] $keys The array of keys to delete. + * + * @return bool True if all items were successfully deleted, false otherwise. + * @throws Psr6InvalidArgumentException + */ + public function deleteItems(array $keys): bool + { + foreach ($keys as $k) { + $this->validateKey((string) $k); + } + $deleted = $this->adapter->deleteItems($keys); + foreach ($keys as $key) { + $this->clearTagMeta((string) $key); + } + $this->metric('delete_batch'); + return $deleted; + } + + /** + * Deletes multiple keys from the cache. + * + * @param iterable $keys + * @throws SimpleCacheInvalidArgument if any key is invalid + */ + public function deleteMultiple(iterable $keys): bool + { + $allSucceeded = true; + foreach ($keys as $k) { + /** @var string $k */ + $this->validateKey($k); + if (!$this->deleteItem($k)) { + $allSucceeded = false; + } + } + return $allSucceeded; + } + + /** + * @return array> + */ + public function exportMetrics(): array + { + $snapshot = $this->metrics->export(); + if ($this->metricsExportHook !== null) { + ($this->metricsExportHook)($snapshot); + } + + return $snapshot; + } + + /** + * Fetches a value from the cache. If the key does not exist, returns $default. + * + * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if the key is invalid + */ + public function get(string $key, mixed $default = null): mixed + { + $this->validateKey($key); + + // If $default is a callable, do a PSR-6 “compute & save” on cache miss. + if (is_callable($default)) { + return $this->remember($key, $default); + } + + try { + $item = $this->adapter->getItem($key); + } catch (Psr6InvalidArgumentException $e) { + throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); + } + + if (!$item->isHit()) { + $this->metric('miss'); + return $default; + } + + if (!$this->isTagMetaValid($key)) { + $this->purgeKeyAndTagMeta($key); + $this->metric('miss'); + return $default; + } + + $this->metric('hit'); + return $item->get(); + } + + /** + * Retrieves a Cache Item representing the specified key. + * + * This method returns a CacheItemInterface object containing the cached value. + * + * @param string $key + * The key of the item to retrieve. + * + * @return CacheItemInterface + * The retrieved Cache Item. + * @throws CacheInvalidArgumentException + * If the $key is invalid or if a CacheLoader is not available when + * the value is not found. + * + */ + public function getItem(string $key): CacheItemInterface + { + $this->validateKey($key); + $item = $this->adapter->getItem($key); + if (!$item->isHit()) { + return $item; + } + + if (!$this->isTagMetaValid($key)) { + $this->purgeKeyAndTagMeta($key); + return $this->adapter->getItem($key); + } + + return $item; + } + + /** + * Returns an iterable of {@see CacheItemInterface} objects for the given + * keys. + * + * If no keys are provided, an empty iterator is returned. + * + * If the adapter supports it, the method will use the adapter's + * `multiFetch` method. Otherwise, it iterates over the keys and calls + * `getItem` on each key. + * + * @param string[] $keys + * An array of keys to fetch from the cache. + * + * @return iterable + * An iterable of CacheItemInterface objects. + */ + public function getItems(array $keys = []): iterable + { + // If empty, return empty iterator + if ($keys === []) { + return new \EmptyIterator(); + } + + foreach ($keys as $key) { + $this->validateKey((string) $key); + } + + $fetched = method_exists($this->adapter, 'multiFetch') + ? $this->adapter->multiFetch($keys) + : iterator_to_array($this->adapter->getItems($keys), true); + + $out = []; + foreach ($keys as $key) { + $k = (string) $key; + $item = $fetched[$k] ?? $this->adapter->getItem($k); + + if (!$item->isHit()) { + $this->metric('miss'); + $out[$k] = $item; + continue; + } + + if (!$this->isTagMetaValid($k)) { + $this->purgeKeyAndTagMeta($k); + $this->metric('miss'); + $out[$k] = $this->adapter->getItem($k); + continue; + } + + $this->metric('hit'); + $out[$k] = $item; + } + + return $out; + } + + + /** + * Returns an iterable of {@see CacheItemInterface} objects for the given + * keys. + * + * If no keys are provided, an empty iterator is returned. + * + * This method is a wrapper for `getItems()`, and is intended for use with + * iterators. + * + * @param string[] $keys + * An array of keys to fetch from the cache. + * + * @return iterable + * An iterable of CacheItemInterface objects. + */ + public function getItemsIterator(array $keys = []): iterable + { + return $this->getItems($keys); + } + + /** + * Obtains multiple values by their keys. + * + * @param iterable $keys + * @return iterable + * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if any key is invalid + */ + public function getMultiple(iterable $keys, mixed $default = null): iterable + { + $result = []; + foreach ($keys as $k) { + /** @var string $k */ + $this->validateKey($k); + $result[$k] = $this->get($k, $default); + } + return $result; + } + + /** + * Determines whether an item exists in the cache. + * + * @throws Psr6InvalidArgumentException if the key is invalid + */ + public function has(string $key): bool + { + return $this->hasItem($key); + } + + /** + * Checks if an item is present in the cache. + * + * @param string $key + * The key to check. + * + * @return bool + * True if the item exists in the cache, false otherwise. + * @throws Psr6InvalidArgumentException + */ + public function hasItem(string $key): bool + { + $this->validateKey($key); + $item = $this->adapter->getItem($key); + if (!$item->isHit()) { + $this->metric('miss'); + return false; + } + + if (!$this->isTagMetaValid($key)) { + $this->purgeKeyAndTagMeta($key); + $this->metric('miss'); + return false; + } + + $this->metric('hit'); + return true; + } + + /** + * Invalidates all cache entries associated with a specific tag. + * + * This method removes all cache items that have been tagged with the given tag. + * It uses an internal tag index to efficiently locate and invalidate tagged entries. + * + * @param string $tag The tag to invalidate. All cache entries with this tag will be removed. + * @return bool True if the operation was successful, false otherwise. + * @throws CacheInvalidArgumentException If the tag is invalid. + * @throws Psr6InvalidArgumentException If there's an issue with cache operations. + */ + public function invalidateTag(string $tag): bool + { + $normalized = $this->normalizeTag($tag); + $next = $this->currentTagVersion($normalized) + 1; + + return $this->writeTagVersion($normalized, $next); + } + + /** + * Invalidates all cache entries associated with multiple tags. + * + * This method iterates through each tag and invalidates all cache entries + * associated with that tag. The operation is successful only if all tags + * are successfully invalidated. + * + * @param array $tags An array of tags to invalidate. + * @return bool True if all tags were successfully invalidated, false if any failed. + * @throws CacheInvalidArgumentException If any tag is invalid. + * @throws Psr6InvalidArgumentException If there's an issue with cache operations. + */ + public function invalidateTags(array $tags): bool + { + $ok = true; + $seen = []; + + foreach ($tags as $tag) { + $normalized = $this->normalizeTag((string) $tag); + if (isset($seen[$normalized])) { + continue; + } + + $seen[$normalized] = true; + $next = $this->currentTagVersion($normalized) + 1; + $ok = $this->writeTagVersion($normalized, $next) && $ok; + } + + return $ok; + } + + /** + * Required by interface ArrayAccess. + * + * {@inheritdoc} + * + * @throws Psr6InvalidArgumentException + * @see has() + */ + public function offsetExists(mixed $offset): bool + { + return $this->has((string) $offset); + } + + /** + * Retrieves the value for the specified offset from the cache. + * + * This method allows the use of array-like syntax to retrieve a value + * from the cache. The offset is converted to a string before retrieval. + * + * @param mixed $offset The key at which to retrieve the value. + * + * @return mixed The value at the specified offset. + * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if the key is invalid + */ + public function offsetGet(mixed $offset): mixed + { + return $this->get((string) $offset); + } + + /** + * Sets a value in the cache at the specified offset. + * + * This method allows the use of array-like syntax to store a value + * in the cache. The offset is converted to a string before storing. + * The time-to-live (TTL) for the cache entry is set to null by default. + * + * @param mixed $offset The key at which to set the value. + * @param mixed $value The value to be stored at the specified offset. + * + * @throws SimpleCacheInvalidArgument if the key is invalid + */ + public function offsetSet(mixed $offset, mixed $value): void + { + $this->set((string) $offset, $value); + } + + /** + * Unsets a key from the cache. + * + * @param string $offset + * @throws Psr6InvalidArgumentException|SimpleCacheInvalidArgument if the key is invalid + */ + public function offsetUnset(mixed $offset): void + { + $this->delete((string) $offset); + } + + /** + * Compute-once helper with cache stampede protection. + * + * On cache miss, this acquires a host-local lock, re-checks cache, computes, + * applies jittered TTL, persists, and returns the computed value. + */ + public function remember( + string $key, + callable $resolver, + mixed $ttl = null, + array $tags = [], + ): mixed { + $this->validateKey($key); + $normalizedTtl = $this->normalizeTtl($ttl); + $normalizedTags = $this->normalizeTagList($tags); + + try { + $item = $this->getItem($key); + } catch (Psr6InvalidArgumentException $e) { + throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); + } + + if ($item->isHit()) { + $this->metric('remember_hit'); + return $item->get(); + } + + $lockHandle = $this->lockProvider->acquire($this->stampedeLockKey($key), self::STAMPEDE_LOCK_WAIT_SECONDS); + try { + // Re-check under lock to avoid duplicate recompute. + $lockedItem = $this->getItem($key); + if ($lockedItem->isHit()) { + $this->metric('remember_hit'); + return $lockedItem->get(); + } + + if ($normalizedTtl !== null) { + $lockedItem->expiresAfter($normalizedTtl); + } + + $computed = $resolver($lockedItem); + $lockedItem->set($computed); + $this->applyJitteredTtl($lockedItem); + $this->save($lockedItem); + + if ($normalizedTags !== [] && !$this->writeTagMeta($key, $normalizedTags, $normalizedTtl)) { + throw new CacheInvalidArgumentException("Unable to store tag metadata for key '$key'"); + } + + $this->metric('remember_miss'); + return $computed; + } finally { + $this->lockProvider->release($lockHandle); + } + } + + /** + * Persists a cache item immediately. + * + * This method will throw a Psr6InvalidArgumentException if the item does not + * implement CacheItemInterface. + * + * @param CacheItemInterface $item + * The cache item to persist. + * + * @return bool + * True if the cache item was successfully persisted, false otherwise. + * @throws Psr6InvalidArgumentException + * If the item does not implement CacheItemInterface. + */ + public function save(CacheItemInterface $item): bool + { + return $this->adapter->save($item); + } + + /** + * Adds a cache item to the deferred queue for later persistence. + * + * This method queues the given cache item, to be saved when the + * `commit()` method is invoked. It does not persist the item immediately. + * + * @param CacheItemInterface $item The cache item to defer. + * @return bool True if the item was successfully deferred, false if the item type is invalid. + */ + public function saveDeferred(CacheItemInterface $item): bool + { + return $this->adapter->saveDeferred($item); + } + + /** + * Persists a value in the cache, optionally with a TTL. + * + * @param int|DateInterval|null $ttl Time-to-live in seconds or a DateInterval + * @throws SimpleCacheInvalidArgument if the key or TTL is invalid + */ + public function set(string $key, mixed $value, mixed $ttl = null): bool + { + $this->validateKey($key); + $ttlSeconds = $this->normalizeTtl($ttl); + + $result = false; + if (method_exists($this->adapter, 'set')) { + try { + $result = $this->adapter->set($key, $value, $ttlSeconds); + } catch (Psr6InvalidArgumentException $e) { + throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); + } + } else { + // Fall back to PSR-6 approach + try { + $item = $this->adapter->getItem($key)->set($value)->expiresAfter($ttlSeconds); + $result = $this->save($item); + } catch (Psr6InvalidArgumentException $e) { + throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); + } + } + + if ($result) { + $this->clearTagMeta($key); + $this->metric('set'); + } + + return $result; + } + + public function setLockProvider(LockProviderInterface $lockProvider): self + { + $this->lockProvider = $lockProvider; + return $this; + } + + public function setMetricsCollector(CacheMetricsCollectorInterface $metrics): self + { + $this->metrics = $metrics; + return $this; + } + + public function setMetricsExportHook(?callable $hook): self + { + $this->metricsExportHook = $hook !== null ? Closure::fromCallable($hook) : null; + return $this; + } + + /** + * Persists multiple key ⇒ value pairs to the cache. + * + * @param iterable $values key ⇒ value mapping + * @param int|DateInterval|null $ttl TTL for all items + * @throws SimpleCacheInvalidArgument if any key is invalid + */ + public function setMultiple(iterable $values, mixed $ttl = null): bool + { + $ttlSeconds = $this->normalizeTtl($ttl); + $allSucceeded = true; + + foreach ($values as $k => $v) { + /** @var string $k */ + $this->validateKey($k); + $ok = $this->set($k, $v, $ttlSeconds); + if (!$ok) { + $allSucceeded = false; + } + } + + return $allSucceeded; + } + + /** + * Changes the namespace and directory for the pool. + * + * If the adapter implements {@see CacheItemPoolInterface::setNamespaceAndDirectory}, + * this call is forwarded to the adapter. Otherwise, a {@see \BadMethodCallException} is thrown. + * + * @param string $namespace The new namespace. + * @param string|null $dir The new directory, or null to use the default. + * + * @throws BadMethodCallException if the adapter does not support this method. + */ + public function setNamespaceAndDirectory(string $namespace, ?string $dir = null): void + { + if (method_exists($this->adapter, 'setNamespaceAndDirectory')) { + $this->adapter->setNamespaceAndDirectory($namespace, $dir); + return; + } + throw new BadMethodCallException( + sprintf('%s does not support setNamespaceAndDirectory()', $this->adapter::class), + ); + } + + /** + * Stores a value and associates it with one or more tags. + * + * This method allows you to tag cache entries for later bulk invalidation. + * Tags provide a way to group related cache items and invalidate them + * together when the underlying data changes. + * + * @param string $key The cache key under which to store the value. + * @param mixed $value The value to store in the cache. + * @param array $tags An array of tags to associate with this cache entry. + * @param int|DateInterval|null $ttl Optional time-to-live for the cache entry. + * @return bool True if the operation was successful, false otherwise. + * @throws CacheInvalidArgumentException If the key or tags are invalid. + * @throws SimpleCacheInvalidArgument If the key or TTL is invalid. + */ + public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = null): bool + { + $normalizedTags = $this->normalizeTagList($tags); + $ok = $this->set($key, $value, $ttl); + if (!$ok) { + return false; + } + + $ttlSeconds = $this->normalizeTtl($ttl); + return $this->writeTagMeta($key, $normalizedTags, $ttlSeconds); + } + + public function useMemcachedLock(?\Memcached $client = null, string $prefix = 'cachelayer:lock:'): self + { + if (!$client && method_exists($this->adapter, 'getClient')) { + $candidate = $this->adapter->getClient(); + if ($candidate instanceof \Memcached) { + $client = $candidate; + } + } + + if (!$client instanceof \Memcached) { + throw new CacheInvalidArgumentException('Memcached lock provider requires a Memcached client instance.'); + } + + return $this->setLockProvider(new MemcachedLockProvider($client, $prefix)); + } + + public function useRedisLock(?\Redis $client = null, string $prefix = 'cachelayer:lock:'): self + { + if (!$client && method_exists($this->adapter, 'getClient')) { + $candidate = $this->adapter->getClient(); + if ($candidate instanceof \Redis) { + $client = $candidate; + } + } + + if (!$client instanceof \Redis) { + throw new CacheInvalidArgumentException('Redis lock provider requires a Redis client instance.'); + } + + return $this->setLockProvider(new RedisLockProvider($client, $prefix)); + } + + private function applyJitteredTtl(CacheItemInterface $item): void + { + if (!method_exists($item, 'ttlSeconds')) { + return; + } + + $ttl = $item->ttlSeconds(); + if ($ttl === null || $ttl <= 1 || self::STAMPEDE_JITTER_PERCENT <= 0) { + return; + } + + $maxJitter = max(1, (int) floor($ttl * (self::STAMPEDE_JITTER_PERCENT / 100))); + $jitter = random_int(0, $maxJitter); + $item->expiresAfter(max(1, $ttl - $jitter)); + } + + private function clearTagMeta(string $key): void + { + $this->adapter->deleteItem($this->tagMetaKey($key)); + } + + private function currentTagVersion(string $normalizedTag): int + { + $key = $this->tagVersionKey($normalizedTag); + $item = $this->adapter->getItem($key); + if ($item->isHit() && is_int($item->get()) && $item->get() > 0) { + return $item->get(); + } + + $item->set(1)->expiresAfter(null); + $this->adapter->save($item); + return 1; + } + + private function isTagMetaValid(string $key): bool + { + $metaItem = $this->adapter->getItem($this->tagMetaKey($key)); + if (!$metaItem->isHit()) { + return true; + } + + $meta = $metaItem->get(); + if (!is_array($meta)) { + return false; + } + + foreach ($meta as $tag => $expectedVersion) { + if (!is_string($tag) || !is_int($expectedVersion)) { + return false; + } + + if ($this->currentTagVersion($tag) !== $expectedVersion) { + return false; + } + } + + return true; + } + + private function metric(string $name): void + { + $this->metrics->increment($this->adapter::class, $name); + } + + private function normalizeTag(string $tag): string + { + $tag = trim($tag); + if ($tag === '') { + throw new CacheInvalidArgumentException('Cache tag cannot be empty.'); + } + + return preg_replace('/[^A-Za-z0-9_.\-]/', '_', $tag) ?? ''; + } + + /** + * @param array $tags + * @return array + */ + private function normalizeTagList(array $tags): array + { + $out = []; + foreach ($tags as $tag) { + $out[] = $this->normalizeTag((string) $tag); + } + + return array_values(array_unique($out)); + } + + /** + * Converts a PSR-16 TTL (int|DateInterval|null) into an integer number of seconds. + */ + private function normalizeTtl(mixed $ttl): ?int + { + if ($ttl === null) { + return null; + } + + if (is_int($ttl)) { + return $ttl >= 0 ? $ttl : throw new CacheInvalidArgumentException('Negative TTL not allowed'); + } + + if ($ttl instanceof DateInterval) { + $now = new DateTime(); + return max(0, $now->add($ttl)->getTimestamp() - (new DateTime())->getTimestamp()); + } + + throw new CacheInvalidArgumentException( + sprintf( + 'Invalid TTL type; expected null, int, or DateInterval, got %s', + get_debug_type($ttl), + ), + ); + } + + private function purgeKeyAndTagMeta(string $key): void + { + $this->adapter->deleteItem($key); + $this->adapter->deleteItem($this->tagMetaKey($key)); + } + + private function stampedeLockKey(string $key): string + { + return '__im_lock_' . hash('xxh128', $key); + } + + private function tagMetaKey(string $key): string + { + return self::TAG_META_PREFIX . hash('xxh3', $key); + } + + private function tagVersionKey(string $normalizedTag): string + { + return self::TAG_VERSION_PREFIX . hash('xxh3', $normalizedTag); + } + + /** + * Validates a cache key per PSR-16 rules (and reuses for PSR-6). + * + * @throws CacheInvalidArgumentException if the key is invalid. + */ + private function validateKey(string $key): void + { + if ($key === '' || !preg_match('/^[A-Za-z0-9_.\-]+$/', $key)) { + throw new CacheInvalidArgumentException( + 'Invalid cache key; allowed characters: A-Z, a-z, 0-9, _, ., -', + ); + } + } + + /** + * @param array $tags + */ + private function writeTagMeta(string $key, array $tags, ?int $ttl): bool + { + if ($tags === []) { + $this->clearTagMeta($key); + return true; + } + + $versions = []; + foreach (array_values(array_unique($tags)) as $tag) { + $normalized = $this->normalizeTag((string) $tag); + $versions[$normalized] = $this->currentTagVersion($normalized); + } + + $metaItem = $this->adapter->getItem($this->tagMetaKey($key)); + $metaItem->set($versions); + $metaItem->expiresAfter($ttl); + return $this->adapter->save($metaItem); + } + + private function writeTagVersion(string $normalizedTag, int $version): bool + { + $item = $this->adapter->getItem($this->tagVersionKey($normalizedTag)); + $item->set(max(1, $version))->expiresAfter(null); + return $this->adapter->save($item); + } +} diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php new file mode 100644 index 0000000..a1f11de --- /dev/null +++ b/src/Cache/CacheInterface.php @@ -0,0 +1,65 @@ +> + */ + public function exportMetrics(): array; + + public function invalidateTag(string $tag): bool; + + /** + * @param array $tags + */ + public function invalidateTags(array $tags): bool; + + /** + * @param array $tags + */ + public function remember(string $key, callable $resolver, mixed $ttl = null, array $tags = []): mixed; + + public function setLockProvider(LockProviderInterface $lockProvider): self; + + public function setMetricsCollector(CacheMetricsCollectorInterface $metrics): self; + + public function setMetricsExportHook(?callable $hook): self; + + /** + * @param array $tags + */ + public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = null): bool; + + public function useMemcachedLock(?\Memcached $client = null, string $prefix = 'cachelayer:lock:'): self; + + public function useRedisLock(?\Redis $client = null, string $prefix = 'cachelayer:lock:'): self; +} diff --git a/src/Cache/Item/AbstractCacheItem.php b/src/Cache/Item/AbstractCacheItem.php new file mode 100644 index 0000000..6cb16ff --- /dev/null +++ b/src/Cache/Item/AbstractCacheItem.php @@ -0,0 +1,123 @@ + $this->key, + 'value' => $this->value, + 'hit' => $this->hit, + 'exp' => $this->exp?->format(DateTimeInterface::ATOM), + ]; + } + + /** + * @throws Exception + */ + public function __unserialize(array $data): void + { + $this->key = $data['key']; + $this->value = ValueSerializer::unwrap($data['value']); + $this->hit = $data['hit']; + $this->exp = isset($data['exp']) ? new DateTime($data['exp']) : null; + $this->pool = null; + } + + public function expiresAfter(int|DateInterval|null $time): static + { + $this->exp = match (true) { + is_int($time) => (new DateTime())->add(new DateInterval("PT{$time}S")), + $time instanceof DateInterval => (new DateTime())->add($time), + default => null, + }; + return $this; + } + + public function expiresAt(?DateTimeInterface $expiration): static + { + $this->exp = $expiration; + return $this; + } + + public function get(): mixed + { + return $this->value; + } + + public function getKey(): string + { + return $this->key; + } + + public function isHit(): bool + { + if (!$this->hit) { + return false; + } + return $this->exp === null || (new DateTime()) < $this->exp; + } + + public function save(): static + { + $this->pool?->internalPersist($this); + return $this; + } + + public function saveDeferred(): static + { + $this->pool?->internalQueue($this); + return $this; + } + + public function set(mixed $value): static + { + $this->value = ValueSerializer::wrap($value); + $this->hit = true; + return $this; + } + + public function ttlSeconds(): ?int + { + return $this->exp ? max(0, $this->exp->getTimestamp() - time()) : null; + } +} diff --git a/src/Cache/Item/ApcuCacheItem.php b/src/Cache/Item/ApcuCacheItem.php new file mode 100644 index 0000000..7ce301f --- /dev/null +++ b/src/Cache/Item/ApcuCacheItem.php @@ -0,0 +1,7 @@ +directory = rtrim( + $directory ?? (sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cachelayer_locks'), + DIRECTORY_SEPARATOR, + ); + $this->retrySleepMicros = max(1_000, $retrySleepMicros); + } + + public function acquire(string $key, float $waitSeconds): ?LockHandle + { + $activeLocks = &self::activeRegistry(); + if (isset($activeLocks[$key])) { + return null; + } + + if (!is_dir($this->directory)) { + @mkdir($this->directory, 0770, true); + } + + $path = $this->directory . DIRECTORY_SEPARATOR . hash('xxh128', $key) . '.lock'; + $handle = @fopen($path, 'c+'); + if (!is_resource($handle)) { + return null; + } + + $deadline = microtime(true) + max(0.0, $waitSeconds); + while (!@flock($handle, LOCK_EX | LOCK_NB)) { + if (microtime(true) >= $deadline) { + @fclose($handle); + return null; + } + + usleep($this->retrySleepMicros); + } + + $token = hash('xxh3', $key . '|' . uniqid('', true)); + $activeLocks[$key] = true; + + return new LockHandle($key, $token, $handle); + } + + public function release(?LockHandle $handle): void + { + if (!$handle instanceof LockHandle) { + return; + } + + $activeLocks = &self::activeRegistry(); + + if (is_resource($handle->resource)) { + @flock($handle->resource, LOCK_UN); + @fclose($handle->resource); + } + + unset($activeLocks[$handle->key]); + } + + /** + * @return array + */ + private static function &activeRegistry(): array + { + static $registry = []; + return $registry; + } +} diff --git a/src/Cache/Lock/LockHandle.php b/src/Cache/Lock/LockHandle.php new file mode 100644 index 0000000..1805e5b --- /dev/null +++ b/src/Cache/Lock/LockHandle.php @@ -0,0 +1,14 @@ +retrySleepMicros = max(1_000, $retrySleepMicros); + } + + public function acquire(string $key, float $waitSeconds): ?LockHandle + { + $deadline = microtime(true) + max(0.0, $waitSeconds); + $lockKey = $this->prefix . hash('xxh128', $key); + $token = hash('xxh128', uniqid($key, true)); + $ttlSeconds = max(1, (int) ceil($waitSeconds + 1.0)); + + do { + if ($this->memcached->add($lockKey, $token, $ttlSeconds)) { + return new LockHandle($lockKey, $token); + } + + if (microtime(true) >= $deadline) { + return null; + } + + usleep($this->retrySleepMicros); + } while (true); + } + + public function release(?LockHandle $handle): void + { + if (!$handle instanceof LockHandle) { + return; + } + + try { + $current = $this->memcached->get($handle->key); + if ($this->memcached->getResultCode() === Memcached::RES_SUCCESS && $current === $handle->token) { + $this->memcached->delete($handle->key); + } + } catch (Throwable) { + // Best effort unlock. + } + } +} diff --git a/src/Cache/Lock/RedisLockProvider.php b/src/Cache/Lock/RedisLockProvider.php new file mode 100644 index 0000000..138b427 --- /dev/null +++ b/src/Cache/Lock/RedisLockProvider.php @@ -0,0 +1,66 @@ +retrySleepMicros = max(1_000, $retrySleepMicros); + } + + public function acquire(string $key, float $waitSeconds): ?LockHandle + { + $deadline = microtime(true) + max(0.0, $waitSeconds); + $lockKey = $this->prefix . hash('xxh128', $key); + $token = hash('xxh128', uniqid($key, true)); + $ttlMs = max(1_000, (int) ceil(($waitSeconds + 1.0) * 1000)); + + do { + $ok = $this->redis->set($lockKey, $token, ['nx', 'px' => $ttlMs]); + if ($ok) { + return new LockHandle($lockKey, $token); + } + + if (microtime(true) >= $deadline) { + return null; + } + + usleep($this->retrySleepMicros); + } while (true); + } + + public function release(?LockHandle $handle): void + { + if (!$handle instanceof LockHandle) { + return; + } + + $script = <<<'LUA' +if redis.call("GET", KEYS[1]) == ARGV[1] then + return redis.call("DEL", KEYS[1]) +end +return 0 +LUA; + + try { + $this->redis->eval($script, [$handle->key, $handle->token], 1); + } catch (Throwable) { + // Best effort unlock. + } + } +} diff --git a/src/Cache/Metrics/CacheMetricsCollectorInterface.php b/src/Cache/Metrics/CacheMetricsCollectorInterface.php new file mode 100644 index 0000000..8759be7 --- /dev/null +++ b/src/Cache/Metrics/CacheMetricsCollectorInterface.php @@ -0,0 +1,14 @@ +> + */ + public function export(): array; + public function increment(string $adapterClass, string $metric): void; +} diff --git a/src/Cache/Metrics/InMemoryCacheMetricsCollector.php b/src/Cache/Metrics/InMemoryCacheMetricsCollector.php new file mode 100644 index 0000000..f45a10e --- /dev/null +++ b/src/Cache/Metrics/InMemoryCacheMetricsCollector.php @@ -0,0 +1,23 @@ +> + */ + private array $counters = []; + + public function export(): array + { + return $this->counters; + } + + public function increment(string $adapterClass, string $metric): void + { + $this->counters[$adapterClass][$metric] = ($this->counters[$adapterClass][$metric] ?? 0) + 1; + } +} diff --git a/src/Exceptions/CacheInvalidArgumentException.php b/src/Exceptions/CacheInvalidArgumentException.php new file mode 100644 index 0000000..a9d19ab --- /dev/null +++ b/src/Exceptions/CacheInvalidArgumentException.php @@ -0,0 +1,11 @@ + */ + private array $__memo = []; + + protected function memoize(string $key, callable $producer): mixed + { + if (!array_key_exists($key, $this->__memo)) { + $this->__memo[$key] = $producer(); + } + + return $this->__memo[$key]; + } + + protected function memoizeClear(?string $key = null): void + { + if ($key === null) { + $this->__memo = []; + return; + } + + unset($this->__memo[$key]); + } +} diff --git a/src/Memoize/Memoizer.php b/src/Memoize/Memoizer.php new file mode 100644 index 0000000..6a002bc --- /dev/null +++ b/src/Memoize/Memoizer.php @@ -0,0 +1,147 @@ +> */ + private WeakMap $objectCache; + + /** @var array */ + private array $staticCache = []; + + private function __construct() + { + $this->objectCache = new WeakMap(); + } + + public static function instance(): self + { + return self::$instance ??= new self(); + } + + public function flush(): void + { + $this->staticCache = []; + $this->objectCache = new WeakMap(); + $this->hits = $this->misses = 0; + } + + /** + * @throws ReflectionException + */ + public function get(callable $callable, array $params = []): mixed + { + $cacheKey = self::buildCacheKey( + self::callableSignature($callable), + $params, + ); + + if (array_key_exists($cacheKey, $this->staticCache)) { + $this->hits++; + return $this->staticCache[$cacheKey]; + } + + $this->misses++; + $value = $callable(...$params); + $this->staticCache[$cacheKey] = $value; + return $value; + } + + /** + * @throws ReflectionException + */ + public function getFor(object $object, callable $callable, array $params = []): mixed + { + $cacheKey = self::buildCacheKey( + self::callableSignature($callable), + $params, + ); + + $bucket = $this->objectCache[$object] ?? []; + if (array_key_exists($cacheKey, $bucket)) { + $this->hits++; + return $bucket[$cacheKey]; + } + + $this->misses++; + $value = $callable(...$params); + $bucket[$cacheKey] = $value; + $this->objectCache[$object] = $bucket; + return $value; + } + + /** + * @return array{hits:int,misses:int,total:int} + */ + public function stats(): array + { + return [ + 'hits' => $this->hits, + 'misses' => $this->misses, + 'total' => $this->hits + $this->misses, + ]; + } + + private static function buildCacheKey(string $signature, array $params): string + { + if ($params === []) { + return $signature; + } + + $normalized = array_map(self::normalizeParam(...), $params); + return $signature . '|' . hash('xxh3', serialize($normalized)); + } + + /** + * @throws ReflectionException + */ + private static function callableSignature(callable $callable): string + { + if ($callable instanceof Closure) { + $rf = new ReflectionFunction($callable); + $file = $rf->getFileName() ?: 'internal'; + return 'closure:' . $file . ':' . $rf->getStartLine() . '-' . $rf->getEndLine(); + } + + if (is_string($callable)) { + return 'string:' . $callable; + } + + if (is_array($callable)) { + $target = is_object($callable[0]) ? $callable[0]::class : (string) $callable[0]; + return 'array:' . $target . '::' . $callable[1]; + } + + if (is_object($callable)) { + return 'invokable:' . $callable::class; + } + + $rf = new ReflectionFunction(Closure::fromCallable($callable)); + $file = $rf->getFileName() ?: 'internal'; + return 'callable:' . $file . ':' . $rf->getStartLine() . '-' . $rf->getEndLine(); + } + + private static function normalizeParam(mixed $value): mixed + { + return match (true) { + $value instanceof Closure => 'closure#' . spl_object_id($value), + is_object($value) => 'obj#' . spl_object_id($value), + is_resource($value) => 'res#' . get_resource_type($value) . '#' . (int) $value, + is_array($value) => array_map(self::normalizeParam(...), $value), + default => $value, + }; + } +} diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php new file mode 100644 index 0000000..96ad8e1 --- /dev/null +++ b/src/Memoize/OnceMemoizer.php @@ -0,0 +1,133 @@ + */ + private array $cache = []; + + /** @var array */ + private array $closureSourceMemo = []; + + /** @var list */ + private array $order = []; + + private function __construct() {} + + public static function instance(): self + { + return self::$instance ??= new self(); + } + + public function flush(): void + { + $this->cache = []; + $this->order = []; + $this->closureSourceMemo = []; + } + + public function once(callable $callback): mixed + { + $key = $this->cacheKey($callback); + if (array_key_exists($key, $this->cache)) { + return $this->cache[$key]; + } + + $value = $callback(); + $this->cache[$key] = $value; + $this->trackCacheKey($key); + + return $value; + } + + private function cacheKey(callable $callback): string + { + $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); + $caller = $bt[2] ?? $bt[1] ?? []; + + return ($caller['file'] ?? '(unknown)') + . ':' . ($caller['class'] ?? '') + . ':' . $this->normalizeCallerFunction($caller['function'] ?? '(unknown)') + . ':' . $this->callbackFingerprint($callback); + } + + private function callbackFingerprint(callable $callback): string + { + return match (true) { + $callback instanceof Closure => $this->closureFingerprint($callback), + is_string($callback) => 'string:' . $callback, + is_array($callback) => 'array:' . (is_object($callback[0]) ? $callback[0]::class : (string) $callback[0]) . '::' . ($callback[1] ?? ''), + is_object($callback) => 'invokable:' . $callback::class, + default => 'callable:' . get_debug_type($callback), + }; + } + + private function closureFingerprint(Closure $closure): string + { + $rf = new ReflectionFunction($closure); + $file = $rf->getFileName(); + $start = $rf->getStartLine(); + $end = $rf->getEndLine(); + $lineFingerprint = 'closure-lines:' . ($file ?: 'internal') . ':' . $start . '-' . $end; + + if (!is_string($file) || $file === '') { + return $lineFingerprint; + } + + $sourceKey = $file . ':' . $start . '-' . $end; + $cached = $this->closureSourceMemo[$sourceKey] ?? null; + if (is_string($cached)) { + return $cached; + } + + $lines = @file($file, FILE_IGNORE_NEW_LINES); + if (!is_array($lines)) { + return $lineFingerprint; + } + + $snippet = implode("\n", array_slice($lines, $start - 1, $end - $start + 1)); + $normalized = preg_replace('/\s+/', '', $snippet) ?? $snippet; + $fingerprint = 'closure-src:' . hash('xxh3', $normalized); + $this->closureSourceMemo[$sourceKey] = $fingerprint; + + return $fingerprint; + } + + private function normalizeCallerFunction(string $callerFunction): string + { + if (str_starts_with($callerFunction, '{closure:')) { + return '{closure}'; + } + + return $callerFunction; + } + + private function trackCacheKey(string $key): void + { + if (in_array($key, $this->order, true)) { + return; + } + + $this->order[] = $key; + if (count($this->order) <= self::LIMIT) { + return; + } + + $oldest = array_shift($this->order); + if ($oldest === null) { + return; + } + + unset($this->cache[$oldest]); + } +} diff --git a/src/Serializer/ValueSerializer.php b/src/Serializer/ValueSerializer.php new file mode 100644 index 0000000..eccfa5a --- /dev/null +++ b/src/Serializer/ValueSerializer.php @@ -0,0 +1,282 @@ + */ + private static array $resourceHandlers = []; + + /** @var array */ + private static array $serializedClosureMemo = []; + + /** + * Clear all registered resource handlers. + * + * Use this method to reset the state of ValueSerializer in test cases, + * or when you want to ensure that no resource handlers are registered. + */ + public static function clearResourceHandlers(): void + { + self::$resourceHandlers = []; + self::$serializedClosureMemo = []; + } + + /** + * Decode a payload produced by {@see encode()}. + * + * @param string $payload The encoded string + * @param bool $base64 True ⇒ expect base64; false ⇒ raw + * @return mixed Original value + * @throws InvalidArgumentException Forwarded from ::unserialize() + */ + public static function decode(string $payload, bool $base64 = true): mixed + { + $blob = $base64 ? base64_decode($payload, true) : $payload; + + if ($blob === false) { + throw new InvalidArgumentException('Invalid base64 payload supplied to ValueSerializer::decode().'); + } + + return self::unserialize($blob); + } + + /** + * Encode any value into a transport-safe (optionally base64) string. + * + * ```php + * $token = ValueSerializer::encode($payload); // base64 by default + * $same = ValueSerializer::decode($token); + * ``` + * + * @param mixed $value Any PHP value + * @param bool $base64 True ⇒ wrap with base64; false ⇒ raw + * @return string Encoded payload + * @throws InvalidArgumentException Forwarded from ::serialize() + */ + public static function encode(mixed $value, bool $base64 = true): string + { + $blob = self::serialize($value); + return $base64 ? base64_encode($blob) : $blob; + } + + /** + * Determines if a given string is a serialized Opis closure. + * + * This method checks if the provided string represents a serialized + * Opis closure by looking for specific patterns associated with + * Opis closures. + * + * @param string $str The string to check. + * + * @return bool True if the string is a serialized Opis closure, false otherwise. + */ + public static function isSerializedClosure(string $str): bool + { + if (array_key_exists($str, self::$serializedClosureMemo)) { + return self::$serializedClosureMemo[$str]; + } + + if (!str_contains($str, 'Opis\\Closure')) { + return self::rememberSerializedClosureMemo($str, false); + } + + return self::rememberSerializedClosureMemo($str, (bool) preg_match( + '/^(?:C:\d+:"Opis\\\\Closure\\\\SerializableClosure|O:\d+:"Opis\\\\Closure\\\\Box"|O:\d+:"Opis\\\\Closure\\\\Serializable")/', + $str, + )); + } + + /** + * Registers a handler for a specific resource type. + * + * The two callables provided are: + * 1. `wrapFn`: takes a resource of type `$type` and returns an array + * (or other serializable value) that represents the resource. + * 2. `restoreFn`: takes the array (or other serializable value) returned + * by `wrapFn` and returns a resource of type `$type`. + * + * @param string $type The type of resource this handler is for. + * @param callable $wrapFn The callable that wraps the resource. + * @param callable $restoreFn The callable that restores the resource. + * + * @throws InvalidArgumentException If a handler for `$type` already exists. + */ + public static function registerResourceHandler( + string $type, + callable $wrapFn, + callable $restoreFn, + ): void { + if (isset(self::$resourceHandlers[$type])) { + throw new InvalidArgumentException("Resource handler already registered for '$type'"); + } + + self::$resourceHandlers[$type] = [ + 'wrap' => $wrapFn, + 'restore' => $restoreFn, + ]; + } + + /** + * Serializes a given value into a string. + * + * This method takes a value, wraps any resources it contains using registered + * resource handlers, and serializes it into a string using Opis Closure's + * serialize function. + * + * @param mixed $value The value to be serialized, which may contain resources. + * + * @return string The serialized string representation of the value. + * + * @throws InvalidArgumentException If a resource type has no registered handler. + */ + public static function serialize(mixed $value): string + { + if (is_scalar($value) || $value === null) { + return serialize($value); + } + return oc_serialize(self::wrapRecursive($value)); + } + + + /** + * Unserializes a given string into its original value. + * + * This method takes a serialized string and converts it back into its + * original value. It first unserializes the string using Opis Closure's + * unserialize function, then recursively unwraps any wrapped resources + * within the resulting value using registered resource handlers. + * + * @param string $blob The serialized string to be converted back to its original form. + * + * @return mixed The original value, with any resources restored. + */ + public static function unserialize(string $blob): mixed + { + if (!ValueSerializer::isSerializedClosure($blob) && str_starts_with($blob, 's:')) { + return unserialize($blob, ['allowed_classes' => true]); + } + return self::unwrapRecursive(oc_unserialize($blob)); + } + + + /** + * Reverse {@see wrap} by recursively unwrapping values that were wrapped by + * {@see wrap}. This method is similar to {@see unserialize}, but it does not + * involve serialisation. + * + * @param mixed $resource A value that may contain wrapped resources. + * + * @return mixed The same value with any wrapped resources restored. + */ + public static function unwrap(mixed $resource): mixed + { + return self::unwrapRecursive($resource); + } + + + /** + * Wraps resources within a given value. + * + * This method acts as a public interface to recursively wrap + * resources found within the provided value using registered + * resource handlers. + * + * @param mixed $value The value to be wrapped, which may contain resources. + * + * @return mixed The value with any resources wrapped, or the original value if no resources are found. + */ + public static function wrap(mixed $value): mixed + { + return self::wrapRecursive($value); + } + + private static function rememberSerializedClosureMemo(string $key, bool $value): bool + { + if (!array_key_exists($key, self::$serializedClosureMemo) + && count(self::$serializedClosureMemo) >= self::SERIALIZED_CLOSURE_MEMO_LIMIT) { + $oldest = array_key_first(self::$serializedClosureMemo); + if ($oldest !== null) { + unset(self::$serializedClosureMemo[$oldest]); + } + } + + self::$serializedClosureMemo[$key] = $value; + return $value; + } + + /** + * Reverse {@see wrapRecursive} by recursively unwrapping values + * that were wrapped by {@see wrapRecursive}. + * + * @param mixed $resource A value that may contain wrapped resources. + * + * @return mixed The same value with any wrapped resources restored. + */ + private static function unwrapRecursive(mixed $resource): mixed + { + if ( + is_array($resource) + && ($resource['__wrapped_resource'] ?? false) + && isset(self::$resourceHandlers[$resource['type']]) + ) { + return (self::$resourceHandlers[$resource['type']]['restore'])($resource['data']); + } + + if (is_array($resource)) { + foreach ($resource as $key => $item) { + $resource[$key] = self::unwrapRecursive($item); + } + } + return $resource; + } + + /** + * Recursively wraps resources within a given value. + * + * This method checks if the provided value is a resource. If so, + * it retrieves the appropriate handler for the resource type and + * uses it to wrap the resource. The wrapped resource is returned + * as an associative array containing a flag, the resource type, + * and the wrapped data. + * + * If the value is an array, the method recursively processes each + * element in the array. + * + * @param mixed $resource The value to be wrapped, which may contain resources. + * + * @return mixed The value with any resources wrapped, or the original value if no resources are found. + * + * @throws InvalidArgumentException If no handler is registered for a resource type. + */ + private static function wrapRecursive(mixed $resource): mixed + { + if (is_resource($resource)) { + $type = get_resource_type($resource); + $arr = self::$resourceHandlers[$type] ?? null; + if (!$arr) { + throw new InvalidArgumentException("No handler for resource type '$type'"); + } + return [ + '__wrapped_resource' => true, + 'type' => $type, + 'data' => ($arr['wrap'])($resource), + ]; + } + + if (is_array($resource)) { + foreach ($resource as $key => $value) { + $resource[$key] = self::wrapRecursive($value); + } + } + return $resource; + } +} diff --git a/src/functions.php b/src/functions.php new file mode 100644 index 0000000..f9e716c --- /dev/null +++ b/src/functions.php @@ -0,0 +1,60 @@ +get($callable, $params); + } +} + +if (! function_exists('remember')) { + /** + * @throws ReflectionException + */ + function remember(?object $object = null, ?callable $callable = null, array $params = []): mixed + { + $memoizer = Memoizer::instance(); + + if ($object === null) { + return $memoizer; + } + + if ($callable === null) { + throw new InvalidArgumentException('remember() requires both object and callable'); + } + + return $memoizer->getFor($object, $callable, $params); + } +} + +if (! function_exists('once')) { + function once(callable $callback): mixed + { + return OnceMemoizer::instance()->once($callback); + } +} diff --git a/tests/Cache/ApcuCachePoolTest.php b/tests/Cache/ApcuCachePoolTest.php new file mode 100644 index 0000000..64c66d4 --- /dev/null +++ b/tests/Cache/ApcuCachePoolTest.php @@ -0,0 +1,174 @@ +skip(); + return; +} +ini_set('apcu.enable_cli', 1); +if (!apcu_enabled()) { + test('APCu not enabled – skipping adapter tests')->skip(); + return; +} + +/* ── boilerplate ──────────────────────────────────────────────────── */ +beforeEach(function () { + apcu_clear_cache(); // fresh memory + $this->cache = Cache::apcu('tests'); // APCu-backed pool + ValueSerializer::clearResourceHandlers(); + + /* register stream handler for resource tests */ + ValueSerializer::registerResourceHandler( + 'stream', + // ----- wrap ---------------------------------------------------- + function (mixed $res): array { + if (!is_resource($res)) { + throw new InvalidArgumentException('Expected resource'); + } + $meta = stream_get_meta_data($res); + rewind($res); + return [ + 'mode' => $meta['mode'], + 'content' => stream_get_contents($res), + ]; + }, + // ----- restore ------------------------------------------------- + function (array $data): mixed { + $s = fopen('php://memory', $data['mode']); + fwrite($s, $data['content']); + rewind($s); + return $s; // <- real resource + } + ); +}); + +afterEach(function () { + apcu_clear_cache(); +}); + +/* ─── convenience get()/set() ─────────────────────────────────────── */ +test('convenience set() and get() (apcu)', function () { + expect($this->cache->get('nope'))->toBeNull() + ->and($this->cache->set('foo', 'bar', 60))->toBeTrue() + ->and($this->cache->get('foo'))->toBe('bar'); +}); + +/* ─── PSR-16 get($key, $default) ─────────────────────────────────── */ +test('get returns default when key missing (apcu)', function () { + // Scalar default + expect($this->cache->get('missing', 'default'))->toBe('default'); + + // Callable default without prior set + $computed = $this->cache->get('dyn', function (ApcuCacheItem $item) { + $item->expiresAfter(1); + return 'computed'; + }); + expect($computed)->toBe('computed'); + + // Now that it’s been set, get() returns the cached value + expect($this->cache->get('dyn'))->toBe('computed'); + + // After expiry, returns the new default again + usleep(2_000_000); + expect($this->cache->get('dyn', 'fallback'))->toBe('fallback'); +}); + +test('get throws for invalid key (apcu)', function () { + expect(fn () => $this->cache->get('bad key', 'x')) + ->toThrow(CacheInvalidArgumentException::class); +}); + +/* ─── PSR-6 behaviour ─────────────────────────────────────────────── */ +test('PSR-6 getItem()/save() (apcu)', function () { + $item = $this->cache->getItem('psr'); + expect($item)->toBeInstanceOf(ApcuCacheItem::class) + ->and($item->isHit())->toBeFalse(); + + $item->set(99)->expiresAfter(null)->save(); + expect($this->cache->getItem('psr')->get())->toBe(99); +}); + +/* ─── deferred queue ──────────────────────────────────────────────── */ +test('saveDeferred() and commit() (apcu)', function () { + $this->cache->getItem('x')->set('X')->saveDeferred(); + expect($this->cache->get('x'))->toBeNull(); + + $this->cache->commit(); + expect($this->cache->get('x'))->toBe('X'); +}); + +/* ─── ArrayAccess / magic props ───────────────────────────────────── */ +test('ArrayAccess & magic props (apcu)', function () { + $this->cache['k'] = 11; + expect($this->cache['k'])->toBe(11); + + $this->cache->alpha = 'β'; + expect($this->cache->alpha)->toBe('β'); +}); + +/* ─── TTL / expiration ───────────────────────────────────────────── */ +test('expiration honours TTL (apcu)', function () { + $this->cache->getItem('ttl')->set('live')->expiresAfter(1)->save(); + usleep(2_000_000); + expect($this->cache->hasItem('ttl'))->toBeFalse(); +}); + +/* ─── closure round-trip ──────────────────────────────────────────── */ +test('closure value survives APCu', function () { + $fn = fn ($n) => $n + 5; + $this->cache->getItem('cb')->set($fn)->save(); + $g = $this->cache->getItem('cb')->get(); + expect($g(10))->toBe(15); +}); + +/* ─── stream resource round-trip ──────────────────────────────────── */ +test('stream resource round-trip (apcu)', function () { + $s = fopen('php://memory', 'r+'); + fwrite($s, 'stream'); + rewind($s); + + $this->cache->getItem('stream')->set($s)->save(); + $restored = $this->cache->getItem('stream')->get(); + expect(stream_get_contents($restored))->toBe('stream'); +}); + +/* ─── invalid key triggers exception ─────────────────────────────── */ +test('invalid key throws (apcu)', function () { + expect(fn () => $this->cache->set('bad key', 'v')) + ->toThrow(InvalidArgumentException::class); +}); + +/* ─── clear() empties cache ───────────────────────────────────────── */ +test('clear() wipes all entries (apcu)', function () { + $this->cache->set('q', '1'); + $this->cache->clear(); + expect($this->cache->hasItem('q'))->toBeFalse(); +}); + +test('APCu adapter multiFetch()', function () { + $this->cache->set('x', 'X'); + $this->cache->set('y', 'Y'); + + $items = $this->cache->getItems(['x', 'y', 'z']); + + expect($items['x']->isHit())->toBeTrue() + ->and($items['x']->get())->toBe('X') + ->and($items['y']->get())->toBe('Y') + ->and($items['z']->isHit())->toBeFalse(); +}); + + diff --git a/tests/Cache/ArrayCachePoolTest.php b/tests/Cache/ArrayCachePoolTest.php new file mode 100644 index 0000000..e048c08 --- /dev/null +++ b/tests/Cache/ArrayCachePoolTest.php @@ -0,0 +1,41 @@ +cache = Cache::memory('array-tests'); +}); + +test('array adapter supports basic set/get/delete', function () { + expect($this->cache->set('alpha', 1))->toBeTrue() + ->and($this->cache->get('alpha'))->toBe(1) + ->and($this->cache->delete('alpha'))->toBeTrue() + ->and($this->cache->get('alpha'))->toBeNull(); +}); + +test('array adapter getItem returns GenericCacheItem', function () { + $item = $this->cache->getItem('x'); + + expect($item)->toBeInstanceOf(GenericCacheItem::class) + ->and($item->isHit())->toBeFalse(); +}); + +test('array adapter honors ttl', function () { + $this->cache->set('ttl', 'v', 1); + usleep(2_000_000); + + expect($this->cache->get('ttl'))->toBeNull(); +}); + +test('array adapter supports getItems', function () { + $this->cache->set('a', 'A'); + $this->cache->set('b', 'B'); + + $items = $this->cache->getItems(['a', 'b', 'c']); + + expect($items['a']->isHit())->toBeTrue() + ->and($items['a']->get())->toBe('A') + ->and($items['b']->get())->toBe('B') + ->and($items['c']->isHit())->toBeFalse(); +}); diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php new file mode 100644 index 0000000..60ef792 --- /dev/null +++ b/tests/Cache/CacheFeaturesTest.php @@ -0,0 +1,205 @@ +cacheDir = sys_get_temp_dir() . '/pest_cache_features_' . uniqid(); + $this->cache = Cache::file('features', $this->cacheDir); +}); + +afterEach(function () { + if (!is_dir($this->cacheDir)) { + return; + } + + $it = new RecursiveDirectoryIterator($this->cacheDir, FilesystemIterator::SKIP_DOTS); + $rim = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); + foreach ($rim as $file) { + $path = $file->getRealPath(); + if ($path === false || !file_exists($path)) { + continue; + } + $file->isDir() ? rmdir($path) : unlink($path); + } + if (is_dir($this->cacheDir)) { + rmdir($this->cacheDir); + } +}); + +test('setTagged + invalidateTag removes all tagged keys', function () { + $this->cache->setTagged('k1', 'A', ['grp']); + $this->cache->setTagged('k2', 'B', ['grp']); + $this->cache->set('k3', 'C'); + + expect($this->cache->invalidateTag('grp'))->toBeTrue() + ->and($this->cache->get('k1'))->toBeNull() + ->and($this->cache->get('k2'))->toBeNull() + ->and($this->cache->get('k3'))->toBe('C'); +}); + +test('remember caches once and supports tag invalidation', function () { + $count = 0; + + $v1 = $this->cache->remember( + 'hot', + function ($item) use (&$count) { + $count++; + $item->expiresAfter(30); + return 'payload'; + }, + null, + ['hot-path'], + ); + + $v2 = $this->cache->remember( + 'hot', + function () use (&$count) { + $count++; + return 'should-not-run'; + }, + ); + + expect($v1)->toBe('payload') + ->and($v2)->toBe('payload') + ->and($count)->toBe(1) + ->and($this->cache->invalidateTag('hot-path'))->toBeTrue() + ->and($this->cache->get('hot'))->toBeNull(); +}); + +test('get callable path still computes once on miss', function () { + $count = 0; + + $a = $this->cache->get('compute', function ($item) use (&$count) { + $count++; + $item->expiresAfter(30); + return 99; + }); + $b = $this->cache->get('compute', function () use (&$count) { + $count++; + return 11; + }); + + expect($a)->toBe(99) + ->and($b)->toBe(99) + ->and($count)->toBe(1); +}); + +test('invalidateTags removes value when duplicate tags are passed', function () { + $this->cache->setTagged('dup', 'V', ['t1', 't1', 't2']); + $this->cache->invalidateTags(['t2', 't1', 't1']); + + expect($this->cache->get('dup'))->toBeNull(); +}); + +test('rejects empty tags in tag operations', function () { + expect(fn () => $this->cache->invalidateTag(' ')) + ->toThrow(CacheInvalidArgumentException::class); + + expect(fn () => $this->cache->setTagged('x', 'y', ['ok', ' '])) + ->toThrow(CacheInvalidArgumentException::class); +}); + +test('remember respects ttl argument expiry', function () { + $this->cache->remember('short', fn ($item) => 'value', 1); + + usleep(2_000_000); + + expect($this->cache->get('short'))->toBeNull(); +}); + +test('cached null value does not fall back to default', function () { + $this->cache->set('nullable', null); + + expect($this->cache->get('nullable', 'fallback'))->toBeNull() + ->and($this->cache->hasItem('nullable'))->toBeTrue(); +}); + +test('delete on missing key is treated as successful', function () { + expect($this->cache->delete('never-there'))->toBeTrue() + ->and($this->cache->deleteItem('never-there'))->toBeTrue() + ->and($this->cache->deleteItems(['never-there', 'also-missing']))->toBeTrue(); +}); + +test('tag version invalidation marks prior entries stale', function () { + $this->cache->setTagged('article', 'v1', ['content']); + expect($this->cache->get('article'))->toBe('v1'); + + $this->cache->invalidateTag('content'); + expect($this->cache->get('article'))->toBeNull(); + + $this->cache->setTagged('article', 'v2', ['content']); + expect($this->cache->get('article'))->toBe('v2'); +}); + +test('remember uses configured lock provider', function () { + $calls = ['acquire' => 0, 'release' => 0]; + + $provider = new class ($calls) implements LockProviderInterface { + public function __construct(private array &$calls) {} + + public function acquire(string $key, float $waitSeconds): ?LockHandle + { + $this->calls['acquire']++; + return new LockHandle($key, 'tkn'); + } + + public function release(?LockHandle $handle): void + { + $this->calls['release']++; + } + }; + + $this->cache->setLockProvider($provider); + $this->cache->remember('guarded', fn () => 123, 10); + + expect($calls['acquire'])->toBe(1) + ->and($calls['release'])->toBe(1); +}); + +test('metrics collector exports hit and miss counters', function () { + $collector = new InMemoryCacheMetricsCollector(); + $this->cache->setMetricsCollector($collector); + + $this->cache->get('x'); + $this->cache->set('x', 1); + $this->cache->get('x'); + + $metrics = $this->cache->exportMetrics(); + $adapter = Infocyph\CacheLayer\Cache\Adapter\FileCacheAdapter::class; + + expect($metrics[$adapter]['miss'] ?? 0)->toBeGreaterThanOrEqual(1) + ->and($metrics[$adapter]['hit'] ?? 0)->toBeGreaterThanOrEqual(1) + ->and($metrics[$adapter]['set'] ?? 0)->toBeGreaterThanOrEqual(1); +}); + +test('metrics export hook receives snapshot', function () { + $snapshot = null; + + $this->cache + ->setMetricsExportHook(function (array $metrics) use (&$snapshot): void { + $snapshot = $metrics; + }); + + $this->cache->get('hook-miss'); + $exported = $this->cache->exportMetrics(); + + expect($snapshot)->toBeArray() + ->and($snapshot)->toBe($exported); +}); + +test('payload compression can be enabled without changing values', function () { + $payload = str_repeat('cache-layer-payload-', 128); + + $this->cache->configurePayloadCompression(128, 6); + $this->cache->set('big', $payload); + + expect($this->cache->get('big'))->toBe($payload); + + $this->cache->configurePayloadCompression(null); +}); + + diff --git a/tests/Cache/ChainCachePoolTest.php b/tests/Cache/ChainCachePoolTest.php new file mode 100644 index 0000000..054dc9e --- /dev/null +++ b/tests/Cache/ChainCachePoolTest.php @@ -0,0 +1,27 @@ +l1 = new ArrayCacheAdapter('l1'); + $this->l2 = new ArrayCacheAdapter('l2'); + $this->cache = Cache::chain([$this->l1, $this->l2]); +}); + +test('chain adapter writes through all pools', function () { + $this->cache->set('k', 'value'); + + expect($this->l1->getItem('k')->isHit())->toBeTrue() + ->and($this->l2->getItem('k')->isHit())->toBeTrue(); +}); + +test('chain adapter promotes value from lower tier to upper tier', function () { + $item = $this->l2->getItem('promote'); + $item->set('from-l2')->save(); + + expect($this->l1->getItem('promote')->isHit())->toBeFalse(); + + expect($this->cache->get('promote'))->toBe('from-l2') + ->and($this->l1->getItem('promote')->isHit())->toBeTrue(); +}); diff --git a/tests/Cache/DynamoDbCachePoolTest.php b/tests/Cache/DynamoDbCachePoolTest.php new file mode 100644 index 0000000..ff634dd --- /dev/null +++ b/tests/Cache/DynamoDbCachePoolTest.php @@ -0,0 +1,103 @@ +client = new class { + /** @var array> */ + private array $items = []; + + public function batchWriteItem(array $params): array + { + foreach ($params['RequestItems'] as $requests) { + foreach ($requests as $request) { + $key = $request['DeleteRequest']['Key']['ckey']['S']; + unset($this->items[$key]); + } + } + + return []; + } + + public function deleteItem(array $params): array + { + $key = $params['Key']['ckey']['S']; + unset($this->items[$key]); + return []; + } + + public function getItem(array $params): array + { + $key = $params['Key']['ckey']['S']; + return isset($this->items[$key]) ? ['Item' => $this->items[$key]] : []; + } + + public function putItem(array $params): array + { + $key = $params['Item']['ckey']['S']; + $this->items[$key] = $params['Item']; + return []; + } + + public function scan(array $params): array + { + $ns = $params['ExpressionAttributeValues'][':ns']['S'] ?? null; + $now = isset($params['ExpressionAttributeValues'][':now']['N']) + ? (int) $params['ExpressionAttributeValues'][':now']['N'] + : null; + + $filtered = []; + foreach ($this->items as $item) { + if (($item['ns']['S'] ?? null) !== $ns) { + continue; + } + + if ($now !== null) { + $expires = isset($item['expires']['N']) ? (int) $item['expires']['N'] : null; + if ($expires !== null && $expires <= $now) { + continue; + } + } + + $filtered[] = $item; + } + + if (($params['Select'] ?? null) === 'COUNT') { + return ['Count' => count($filtered)]; + } + + $projected = []; + foreach ($filtered as $item) { + $projected[] = ['ckey' => ['S' => $item['ckey']['S']]]; + } + + return ['Items' => $projected]; + } + }; + + $this->cache = new Cache(new DynamoDbCacheAdapter($this->client, 'cachelayer_entries', 'ddb-tests')); +}); + +test('dynamodb adapter stores and retrieves values', function () { + $this->cache->set('k', 'value'); + + expect($this->cache->get('k'))->toBe('value') + ->and($this->cache->count())->toBe(1); +}); + +test('dynamodb adapter clears namespace entries', function () { + $this->cache->set('a', 1); + $this->cache->set('b', 2); + + $this->cache->clear(); + + expect($this->cache->count())->toBe(0); +}); + +test('dynamodb cache factory accepts injected client', function () { + $cache = Cache::dynamoDb('ddb-tests', 'cachelayer_entries', $this->client); + $cache->set('x', 'X'); + + expect($cache->get('x'))->toBe('X'); +}); diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php new file mode 100644 index 0000000..c077c10 --- /dev/null +++ b/tests/Cache/FileCachePoolTest.php @@ -0,0 +1,236 @@ +cacheDir = sys_get_temp_dir() . '/pest_cache_' . uniqid(); + + /* build a file-backed cachepool via static factory */ + $this->cache = Cache::file('tests', $this->cacheDir); + /* register stream handler only for the test run */ + ValueSerializer::registerResourceHandler( + 'stream', + // ----- wrap ---------------------------------------------------- + function (mixed $res): array { + if (!is_resource($res)) { + throw new InvalidArgumentException('Expected resource'); + } + $meta = stream_get_meta_data($res); + rewind($res); + return [ + 'mode' => $meta['mode'], + 'content' => stream_get_contents($res), + ]; + }, + // ----- restore ------------------------------------------------- + function (array $data): mixed { + $s = fopen('php://memory', $data['mode']); + fwrite($s, $data['content']); + rewind($s); + return $s; // <- real resource + } + ); +}); + +afterEach(function () { + /* recursive dir cleanup */ + if (!is_dir($this->cacheDir)) { + return; + } + + $it = new RecursiveDirectoryIterator($this->cacheDir, FilesystemIterator::SKIP_DOTS); + $rim = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); + + foreach ($rim as $file) { + $file->isDir() ? rmdir($file->getRealPath()) : unlink($file->getRealPath()); + } + rmdir($this->cacheDir); +}); + +/* ───────────────────────────────────────────────────────────── */ + +/* ─── convenience set()/get() ─────────────────────────────────── */ +test('convenience set() and get()', function () { + expect($this->cache->get('nope'))->toBeNull() + ->and($this->cache->set('foo', 'bar', 60))->toBeTrue() + ->and($this->cache->get('foo'))->toBe('bar'); +}); + +/* ─── PSR-16 get($key, $default) ───────────────────────────────── */ +test('get returns default when key missing (file)', function () { + // Scalar default + expect($this->cache->get('missing', 'def'))->toBe('def'); + + // Callable default + $computed = $this->cache->get('x', function (FileCacheItem $item) { + $item->expiresAfter(1); + return 'xyz'; + }); + expect($computed)->toBe('xyz'); + expect($this->cache->get('x'))->toBe('xyz'); + + // After TTL expires, fallback + usleep(2_000_000); + expect($this->cache->get('x', 'fallback'))->toBe('fallback'); +}); + +test('get throws for invalid key (file)', function () { + expect(fn () => $this->cache->get('bad key', 'v')) + ->toThrow(CacheInvalidArgumentException::class); +}); + +/* ─── PSR-6 getItem()/save() ───────────────────────────────────── */ +test('PSR-6 getItem()/save()', function () { + $item = $this->cache->getItem('psr'); + + expect($item)->toBeInstanceOf(FileCacheItem::class) + ->and($item->isHit())->toBeFalse(); + + $item->set(123)->expiresAfter(null)->save(); + + $fetched = $this->cache->getItem('psr'); + expect($fetched->isHit())->toBeTrue() + ->and($fetched->get())->toBe(123); +}); + +test('saveDeferred() and commit()', function () { + expect($this->cache->get('a'))->toBeNull(); + + $this->cache->getItem('a')->set('A')->saveDeferred(); + $this->cache->getItem('b')->set('B')->saveDeferred(); + + expect($this->cache->get('a'))->toBeNull(); // not yet persisted + + $this->cache->commit(); + + expect($this->cache->get('a'))->toBe('A') + ->and($this->cache->get('b'))->toBe('B'); +}); + +test('ArrayAccess support', function () { + $this->cache['x'] = 10; + + expect(isset($this->cache['x']))->toBeTrue() + ->and($this->cache['x'])->toBe(10); + + unset($this->cache['x']); + expect(isset($this->cache['x']))->toBeFalse(); +}); + +test('magic __get/__set/__isset/__unset', function () { + $this->cache->alpha = 'beta'; + + expect(isset($this->cache->alpha))->toBeTrue() + ->and($this->cache->alpha)->toBe('beta'); + + unset($this->cache->alpha); + expect(isset($this->cache->alpha))->toBeFalse(); +}); +test('runtime re-namespace and directory swap', function () { + $newDir = sys_get_temp_dir() . '/pest_cache_new_' . uniqid(); + + $this->cache->setNamespaceAndDirectory('newns', $newDir); + + expect($this->cache->set('foo', 'bar'))->toBeTrue() + ->and($this->cache->get('foo'))->toBe('bar'); + + $namespaceDir = $newDir . '/cache_newns'; + expect(is_dir($namespaceDir)) + ->toBeTrue() + ->and(glob($namespaceDir . '/*.cache'))->not->toBeEmpty(); + + /* manual clean-up of this secondary dir (afterEach cleans only first dir) */ + foreach (glob($namespaceDir . '/*') as $f) { + @unlink($f); + } + @rmdir($namespaceDir); + @rmdir($newDir); +}); + +test('expiration honours TTL', function () { + $this->cache->getItem('ttl')->set('x')->expiresAfter(1)->save(); + usleep(2_000_000); + expect($this->cache->getItem('ttl')->isHit())->toBeFalse(); +}); + +test('closure round-trips via ValueSerializer', function () { + $double = fn (int $n) => $n * 2; + $this->cache->getItem('cb')->set($double)->save(); + $restored = $this->cache->getItem('cb')->get(); + expect($restored(7))->toBe(14); +}); + +test('stream resource round-trip', function () { + + $s = fopen('php://memory', 'r+'); + fwrite($s, 'hello'); + rewind($s); + $this->cache->getItem('stream')->set($s)->save(); + $r = $this->cache->getItem('stream')->get(); + expect(stream_get_contents($r))->toBe('hello'); +}); + +test('custom resource handler works', function () { + $dirPath = __DIR__; // path we will open/restore + $dirRes = opendir($dirPath); + $resType = get_resource_type($dirRes); // "stream" + ValueSerializer::clearResourceHandlers(); + + // register handler *capturing* $dirPath + ValueSerializer::registerResourceHandler( + $resType, + fn ($r) => ['path' => $dirPath], // wrap + fn (array $data) => opendir($data['path']) // restore + ); + + $this->cache->getItem('dirRes')->set($dirRes)->save(); + + $restored = $this->cache->getItem('dirRes')->get(); + expect(is_resource($restored))->toBeTrue() + ->and(get_resource_type($restored))->toBe($resType); +}); + +test('invalid cache key throws', function () { + expect(fn () => $this->cache->set('space key', 'v')) + ->toThrow(InvalidArgumentException::class); +}); + +test('persistence across pool instances', function () { + $this->cache->set('persist', 'yes'); + + $again = Cache::file('tests', $this->cacheDir); // new instance + expect($again->get('persist'))->toBe('yes'); +}); + +test('clear() removes everything', function () { + $this->cache->set('a', 1); + $this->cache->set('b', 2); + + $this->cache->clear(); + + expect($this->cache->hasItem('a'))->toBeFalse() + ->and($this->cache->hasItem('b'))->toBeFalse(); +}); + +test('file adapter bulk getItems()', function () { + $this->cache->set('a', 1); + $this->cache->set('b', 2); + + $items = $this->cache->getItems(['a', 'b', 'c']); + + expect($items['a']->isHit())->toBeTrue() + ->and($items['a']->get())->toBe(1) + ->and($items['b']->get())->toBe(2) + ->and($items['c']->isHit())->toBeFalse(); +}); + + diff --git a/tests/Cache/MemCachePoolTest.php b/tests/Cache/MemCachePoolTest.php new file mode 100644 index 0000000..8c0a0a0 --- /dev/null +++ b/tests/Cache/MemCachePoolTest.php @@ -0,0 +1,180 @@ +skip(); + return; +} + +$probe = new Memcached(); +$probe->addServer('127.0.0.1', 11211); +$probe->set('ping', 'pong'); +if ($probe->getResultCode() !== Memcached::RES_SUCCESS) { + test('No Memcached server at 127.0.0.1:11211 – skipping')->skip(); + return; +} + +/* ── Test bootstrap / teardown ───────────────────────────────────── */ + +beforeEach(function () { + $client = new Memcached(); + $client->addServer('127.0.0.1', 11211); + $client->flush(); // fresh slate + ValueSerializer::clearResourceHandlers(); + + $this->cache = Cache::memcache( + 'tests', + [['127.0.0.1', 11211, 0]], + $client + ); + + /* register stream handler for resource test */ + ValueSerializer::registerResourceHandler( + 'stream', + // ----- wrap ---------------------------------------------------- + function (mixed $res): array { + if (!is_resource($res)) { + throw new InvalidArgumentException('Expected resource'); + } + $meta = stream_get_meta_data($res); + rewind($res); + return [ + 'mode' => $meta['mode'], + 'content' => stream_get_contents($res), + ]; + }, + // ----- restore ------------------------------------------------- + function (array $data): mixed { + $s = fopen('php://memory', $data['mode']); + fwrite($s, $data['content']); + rewind($s); + return $s; // <- real resource + } + ); +}); + +afterEach(function () { + /** @var \Infocyph\CacheLayer\Cache\Adapter\MemCacheAdapter $adapt */ + $adapt = (new ReflectionObject($this->cache)) + ->getProperty('adapter')->getValue($this->cache); + (new ReflectionProperty($adapt, 'mc')) + ->getValue($adapt) + ->flush(); +}); + +/* ── Convenience helpers ────────────────────────────────────────── */ + +/* ─── convenience set()/get() ─────────────────────────────────── */ +test('memcache set()/get()', function () { + expect($this->cache->get('miss'))->toBeNull() + ->and($this->cache->set('foo', 'bar'))->toBeTrue() + ->and($this->cache->get('foo'))->toBe('bar'); +}); + +/* ─── PSR-16 get($key, $default) ───────────────────────────────── */ +test('get returns default when key missing (memcached)', function () { + // Scalar + expect($this->cache->get('nobody', 'dflt'))->toBe('dflt'); + + // Callable + $val = $this->cache->get('call', function (MemCacheItem $item) { + $item->expiresAfter(1); + return 'hello'; + }); + expect($val)->toBe('hello'); + expect($this->cache->get('call'))->toBe('hello'); + + usleep(2_000_000); + expect($this->cache->get('call', 'again'))->toBe('again'); +}); + +test('get throws for invalid key (memcached)', function () { + expect(fn () => $this->cache->get('bad key', 'x')) + ->toThrow(CacheInvalidArgumentException::class); +}); + +/* ─── PSR-6 getItem()/save() ───────────────────────────────────── */ +test('PSR-6 getItem()/save()', function () { + $it = $this->cache->getItem('psr'); + expect($it)->toBeInstanceOf(MemCacheItem::class) + ->and($it->isHit())->toBeFalse(); + + $it->set(321)->save(); + expect($this->cache->getItem('psr')->get())->toBe(321); +}); + +test('saveDeferred() + commit()', function () { + $this->cache->getItem('a')->set('A')->saveDeferred(); + expect($this->cache->get('a'))->toBeNull(); + + $this->cache->commit(); + expect($this->cache->get('a'))->toBe('A'); +}); + +test('ArrayAccess & magic props', function () { + $this->cache['x'] = 7; + expect($this->cache['x'])->toBe(7); + + $this->cache->alpha = 'ω'; + expect($this->cache->alpha)->toBe('ω'); +}); + +test('TTL expiration', function () { + $this->cache->getItem('ttl')->set('x')->expiresAfter(1)->save(); + usleep(2_000_000); + expect($this->cache->hasItem('ttl'))->toBeFalse(); +}); + +test('closure round-trip', function () { + $fn = fn ($n) => $n * 3; + $this->cache->getItem('cb')->set($fn)->save(); + $g = $this->cache->getItem('cb')->get(); + expect($g(3))->toBe(9); +}); + +test('stream resource round-trip', function () { + $s = fopen('php://memory', 'r+'); + fwrite($s, 'data'); + rewind($s); + $this->cache->getItem('stream')->set($s)->save(); + $r = $this->cache->getItem('stream')->get(); + expect(stream_get_contents($r))->toBe('data'); +}); + +test('invalid key throws', function () { + expect(fn () => $this->cache->set('bad key', 'v')) + ->toThrow(InvalidArgumentException::class); +}); + +test('clear() flushes cache', function () { + $this->cache->set('z', 9); + $this->cache->clear(); + expect($this->cache->hasItem('z'))->toBeFalse(); +}); + +test('Memcached adapter multiFetch()', function () { + $this->cache->set('m1', 'foo'); + $this->cache->set('m2', 'bar'); + + $items = $this->cache->getItems(['m1', 'm2', 'missing']); + + expect($items['m1']->get())->toBe('foo') + ->and($items['m2']->get())->toBe('bar') + ->and($items['missing']->isHit())->toBeFalse(); +}); + + diff --git a/tests/Cache/MongoDbCachePoolTest.php b/tests/Cache/MongoDbCachePoolTest.php new file mode 100644 index 0000000..93f5587 --- /dev/null +++ b/tests/Cache/MongoDbCachePoolTest.php @@ -0,0 +1,79 @@ +collection = new class { + /** @var array> */ + public array $docs = []; + + public function countDocuments(array $filter): int + { + $count = 0; + $now = time(); + + foreach ($this->docs as $doc) { + if (($doc['ns'] ?? null) !== ($filter['ns'] ?? null)) { + continue; + } + + $expires = is_numeric($doc['expires'] ?? null) ? (int) $doc['expires'] : null; + if ($expires !== null && $expires <= $now) { + continue; + } + + $count++; + } + + return $count; + } + + public function deleteMany(array $filter): void + { + foreach ($this->docs as $key => $doc) { + if (($doc['ns'] ?? null) === ($filter['ns'] ?? null)) { + unset($this->docs[$key]); + } + } + } + + public function deleteOne(array $filter): void + { + unset($this->docs[$filter['_id']]); + } + + public function findOne(array $filter): ?array + { + return $this->docs[$filter['_id']] ?? null; + } + + public function updateOne(array $filter, array $update, array $options = []): void + { + $this->docs[$filter['_id']] = $update['$set']; + } + }; + + $this->cache = new Cache(new MongoDbCacheAdapter($this->collection, 'mongo-tests')); +}); + +test('mongo adapter stores and retrieves values', function () { + $this->cache->set('k', 'value'); + + expect($this->cache->get('k'))->toBe('value') + ->and($this->cache->count())->toBe(1); +}); + +test('mongo adapter honors ttl', function () { + $this->cache->set('ttl', 'v', 1); + usleep(2_000_000); + + expect($this->cache->get('ttl'))->toBeNull(); +}); + +test('mongodb cache factory accepts injected collection', function () { + $cache = Cache::mongodb('mongo-tests', $this->collection); + $cache->set('f', 'ok'); + + expect($cache->get('f'))->toBe('ok'); +}); diff --git a/tests/Cache/NullCachePoolTest.php b/tests/Cache/NullCachePoolTest.php new file mode 100644 index 0000000..caa9a13 --- /dev/null +++ b/tests/Cache/NullCachePoolTest.php @@ -0,0 +1,30 @@ +cache = Cache::nullStore(); +}); + +test('null adapter never stores data', function () { + expect($this->cache->set('x', 10))->toBeTrue() + ->and($this->cache->get('x'))->toBeNull() + ->and($this->cache->hasItem('x'))->toBeFalse(); +}); + +test('remember always recomputes with null adapter', function () { + $calls = 0; + + $a = $this->cache->remember('k', function () use (&$calls) { + $calls++; + return 'v'; + }); + $b = $this->cache->remember('k', function () use (&$calls) { + $calls++; + return 'v'; + }); + + expect($a)->toBe('v') + ->and($b)->toBe('v') + ->and($calls)->toBe(2); +}); diff --git a/tests/Cache/PhpFilesCachePoolTest.php b/tests/Cache/PhpFilesCachePoolTest.php new file mode 100644 index 0000000..c12b5a6 --- /dev/null +++ b/tests/Cache/PhpFilesCachePoolTest.php @@ -0,0 +1,42 @@ +cacheDir = sys_get_temp_dir() . '/pest_phpfiles_cache_' . uniqid(); + $this->cache = Cache::phpFiles('php-files-tests', $this->cacheDir); +}); + +afterEach(function () { + if (!is_dir($this->cacheDir)) { + return; + } + + $it = new RecursiveDirectoryIterator($this->cacheDir, FilesystemIterator::SKIP_DOTS); + $rim = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); + foreach ($rim as $file) { + $path = $file->getRealPath(); + if ($path === false || !file_exists($path)) { + continue; + } + $file->isDir() ? rmdir($path) : unlink($path); + } + if (is_dir($this->cacheDir)) { + rmdir($this->cacheDir); + } +}); + +test('php files adapter persists values', function () { + $this->cache->set('x', 'X'); + + $again = Cache::phpFiles('php-files-tests', $this->cacheDir); + + expect($again->get('x'))->toBe('X'); +}); + +test('php files adapter honors ttl', function () { + $this->cache->set('ttl', 'v', 1); + usleep(2_000_000); + + expect($this->cache->get('ttl'))->toBeNull(); +}); diff --git a/tests/Cache/PostgresCachePoolTest.php b/tests/Cache/PostgresCachePoolTest.php new file mode 100644 index 0000000..c7efda8 --- /dev/null +++ b/tests/Cache/PostgresCachePoolTest.php @@ -0,0 +1,54 @@ +skip(); + return; +} + +$dsn = getenv('CACHELAYER_PG_DSN') ?: 'pgsql:host=127.0.0.1;port=5432;dbname=cachelayer'; +$user = getenv('CACHELAYER_PG_USER') ?: 'postgres'; +$pass = getenv('CACHELAYER_PG_PASS') ?: 'postgres'; + +try { + $probe = new PDO($dsn, $user, $pass); + $probe->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $probe->query('SELECT 1'); +} catch (Throwable) { + test('PostgreSQL server unreachable')->skip(); + return; +} + +beforeEach(function () use ($dsn, $user, $pass) { + $this->cache = Cache::postgres('pg-tests', $dsn, $user, $pass, null, 'cachelayer_entries'); + $this->cache->clear(); +}); + +afterEach(function () { + $this->cache->clear(); +}); + +test('postgres adapter set and get', function () { + expect($this->cache->set('alpha', 11))->toBeTrue() + ->and($this->cache->get('alpha'))->toBe(11); +}); + +test('postgres adapter ttl expiry', function () { + $this->cache->set('ttl', 'v', 1); + usleep(2_000_000); + + expect($this->cache->get('ttl'))->toBeNull(); +}); + +test('postgres adapter delete and count', function () { + $this->cache->set('a', 'A'); + $this->cache->set('b', 'B'); + + expect($this->cache->count())->toBe(2); + + $this->cache->delete('a'); + expect($this->cache->count())->toBe(1) + ->and($this->cache->get('a'))->toBeNull() + ->and($this->cache->get('b'))->toBe('B'); +}); diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php new file mode 100644 index 0000000..ed2e599 --- /dev/null +++ b/tests/Cache/RedisCachePoolTest.php @@ -0,0 +1,177 @@ +skip(); + return; +} +try { + $probe = new Redis(); + $probe->connect('127.0.0.1', 6379, 0.5); + $probe->ping(); +} catch (Throwable) { + test('Redis server unreachable – skipping')->skip(); + return; +} + +/* ── bootstrap / teardown ────────────────────────────────────────── */ +beforeEach(function () { + $client = new Redis(); + $client->connect('127.0.0.1', 6379); + $client->flushDB(); // fresh DB 0 + ValueSerializer::clearResourceHandlers(); + + $this->cache = Cache::redis( + 'tests', + 'redis://127.0.0.1:6379', + $client + ); + + ValueSerializer::registerResourceHandler( + 'stream', + // ----- wrap ---------------------------------------------------- + function (mixed $res): array { + if (!is_resource($res)) { + throw new InvalidArgumentException('Expected resource'); + } + $meta = stream_get_meta_data($res); + rewind($res); + return [ + 'mode' => $meta['mode'], + 'content' => stream_get_contents($res), + ]; + }, + // ----- restore ------------------------------------------------- + function (array $data): mixed { + $s = fopen('php://memory', $data['mode']); + fwrite($s, $data['content']); + rewind($s); + return $s; // <- real resource + } + ); +}); + +afterEach(function () { + $this->cache->clear(); +}); + +/* ── 1. convenience set()/get() ───────────────────────────────────── */ +test('Redis set()/get()', function () { + expect($this->cache->get('none'))->toBeNull() + ->and($this->cache->set('foo', 'bar'))->toBeTrue() + ->and($this->cache->get('foo'))->toBe('bar'); +}); + +/* ─── PSR-16 get($key, $default) ───────────────────────────────── */ +test('get returns default when key missing (redis)', function () { + expect($this->cache->get('nobody', 'dflt'))->toBe('dflt'); + + $val = $this->cache->get('dynamic', function (RedisCacheItem $item) { + $item->expiresAfter(1); + return 'xyz'; + }); + expect($val)->toBe('xyz'); + expect($this->cache->get('dynamic'))->toBe('xyz'); + + usleep(2_000_000); + expect($this->cache->get('dynamic', 'again'))->toBe('again'); +}); + +test('get throws for invalid key (redis)', function () { + expect(fn () => $this->cache->get('bad key', 'v')) + ->toThrow(CacheInvalidArgumentException::class); +}); + +/* ── 2. PSR-6 behaviour ─────────────────────────────────────────── */ +test('getItem()/save() (redis)', function () { + $it = $this->cache->getItem('psr'); + expect($it)->toBeInstanceOf(RedisCacheItem::class) + ->and($it->isHit())->toBeFalse(); + + $it->set(777)->save(); + expect($this->cache->getItem('psr')->get())->toBe(777); +}); + +/* ── 3. deferred queue ──────────────────────────────────────────── */ +test('saveDeferred() & commit() (redis)', function () { + $this->cache->getItem('a')->set('A')->saveDeferred(); + expect($this->cache->get('a'))->toBeNull(); + + $this->cache->commit(); + expect($this->cache->get('a'))->toBe('A'); +}); + +/* ── 4. ArrayAccess & magic props ───────────────────────────────── */ +test('ArrayAccess & magic (redis)', function () { + $this->cache['k'] = 12; + expect($this->cache['k'])->toBe(12); + + $this->cache->alpha = 'ζ'; + expect($this->cache->alpha)->toBe('ζ'); +}); + +/* ── 6. TTL expiration ─────────────────────────────────────────── */ +test('expiration honours TTL (redis)', function () { + $this->cache->getItem('ttl')->set('x')->expiresAfter(1)->save(); + usleep(2_000_000); + expect($this->cache->hasItem('ttl'))->toBeFalse(); +}); + +/* ── 7. closure round-trip ──────────────────────────────────────── */ +test('closure persists in redis', function () { + $double = fn ($n) => $n * 2; + $this->cache->getItem('cb')->set($double)->save(); + $fn = $this->cache->getItem('cb')->get(); + expect($fn(5))->toBe(10); +}); + +/* ── 8. stream resource round-trip ─────────────────────────────── */ +test('stream resource round-trip (redis)', function () { + $s = fopen('php://memory', 'r+'); + fwrite($s, 'blob'); + rewind($s); + $this->cache->getItem('stream')->set($s)->save(); + $rest = $this->cache->getItem('stream')->get(); + expect(stream_get_contents($rest))->toBe('blob'); +}); + +/* ── 9. invalid key guard ───────────────────────────────────────── */ +test('invalid key throws (redis)', function () { + expect(fn () => $this->cache->set('bad key', 'v')) + ->toThrow(InvalidArgumentException::class); +}); + +/* ── 10. clear wipes namespace ----------------------------------- */ +test('clear() wipes entries (redis)', function () { + $this->cache->set('z', 9); + $this->cache->clear(); + expect($this->cache->hasItem('z'))->toBeFalse(); +}); + +test('Redis adapter multiFetch()', function () { + $this->cache->set('r1', 10); + $this->cache->set('r2', 20); + + $items = $this->cache->getItems(['r1', 'r2', 'none']); + + expect($items['r1']->get())->toBe(10) + ->and($items['r2']->get())->toBe(20) + ->and($items['none']->isHit())->toBeFalse(); +}); + + diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php new file mode 100644 index 0000000..529bae3 --- /dev/null +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -0,0 +1,120 @@ +cluster = new class { + /** @var array */ + private array $kv = []; + /** @var array> */ + private array $sets = []; + + public function del(string|array $keys): int + { + $keys = is_array($keys) ? $keys : [$keys]; + $deleted = 0; + foreach ($keys as $key) { + if (isset($this->kv[$key])) { + unset($this->kv[$key]); + $deleted++; + } + if (isset($this->sets[$key])) { + unset($this->sets[$key]); + $deleted++; + } + } + + return $deleted; + } + + public function exists(string $key): int + { + $this->pruneKey($key); + return isset($this->kv[$key]) ? 1 : 0; + } + + public function get(string $key): string|false + { + $this->pruneKey($key); + return $this->kv[$key]['value'] ?? false; + } + + public function sAdd(string $key, string $member): int + { + $exists = isset($this->sets[$key][$member]); + $this->sets[$key][$member] = true; + return $exists ? 0 : 1; + } + + public function sCard(string $key): int + { + return count($this->sets[$key] ?? []); + } + + public function sMembers(string $key): array + { + return array_keys($this->sets[$key] ?? []); + } + + public function sRem(string $key, string $member): int + { + if (!isset($this->sets[$key][$member])) { + return 0; + } + + unset($this->sets[$key][$member]); + return 1; + } + + public function set(string $key, string $value): bool + { + $this->kv[$key] = ['value' => $value, 'expires' => null]; + return true; + } + + public function setex(string $key, int $ttl, string $value): bool + { + $this->kv[$key] = ['value' => $value, 'expires' => time() + max(1, $ttl)]; + return true; + } + + private function pruneKey(string $key): void + { + if (!isset($this->kv[$key])) { + return; + } + + $expires = $this->kv[$key]['expires']; + if ($expires !== null && $expires <= time()) { + unset($this->kv[$key]); + } + } + }; + + $this->cache = Cache::redisCluster('cluster-tests', ['127.0.0.1:7000'], 1.0, 1.0, false, $this->cluster); +}); + +test('redis cluster adapter stores and retrieves values', function () { + $this->cache->set('k', 'value'); + + expect($this->cache->get('k'))->toBe('value') + ->and($this->cache->count())->toBe(1); +}); + +test('redis cluster adapter honors ttl', function () { + $this->cache->set('ttl', 'v', 1); + usleep(2_000_000); + + expect($this->cache->get('ttl'))->toBeNull(); +}); + +test('redis cluster adapter clear removes cached values', function () { + $this->cache->set('a', 1); + $this->cache->set('b', 2); + + $this->cache->clear(); + + expect($this->cache->count())->toBe(0) + ->and($this->cache->get('a'))->toBeNull() + ->and($this->cache->get('b'))->toBeNull(); +}); diff --git a/tests/Cache/S3CachePoolTest.php b/tests/Cache/S3CachePoolTest.php new file mode 100644 index 0000000..6186f63 --- /dev/null +++ b/tests/Cache/S3CachePoolTest.php @@ -0,0 +1,79 @@ +client = new class { + /** @var array */ + private array $objects = []; + + public function deleteObject(array $params): array + { + unset($this->objects[$params['Key']]); + return []; + } + + public function deleteObjects(array $params): array + { + foreach ($params['Delete']['Objects'] as $object) { + unset($this->objects[$object['Key']]); + } + + return []; + } + + public function getObject(array $params): array + { + $key = $params['Key']; + if (!array_key_exists($key, $this->objects)) { + throw new RuntimeException('Not found'); + } + + return ['Body' => $this->objects[$key]]; + } + + public function listObjectsV2(array $params): array + { + $prefix = $params['Prefix'] ?? ''; + $rows = []; + foreach (array_keys($this->objects) as $key) { + if (str_starts_with($key, $prefix)) { + $rows[] = ['Key' => $key]; + } + } + + return ['Contents' => $rows]; + } + + public function putObject(array $params): array + { + $this->objects[$params['Key']] = (string) $params['Body']; + return []; + } + }; + + $this->cache = new Cache(new S3CacheAdapter($this->client, 'bucket', 'cachelayer', 's3-tests')); +}); + +test('s3 adapter stores and retrieves values', function () { + $this->cache->set('k', 'value'); + + expect($this->cache->get('k'))->toBe('value') + ->and($this->cache->count())->toBe(1); +}); + +test('s3 adapter clear removes namespace objects', function () { + $this->cache->set('a', 1); + $this->cache->set('b', 2); + $this->cache->clear(); + + expect($this->cache->count())->toBe(0); +}); + +test('s3 cache factory accepts injected client', function () { + $cache = Cache::s3('s3-tests', 'bucket', $this->client); + $cache->set('x', 'X'); + + expect($cache->get('x'))->toBe('X'); +}); diff --git a/tests/Cache/SharedMemoryCachePoolTest.php b/tests/Cache/SharedMemoryCachePoolTest.php new file mode 100644 index 0000000..4d04b80 --- /dev/null +++ b/tests/Cache/SharedMemoryCachePoolTest.php @@ -0,0 +1,19 @@ +skip(); + return; +} + +test('shared memory adapter shares values across instances', function () { + $a = Cache::sharedMemory('shm-tests'); + $b = Cache::sharedMemory('shm-tests'); + + $a->set('k', 'value'); + + expect($b->get('k'))->toBe('value'); + + $a->clear(); +}); diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php new file mode 100644 index 0000000..6bf5849 --- /dev/null +++ b/tests/Cache/SqliteCachePoolTest.php @@ -0,0 +1,163 @@ +skip(); + return; +} + +/* ── bootstrap / teardown ────────────────────────────────────────── */ +beforeEach(function () { + $this->dbFile = sys_get_temp_dir() . '/pest_sqlite_' . uniqid() . '.sqlite'; + $this->cache = Cache::sqlite('tests', $this->dbFile); + ValueSerializer::clearResourceHandlers(); + + /* stream handler for resource test */ + ValueSerializer::registerResourceHandler( + 'stream', + // ----- wrap ---------------------------------------------------- + function (mixed $res): array { + if (!is_resource($res)) { + throw new InvalidArgumentException('Expected resource'); + } + $meta = stream_get_meta_data($res); + rewind($res); + return [ + 'mode' => $meta['mode'], + 'content' => stream_get_contents($res), + ]; + }, + // ----- restore ------------------------------------------------- + function (array $data): mixed { + $s = fopen('php://memory', $data['mode']); + fwrite($s, $data['content']); + rewind($s); + return $s; // <- real resource + } + ); +}); + +afterEach(function () { + // Release SQLite handles before unlink on Windows. + $this->cache = null; + gc_collect_cycles(); + @unlink($this->dbFile); +}); + +/* ── 1. convenience set / get ───────────────────────────────────── */ +test('sqlite set()/get()', function () { + expect($this->cache->get('none'))->toBeNull() + ->and($this->cache->set('foo', 'bar'))->toBeTrue() + ->and($this->cache->get('foo'))->toBe('bar'); +}); + +/* ─── PSR-16 get($key, $default) ───────────────────────────────── */ +test('get returns default when key missing (sqlite)', function () { + expect($this->cache->get('none', 'dflt'))->toBe('dflt'); + + $val = $this->cache->get('compute', function (SqliteCacheItem $item) { + $item->expiresAfter(1); + return 'val'; + }); + expect($val) + ->toBe('val') + ->and($this->cache->get('compute'))->toBe('val'); + + usleep(2_000_000); + expect($this->cache->get('compute', 'again'))->toBe('again'); +}); + +test('get throws for invalid key (sqlite)', function () { + expect(fn () => $this->cache->get('bad key', 'v')) + ->toThrow(CacheInvalidArgumentException::class); +}); + +/* ── 2. PSR-6 behaviour ─────────────────────────────────────────── */ +test('getItem()/save() (sqlite)', function () { + $item = $this->cache->getItem('psr'); + expect($item)->toBeInstanceOf(SqliteCacheItem::class) + ->and($item->isHit())->toBeFalse(); + + $item->set(42)->save(); + expect($this->cache->getItem('psr')->get())->toBe(42); +}); + +/* ── 3. deferred queue ──────────────────────────────────────────── */ +test('saveDeferred() & commit() (sqlite)', function () { + $this->cache->getItem('a')->set('A')->saveDeferred(); + expect($this->cache->get('a'))->toBeNull(); + + $this->cache->commit(); + expect($this->cache->get('a'))->toBe('A'); +}); + +/* ── 4. ArrayAccess & magic props ───────────────────────────────── */ +test('ArrayAccess & magic (sqlite)', function () { + $this->cache['x'] = 5; + expect($this->cache['x'])->toBe(5); + + $this->cache->alpha = 'ω'; + expect($this->cache->alpha)->toBe('ω'); +}); + +/* ── 6. TTL expiration ─────────────────────────────────────────── */ +test('expiration honours TTL (sqlite)', function () { + $this->cache->getItem('ttl')->set('x')->expiresAfter(1)->save(); + usleep(2_000_000); + expect($this->cache->hasItem('ttl'))->toBeFalse(); +}); + +/* ── 7. closure round-trip ──────────────────────────────────────── */ +test('closure survives sqlite', function () { + $fn = fn ($n) => $n + 3; + $this->cache->getItem('cb')->set($fn)->save(); + expect(($this->cache->getItem('cb')->get())(4))->toBe(7); +}); + +/* ── 8. stream resource round-trip ─────────────────────────────── */ +test('stream resource round-trip (sqlite)', function () { + $s = fopen('php://memory', 'r+'); + fwrite($s, 'hello'); + rewind($s); + $this->cache->getItem('stream')->set($s)->save(); + $rest = $this->cache->getItem('stream')->get(); + expect(stream_get_contents($rest))->toBe('hello'); +}); + +/* ── 9. invalid key guard ───────────────────────────────────────── */ +test('invalid key throws (sqlite)', function () { + expect(fn () => $this->cache->set('bad key', 'v')) + ->toThrow(InvalidArgumentException::class); +}); + +/* ── 10. clear wipes all entries ───────────────────────────────── */ +test('clear() flushes table (sqlite)', function () { + $this->cache->set('z', 9); + $this->cache->clear(); + expect($this->cache->hasItem('z'))->toBeFalse(); +}); + +test('SQLite adapter multiFetch()', function () { + $this->cache->set('s1', 'A'); + $this->cache->set('s2', 'B'); + + $items = $this->cache->getItems(['s1', 's2', 'void']); + + expect($items['s1']->get())->toBe('A') + ->and($items['s2']->get())->toBe('B') + ->and($items['void']->isHit())->toBeFalse(); +}); + + diff --git a/tests/Cache/WeakMapCachePoolTest.php b/tests/Cache/WeakMapCachePoolTest.php new file mode 100644 index 0000000..c3c1700 --- /dev/null +++ b/tests/Cache/WeakMapCachePoolTest.php @@ -0,0 +1,25 @@ +cache = Cache::weakMap('weak-tests'); +}); + +test('weak map adapter stores scalar values', function () { + $this->cache->set('a', 123); + + expect($this->cache->get('a'))->toBe(123) + ->and($this->cache->hasItem('a'))->toBeTrue(); +}); + +test('weak map adapter returns same object while strongly referenced', function () { + $obj = new stdClass(); + $obj->name = 'cache-object'; + + $this->cache->set('obj', $obj); + $restored = $this->cache->get('obj'); + + expect($restored)->toBe($obj) + ->and($restored->name)->toBe('cache-object'); +}); diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php new file mode 100644 index 0000000..d8cf03b --- /dev/null +++ b/tests/Memoize/MemoizeTest.php @@ -0,0 +1,87 @@ +flush(); + OnceMemoizer::instance()->flush(); +}); + +it('memoize() returns Memoizer when called without args', function () { + $memoizer = memoize(); + expect($memoizer)->toBeInstanceOf(Memoizer::class); +}); + +it('memoize() caches global callables', function () { + $fn = fn (int $x): int => $x + 1; + + $a = memoize($fn, [1]); + $b = memoize($fn, [1]); + + expect($a)->toBe(2)->and($b)->toBe(2); + + $stats = memoize()->stats(); + expect($stats)->toMatchArray([ + 'hits' => 1, + 'misses' => 1, + 'total' => 2, + ]); +}); + +it('remember() returns Memoizer when called with no object', function () { + $memoizer = remember(); + expect($memoizer)->toBeInstanceOf(Memoizer::class); +}); + +it('remember() caches per-instance callables', function () { + $obj = new stdClass(); + $counter = 0; + $fn = function () use (&$counter) { + return ++$counter; + }; + + $first = remember($obj, $fn); + $second = remember($obj, $fn); + + expect($first)->toBe(1) + ->and($second)->toBe(1) + ->and(memoize()->stats()['hits'])->toBe(1); +}); + +it('once() caches by call site', function () { + $counter = 0; + + $value = (function () use (&$counter) { + return once(function () use (&$counter) { + return ++$counter; + }); + })(); + + $valueAgain = (function () use (&$counter) { + return once(function () use (&$counter) { + return ++$counter; + }); + })(); + + expect($value)->toBe(1) + ->and($valueAgain)->toBe(1) + ->and($counter)->toBe(1); +}); + +it('memoize trait caches values within object', function () { + $inst = new class () { + use MemoizeTrait; + public int $count = 0; + + public function next(): int + { + return $this->memoize(__METHOD__, fn () => ++$this->count); + } + }; + + expect($inst->next())->toBe(1) + ->and($inst->next())->toBe(1) + ->and($inst->count)->toBe(1); +}); diff --git a/tests/Memoize/OnceMemoizerTest.php b/tests/Memoize/OnceMemoizerTest.php new file mode 100644 index 0000000..701be89 --- /dev/null +++ b/tests/Memoize/OnceMemoizerTest.php @@ -0,0 +1,107 @@ +flush(); +}); + +it('caches repeated calls from the same caller', function () { + $memoizer = OnceMemoizer::instance(); + $counter = 0; + + $run = function () use ($memoizer, &$counter): int { + return $memoizer->once(function () use (&$counter) { + return ++$counter; + }); + }; + + $first = $run(); + $second = $run(); + + expect($first)->toBe(1) + ->and($second)->toBe(1) + ->and($counter)->toBe(1); +}); + +it('isolates cache entries by caller context', function () { + $counter = 0; + + $caller = new class () { + public function one(int &$counter): int + { + return OnceMemoizer::instance()->once(function () use (&$counter) { + return ++$counter; + }); + } + + public function two(int &$counter): int + { + return OnceMemoizer::instance()->once(function () use (&$counter) { + return ++$counter; + }); + } + }; + + $a1 = $caller->one($counter); + $a2 = $caller->one($counter); + $b1 = $caller->two($counter); + $b2 = $caller->two($counter); + + expect($a1)->toBe(1) + ->and($a2)->toBe(1) + ->and($b1)->toBe(2) + ->and($b2)->toBe(2) + ->and($counter)->toBe(2); +}); + +it('keeps internal cache bounded', function () { + $memoizer = OnceMemoizer::instance(); + $ref = new ReflectionClass(OnceMemoizer::class); + $cacheProp = $ref->getProperty('cache'); + $orderProp = $ref->getProperty('order'); + + $seedCache = []; + $seedOrder = []; + for ($i = 0; $i < 2048; $i++) { + $key = 'seed-' . $i; + $seedCache[$key] = $i; + $seedOrder[] = $key; + } + + $cacheProp->setValue($memoizer, $seedCache); + $orderProp->setValue($memoizer, $seedOrder); + + $value = (function () use ($memoizer): string { + return $memoizer->once(static fn (): string => 'fresh'); + })(); + + $cache = $cacheProp->getValue($memoizer); + $order = $orderProp->getValue($memoizer); + + expect($value)->toBe('fresh') + ->and(count($cache))->toBe(2048) + ->and(count($order))->toBe(2048) + ->and(array_key_exists('seed-0', $cache))->toBeFalse(); +}); + +it('flush resets cache state', function () { + $memoizer = OnceMemoizer::instance(); + $counter = 0; + + $run = function () use (&$counter, $memoizer): int { + return $memoizer->once(function () use (&$counter) { + return ++$counter; + }); + }; + + $first = $run(); + $second = $run(); + $memoizer->flush(); + $third = $run(); + + expect($first)->toBe(1) + ->and($second)->toBe(1) + ->and($third)->toBe(2) + ->and($counter)->toBe(2); +}); diff --git a/tests/Serializer/ValueSerializerTest.php b/tests/Serializer/ValueSerializerTest.php new file mode 100644 index 0000000..20142b5 --- /dev/null +++ b/tests/Serializer/ValueSerializerTest.php @@ -0,0 +1,74 @@ + 'x', 'b' => ['nested' => true]], + ]; + + foreach ($values as $v) { + $blob = ValueSerializer::serialize($v); + $out = ValueSerializer::unserialize($blob); + expect($out)->toBe($v); + } +}); + +it('round-trips closures', function () { + $fn = fn (int $x): int => $x + 2; + $blob = ValueSerializer::serialize($fn); + $rest = ValueSerializer::unserialize($blob); + + expect(is_callable($rest)) + ->toBeTrue() + ->and($rest(5))->toBe(7); +}); + +it('wraps and unwraps without full serialization', function () { + $data = ['foo' => 'bar', 'baz' => [1, 2, 3]]; + $wrapped = ValueSerializer::wrap($data); + expect($wrapped)->toBe($data); + + $unwrapped = ValueSerializer::unwrap($wrapped); + expect($unwrapped)->toBe($data); +}); + +it('throws when wrapping a resource with no handler', function () { + $s = fopen('php://memory', 'r+'); + + expect(fn () => ValueSerializer::wrap($s)) + ->toThrow(InvalidArgumentException::class) + ->and(fn () => ValueSerializer::serialize($s)) + ->toThrow(InvalidArgumentException::class); + + fclose($s); +}); + +it('throws when registering the same resource handler twice', function () { + ValueSerializer::registerResourceHandler('stream', fn ($r) => $r, fn ($d) => $d); + + expect(fn () => ValueSerializer::registerResourceHandler('stream', fn ($r) => $r, fn ($d) => $d)) + ->toThrow(InvalidArgumentException::class); +}); + +it('keeps serialized closure memo cache bounded', function () { + for ($i = 0; $i < 2200; $i++) { + ValueSerializer::isSerializedClosure('x' . $i); + } + + $ref = new ReflectionClass(ValueSerializer::class); + $memo = $ref->getProperty('serializedClosureMemo'); + + expect(count($memo->getValue()))->toBeLessThanOrEqual(2048); +}); + +