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); +}); + +