diff --git a/.codex b/.codex deleted file mode 100644 index e69de29..0000000 diff --git a/.github/scripts/composer-audit-guard.php b/.github/scripts/composer-audit-guard.php deleted file mode 100644 index a1b1cdb..0000000 --- a/.github/scripts/composer-audit-guard.php +++ /dev/null @@ -1,85 +0,0 @@ - ['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 deleted file mode 100644 index 2b01b26..0000000 --- a/.github/scripts/phpstan-sarif.php +++ /dev/null @@ -1,178 +0,0 @@ - [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 deleted file mode 100644 index 043bf53..0000000 --- a/.github/scripts/syntax.php +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index f633d38..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,191 +0,0 @@ -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: | - composer test:syntax - 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 release:audit - - - 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/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..61b240c --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,38 @@ +name: "Security & Standards" + +on: + schedule: + - cron: "0 0 * * 0" + push: + branches: [ "main", "master" ] + pull_request: + branches: [ "main", "master", "develop", "development" ] + +jobs: + phpforge: + uses: infocyph/phpforge/.github/workflows/security-standards.yml@main + permissions: + security-events: write + actions: read + contents: read + with: + php_versions: '["8.4","8.5"]' + dependency_versions: '["prefer-lowest","prefer-stable"]' + php_extensions: "apcu, mbstring, memcached, mongodb, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true + run_svg_report: true + fail_on_skipped_tests: true + enable_redis_service: true + enable_valkey_service: true + enable_memcached_service: true + enable_postgres_service: true + enable_mysql_service: true + enable_scylladb_service: true + enable_mongodb_service: true + service_db_name: "cachelayer" + service_db_user: "cachelayer" + service_db_password: "123456pass" + artifact_retention_days: 61 diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..9edb388 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,65 @@ +# Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to make participation in this project and +our community a harassment-free experience for everyone, regardless of age, +body size, disability, ethnicity, sex characteristics, gender identity and +expression, level of experience, education, socio-economic status, nationality, +personal appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to a positive environment include: + +- Using welcoming and inclusive language. +- Being respectful of differing viewpoints and experiences. +- Gracefully accepting constructive criticism. +- Focusing on what is best for the community. +- Showing empathy toward other community members. + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery and unwelcome sexual attention or + advances. +- Trolling, insulting or derogatory comments, and personal or political attacks. +- Public or private harassment. +- Publishing others' private information, such as a physical or electronic + address, without explicit permission. +- Other conduct which could reasonably be considered inappropriate in a + professional setting. + +## Our Responsibilities + +Project maintainers are responsible for clarifying standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned with this Code of Conduct. + +## Scope + +This Code of Conduct applies within all project spaces, and it also applies +when an individual is representing the project or its community in public +spaces. Examples of representing a project or community include using an +official project email address, posting via an official social media account, +or acting as an appointed representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the project maintainers by opening a private security report or by +contacting the maintainer email listed in `composer.json`. + +All complaints will be reviewed and investigated and will result in a response +that is deemed necessary and appropriate to the circumstances. Project +maintainers are obligated to maintain confidentiality with regard to the +reporter of an incident. + +## Attribution + +This Code of Conduct is adapted from the Contributor Covenant, version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct/ diff --git a/README.md b/README.md index ee21b0b..037bc08 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CacheLayer -[![Security & Standards](https://github.com/infocyph/CacheLayer/actions/workflows/build.yml/badge.svg)](https://github.com/infocyph/CacheLayer/actions/workflows/build.yml) +[![Security & Standards](https://github.com/infocyph/CacheLayer/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/CacheLayer/actions/workflows/security-standards.yml) ![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/CacheLayer?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2FCacheLayer) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) ![Packagist Version](https://img.shields.io/packagist/v/infocyph/CacheLayer) @@ -20,7 +20,8 @@ visibility, maintenance focus, and faster feature enrichment for caching. ## Features - Unified `Cache` facade implementing PSR-6, PSR-16, `ArrayAccess`, and `Countable` -- Adapter support for APCu, File, PHP Files, Memcached, Redis, Redis Cluster, PDO (SQLite default), Shared Memory, MongoDB, DynamoDB, and S3 +- Adapter support for APCu, File, PHP Files, Memcached, Redis, Valkey, Redis Cluster, PDO (SQLite default), Shared Memory, MongoDB, and ScyllaDB +- Tiered cache composition via `Cache::tiered()` (L1/L2/... descriptors or pool instances) - Tagged invalidation with versioned tags: `setTagged()`, `invalidateTag()`, `invalidateTags()` - Stampede-safe `remember()` with pluggable lock providers - Per-adapter metrics counters and export hooks @@ -41,7 +42,7 @@ Optional extensions/packages depend on adapter choice: - `ext-pdo` + driver (`pdo_sqlite`, `pdo_pgsql`, `pdo_mysql`, ...) - `ext-sysvshm` - `mongodb/mongodb` -- `aws/aws-sdk-php` +- `ext-cassandra` ## Installation @@ -68,6 +69,33 @@ $cache->invalidateTag('users'); $metrics = $cache->exportMetrics(); ``` +## Tiered Flow (L1 -> L2 -> DB) + +```php +use Infocyph\CacheLayer\Cache\Cache; + +$cache = Cache::tiered([ + ['driver' => 'apcu', 'namespace' => 'app'], // L1 + ['driver' => 'valkey', 'namespace' => 'app', 'dsn' => 'valkey://127.0.0.1:6379'], // L2 +], writeToL1: false); // optional L1 write-through + +$value = $cache->remember('user:42', function ($item) use ($pdo) { + $item->expiresAfter(300); + + $stmt = $pdo->prepare('SELECT payload FROM users_cache_source WHERE id = ?'); + $stmt->execute([42]); + + return $stmt->fetchColumn(); +}); +``` + +Request flow: +- check APCu (L1) +- check Redis/Valkey (L2) +- query DB on miss +- write L2 +- optionally write L1 (controlled by `writeToL1`) + ## Security Hardening CacheLayer includes optional payload/serialization hardening controls: @@ -114,6 +142,7 @@ Contributions are welcome. - Open an issue for bug reports or feature discussions - Open a pull request with focused changes and tests - Keep coding style and static checks passing before submitting +- Follow the [Code of Conduct](CODE_OF_CONDUCT.md) ## License diff --git a/SECURITY.md b/SECURITY.md index 9bea76c..e742ad1 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -72,6 +72,29 @@ These paths are created with restrictive permissions and world-writable checks. 3. Use explicit, private cache directories outside shared temp space. 4. Prefer non-executable file storage adapters over `phpFiles` where possible. +## Backend-Specific Notes + +### Redis / Valkey + +- Require authentication and network-level access controls. +- Prefer TLS-enabled connections when crossing host boundaries. +- Avoid exposing Redis/Valkey ports directly to public networks. + +### MongoDB / ScyllaDB / SQL Backends + +- Use least-privilege database credentials scoped to cache tables/collections. +- Enforce transport security (TLS) where supported. +- Keep cache schema/table permissions separate from application primary data. + +### Tiered Cache Deployments (L1/L2/DB) + +For `Cache::tiered()` production setups: + +- keep L1 (APCu) local-process only +- protect L2 (Redis/Valkey) as a private service +- treat DB fallback resolvers as trusted code paths only +- configure bounded TTLs to reduce stale or poisoned cache lifetime + ## Disclosure If you discover a security issue, please open a private report to project diff --git a/benchmarks/CacheFileBench.php b/benchmarks/CacheFileBench.php index 63181ab..c32b895 100644 --- a/benchmarks/CacheFileBench.php +++ b/benchmarks/CacheFileBench.php @@ -12,6 +12,7 @@ final class CacheFileBench { private Cache $cache; + private string $dir; public function setUp(): void @@ -35,10 +36,16 @@ public function tearDown(): void continue; } - $file->isDir() ? @rmdir($path) : @unlink($path); + if ($file->isDir() && is_dir($path)) { + rmdir($path); + } elseif (is_file($path)) { + unlink($path); + } } - @rmdir($this->dir); + if (is_dir($this->dir)) { + rmdir($this->dir); + } } #[Bench\BeforeMethods(['setUp'])] diff --git a/benchmarks/MemoizeBench.php b/benchmarks/MemoizeBench.php index c6c5bf5..6ebddc5 100644 --- a/benchmarks/MemoizeBench.php +++ b/benchmarks/MemoizeBench.php @@ -15,6 +15,7 @@ public function setUp(): void { Memoizer::instance()->flush(); } + #[Bench\BeforeMethods(['setUp'])] public function benchGlobalMemoizeHit(): int { diff --git a/captainhook.json b/captainhook.json index fa19900..782a292 100644 --- a/captainhook.json +++ b/captainhook.json @@ -15,11 +15,15 @@ "options": [] }, { - "action": "composer release:audit", + "action": "composer normalize --dry-run", "options": [] }, { - "action": "composer tests", + "action": "composer ic:release:audit", + "options": [] + }, + { + "action": "composer ic:ci", "options": [] } ] diff --git a/composer.json b/composer.json index df5b4a8..d4e8c61 100644 --- a/composer.json +++ b/composer.json @@ -1,8 +1,8 @@ { "name": "infocyph/cachelayer", "description": "PSR-6/PSR-16 cache layer with local, distributed, and cloud adapters.", - "type": "library", "license": "MIT", + "type": "library", "keywords": [ "cache", "memoize", @@ -18,119 +18,68 @@ "mariadb", "postgres", "mongodb", - "dynamodb", - "s3", + "scylladb", + "valkey", "weakmap", "chain-cache" ], "authors": [ + { + "name": "Infocyph", + "email": "infocyph@gmail.com" + }, { "name": "abmmhasan", "email": "abmmhasan@gmail.com" } ], + "require": { + "php": ">=8.3", + "opis/closure": "^4.5", + "psr/cache": "^3.0", + "psr/simple-cache": "^3.0" + }, + "require-dev": { + "infocyph/phpforge": "dev-main", + "mongodb/mongodb": "^1.20 || ^2.0" + }, "suggest": { "ext-apcu": "For APCu-based caching (in-memory, per-process)", - "ext-redis": "For Redis-based caching (persistent, networked)", + "ext-cassandra": "For ScyllaDB/Cassandra usage via ScyllaDbCacheAdapter", + "ext-mbstring": "Recommended for development tools output formatting (Pest/Termwind)", "ext-memcached": "For Memcached-based caching (distributed, RAM)", + "ext-mongodb": "For MongoDB usage via MongoDbCacheAdapter", "ext-pdo": "For PDO-based SQL caching (MySQL/MariaDB/PostgreSQL/etc.)", - "ext-pdo_sqlite": "For default SQLite usage via Cache::pdo(...) or Cache::sqlite(...)", - "ext-pdo_pgsql": "For PostgreSQL usage via Cache::pdo(...)", "ext-pdo_mysql": "For MySQL/MariaDB usage via Cache::pdo(...)", + "ext-pdo_pgsql": "For PostgreSQL usage via Cache::pdo(...)", + "ext-pdo_sqlite": "For default SQLite usage via Cache::pdo(...) or Cache::sqlite(...)", + "ext-redis": "For Redis-based caching (persistent, networked)", "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)" + "mongodb/mongodb": "For MongoDB caching via MongoDbCacheAdapter" }, + "minimum-stability": "stable", + "prefer-stable": true, "autoload": { - "files": [ - "src/functions.php" - ], "psr-4": { "Infocyph\\CacheLayer\\": "src/" - } + }, + "files": [ + "src/functions.php" + ] }, "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.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": { + "ergebnis/composer-normalize": true, + "infocyph/phpforge": true, "pestphp/pest-plugin": true - } + }, + "classmap-authoritative": true, + "optimize-autoloader": true, + "sort-packages": true } } diff --git a/docs/adapters/dynamodb.rst b/docs/adapters/dynamodb.rst deleted file mode 100644 index fa05dc7..0000000 --- a/docs/adapters/dynamodb.rst +++ /dev/null @@ -1,43 +0,0 @@ -.. _adapters.dynamodb: - -================================ -DynamoDB Adapter (``dynamoDb``) -================================ - -Factory: - -``Cache::dynamoDb(string $namespace = 'default', string $table = 'cachelayer_entries', ?object $client = null, array $config = [])`` - -Requirements: - -* ``aws/aws-sdk-php`` for default client path, or -* injected client implementing required DynamoDB methods - -Highlights: - -* namespace-scoped row storage -* clear via scan + chunked ``batchWriteItem`` delete requests -* TTL stored as absolute timestamp in ``expires`` - -Supported injected client methods: - -* ``getItem`` -* ``putItem`` -* ``deleteItem`` -* ``scan`` -* ``batchWriteItem`` - -Example -------- - -.. code-block:: php - - use Infocyph\CacheLayer\Cache\Cache; - - $cache = Cache::dynamoDb( - namespace: 'edge', - table: 'cachelayer_entries', - config: ['region' => 'us-east-1', 'version' => 'latest'], - ); - - $cache->set('homepage:blocks', $blocks, 45); diff --git a/docs/adapters/index.rst b/docs/adapters/index.rst index 1c65de3..f545b75 100644 --- a/docs/adapters/index.rst +++ b/docs/adapters/index.rst @@ -11,8 +11,8 @@ Choosing quickly: * Start with ``file`` or ``pdo`` for most applications. * Use ``memory``/``apcu`` for fastest local access. -* Use ``redis``/``memcache`` for distributed deployments. -* Use cloud adapters (``mongodb``, ``dynamoDb``, ``s3``) when cache must live outside app hosts. +* Use ``redis``/``valkey``/``memcache`` for distributed deployments. +* Use cloud adapters (``mongodb``, ``scyllaDb``) when cache must live outside app hosts. .. toctree:: :maxdepth: 1 @@ -26,11 +26,11 @@ Choosing quickly: apcu memcached redis + valkey redis-cluster sqlite pdo shared-memory mongodb - dynamodb - s3 + scylladb serialization diff --git a/docs/adapters/s3.rst b/docs/adapters/s3.rst deleted file mode 100644 index 83626ef..0000000 --- a/docs/adapters/s3.rst +++ /dev/null @@ -1,44 +0,0 @@ -.. _adapters.s3: - -===================== -S3 Adapter (``s3``) -===================== - -Factory: - -``Cache::s3(string $namespace = 'default', string $bucket = 'cachelayer', ?object $client = null, array $config = [], string $prefix = 'cachelayer')`` - -Requirements: - -* ``aws/aws-sdk-php`` for default client path, or -* injected S3-compatible client implementing required methods - -Highlights: - -* object-key based cache persistence in S3 bucket -* namespace + hashed key object naming -* clear/count over namespace prefix listing - -Supported injected client methods: - -* ``putObject`` -* ``getObject`` -* ``deleteObject`` -* ``listObjectsV2`` -* ``deleteObjects`` - -Example -------- - -.. code-block:: php - - use Infocyph\CacheLayer\Cache\Cache; - - $cache = Cache::s3( - namespace: 'reports', - bucket: 'my-cache-bucket', - config: ['region' => 'us-east-1', 'version' => 'latest'], - prefix: 'cachelayer/reports', - ); - - $cache->set('monthly:2026-04', $reportPayload, 3600); diff --git a/docs/adapters/scylladb.rst b/docs/adapters/scylladb.rst new file mode 100644 index 0000000..83b462a --- /dev/null +++ b/docs/adapters/scylladb.rst @@ -0,0 +1,40 @@ +.. _adapters.scylladb: + +================================== +ScyllaDB Adapter (``scyllaDb``) +================================== + +Factory: + +``Cache::scyllaDb(string $namespace = 'default', ?object $session = null, string $keyspace = 'cachelayer', string $table = 'cachelayer_entries')`` + +Requirements: + +* injected ScyllaDB/Cassandra session object exposing ``execute()``, or +* ``ext-cassandra`` (for default session creation path) + +Highlights: + +* keyspace/table-backed cache entries with namespace partitioning +* schema bootstrap with ``CREATE TABLE IF NOT EXISTS`` +* TTL stored as absolute timestamp in ``expires`` + +Supported injected session methods: + +* ``execute`` +* ``prepare`` (optional, used when available) + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::scyllaDb( + namespace: 'edge', + keyspace: 'cachelayer', + table: 'cachelayer_entries', + ); + + $cache->set('homepage:blocks', $blocks, 45); diff --git a/docs/adapters/valkey.rst b/docs/adapters/valkey.rst new file mode 100644 index 0000000..9236252 --- /dev/null +++ b/docs/adapters/valkey.rst @@ -0,0 +1,40 @@ +.. _adapters.valkey: + +=========================== +Valkey Adapter (``valkey``) +=========================== + +Factory: + +``Cache::valkey(string $namespace = 'default', string $dsn = 'valkey://127.0.0.1:6379', ?Redis $client = null)`` + +Requirements: + +* ``ext-redis`` (phpredis client) +* reachable Valkey server + +Highlights: + +* Redis-protocol compatible adapter for Valkey deployments +* namespace-prefixed keys +* ``MGET`` batch retrieval +* factory auto-configures ``RedisLockProvider`` for ``remember()`` + +DSN notes: + +* host/port parsed from DSN +* optional password and DB selection (``/db-index``) are supported + +Example +------- + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::valkey('api', 'valkey://127.0.0.1:6379/0'); + + $payload = $cache->remember('endpoint:/v1/users?page=1', function ($item) { + $item->expiresAfter(30); + return fetchApiPayload(); + }, tags: ['users']); diff --git a/docs/cache.rst b/docs/cache.rst index 7b6a3be..a0cc14d 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -52,6 +52,7 @@ The facade exposes factory methods for all bundled adapters: * ``Cache::apcu(string $namespace = 'default')`` * ``Cache::memcache(string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], ?Memcached $client = null)`` * ``Cache::redis(string $namespace = 'default', string $dsn = 'redis://127.0.0.1:6379', ?Redis $client = null)`` +* ``Cache::valkey(string $namespace = 'default', string $dsn = 'valkey://127.0.0.1:6379', ?Redis $client = null)`` * ``Cache::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)`` * ``Cache::sqlite(string $namespace = 'default', ?string $file = null)`` * ``Cache::pdo(string $namespace = 'default', ?string $dsn = null, ?string $username = null, ?string $password = null, ?PDO $pdo = null, string $table = 'cachelayer_entries')`` @@ -60,15 +61,48 @@ The facade exposes factory methods for all bundled adapters: * ``Cache::sharedMemory(string $namespace = 'default', int $segmentSize = 16777216)`` * ``Cache::nullStore()`` * ``Cache::chain(array $pools)`` +* ``Cache::tiered(array $tiers, bool $writeToL1 = true)`` * ``Cache::mongodb(string $namespace = 'default', ?object $collection = null, ?object $client = null, string $database = 'cachelayer', string $collectionName = 'entries', string $uri = 'mongodb://127.0.0.1:27017')`` -* ``Cache::dynamoDb(string $namespace = 'default', string $table = 'cachelayer_entries', ?object $client = null, array $config = [])`` -* ``Cache::s3(string $namespace = 'default', string $bucket = 'cachelayer', ?object $client = null, array $config = [], string $prefix = 'cachelayer')`` +* ``Cache::scyllaDb(string $namespace = 'default', ?object $session = null, string $keyspace = 'cachelayer', string $table = 'cachelayer_entries')`` ``local()`` chooses APCu when available (``extension_loaded('apcu')`` and ``apcu_enabled()``), otherwise File cache. ``pdo()`` defaults to SQLite (temp-file database per namespace) when DSN/PDO is not provided. ``sqlite()`` is a convenience wrapper over ``pdo()`` for explicit SQLite file selection. +``tiered()`` accepts either concrete pool instances or descriptor arrays with a +``driver`` key (for example ``apcu``, ``valkey``, ``redis``, ``pdo``, ``sqlite``). +Use ``writeToL1 = false`` to skip write-through to the first tier while still +allowing promotion from lower tiers on read. + +Tiered L1/L2/DB flow example: + +.. code-block:: php + + use Infocyph\CacheLayer\Cache\Cache; + + $cache = Cache::tiered([ + ['driver' => 'apcu', 'namespace' => 'app'], // L1 + ['driver' => 'valkey', 'namespace' => 'app', 'dsn' => 'valkey://127.0.0.1:6379'], // L2 + ], writeToL1: false); // optional L1 write-through + + $value = $cache->remember('user:42', function ($item) use ($pdo) { + $item->expiresAfter(300); + + $stmt = $pdo->prepare('SELECT payload FROM users_cache_source WHERE id = ?'); + $stmt->execute([42]); + + return $stmt->fetchColumn(); + }); + +Request path: + +* check APCu (L1) +* check Redis/Valkey (L2) +* query DB on miss +* write L2 +* optionally write L1 (``writeToL1``) + Key and TTL Rules ----------------- @@ -173,11 +207,13 @@ Lock provider selection: * ``setLockProvider(LockProviderInterface $provider): self`` * ``useRedisLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self`` +* ``useValkeyLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self`` * ``useMemcachedLock(?Memcached $client = null, string $prefix = 'cachelayer:lock:'): self`` Factory defaults: * ``Cache::redis(...)`` auto-configures ``RedisLockProvider`` +* ``Cache::valkey(...)`` auto-configures ``RedisLockProvider`` * ``Cache::memcache(...)`` auto-configures ``MemcachedLockProvider`` * ``Cache::pdo(...)`` / ``Cache::sqlite(...)`` auto-configure ``PdoLockProvider`` * other adapters default to ``FileLockProvider`` diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst index 0dd035b..8fbc6ce 100644 --- a/docs/metrics-and-locking.rst +++ b/docs/metrics-and-locking.rst @@ -15,7 +15,7 @@ Cache facade metrics API: Default collector is ``InMemoryCacheMetricsCollector``. Exported snapshots use readable adapter keys such as ``file``, ``pdo``, -``redis``, ``memory``, and ``redis_cluster``. +``redis``, ``valkey``, ``memory``, ``scylladb``, and ``redis_cluster``. Metric counters are tracked per adapter name, for example: @@ -58,6 +58,7 @@ Facade helpers: * ``setLockProvider(LockProviderInterface $provider): self`` * ``useRedisLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self`` +* ``useValkeyLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self`` * ``useMemcachedLock(?Memcached $client = null, string $prefix = 'cachelayer:lock:'): self`` Custom lock providers can implement ``LockProviderInterface``: @@ -75,6 +76,7 @@ Custom lock providers can implement ``LockProviderInterface``: Adapter defaults: * Redis adapter factory sets ``RedisLockProvider`` +* Valkey adapter factory sets ``RedisLockProvider`` * Memcached adapter factory sets ``MemcachedLockProvider`` * PDO/SQLite adapter factories set ``PdoLockProvider`` * all other adapters use ``FileLockProvider`` by default diff --git a/pest.xml b/pest.xml deleted file mode 100644 index d5d12d8..0000000 --- a/pest.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/phpbench.json b/phpbench.json deleted file mode 100644 index fff7e05..0000000 --- a/phpbench.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "$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 deleted file mode 100644 index 666c061..0000000 --- a/phpcs.xml.dist +++ /dev/null @@ -1,52 +0,0 @@ - - - Semantic PHPCS checks not covered by Pint/Psalm/PHPStan. - - - - - - - ./src - ./tests - - */vendor/* - */.git/* - */.idea/* - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index 0adc1df..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,14 +0,0 @@ -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 deleted file mode 100644 index d5d12d8..0000000 --- a/phpunit.xml +++ /dev/null @@ -1,22 +0,0 @@ - - - - - ./tests - - - - - ./src - - - - - - - diff --git a/pint.json b/pint.json deleted file mode 100644 index 46529c3..0000000 --- a/pint.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "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 deleted file mode 100644 index 49a4a35..0000000 --- a/psalm.xml +++ /dev/null @@ -1,40 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/rector.php b/rector.php deleted file mode 100644 index e30ddf8..0000000 --- a/rector.php +++ /dev/null @@ -1,14 +0,0 @@ -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 index e9b3e51..95dae0a 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -5,6 +5,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Countable; +use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; @@ -38,15 +39,21 @@ public function commit(): bool $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; } + /** + * @param list $keys + * @return iterable + */ public function getItems(array $keys = []): iterable { foreach ($keys as $key) { @@ -70,6 +77,7 @@ public function saveDeferred(CacheItemInterface $item): bool return false; } $this->deferred[$item->getKey()] = $item; + return true; } @@ -77,6 +85,151 @@ public function set(string $key, mixed $value, ?int $ttl = null): bool { $item = $this->getItem($key); $item->set($value)->expiresAfter($ttl); + return $this->save($item); } + + /** + * @return array{value:mixed,expires:int|null}|null + */ + protected function decodeRecordFromBase64(string $payload): ?array + { + $blob = base64_decode($payload, true); + if (!is_string($blob)) { + return null; + } + + return $this->decodeRecordFromBlob($blob); + } + + /** + * @return array{value:mixed,expires:int|null}|null + */ + protected function decodeRecordFromBlob(string $blob): ?array + { + $record = CachePayloadCodec::decode($blob); + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + return null; + } + + return $record; + } + + protected function genericDeleteAndMiss(string $key): GenericCacheItem + { + $this->deleteItem($key); + + return $this->genericMiss($key); + } + + protected function genericFromBase64(string $key, ?string $payload): GenericCacheItem + { + return $this->genericFromBase64WithInvalidator( + $key, + $payload, + fn(): bool => $this->deleteItem($key), + ); + } + + /** + * @param callable():bool $onInvalid + */ + protected function genericFromBase64WithInvalidator(string $key, ?string $payload, callable $onInvalid): GenericCacheItem + { + return $this->genericFromEncodedWithInvalidator($key, $payload, $onInvalid, $this->decodeRecordFromBase64(...)); + } + + protected function genericFromBlob(string $key, ?string $blob): GenericCacheItem + { + return $this->genericFromBlobWithInvalidator( + $key, + $blob, + fn(): bool => $this->deleteItem($key), + ); + } + + /** + * @param callable():bool $onInvalid + */ + protected function genericFromBlobWithInvalidator(string $key, ?string $blob, callable $onInvalid): GenericCacheItem + { + return $this->genericFromEncodedWithInvalidator($key, $blob, $onInvalid, $this->decodeRecordFromBlob(...)); + } + + /** + * @param array{value:mixed,expires:int|null} $record + */ + protected function genericItemFromRecord(string $key, array $record): GenericCacheItem + { + $item = new GenericCacheItem($this, $key); + $item->set($record['value']); + if ($record['expires'] !== null) { + $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); + } + + return $item; + } + + protected function genericMiss(string $key): GenericCacheItem + { + return new GenericCacheItem($this, $key); + } + + /** + * @template T of CacheItemInterface + * + * @param list $keys + * @param callable(string):T $fetcher + * @return array + */ + protected function multiFetchItems(array $keys, callable $fetcher): array + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $fetcher($key); + } + + return $items; + } + + /** + * @param callable(CacheItemInterface,array{ttl:int|null,expiresAt:int|null}):bool $writer + */ + protected function saveEncoded(CacheItemInterface $item, callable $writer): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] === 0) { + return $this->deleteItem($item->getKey()); + } + + return $writer($item, $expires); + } + + /** + * @param callable(string):(array{value:mixed,expires:int|null}|null) $decoder + * @param callable():bool $onInvalid + */ + private function genericFromEncodedWithInvalidator( + string $key, + ?string $encoded, + callable $onInvalid, + callable $decoder, + ): GenericCacheItem { + if (!is_string($encoded)) { + return $this->genericMiss($key); + } + + $record = $decoder($encoded); + if ($record === null) { + $onInvalid(); + + return $this->genericMiss($key); + } + + return $this->genericItemFromRecord($key, $record); + } } diff --git a/src/Cache/Adapter/AdapterValueNormalizer.php b/src/Cache/Adapter/AdapterValueNormalizer.php new file mode 100644 index 0000000..baef5aa --- /dev/null +++ b/src/Cache/Adapter/AdapterValueNormalizer.php @@ -0,0 +1,67 @@ +|null + */ + public static function fromArrayLikeOrToArray(mixed $value): ?array + { + return match (true) { + $value === null => null, + is_array($value) => self::normalizeAssoc($value), + $value instanceof \ArrayAccess && $value instanceof \Traversable => self::normalizeAssoc(iterator_to_array($value)), + is_object($value) => self::normalizeFromToArray($value), + default => null, + }; + } + + /** + * @return array|null + */ + public static function fromJsonOrArrayLike(mixed $value): ?array + { + if ($value instanceof \JsonSerializable) { + $json = $value->jsonSerialize(); + + return is_array($json) ? self::normalizeAssoc($json) : null; + } + + return self::fromArrayLikeOrToArray($value); + } + + /** + * @param array $value + * @return array + */ + public static function normalizeAssoc(array $value): array + { + $out = []; + foreach ($value as $k => $v) { + if (is_string($k)) { + $out[$k] = $v; + } + } + + return $out; + } + + /** + * @return array|null + */ + private static function normalizeFromToArray(object $value): ?array + { + $toArray = [$value, 'toArray']; + if (!is_callable($toArray)) { + return null; + } + + $arrayValue = $toArray(); + + return is_array($arrayValue) ? self::normalizeAssoc($arrayValue) : null; + } +} diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 2dce444..83cba3d 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -26,6 +26,7 @@ class ApcuCacheAdapter extends AbstractCacheAdapter * Creates a new APCu cache adapter. * * @param string $namespace A namespace prefix to avoid key collisions. + * * @throws RuntimeException If the APCu extension is not enabled. */ public function __construct(string $namespace = 'default') @@ -42,6 +43,7 @@ public function clear(): bool apcu_delete($apcuKey); } $this->deferred = []; + return true; } @@ -60,12 +62,16 @@ public function deleteItem(string $key): bool return apcu_delete($mapped); } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { $ok = true; foreach ($keys as $k) { $ok = $ok && $this->deleteItem($k); } + return $ok; } @@ -76,18 +82,14 @@ public function getItem(string $key): ApcuCacheItem $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']), - ); + $item = $this->hitItemFromBlob($key, $raw); + if ($item instanceof ApcuCacheItem) { + return $item; } + apcu_delete($apcuKey); } + return new ApcuCacheItem($this, $key); } @@ -96,32 +98,29 @@ public function hasItem(string $key): bool return apcu_exists($this->map($key)); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { if ($keys === []) { return []; } + $prefixed = array_map($this->map(...), $keys); $raw = apcu_fetch($prefixed); + if (!is_array($raw)) { + $raw = []; + } $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; + if ($this->appendFetchedHit($items, $stale, $k, $raw)) { + continue; } + $items[$k] = new ApcuCacheItem($this, $k); } @@ -141,10 +140,12 @@ public function save(CacheItemInterface $item): bool $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); } @@ -153,6 +154,51 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof ApcuCacheItem; } + /** + * @param array $items + * @param list $stale + * @param array $raw + */ + private function appendFetchedHit(array &$items, array &$stale, string $key, array $raw): bool + { + $mapped = $this->map($key); + if (!array_key_exists($mapped, $raw) || !is_string($raw[$mapped])) { + return false; + } + + $item = $this->hitItemFromBlob($key, $raw[$mapped]); + if ($item instanceof ApcuCacheItem) { + $items[$key] = $item; + + return true; + } + + $stale[] = $mapped; + + return false; + } + + private function hitItemFromBlob(string $key, string $blob): ?ApcuCacheItem + { + $record = $this->decodeRecordFromBlob($blob); + if ($record === null) { + return null; + } + + $expiresAt = CachePayloadCodec::toDateTime($record['expires']); + + return new ApcuCacheItem( + pool: $this, + key: $key, + value: $record['value'], + hit: true, + exp: $expiresAt, + ); + } + + /** + * @return list + */ private function listKeys(): array { $iter = new \APCUIterator( @@ -163,6 +209,7 @@ private function listKeys(): array foreach ($iter as $k => $unused) { $out[] = $k; } + return $out; } diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 3508e18..99f7198 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -10,6 +10,7 @@ final class ArrayCacheAdapter extends AbstractCacheAdapter { private readonly string $ns; + /** @var array */ private array $store = []; @@ -22,63 +23,71 @@ 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; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { foreach ($keys as $key) { - unset($this->store[$this->map((string) $key)]); + unset($this->store[$this->map($key)]); } return true; } public function getItem(string $key): GenericCacheItem + { + $mapped = $this->map($key); + $blob = $this->store[$mapped] ?? null; + + return $this->genericFromBlob($key, is_string($blob) ? $blob : null); + } + + public function hasItem(string $key): bool { $mapped = $this->map($key); $blob = $this->store[$mapped] ?? null; if (!is_string($blob)) { - return new GenericCacheItem($this, $key); + return false; } - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $record = $this->decodeRecordFromBlob($blob); + if ($record === null) { 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 false; } - return $item; - } - - public function hasItem(string $key): bool - { - return $this->getItem($key)->isHit(); + return true; } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { $items = []; foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); + $items[$key] = $this->getItem($key); } return $items; @@ -86,17 +95,11 @@ public function multiFetch(array $keys): array public function save(CacheItemInterface $item): bool { - if (!$this->supportsItem($item)) { - return false; - } + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $this->store[$this->map($saveItem->getKey())] = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); - $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; + return true; + }); } protected function supportsItem(CacheItemInterface $item): bool diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index d288e82..dda91be 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -6,6 +6,7 @@ use DateTimeImmutable; use DateTimeInterface; +use Infocyph\CacheLayer\Cache\Item\AbstractCacheItem; use Infocyph\CacheLayer\Serializer\ValueSerializer; use Psr\Cache\CacheItemInterface; use Throwable; @@ -13,12 +14,19 @@ final class CachePayloadCodec { private const string COMPRESSED_PREFIX = 'imx-gz:'; + private const string FORMAT = 'imx-record-v1'; + private const string SIGNED_PREFIX = 'imx-sig-v1:'; + private static int $compressionLevel = 6; + private static ?int $compressionThresholdBytes = null; + private static ?string $integrityKey = null; + private static ?int $maxPayloadBytes = 8_388_608; + private static bool $securityBootstrapped = false; public static function configureCompression(?int $thresholdBytes = null, int $level = 6): void @@ -99,7 +107,7 @@ public static function encode(mixed $value, ?int $expiresAt): string */ public static function expirationFromItem(CacheItemInterface $item): array { - $ttl = method_exists($item, 'ttlSeconds') ? $item->ttlSeconds() : null; + $ttl = $item instanceof AbstractCacheItem ? $item->ttlSeconds() : null; $expiresAt = $ttl === null ? null : time() + $ttl; return ['ttl' => $ttl, 'expiresAt' => $expiresAt]; @@ -122,6 +130,7 @@ private static function attachSignature(string $payload): string } $signature = hash_hmac('sha256', $payload, self::$integrityKey); + return self::SIGNED_PREFIX . $signature . ':' . $payload; } @@ -152,7 +161,14 @@ private static function decodeArrayPayload(mixed $decoded): ?array return null; } - $fromFormatted = self::decodeFormattedPayload($decoded); + $normalized = []; + foreach ($decoded as $key => $value) { + if (is_string($key)) { + $normalized[$key] = $value; + } + } + + $fromFormatted = self::decodeFormattedPayload($normalized); if ($fromFormatted !== null) { return $fromFormatted; } @@ -208,6 +224,7 @@ private static function expandIfCompressed(string $blob): string } $decoded = gzdecode($raw); + return is_string($decoded) ? $decoded : $blob; } diff --git a/src/Cache/Adapter/ChainCacheAdapter.php b/src/Cache/Adapter/ChainCacheAdapter.php index e3d65de..47c081f 100644 --- a/src/Cache/Adapter/ChainCacheAdapter.php +++ b/src/Cache/Adapter/ChainCacheAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\Item\AbstractCacheItem; use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; use InvalidArgumentException; use Psr\Cache\CacheItemInterface; @@ -14,8 +15,10 @@ final class ChainCacheAdapter extends AbstractCacheAdapter /** * @param array $pools */ - public function __construct(private readonly array $pools) - { + public function __construct( + private readonly array $pools, + private readonly bool $writeToL1 = true, + ) { if ($pools === []) { throw new InvalidArgumentException('ChainCacheAdapter requires at least one pool.'); } @@ -29,12 +32,14 @@ public function clear(): bool } $this->deferred = []; + return $ok; } public function count(): int { $first = $this->pools[0]; + return $first instanceof \Countable ? count($first) : 0; } @@ -48,6 +53,9 @@ public function deleteItem(string $key): bool return $ok; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { $ok = true; @@ -67,7 +75,7 @@ public function getItem(string $key): GenericCacheItem } $value = $item->get(); - $ttl = method_exists($item, 'ttlSeconds') ? $item->ttlSeconds() : null; + $ttl = $item instanceof AbstractCacheItem ? $item->ttlSeconds() : null; for ($i = 0; $i < $idx; $i++) { $promote = $this->pools[$i]->getItem($key); @@ -79,6 +87,7 @@ public function getItem(string $key): GenericCacheItem $out = new GenericCacheItem($this, $key); $out->set($value); $out->expiresAfter($ttl); + return $out; } @@ -90,36 +99,31 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { - $items = []; - foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); - } - - return $items; + return $this->multiFetchItems($keys, $this->getItem(...)); } 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 $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $ok = true; + $poolCount = count($this->pools); + $start = $this->writeToL1 || $poolCount === 1 ? 0 : 1; + for ($idx = $start; $idx < $poolCount; $idx++) { + $pool = $this->pools[$idx]; + $target = $pool->getItem($saveItem->getKey()); + $target->set($saveItem->get()); + $target->expiresAfter($expires['ttl']); + $ok = $pool->save($target) && $ok; + } - return $ok; + return $ok; + }); } protected function supportsItem(CacheItemInterface $item): bool diff --git a/src/Cache/Adapter/DynamoDbCacheAdapter.php b/src/Cache/Adapter/DynamoDbCacheAdapter.php deleted file mode 100644 index 78fa03f..0000000 --- a/src/Cache/Adapter/DynamoDbCacheAdapter.php +++ /dev/null @@ -1,246 +0,0 @@ -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 index 430c45a..5378ca0 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -19,7 +19,10 @@ */ class FileCacheAdapter extends AbstractCacheAdapter { + use SecuresFilesystemDirectories; + private const string DEFAULT_BASE_DIR = 'cachelayer/files'; + private string $dir; /** @@ -27,6 +30,7 @@ class FileCacheAdapter extends AbstractCacheAdapter * * @param string $namespace A namespace prefix for cache files to avoid collisions. * @param string|null $baseDir The base directory for cache files. If null, uses system temp directory. + * * @throws RuntimeException If the cache directory cannot be created or is not writable. */ public function __construct(string $namespace = 'default', ?string $baseDir = null) @@ -37,10 +41,15 @@ public function __construct(string $namespace = 'default', ?string $baseDir = nu public function clear(): bool { $ok = true; - foreach (glob("$this->dir*.cache") as $f) { - $ok = $ok && @unlink($f); + $files = glob("$this->dir*.cache"); + if ($files === false) { + $files = []; + } + foreach ($files as $f) { + $ok = $ok && (!is_file($f) || unlink($f)); } $this->deferred = []; + return $ok; } @@ -53,15 +62,19 @@ public function deleteItem(string $key): bool { $file = $this->fileFor($key); - return !is_file($file) || @unlink($file); + return !is_file($file) || unlink($file); } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { $ok = true; foreach ($keys as $k) { $ok = $ok && $this->deleteItem($k); } + return $ok; } @@ -83,7 +96,7 @@ public function getItem(string $key): FileCacheItem ); } } - @unlink($file); + unlink($file); } return new FileCacheItem($this, $key); @@ -113,12 +126,18 @@ public function save(CacheItemInterface $item): bool } if (file_put_contents($tmp, $blob) === false) { - @unlink($tmp); + if (is_file($tmp)) { + unlink($tmp); + } + return false; } - if (!@rename($tmp, $this->fileFor($item->getKey()))) { - @unlink($tmp); + if (!rename($tmp, $this->fileFor($item->getKey()))) { + if (is_file($tmp)) { + unlink($tmp); + } + return false; } @@ -136,23 +155,6 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof FileCacheItem; } - private function assertPathNotSymlink(string $path, string $label): void - { - if (is_link($path)) { - throw new RuntimeException($label . " must not be a symlink: {$path}"); - } - } - - private function assertSecureDirectory(string $path, string $label): void - { - $this->assertPathNotSymlink($path, $label); - - $perms = fileperms($path); - if ($perms !== false && (($perms & 0x0002) === 0x0002)) { - throw new RuntimeException($label . " must not be world-writable: {$path}"); - } - } - private function assertWritableDirectory(string $path, string $message): void { if (!is_writable($path)) { @@ -169,6 +171,7 @@ private function createDirectory(string $ns, ?string $baseDir): void if (is_dir($this->dir)) { $this->assertWritableDirectory($this->dir, "Cache directory '$this->dir' exists but is not writable"); $this->assertSecureDirectory($this->dir, 'Cache directory'); + return; } @@ -195,7 +198,7 @@ private function ensureBaseDirectoryExists(string $baseDir): void ); } - if (!is_dir($baseDir) && !@mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { + if (!is_dir($baseDir) && !mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { $this->throwCreationError('Failed to create base directory ' . $baseDir); } @@ -212,7 +215,7 @@ private function ensureCacheDirectoryExists(string $cacheDir): void ); } - if (!@mkdir($cacheDir, 0700, true) && !is_dir($cacheDir)) { + if (!mkdir($cacheDir, 0700, true) && !is_dir($cacheDir)) { $this->throwCreationError('Failed to create cache directory ' . $cacheDir); } @@ -227,6 +230,7 @@ private function fileFor(string $key): string private function throwCreationError(string $prefix): void { $err = error_get_last()['message'] ?? 'unknown error'; + throw new RuntimeException($prefix . ": $err"); } } diff --git a/src/Cache/Adapter/MemCacheAdapter.php b/src/Cache/Adapter/MemCacheAdapter.php index 70a3b17..6750178 100644 --- a/src/Cache/Adapter/MemCacheAdapter.php +++ b/src/Cache/Adapter/MemCacheAdapter.php @@ -6,27 +6,32 @@ use Infocyph\CacheLayer\Cache\Item\MemCacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Memcached; use Psr\Cache\CacheItemInterface; use RuntimeException; class MemCacheAdapter extends AbstractCacheAdapter { - private readonly Memcached $mc; + private readonly \Memcached $mc; + private readonly string $ns; + + /** @var array */ private array $knownKeys = []; + /** + * @param array $servers + */ public function __construct( string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], - ?Memcached $client = null, + ?\Memcached $client = null, ) { - if (!class_exists(Memcached::class)) { + if (!class_exists(\Memcached::class)) { throw new RuntimeException('Memcached extension not loaded'); } $this->ns = sanitize_cache_ns($namespace); - $this->mc = $client ?? new Memcached(); + $this->mc = $client ?? new \Memcached(); if (!$client) { $this->mc->addServers($servers); } @@ -37,6 +42,7 @@ public function clear(): bool $this->mc->flush(); $this->deferred = []; $this->knownKeys = []; + return true; } @@ -49,48 +55,55 @@ public function deleteItem(string $key): bool { $this->mc->delete($this->map($key)); unset($this->knownKeys[$key]); + return true; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { foreach ($keys as $k) { $this->deleteItem($k); } + return true; } - public function getClient(): Memcached + 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']), - ); + $mappedKey = $this->map($key); + $raw = $this->mc->get($mappedKey); + if ($this->mc->getResultCode() === \Memcached::RES_SUCCESS && is_string($raw)) { + $item = $this->hitItemFromBlob($key, $raw); + if ($item instanceof MemCacheItem) { + return $item; } - $this->mc->delete($this->map($key)); + + $this->mc->delete($mappedKey); unset($this->knownKeys[$key]); } - return new MemCacheItem($this, $key); + + return $this->missItem($key); } public function hasItem(string $key): bool { $this->mc->get($this->map($key)); + return $this->mc->getResultCode() === \Memcached::RES_SUCCESS; } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { if ($keys === []) { @@ -98,29 +111,21 @@ public function multiFetch(array $keys): array } $prefixed = array_map($this->map(...), $keys); - $raw = $this->mc->getMulti($prefixed, Memcached::GET_PRESERVE_ORDER) ?: []; + $raw = $this->mc->getMulti($prefixed, \Memcached::GET_PRESERVE_ORDER); + if (!is_array($raw)) { + $raw = []; + } $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; + if (isset($raw[$p]) && $this->appendFetchedHit($items, $stale, $staleLogicalKeys, $k, $p, $raw[$p])) { + continue; } - $items[$k] = new MemCacheItem($this, $k); + + $items[$k] = $this->missItem($k); } if ($stale !== []) { @@ -144,6 +149,7 @@ public function save(CacheItemInterface $item): bool if ($ttl === 0) { $this->mc->delete($this->map($item->getKey())); unset($this->knownKeys[$item->getKey()]); + return true; } @@ -152,6 +158,7 @@ public function save(CacheItemInterface $item): bool if ($ok) { $this->knownKeys[$item->getKey()] = true; } + return $ok; } @@ -160,6 +167,36 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof MemCacheItem; } + /** + * @param array $items + * @param list $stale + * @param list $staleLogicalKeys + */ + private function appendFetchedHit( + array &$items, + array &$stale, + array &$staleLogicalKeys, + string $logicalKey, + string $mappedKey, + mixed $rawEntry, + ): bool { + if (!is_string($rawEntry)) { + return false; + } + + $item = $this->hitItemFromBlob($logicalKey, $rawEntry); + if ($item instanceof MemCacheItem) { + $items[$logicalKey] = $item; + + return true; + } + + $stale[] = $mappedKey; + $staleLogicalKeys[] = $logicalKey; + + return false; + } + /** * @param array $seen * @param list $out @@ -177,7 +214,10 @@ private function collectDumpedKeys( return; } - $keys = $this->stripNamespace(array_keys($dump[$server]), $pref); + $keys = $this->stripNamespace(array_values(array_filter( + array_map(strval(...), array_keys($dump[$server])), + static fn(string $value): bool => $value !== '', + )), $pref); foreach ($keys as $key) { if (isset($seen[$key])) { @@ -191,7 +231,6 @@ private function collectDumpedKeys( /** * @param array $items - * * @return list */ private function extractSlabIds(array $items): array @@ -209,11 +248,17 @@ private function extractSlabIds(array $items): array return array_values(array_unique($ids)); } + /** + * @return list + */ private function fastKnownKeys(): array { return $this->knownKeys ? array_keys($this->knownKeys) : []; } + /** + * @return list + */ private function fetchKeys(): array { if ($quick = $this->fastKnownKeys()) { @@ -224,21 +269,54 @@ private function fetchKeys(): array if ($keys = $this->keysFromGetAll($pref)) { return $keys; } + return $this->keysFromSlabDump($pref); } + private function hitItemFromBlob(string $key, string $blob): ?MemCacheItem + { + $record = $this->decodeRecordFromBlob($blob); + if ($record === null) { + return null; + } + + return new MemCacheItem( + $this, + $key, + $record['value'], + true, + CachePayloadCodec::toDateTime($record['expires']), + ); + } + + /** + * @return list + */ private function keysFromGetAll(string $pref): array { $all = $this->mc->getAllKeys(); if (!is_array($all)) { return []; } - return $this->stripNamespace($all, $pref); + + $keys = []; + foreach ($all as $key) { + if (is_string($key)) { + $keys[] = $key; + } + } + + return $this->stripNamespace($keys, $pref); } + /** + * @return list + */ private function keysFromSlabDump(string $pref): array { + /** @var list $out */ $out = []; + /** @var array $seen */ $seen = []; foreach ($this->slabIdsByServer() as $server => $slabIds) { @@ -255,6 +333,11 @@ private function map(string $key): string return $this->ns . ':' . $key; } + private function missItem(string $key): MemCacheItem + { + return new MemCacheItem($this, $key); + } + /** * @return array> */ @@ -262,9 +345,22 @@ private function slabIdsByServer(): array { $stats = $this->mc->getStats('items'); - return array_map($this->extractSlabIds(...), $stats); + $mapped = []; + foreach ($stats as $server => $items) { + if (!is_string($server) || !is_array($items)) { + continue; + } + + $mapped[$server] = $this->extractSlabIds($items); + } + + return $mapped; } + /** + * @param array $fullKeys + * @return list + */ private function stripNamespace(array $fullKeys, string $pref): array { return array_values(array_map( diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index 46a3346..57698ba 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -39,6 +39,7 @@ public static function fromClient( /** @var object $selected */ $selected = $client->selectCollection($database, $collection); + return new self($selected, $namespace); } @@ -46,26 +47,33 @@ public function clear(): bool { $this->collection->deleteMany(['ns' => $this->ns]); $this->deferred = []; + return true; } public function count(): int { - return (int) $this->collection->countDocuments([ + $count = $this->collection->countDocuments([ 'ns' => $this->ns, '$or' => [ ['expires' => null], ['expires' => ['$gt' => time()]], ], ]); + + return is_numeric($count) ? max(0, (int) $count) : 0; } public function deleteItem(string $key): bool { $this->collection->deleteOne(['_id' => $this->map($key)]); + return true; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { foreach ($keys as $key) { @@ -78,78 +86,56 @@ public function deleteItems(array $keys): bool public function getItem(string $key): GenericCacheItem { $doc = $this->collection->findOne(['_id' => $this->map($key)]); - $row = $this->toArray($doc); + $row = AdapterValueNormalizer::fromJsonOrArrayLike($doc); - if ($row === null || !is_string($row['payload'] ?? null)) { - return new GenericCacheItem($this, $key); + if ($row === null) { + return $this->genericMiss($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); - } + $payload = $row['payload'] ?? null; - $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; + return $this->genericFromBase64($key, is_string($payload) ? $payload : null); } public function hasItem(string $key): bool { - return $this->getItem($key)->isHit(); + $count = $this->collection->countDocuments([ + '_id' => $this->map($key), + '$or' => [ + ['expires' => null], + ['expires' => ['$gt' => time()]], + ], + ]); + + return is_numeric($count) && (int) $count > 0; } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { - $items = []; - foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); - } - - return $items; + return $this->multiFetchItems($keys, $this->getItem(...)); } 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'], + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $this->collection->updateOne( + ['_id' => $this->map($saveItem->getKey())], + [ + '$set' => [ + 'ns' => $this->ns, + 'payload' => base64_encode(CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt'])), + 'expires' => $expires['expiresAt'], + ], ], - ], - ['upsert' => true], - ); + ['upsert' => true], + ); - return true; + return true; + }); } protected function supportsItem(CacheItemInterface $item): bool @@ -161,34 +147,4 @@ 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 index d9ef7fd..51deb1e 100644 --- a/src/Cache/Adapter/NullCacheAdapter.php +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -12,6 +12,7 @@ final class NullCacheAdapter extends AbstractCacheAdapter public function clear(): bool { $this->deferred = []; + return true; } @@ -22,11 +23,18 @@ public function count(): int public function deleteItem(string $key): bool { + unset($key); + return true; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { + unset($keys); + return true; } @@ -37,14 +45,20 @@ public function getItem(string $key): GenericCacheItem public function hasItem(string $key): bool { + unset($key); + return false; } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { $items = []; foreach ($keys as $key) { - $items[(string) $key] = new GenericCacheItem($this, (string) $key); + $items[$key] = new GenericCacheItem($this, $key); } return $items; diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index b747d09..ea5b40a 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -5,17 +5,19 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; -use PDO; -use PDOException; use Psr\Cache\CacheItemInterface; use RuntimeException; final class PdoCacheAdapter extends AbstractCacheAdapter { private const string DEFAULT_SQLITE_DIR = 'cachelayer/pdo'; + private readonly string $driver; + private readonly string $ns; - private readonly PDO $pdo; + + private readonly \PDO $pdo; + private readonly string $table; public function __construct( @@ -23,7 +25,7 @@ public function __construct( ?string $dsn = null, ?string $username = null, ?string $password = null, - ?PDO $pdo = null, + ?\PDO $pdo = null, string $table = 'cachelayer_entries', ) { if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) { @@ -37,9 +39,19 @@ public function __construct( $resolvedDsn = 'sqlite:' . self::defaultSqliteFileForNamespace($this->ns); } - $this->pdo = $pdo ?? new PDO((string) $resolvedDsn, $username, $password); - $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $this->driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + if ($pdo !== null) { + $this->pdo = $pdo; + } else { + if (!is_string($resolvedDsn)) { + throw new RuntimeException('Unable to resolve PDO DSN.'); + } + + $this->pdo = new \PDO($resolvedDsn, $username, $password); + } + + $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $this->driver = is_string($driver) ? $driver : ''; $this->configureDriverDefaults(); $this->createSchemaIfMissing(); @@ -62,6 +74,7 @@ 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; } @@ -77,28 +90,35 @@ public function count(): int ':now' => time(), ]); - return (int) $stmt->fetchColumn(); + $count = $stmt->fetchColumn(); + + return is_numeric($count) ? max(0, (int) $count) : 0; } public function deleteItem(string $key): bool { $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey = :k"); + return $stmt->execute([':k' => $this->map($key)]); } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { if ($keys === []) { return true; } - $mapped = array_map($this->map(...), array_map(strval(...), $keys)); + $mapped = array_map($this->map(...), $keys); $marks = implode(',', array_fill(0, count($mapped), '?')); $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey IN ($marks)"); + return $stmt->execute($mapped); } - public function getClient(): PDO + public function getClient(): \PDO { return $this->pdo; } @@ -109,7 +129,7 @@ public function getItem(string $key): GenericCacheItem "SELECT payload, expires FROM {$this->table} WHERE ckey = :k LIMIT 1", ); $stmt->execute([':k' => $this->map($key)]); - $row = $stmt->fetch(PDO::FETCH_ASSOC); + $row = $stmt->fetch(\PDO::FETCH_ASSOC); if (!is_array($row)) { return new GenericCacheItem($this, $key); @@ -118,18 +138,28 @@ public function getItem(string $key): GenericCacheItem $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); + $payload = $row['payload'] ?? null; + if (!is_string($payload)) { + $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); } @@ -147,6 +177,10 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { if ($keys === []) { @@ -155,28 +189,27 @@ public function multiFetch(array $keys): array $mappedByLogical = []; foreach ($keys as $key) { - $logical = (string) $key; - $mapped = $this->map($logical); - $mappedByLogical[$logical] = $mapped; + $mappedByLogical[$key] = $this->map($key); } $rows = $this->fetchRowsByMappedKeys(array_values($mappedByLogical)); $items = []; $staleMapped = []; - foreach ($keys as $key) { - $logical = (string) $key; + foreach ($keys as $logical) { $mapped = $mappedByLogical[$logical]; $row = $rows[$mapped] ?? null; if (!is_array($row)) { $items[$logical] = new GenericCacheItem($this, $logical); + continue; } $item = $this->hydrateItemFromRow($logical, $row); if ($item instanceof GenericCacheItem) { $items[$logical] = $item; + continue; } @@ -222,7 +255,7 @@ private static function ensureSecureDirectory(string $path, int $mode): void throw new RuntimeException("Refusing symlinked SQLite cache directory: {$path}"); } - if (!is_dir($path) && !@mkdir($path, $mode, true) && !is_dir($path)) { + if (!is_dir($path) && !mkdir($path, $mode, true) && !is_dir($path)) { throw new RuntimeException("Unable to create SQLite cache directory: {$path}"); } @@ -230,10 +263,6 @@ private static function ensureSecureDirectory(string $path, int $mode): void throw new RuntimeException("SQLite cache directory is not writable: {$path}"); } - if (is_link($path)) { - throw new RuntimeException("Refusing symlinked SQLite cache directory: {$path}"); - } - $perms = fileperms($path); if ($perms !== false && (($perms & 0x0002) === 0x0002)) { throw new RuntimeException("SQLite cache directory must not be world-writable: {$path}"); @@ -248,7 +277,7 @@ private function configureDriverDefaults(): void try { $this->pdo->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;'); - } catch (PDOException) { + } catch (\PDOException) { // Best effort sqlite tuning. } } @@ -260,15 +289,16 @@ private function createExpiresIndexIfMissing(): void try { if (in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$this->table}(expires)"); + return; } $this->pdo->exec("CREATE INDEX {$index} ON {$this->table}(expires)"); - } catch (PDOException) { + } catch (\PDOException) { // Retry once for engines that do not support IF NOT EXISTS on indexes. try { $this->pdo->exec("CREATE INDEX {$index} ON {$this->table}(expires)"); - } catch (PDOException) { + } catch (\PDOException) { // Ignore duplicate index/feature support errors. } } @@ -322,9 +352,13 @@ private function fetchRowsByMappedKeys(array $mappedKeys): array $stmt->execute($mappedKeys); $rows = []; - foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $key = (string) ($row['ckey'] ?? ''); - if ($key === '' || !is_string($row['payload'] ?? null)) { + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { + if (!is_array($row)) { + continue; + } + + $key = $row['ckey'] ?? null; + if (!is_string($key) || $key === '' || !is_string($row['payload'] ?? null)) { continue; } @@ -392,6 +426,7 @@ private function upsert(array $params, string $mappedKey): bool $nativeSql = $this->nativeUpsertSql(); if ($nativeSql !== null) { $stmt = $this->pdo->prepare($nativeSql); + return $stmt->execute($params); } @@ -416,7 +451,7 @@ private function upsert(array $params, string $mappedKey): bool try { return $insert->execute($params); - } catch (PDOException) { + } catch (\PDOException) { // Another process may have inserted concurrently. $updateByKey = $this->pdo->prepare( "UPDATE {$this->table} diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 62b3e60..74edd54 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -10,7 +10,10 @@ final class PhpFilesCacheAdapter extends AbstractCacheAdapter { + use SecuresFilesystemDirectories; + private const string DEFAULT_BASE_DIR = 'cachelayer/phpfiles'; + private string $dir; public function __construct(string $namespace = 'default', ?string $baseDir = null) @@ -22,11 +25,12 @@ public function clear(): bool { $ok = true; foreach (glob($this->dir . '*.php') ?: [] as $file) { - $ok = (@unlink($file) || !is_file($file)) && $ok; + $ok = (!is_file($file) || unlink($file)) && $ok; $this->invalidateOpcache($file); } $this->deferred = []; + return $ok; } @@ -34,7 +38,7 @@ public function count(): int { $count = 0; foreach (glob($this->dir . '*.php') ?: [] as $file) { - $row = @require $file; + $row = require $file; if (!is_array($row) || !isset($row['p']) || !is_string($row['p'])) { continue; } @@ -56,11 +60,15 @@ public function count(): int public function deleteItem(string $key): bool { $file = $this->fileFor($key); - $ok = !is_file($file) || @unlink($file); + $ok = !is_file($file) || unlink($file); $this->invalidateOpcache($file); + return $ok; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { $ok = true; @@ -75,34 +83,22 @@ public function getItem(string $key): GenericCacheItem { $file = $this->fileFor($key); if (!is_file($file)) { - return new GenericCacheItem($this, $key); + return $this->genericMiss($key); } - $row = @require $file; - if (!is_array($row) || !isset($row['p']) || !is_string($row['p'])) { - $this->deleteItem($key); - return new GenericCacheItem($this, $key); + $row = require $file; + $payload = is_array($row) && is_string($row['p'] ?? null) + ? $row['p'] + : null; + if (!is_string($payload)) { + return $this->genericDeleteAndMiss($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; + return $this->genericFromBase64WithInvalidator( + $key, + $payload, + fn(): bool => $this->deleteItem($key), + ); } public function hasItem(string $key): bool @@ -110,11 +106,15 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { $items = []; foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); + $items[$key] = $this->getItem($key); } return $items; @@ -142,16 +142,23 @@ public function save(CacheItemInterface $item): bool } if (file_put_contents($tmp, $code) === false) { - @unlink($tmp); + if (is_file($tmp)) { + unlink($tmp); + } + return false; } - if (!@rename($tmp, $file)) { - @unlink($tmp); + if (!rename($tmp, $file)) { + if (is_file($tmp)) { + unlink($tmp); + } + return false; } $this->invalidateOpcache($file); + return true; } @@ -166,23 +173,6 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof GenericCacheItem; } - private function assertPathNotSymlink(string $path, string $label): void - { - if (is_link($path)) { - throw new RuntimeException($label . " must not be a symlink: {$path}"); - } - } - - private function assertSecureDirectory(string $path, string $label): void - { - $this->assertPathNotSymlink($path, $label); - - $perms = fileperms($path); - if ($perms !== false && (($perms & 0x0002) === 0x0002)) { - throw new RuntimeException($label . " must not be world-writable: {$path}"); - } - } - private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); @@ -192,11 +182,11 @@ private function createDirectory(string $ns, ?string $baseDir): void $this->assertPathNotSymlink($baseDir, 'PHP cache base directory'); $this->assertPathNotSymlink($this->dir, 'PHP cache directory'); - if (!is_dir($baseDir) && !@mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { + if (!is_dir($baseDir) && !mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { throw new RuntimeException("Unable to create PHP cache base directory: {$baseDir}"); } - if (!is_dir($this->dir) && !@mkdir($this->dir, 0700, true) && !is_dir($this->dir)) { + if (!is_dir($this->dir) && !mkdir($this->dir, 0700, true) && !is_dir($this->dir)) { throw new RuntimeException("Unable to create PHP cache directory: {$this->dir}"); } @@ -223,7 +213,9 @@ private function fileFor(string $key): string private function invalidateOpcache(string $file): void { if (function_exists('opcache_invalidate')) { - @opcache_invalidate($file, true); + if (is_file($file)) { + opcache_invalidate($file, true); + } } } } diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index cb8690c..7b57b7e 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -7,7 +7,6 @@ use Infocyph\CacheLayer\Cache\Item\RedisCacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\CacheItemInterface; -use Redis; use RuntimeException; /** @@ -22,22 +21,24 @@ class RedisCacheAdapter extends AbstractCacheAdapter { private readonly string $ns; - private readonly Redis $redis; + + private readonly \Redis $redis; /** * Creates a new Redis cache adapter. * * @param string $namespace A namespace prefix to avoid key collisions. * @param string $dsn The Redis connection DSN (e.g., 'redis://127.0.0.1:6379'). - * @param Redis|null $client Optional pre-configured Redis client instance. + * @param \Redis|null $client Optional pre-configured Redis client instance. + * * @throws RuntimeException If the phpredis extension is not loaded. */ public function __construct( string $namespace = 'default', string $dsn = 'redis://127.0.0.1:6379', - ?Redis $client = null, + ?\Redis $client = null, ) { - if (!class_exists(Redis::class)) { + if (!class_exists(\Redis::class)) { throw new RuntimeException('phpredis extension not loaded'); } @@ -55,6 +56,7 @@ public function clear(): bool } } while ($cursor); $this->deferred = []; + return true; } @@ -65,6 +67,7 @@ public function count(): int while ($keys = $this->redis->scan($iter, $this->ns . ':*', 1000)) { $count += count($keys); } + return $count; } @@ -73,6 +76,9 @@ public function deleteItem(string $key): bool return $this->redis->del($this->map($key)) !== false; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { if ($keys === []) { @@ -80,10 +86,11 @@ public function deleteItems(array $keys): bool } $full = array_map($this->map(...), $keys); + return $this->redis->del($full) !== false; } - public function getClient(): Redis + public function getClient(): \Redis { return $this->redis; } @@ -104,6 +111,7 @@ public function getItem(string $key): RedisCacheItem } $this->redis->del($this->map($key)); } + return new RedisCacheItem($this, $key); } @@ -112,6 +120,10 @@ public function hasItem(string $key): bool return $this->redis->exists($this->map($key)) === 1; } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { if ($keys === []) { @@ -120,13 +132,23 @@ public function multiFetch(array $keys): array $prefixed = array_map($this->map(...), $keys); $rawVals = $this->redis->mget($prefixed); + if (!is_array($rawVals)) { + $rawVals = []; + } + $rawVals = array_values($rawVals); $items = []; $stale = []; foreach ($keys as $idx => $k) { - $v = $rawVals[$idx]; + $v = $rawVals[$idx] ?? null; if ($v !== null && $v !== false) { - $record = CachePayloadCodec::decode((string) $v); + if (!is_string($v)) { + $items[$k] = new RedisCacheItem($this, $k); + + continue; + } + + $record = CachePayloadCodec::decode($v); if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { $items[$k] = new RedisCacheItem( $this, @@ -135,6 +157,7 @@ public function multiFetch(array $keys): array true, CachePayloadCodec::toDateTime($record['expires']), ); + continue; } $stale[] = $this->map($k); @@ -159,10 +182,12 @@ public function save(CacheItemInterface $item): bool $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); @@ -173,23 +198,24 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof RedisCacheItem; } - private function connect(string $dsn): Redis + private function connect(string $dsn): \Redis { - $r = new 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'])) { + $host = is_string($parts['host'] ?? null) ? $parts['host'] : '127.0.0.1'; + $port = is_int($parts['port'] ?? null) ? $parts['port'] : 6379; + $r->connect($host, $port); + if (is_string($parts['pass'] ?? null) && $parts['pass'] !== '') { $r->auth($parts['pass']); } - if (isset($parts['path']) && $parts['path'] !== '/') { + if (is_string($parts['path'] ?? null) && $parts['path'] !== '/') { $db = (int) ltrim($parts['path'], '/'); $r->select($db); } + return $r; } diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 5006278..510d4dc 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -11,6 +11,7 @@ final class RedisClusterCacheAdapter extends AbstractCacheAdapter { private readonly object $cluster; + private readonly string $ns; /** @@ -45,33 +46,42 @@ public function __construct( public function clear(): bool { - $keys = $this->cluster->sMembers($this->indexKey()); + $keys = $this->call('sMembers', $this->indexKey()); if (is_array($keys) && $keys !== []) { foreach ($keys as $key) { - $this->cluster->del((string) $key); + if (is_string($key)) { + $this->call('del', $key); + } } } - $this->cluster->del($this->indexKey()); + $this->call('del', $this->indexKey()); $this->deferred = []; + return true; } public function count(): int { - return (int) $this->cluster->sCard($this->indexKey()); + $count = $this->call('sCard', $this->indexKey()); + + return is_int($count) ? max(0, $count) : 0; } public function deleteItem(string $key): bool { $mapped = $this->map($key); - $this->cluster->sRem($this->indexKey(), $mapped); - return $this->cluster->del($mapped) !== false; + $this->call('sRem', $this->indexKey(), $mapped); + + return $this->call('del', $mapped) !== false; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { foreach ($keys as $key) { - $this->deleteItem((string) $key); + $this->deleteItem($key); } return true; @@ -85,64 +95,43 @@ public function getClient(): object 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); - } + $raw = $this->call('get', $mapped); - $item = new GenericCacheItem($this, $key); - $item->set($record['value']); - if ($record['expires'] !== null) { - $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); - } - - return $item; + return $this->genericFromBlob($key, is_string($raw) ? $raw : null); } public function hasItem(string $key): bool { - return $this->cluster->exists($this->map($key)) > 0; + $exists = $this->call('exists', $this->map($key)); + + return is_int($exists) && $exists > 0; } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { - $items = []; - foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); - } - - return $items; + return $this->multiFetchItems($keys, $this->getItem(...)); } 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']); + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $mapped = $this->map($saveItem->getKey()); + $blob = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); - $ok = $expires['ttl'] === null - ? $this->cluster->set($mapped, $blob) - : $this->cluster->setex($mapped, max(1, $expires['ttl']), $blob); + $ok = $expires['ttl'] === null + ? $this->call('set', $mapped, $blob) + : $this->call('setex', $mapped, max(1, $expires['ttl']), $blob); - if ($ok) { - $this->cluster->sAdd($this->indexKey(), $mapped); - } + if ($ok) { + $this->call('sAdd', $this->indexKey(), $mapped); + } - return (bool) $ok; + return (bool) $ok; + }); } protected function supportsItem(CacheItemInterface $item): bool @@ -161,6 +150,11 @@ private function assertClientShape(object $client): void } } + private function call(string $method, mixed ...$arguments): mixed + { + return $this->cluster->{$method}(...$arguments); + } + private function indexKey(): string { return $this->ns . ':__keys'; diff --git a/src/Cache/Adapter/S3CacheAdapter.php b/src/Cache/Adapter/S3CacheAdapter.php deleted file mode 100644 index 18859bb..0000000 --- a/src/Cache/Adapter/S3CacheAdapter.php +++ /dev/null @@ -1,256 +0,0 @@ -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/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php new file mode 100644 index 0000000..46d4234 --- /dev/null +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -0,0 +1,325 @@ + */ + private array $preparedStatements = []; + + public function __construct( + private readonly object $session, + string $keyspace = 'cachelayer', + string $table = 'cachelayer_entries', + string $namespace = 'default', + ) { + if (!$this->supportsSessionMethod('execute')) { + throw new RuntimeException('ScyllaDbCacheAdapter requires session method `execute()`.'); + } + + $this->ns = sanitize_cache_ns($namespace); + $resolvedTable = self::validateIdentifier($table, 'table'); + $resolvedKeyspace = self::validateIdentifier($keyspace, 'keyspace'); + $this->qualifiedTable = $resolvedKeyspace . '.' . $resolvedTable; + + $this->createSchemaIfMissing(); + } + + public function clear(): bool + { + $this->executeCql( + "DELETE FROM {$this->qualifiedTable} WHERE ns = ?", + [$this->ns], + ); + $this->deferred = []; + + return true; + } + + public function count(): int + { + $rows = $this->queryRows( + "SELECT expires FROM {$this->qualifiedTable} WHERE ns = ?", + [$this->ns], + ); + $now = time(); + $count = 0; + + foreach ($rows as $row) { + $expiresAt = $this->normalizeExpiry($row['expires'] ?? null); + if ($expiresAt === null || $expiresAt > $now) { + $count++; + } + } + + return $count; + } + + public function deleteItem(string $key): bool + { + $this->executeCql( + "DELETE FROM {$this->qualifiedTable} WHERE ns = ? AND ckey = ?", + [$this->ns, $key], + ); + + return true; + } + + /** + * @param list $keys + */ + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + $this->deleteItem((string) $key); + } + + return true; + } + + public function getItem(string $key): GenericCacheItem + { + $row = $this->firstRow( + "SELECT payload, expires FROM {$this->qualifiedTable} WHERE ns = ? AND ckey = ? LIMIT 1", + [$this->ns, $key], + ); + + if ($row === null) { + return $this->genericMiss($key); + } + + $expiresAt = $this->normalizeExpiry($row['expires'] ?? null); + if ($expiresAt !== null && $expiresAt <= time()) { + return $this->genericDeleteAndMiss($key); + } + + $payload = $this->normalizeString($row['payload'] ?? null); + + return $this->genericFromBase64($key, $payload); + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + /** + * @param list $keys + * @return array + */ + public function multiFetch(array $keys): array + { + return $this->multiFetchItems($keys, $this->getItem(...)); + } + + public function save(CacheItemInterface $item): bool + { + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $this->executeCql( + "INSERT INTO {$this->qualifiedTable} (ns, ckey, payload, expires) VALUES (?, ?, ?, ?)", + [ + $this->ns, + $saveItem->getKey(), + base64_encode(CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt'])), + $expires['expiresAt'], + ], + ); + + return true; + }); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof GenericCacheItem; + } + + private static function validateIdentifier(string $value, string $label): string + { + if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $value)) { + throw new RuntimeException(sprintf('Invalid ScyllaDB %s name `%s`.', $label, $value)); + } + + return $value; + } + + /** + * @param array $arguments + */ + private function callSession(string $method, array $arguments): mixed + { + $callable = [$this->session, $method]; + if (!is_callable($callable)) { + throw new RuntimeException( + sprintf('ScyllaDbCacheAdapter requires session method `%s()`.', $method), + ); + } + + return $callable(...$arguments); + } + + private function createSchemaIfMissing(): void + { + $this->executeCql( + "CREATE TABLE IF NOT EXISTS {$this->qualifiedTable} ( + ns text, + ckey text, + payload text, + expires bigint, + PRIMARY KEY (ns, ckey) + )", + ); + } + + /** + * @param array $arguments + */ + private function executeCql(string $cql, array $arguments = []): mixed + { + $statement = $this->statementFor($cql); + $options = $this->executionOptions($arguments); + + try { + return $this->callSession('execute', [$statement, $options]); + } catch (Throwable) { + return $this->callSession('execute', [$statement]); + } + } + + /** + * @param array $arguments + */ + private function executionOptions(array $arguments): mixed + { + $options = ['arguments' => $arguments]; + if (class_exists(ExecutionOptions::class)) { + return new ExecutionOptions($options); + } + + return $options; + } + + /** + * @param array $arguments + * @return array|null + */ + private function firstRow(string $cql, array $arguments = []): ?array + { + foreach ($this->queryRows($cql, $arguments) as $row) { + return $row; + } + + return null; + } + + private function normalizeExpiry(mixed $value): ?int + { + if (is_int($value)) { + return $value; + } + + if (is_float($value) || (is_string($value) && is_numeric($value))) { + return (int) $value; + } + + if (is_object($value) && is_callable([$value, '__toString'])) { + $stringValue = (string) $value; + if (is_numeric($stringValue)) { + return (int) $stringValue; + } + } + + return null; + } + + /** + * @param array $rows + * @return array> + */ + private function normalizeRows(array $rows): array + { + $normalized = []; + foreach ($rows as $row) { + $assoc = AdapterValueNormalizer::fromJsonOrArrayLike($row); + if ($assoc !== null) { + $normalized[] = $assoc; + } + } + + return $normalized; + } + + private function normalizeString(mixed $value): ?string + { + if (is_string($value)) { + return $value; + } + + if (is_object($value) && is_callable([$value, '__toString'])) { + return (string) $value; + } + + return null; + } + + /** + * @param array $arguments + * @return array> + */ + private function queryRows(string $cql, array $arguments = []): array + { + $result = $this->executeCql($cql, $arguments); + + if (is_array($result)) { + return $this->normalizeRows($result); + } + + if ($result instanceof Traversable) { + return $this->normalizeRows(iterator_to_array($result)); + } + + if (is_object($result) && is_callable([$result, 'toArray'])) { + $rows = $result->toArray(); + + return is_array($rows) ? $this->normalizeRows($rows) : []; + } + + return []; + } + + private function statementFor(string $cql): mixed + { + if ($this->supportsSessionMethod('prepare')) { + if (!array_key_exists($cql, $this->preparedStatements)) { + $this->preparedStatements[$cql] = $this->callSession('prepare', [$cql]); + } + + return $this->preparedStatements[$cql]; + } + + if (class_exists(SimpleStatement::class)) { + return new SimpleStatement($cql); + } + + return $cql; + } + + private function supportsSessionMethod(string $method): bool + { + return method_exists($this->session, $method) || is_callable([$this->session, $method]); + } +} diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php new file mode 100644 index 0000000..e407254 --- /dev/null +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -0,0 +1,27 @@ +assertPathNotSymlink($path, $label); + + $perms = fileperms($path); + if ($perms !== false && (($perms & 0x0002) === 0x0002)) { + throw new RuntimeException($label . " must not be world-writable: {$path}"); + } + } +} diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 9733aa4..de94af6 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -11,8 +11,11 @@ final class SharedMemoryCacheAdapter extends AbstractCacheAdapter { private const int VAR_ID = 1; + private readonly string $ns; + private readonly mixed $segment; + private readonly string $tokenFile; public function __construct( @@ -26,7 +29,7 @@ public function __construct( $this->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); + touch($this->tokenFile); } $projectId = function_exists('ftok') ? ftok($this->tokenFile, 'C') : false; @@ -34,7 +37,7 @@ public function __construct( ? $projectId : abs(crc32('cachelayer:' . $this->ns)); - $segment = @shm_attach($shmKey, max(1_048_576, $segmentSize), 0666); + $segment = shm_attach($shmKey, max(1_048_576, $segmentSize), 0666); if ($segment === false) { throw new RuntimeException('Unable to attach shared-memory segment'); } @@ -48,6 +51,7 @@ public function __construct( public function clear(): bool { $this->deferred = []; + return shm_put_var($this->segment, self::VAR_ID, []); } @@ -62,6 +66,7 @@ public function count(): int if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { unset($store[$key]); $changed = true; + continue; } @@ -79,9 +84,13 @@ public function deleteItem(string $key): bool { $store = $this->loadStore(); unset($store[$this->map($key)]); + return $this->store($store); } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { $store = $this->loadStore(); @@ -97,24 +106,16 @@ 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 $this->genericFromBlobWithInvalidator( + $key, + is_string($blob) ? $blob : null, + function () use (&$store, $mapped): bool { + unset($store[$mapped]); - return $item; + return $this->store($store); + }, + ); } public function hasItem(string $key): bool @@ -122,30 +123,23 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { - $items = []; - foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); - } - - return $items; + return $this->multiFetchItems($keys, $this->getItem(...)); } 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()); - } + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $store = $this->loadStore(); + $store[$this->map($saveItem->getKey())] = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); - $store = $this->loadStore(); - $store[$this->map($item->getKey())] = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); - return $this->store($store); + return $this->store($store); + }); } protected function supportsItem(CacheItemInterface $item): bool @@ -153,6 +147,9 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof GenericCacheItem; } + /** + * @return array + */ private function loadStore(): array { if (!shm_has_var($this->segment, self::VAR_ID)) { @@ -160,7 +157,19 @@ private function loadStore(): array } $store = shm_get_var($this->segment, self::VAR_ID); - return is_array($store) ? $store : []; + + if (!is_array($store)) { + return []; + } + + $out = []; + foreach ($store as $key => $value) { + if (is_string($key) && is_string($value)) { + $out[$key] = $value; + } + } + + return $out; } private function map(string $key): string @@ -168,6 +177,9 @@ private function map(string $key): string return $this->ns . ':' . $key; } + /** + * @param array $store + */ private function store(array $store): bool { return shm_put_var($this->segment, self::VAR_ID, $store); diff --git a/src/Cache/Adapter/ValkeyCacheAdapter.php b/src/Cache/Adapter/ValkeyCacheAdapter.php new file mode 100644 index 0000000..0a8aedf --- /dev/null +++ b/src/Cache/Adapter/ValkeyCacheAdapter.php @@ -0,0 +1,16 @@ + */ private array $scalarStore = []; + /** @var array */ private array $weakExpires = []; + + /** @var WeakMap */ private WeakMap $weakObjects; - /** @var array */ + + /** @var array> */ private array $weakRefs = []; public function __construct(string $namespace = 'default') @@ -33,6 +38,7 @@ public function clear(): bool $this->weakExpires = []; $this->weakObjects = new WeakMap(); $this->deferred = []; + return true; } @@ -72,9 +78,13 @@ public function deleteItem(string $key): bool } unset($this->weakRefs[$mapped]); + return true; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { foreach ($keys as $key) { @@ -111,19 +121,15 @@ public function getItem(string $key): GenericCacheItem 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 $this->genericFromBlobWithInvalidator( + $key, + $this->scalarStore[$mapped], + function () use ($mapped): bool { + unset($this->scalarStore[$mapped]); - return $item; + return true; + }, + ); } public function hasItem(string $key): bool @@ -131,42 +137,36 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { - $items = []; - foreach ($keys as $key) { - $items[(string) $key] = $this->getItem((string) $key); - } - - return $items; + return $this->multiFetchItems($keys, $this->getItem(...)); } public function save(CacheItemInterface $item): bool { - if (!$this->supportsItem($item)) { - return false; - } + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $mapped = $this->map($saveItem->getKey()); + $value = $saveItem->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]); - $expires = CachePayloadCodec::expirationFromItem($item); - if ($expires['ttl'] === 0) { - return $this->deleteItem($item->getKey()); - } + return true; + } - $mapped = $this->map($item->getKey()); - $value = $item->get(); + unset($this->weakRefs[$mapped], $this->weakExpires[$mapped]); + $this->scalarStore[$mapped] = CachePayloadCodec::encode($value, $expires['expiresAt']); - 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 diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index e8c8056..bb1632b 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -9,14 +9,18 @@ use Countable; use DateInterval; use DateTime; +use Infocyph\CacheLayer\Cache\Item\AbstractCacheItem; use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Cache\Lock\MemcachedLockProvider; +use Infocyph\CacheLayer\Cache\Lock\PdoLockProvider; use Infocyph\CacheLayer\Cache\Lock\RedisLockProvider; use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; +use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Serializer\ValueSerializer; +use MongoDB\Client; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; @@ -24,12 +28,22 @@ final class Cache implements CacheInterface { + use CacheReadRememberTrait { + get as private traitGet; + getItem as private traitGetItem; + getItems as private traitGetItems; + hasItem as private traitHasItem; + remember as private traitRemember; + } + private const int STAMPEDE_JITTER_PERCENT = 8; + private const float STAMPEDE_LOCK_WAIT_SECONDS = 5.0; + private const string TAG_META_PREFIX = '__im_tagm_'; + private const string TAG_VERSION_PREFIX = '__im_tagv_'; - private LockProviderInterface $lockProvider; - private CacheMetricsCollectorInterface $metrics; + private ?Closure $metricsExportHook = null; /** @@ -39,12 +53,9 @@ final class Cache implements CacheInterface */ public function __construct( private readonly CacheItemPoolInterface $adapter, - ?LockProviderInterface $lockProvider = null, - ?CacheMetricsCollectorInterface $metrics = null, - ) { - $this->lockProvider = $lockProvider ?? new FileLockProvider(); - $this->metrics = $metrics ?? new InMemoryCacheMetricsCollector(); - } + private LockProviderInterface $lockProvider = new FileLockProvider(), + private CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), + ) {} /** * Retrieves a value from the cache using magic property access. @@ -54,6 +65,7 @@ public function __construct( * * @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 @@ -116,28 +128,6 @@ 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. * @@ -149,7 +139,6 @@ public static function file(string $namespace = 'default', ?string $dir = null): return new self(new Adapter\FileCacheAdapter($namespace, $dir)); } - /** * Static factory for local cache selection. * @@ -175,8 +164,8 @@ public static function local( * 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 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( @@ -206,13 +195,13 @@ public static function mongodb( ): self { if ($collection === null) { if ($client === null) { - if (!class_exists(\MongoDB\Client::class)) { + if (!class_exists(Client::class)) { throw new CacheInvalidArgumentException( 'mongodb/mongodb is required unless a collection/client is provided.', ); } - $client = new \MongoDB\Client($uri); + $client = new Client($uri); } $adapter = Adapter\MongoDbCacheAdapter::fromClient( @@ -243,9 +232,8 @@ public static function pdo( ): self { $adapter = new Adapter\PdoCacheAdapter($namespace, $dsn, $username, $password, $pdo, $table); $lockProvider = new FileLockProvider(); - $pdoLockProviderClass = \Infocyph\CacheLayer\Cache\Lock\PdoLockProvider::class; + $pdoLockProviderClass = PdoLockProvider::class; if (class_exists($pdoLockProviderClass)) { - /** @var LockProviderInterface $lockProvider */ $lockProvider = new $pdoLockProviderClass($adapter->getClient()); } @@ -264,7 +252,7 @@ public static function phpFiles(string $namespace = 'default', ?string $dir = nu * * @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'). + * or null to use the default ('redis://127.0.0.1:6379'). * @param \Redis|null $client Optional preconfigured Redis instance. */ public static function redis( @@ -279,6 +267,9 @@ public static function redis( ); } + /** + * @param array $seeds + */ public static function redisCluster( string $namespace = 'default', array $seeds = ['127.0.0.1:6379'], @@ -299,27 +290,24 @@ public static function redisCluster( ); } - public static function s3( + public static function scyllaDb( string $namespace = 'default', - string $bucket = 'cachelayer', - ?object $client = null, - array $config = [], - string $prefix = 'cachelayer', + ?object $session = null, + string $keyspace = 'cachelayer', + string $table = 'cachelayer_entries', ): self { - if ($client === null) { - if (!class_exists(\Aws\S3\S3Client::class)) { + if ($session === null) { + if (!class_exists(\Cassandra::class)) { throw new CacheInvalidArgumentException( - 'aws/aws-sdk-php is required unless an S3 client is provided.', + 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', ); } - $client = new \Aws\S3\S3Client($config + [ - 'version' => 'latest', - 'region' => 'us-east-1', - ]); + /** @var object $session */ + $session = \Cassandra::cluster()->build()->connect($keyspace); } - return new self(new Adapter\S3CacheAdapter($client, $bucket, $prefix, $namespace)); + return new self(new Adapter\ScyllaDbCacheAdapter($session, $keyspace, $table, $namespace)); } public static function sharedMemory(string $namespace = 'default', int $segmentSize = 16_777_216): self @@ -343,6 +331,37 @@ public static function sqlite(string $namespace = 'default', ?string $file = nul ); } + /** + * Builds a tiered cache from pool instances and/or descriptor arrays. + * + * @param array> $tiers + */ + public static function tiered(array $tiers, bool $writeToL1 = true): self + { + $pools = TieredPoolFactory::fromArray($tiers); + + return new self(new Adapter\ChainCacheAdapter($pools, $writeToL1)); + } + + /** + * Static factory for Valkey cache. + * + * @param string $namespace Cache prefix. + * @param string $dsn DSN for Valkey connection (e.g. 'valkey://127.0.0.1:6379'). + * @param \Redis|null $client Optional preconfigured Redis-compatible instance. + */ + public static function valkey( + string $namespace = 'default', + string $dsn = 'valkey://127.0.0.1:6379', + ?\Redis $client = null, + ): self { + $adapter = new Adapter\ValkeyCacheAdapter($namespace, $dsn, $client); + + return (new self($adapter))->setLockProvider( + new RedisLockProvider($adapter->getClient()), + ); + } + public static function weakMap(string $namespace = 'default'): self { return new self(new Adapter\WeakMapCacheAdapter($namespace)); @@ -352,7 +371,7 @@ public static function weakMap(string $namespace = 'default'): self * Removes all items from the cache. * * @return bool - * True if the operation was successful, false otherwise. + * True if the operation was successful, false otherwise. */ public function clear(): bool { @@ -385,12 +404,14 @@ public function commit(): bool public function configurePayloadCompression(?int $thresholdBytes = null, int $level = 6): self { Adapter\CachePayloadCodec::configureCompression($thresholdBytes, $level); + return $this; } public function configurePayloadSecurity(?string $integrityKey = null, ?int $maxPayloadBytes = 8_388_608): self { Adapter\CachePayloadCodec::configureSecurity($integrityKey, $maxPayloadBytes); + return $this; } @@ -450,10 +471,10 @@ public function delete(string $key): bool * not exist, it is silently ignored. * * @param string $key - * The key of the item to delete. - * + * The key of the item to delete. * @return bool - * True if the item was successfully deleted, false otherwise. + * True if the item was successfully deleted, false otherwise. + * * @throws Psr6InvalidArgumentException */ public function deleteItem(string $key): bool @@ -462,6 +483,7 @@ public function deleteItem(string $key): bool $deleted = $this->adapter->deleteItem($key); $this->clearTagMeta($key); $this->metric('delete'); + return $deleted; } @@ -469,8 +491,8 @@ public function deleteItem(string $key): bool * 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 @@ -483,6 +505,7 @@ public function deleteItems(array $keys): bool $this->clearTagMeta((string) $key); } $this->metric('delete_batch'); + return $deleted; } @@ -490,6 +513,7 @@ public function deleteItems(array $keys): bool * Deletes multiple keys from the cache. * * @param iterable $keys + * * @throws SimpleCacheInvalidArgument if any key is invalid */ public function deleteMultiple(iterable $keys): bool @@ -502,6 +526,7 @@ public function deleteMultiple(iterable $keys): bool $allSucceeded = false; } } + return $allSucceeded; } @@ -527,32 +552,7 @@ public function exportMetrics(): array */ 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(); + return $this->traitGet($key, $default); } /** @@ -561,29 +561,17 @@ public function get(string $key, mixed $default = null): mixed * This method returns a CacheItemInterface object containing the cached value. * * @param string $key - * The key of the item to retrieve. - * + * 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. + * 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; + return $this->traitGetItem($key); } /** @@ -597,52 +585,15 @@ public function getItem(string $key): CacheItemInterface * `getItem` on each key. * * @param string[] $keys - * An array of keys to fetch from the cache. - * + * An array of keys to fetch from the cache. * @return iterable - * An iterable of CacheItemInterface objects. + * 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; + return $this->traitGetItems($keys); } - /** * Returns an iterable of {@see CacheItemInterface} objects for the given * keys. @@ -653,10 +604,9 @@ public function getItems(array $keys = []): iterable * iterators. * * @param string[] $keys - * An array of keys to fetch from the cache. - * + * An array of keys to fetch from the cache. * @return iterable - * An iterable of CacheItemInterface objects. + * An iterable of CacheItemInterface objects. */ public function getItemsIterator(array $keys = []): iterable { @@ -668,6 +618,7 @@ public function getItemsIterator(array $keys = []): iterable * * @param iterable $keys * @return iterable + * * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if any key is invalid */ public function getMultiple(iterable $keys, mixed $default = null): iterable @@ -678,6 +629,7 @@ public function getMultiple(iterable $keys, mixed $default = null): iterable $this->validateKey($k); $result[$k] = $this->get($k, $default); } + return $result; } @@ -695,29 +647,15 @@ public function has(string $key): bool * Checks if an item is present in the cache. * * @param string $key - * The key to check. - * + * The key to check. * @return bool - * True if the item exists in the cache, false otherwise. + * 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; + return $this->traitHasItem($key); } /** @@ -728,6 +666,7 @@ public function hasItem(string $key): bool * * @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. */ @@ -748,6 +687,7 @@ public function invalidateTag(string $tag): bool * * @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. */ @@ -775,12 +715,15 @@ public function invalidateTags(array $tags): bool * * {@inheritdoc} * + * @param string $offset + * * @throws Psr6InvalidArgumentException + * * @see has() */ public function offsetExists(mixed $offset): bool { - return $this->has((string) $offset); + return $this->has($offset); } /** @@ -789,14 +732,14 @@ public function offsetExists(mixed $offset): bool * 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. - * + * @param string $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); + return $this->get($offset); } /** @@ -806,25 +749,26 @@ public function offsetGet(mixed $offset): mixed * 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 string $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); + $this->set($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); + $this->delete($offset); } /** @@ -839,48 +783,7 @@ public function remember( 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); - } + return $this->traitRemember($key, $resolver, $ttl, $tags); } /** @@ -890,12 +793,12 @@ public function remember( * implement CacheItemInterface. * * @param CacheItemInterface $item - * The cache item to persist. - * + * The cache item to persist. * @return bool - * True if the cache item was successfully persisted, false otherwise. + * True if the cache item was successfully persisted, false otherwise. + * * @throws Psr6InvalidArgumentException - * If the item does not implement CacheItemInterface. + * If the item does not implement CacheItemInterface. */ public function save(CacheItemInterface $item): bool { @@ -920,6 +823,7 @@ public function saveDeferred(CacheItemInterface $item): bool * 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 @@ -949,24 +853,27 @@ public function set(string $key, mixed $value, mixed $ttl = null): bool $this->metric('set'); } - return $result; + return (bool) $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; } @@ -975,6 +882,7 @@ public function setMetricsExportHook(?callable $hook): self * * @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 @@ -998,7 +906,7 @@ public function setMultiple(iterable $values, mixed $ttl = null): bool * 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. + * 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. @@ -1009,8 +917,10 @@ public function setNamespaceAndDirectory(string $namespace, ?string $dir = null) { if (method_exists($this->adapter, 'setNamespaceAndDirectory')) { $this->adapter->setNamespaceAndDirectory($namespace, $dir); + return; } + throw new BadMethodCallException( sprintf('%s does not support setNamespaceAndDirectory()', $this->adapter::class), ); @@ -1028,6 +938,7 @@ public function setNamespaceAndDirectory(string $namespace, ?string $dir = null) * @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. */ @@ -1040,6 +951,7 @@ public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = n } $ttlSeconds = $this->normalizeTtl($ttl); + return $this->writeTagMeta($key, $normalizedTags, $ttlSeconds); } @@ -1075,18 +987,23 @@ public function useRedisLock(?\Redis $client = null, string $prefix = 'cachelaye return $this->setLockProvider(new RedisLockProvider($client, $prefix)); } + public function useValkeyLock(?\Redis $client = null, string $prefix = 'cachelayer:lock:'): self + { + return $this->useRedisLock($client, $prefix); + } + private function applyJitteredTtl(CacheItemInterface $item): void { - if (!method_exists($item, 'ttlSeconds')) { + if (!$item instanceof AbstractCacheItem) { return; } $ttl = $item->ttlSeconds(); - if ($ttl === null || $ttl <= 1 || self::STAMPEDE_JITTER_PERCENT <= 0) { + if ($ttl === null || $ttl <= 1) { return; } - $maxJitter = max(1, (int) floor($ttl * (self::STAMPEDE_JITTER_PERCENT / 100))); + $maxJitter = max(1, intdiv($ttl * self::STAMPEDE_JITTER_PERCENT, 100)); $jitter = random_int(0, $maxJitter); $item->expiresAfter(max(1, $ttl - $jitter)); } @@ -1106,6 +1023,7 @@ private function currentTagVersion(string $normalizedTag): int $item->set(1)->expiresAfter(null); $this->adapter->save($item); + return 1; } @@ -1178,6 +1096,7 @@ private function normalizeTtl(mixed $ttl): ?int if ($ttl instanceof DateInterval) { $now = new DateTime(); + return max(0, $now->add($ttl)->getTimestamp() - (new DateTime())->getTimestamp()); } @@ -1215,7 +1134,8 @@ private function readableAdapterName(string $adapterClass): string 'SharedMemory' => 'shared_memory', 'WeakMap' => 'weak_map', 'RedisCluster' => 'redis_cluster', - 'DynamoDb' => 'dynamodb', + 'Valkey' => 'valkey', + 'ScyllaDb' => 'scylladb', 'MongoDb' => 'mongodb', default => strtolower((string) preg_replace('/(?clearTagMeta($key); + return true; } @@ -1287,6 +1208,7 @@ private function writeTagMeta(string $key, array $tags, ?int $ttl): bool $metaItem = $this->adapter->getItem($this->tagMetaKey($key)); $metaItem->set($versions); $metaItem->expiresAfter($ttl); + return $this->adapter->save($metaItem); } @@ -1294,6 +1216,7 @@ 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 index c985008..2c47f94 100644 --- a/src/Cache/CacheInterface.php +++ b/src/Cache/CacheInterface.php @@ -24,8 +24,10 @@ * Implementations of this interface provide a unified API for both simple * and advanced caching use cases, supporting features like tagged cache * invalidation, cache stampede protection, and multiple storage adapters. + * + * @extends ArrayAccess */ -interface CacheInterface extends CacheItemPoolInterface, SimpleCacheInterface, ArrayAccess, Countable +interface CacheInterface extends ArrayAccess, CacheItemPoolInterface, Countable, SimpleCacheInterface { public function clearCache(): bool; @@ -72,4 +74,6 @@ public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = n public function useMemcachedLock(?\Memcached $client = null, string $prefix = 'cachelayer:lock:'): self; public function useRedisLock(?\Redis $client = null, string $prefix = 'cachelayer:lock:'): self; + + public function useValkeyLock(?\Redis $client = null, string $prefix = 'cachelayer:lock:'): self; } diff --git a/src/Cache/CacheReadRememberTrait.php b/src/Cache/CacheReadRememberTrait.php new file mode 100644 index 0000000..287a176 --- /dev/null +++ b/src/Cache/CacheReadRememberTrait.php @@ -0,0 +1,186 @@ +validateKey($key); + + 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(); + } + + 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; + } + + /** + * @param string[] $keys + * @return iterable + */ + public function getItems(array $keys = []): iterable + { + 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); + + /** @var array $out */ + $out = []; + foreach ($keys as $key) { + $k = (string) $key; + $fetchedItem = is_array($fetched) ? ($fetched[$k] ?? null) : null; + $item = $fetchedItem instanceof CacheItemInterface ? $fetchedItem : $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; + } + + 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; + } + + /** + * @throws Psr6InvalidArgumentException + */ + 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 { + $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); + } + } +} diff --git a/src/Cache/Item/AbstractCacheItem.php b/src/Cache/Item/AbstractCacheItem.php index 6cb16ff..404c8c7 100644 --- a/src/Cache/Item/AbstractCacheItem.php +++ b/src/Cache/Item/AbstractCacheItem.php @@ -52,6 +52,8 @@ public function __serialize(): array } /** + * @param array{key:string,value:mixed,hit:bool,exp?:string|null} $data + * * @throws Exception */ public function __unserialize(array $data): void @@ -70,12 +72,14 @@ public function expiresAfter(int|DateInterval|null $time): static $time instanceof DateInterval => (new DateTime())->add($time), default => null, }; + return $this; } public function expiresAt(?DateTimeInterface $expiration): static { $this->exp = $expiration; + return $this; } @@ -94,18 +98,21 @@ 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; } @@ -113,6 +120,7 @@ public function set(mixed $value): static { $this->value = ValueSerializer::wrap($value); $this->hit = true; + return $this; } diff --git a/src/Cache/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index 6260a95..d8f95b4 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -7,6 +7,7 @@ final readonly class FileLockProvider implements LockProviderInterface { private string $directory; + private int $retrySleepMicros; public function __construct( @@ -27,20 +28,21 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle return null; } - if (!is_dir($this->directory)) { - @mkdir($this->directory, 0770, true); + if (!is_dir($this->directory) && !mkdir($this->directory, 0770, true) && !is_dir($this->directory)) { + return null; } $path = $this->directory . DIRECTORY_SEPARATOR . hash('xxh128', $key) . '.lock'; - $handle = @fopen($path, 'c+'); + $handle = $this->openLockFile($path); if (!is_resource($handle)) { return null; } $deadline = microtime(true) + max(0.0, $waitSeconds); - while (!@flock($handle, LOCK_EX | LOCK_NB)) { + while (!flock($handle, LOCK_EX | LOCK_NB)) { if (microtime(true) >= $deadline) { - @fclose($handle); + fclose($handle); + return null; } @@ -49,8 +51,9 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle $token = self::generateToken(); if ($token === null) { - @flock($handle, LOCK_UN); - @fclose($handle); + flock($handle, LOCK_UN); + fclose($handle); + return null; } $activeLocks[$key] = true; @@ -67,8 +70,8 @@ public function release(?LockHandle $handle): void $activeLocks = &self::activeRegistry(); if (is_resource($handle->resource)) { - @flock($handle->resource, LOCK_UN); - @fclose($handle->resource); + flock($handle->resource, LOCK_UN); + fclose($handle->resource); } unset($activeLocks[$handle->key]); @@ -79,7 +82,9 @@ public function release(?LockHandle $handle): void */ private static function &activeRegistry(): array { + /** @var array $registry */ static $registry = []; + return $registry; } @@ -91,4 +96,18 @@ private static function generateToken(): ?string return null; } } + + /** + * @return resource|false + */ + private function openLockFile(string $path): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return fopen($path, 'c+'); + } finally { + restore_error_handler(); + } + } } diff --git a/src/Cache/Lock/GeneratesLockTokens.php b/src/Cache/Lock/GeneratesLockTokens.php new file mode 100644 index 0000000..61c13f9 --- /dev/null +++ b/src/Cache/Lock/GeneratesLockTokens.php @@ -0,0 +1,19 @@ +retrySleepMicros = max(1_000, $retrySleepMicros); + $this->retrySleepMicros = self::normalizeRetrySleepMicros($retrySleepMicros); } public function acquire(string $key, float $waitSeconds): ?LockHandle { - $deadline = microtime(true) + max(0.0, $waitSeconds); - $lockKey = $this->prefix . hash('xxh128', $key); - $token = self::generateToken(); - if ($token === null) { - return null; - } $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); + return $this->acquireWithRetry( + $this->prefix, + $key, + $waitSeconds, + fn(string $lockKey, string $token): bool => $this->memcached->add($lockKey, $token, $ttlSeconds), + ); } 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); + $this->releaseWithGuard($handle, function (LockHandle $lock): void { + $current = $this->memcached->get($lock->key); + if ($this->memcached->getResultCode() === \Memcached::RES_SUCCESS && $current === $lock->token) { + $this->memcached->delete($lock->key); } - } catch (Throwable) { - // Best effort unlock. - } - } - - private static function generateToken(): ?string - { - try { - return bin2hex(random_bytes(16)); - } catch (Throwable) { - return null; - } + }); } } diff --git a/src/Cache/Lock/PdoLockProvider.php b/src/Cache/Lock/PdoLockProvider.php index 8b04d1c..ae5f104 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -4,25 +4,25 @@ namespace Infocyph\CacheLayer\Cache\Lock; -use PDO; -use RuntimeException; use Throwable; final readonly class PdoLockProvider implements LockProviderInterface { + use GeneratesLockTokens; + private string $driver; - private FileLockProvider $fallback; + private int $retrySleepMicros; public function __construct( - private PDO $pdo, + private \PDO $pdo, private string $prefix = 'cachelayer:lock:', int $retrySleepMicros = 50_000, - ?FileLockProvider $fallback = null, + private FileLockProvider $fallback = new FileLockProvider(), ) { $this->retrySleepMicros = max(1_000, $retrySleepMicros); - $this->driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); - $this->fallback = $fallback ?? new FileLockProvider(); + $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $this->driver = is_string($driver) ? $driver : ''; } public function acquire(string $key, float $waitSeconds): ?LockHandle @@ -47,23 +47,11 @@ public function release(?LockHandle $handle): void }; } - private static function generateToken(): ?string - { - try { - return bin2hex(random_bytes(16)); - } catch (Throwable) { - return null; - } - } - private static function signedCrc32(string $value): int { $u = crc32($value); - if ($u === false) { - throw new RuntimeException('Unable to hash advisory lock key.'); - } - return $u > 0x7fffffff ? $u - 0x100000000 : $u; + return $u > 0x7FFFFFFF ? $u - 0x100000000 : $u; } private function acquireMysql(string $key, float $waitSeconds): ?LockHandle @@ -110,7 +98,7 @@ private function acquirePgsql(string $key, float $waitSeconds): ?LockHandle $stmt = $this->pdo->prepare('SELECT pg_try_advisory_lock(:k)'); $stmt->execute([':k' => $advisoryKey]); $result = $stmt->fetchColumn(); - if ($result === true || $result === 1 || $result === 't' || $result === '1') { + if ($result === 1 || $result === 't' || $result === '1') { return new LockHandle($lockKey, $token, $advisoryKey); } } catch (Throwable) { diff --git a/src/Cache/Lock/PollingLockProviderHelpers.php b/src/Cache/Lock/PollingLockProviderHelpers.php new file mode 100644 index 0000000..51f2f66 --- /dev/null +++ b/src/Cache/Lock/PollingLockProviderHelpers.php @@ -0,0 +1,60 @@ += $deadline) { + return null; + } + + usleep($this->retrySleepMicros); + } while (true); + } + + /** + * @param callable(LockHandle):void $releaser + */ + protected function releaseWithGuard(?LockHandle $handle, callable $releaser): void + { + if (!$handle instanceof LockHandle) { + return; + } + + try { + $releaser($handle); + } catch (Throwable) { + // Best effort unlock. + } + } +} diff --git a/src/Cache/Lock/RedisLockProvider.php b/src/Cache/Lock/RedisLockProvider.php index d02540e..27c4be5 100644 --- a/src/Cache/Lock/RedisLockProvider.php +++ b/src/Cache/Lock/RedisLockProvider.php @@ -4,55 +4,38 @@ namespace Infocyph\CacheLayer\Cache\Lock; -use Redis; use RuntimeException; -use Throwable; final readonly class RedisLockProvider implements LockProviderInterface { + use GeneratesLockTokens; + use PollingLockProviderHelpers; + private int $retrySleepMicros; public function __construct( - private Redis $redis, + private \Redis $redis, private string $prefix = 'cachelayer:lock:', int $retrySleepMicros = 50_000, ) { - if (!class_exists(Redis::class)) { - throw new RuntimeException('phpredis extension not loaded'); - } - $this->retrySleepMicros = max(1_000, $retrySleepMicros); + $this->assertRedisExtensionLoaded(); + $this->retrySleepMicros = self::normalizeRetrySleepMicros($retrySleepMicros); } public function acquire(string $key, float $waitSeconds): ?LockHandle { - $deadline = microtime(true) + max(0.0, $waitSeconds); - $lockKey = $this->prefix . hash('xxh128', $key); - $token = self::generateToken(); - if ($token === null) { - return null; - } $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); + return $this->acquireWithRetry( + $this->prefix, + $key, + $waitSeconds, + fn(string $lockKey, string $token): bool => (bool) $this->redis->set($lockKey, $token, ['nx', 'px' => $ttlMs]), + ); } 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]) @@ -60,19 +43,18 @@ public function release(?LockHandle $handle): void return 0 LUA; - try { - $this->redis->eval($script, [$handle->key, $handle->token], 1); - } catch (Throwable) { - // Best effort unlock. - } + $this->releaseWithGuard( + $handle, + function (LockHandle $lock) use ($script): void { + $this->redis->eval($script, [$lock->key, $lock->token], 1); + }, + ); } - private static function generateToken(): ?string + private function assertRedisExtensionLoaded(): void { - try { - return bin2hex(random_bytes(16)); - } catch (Throwable) { - return null; + if (!class_exists(\Redis::class)) { + throw new RuntimeException('phpredis extension not loaded'); } } } diff --git a/src/Cache/Metrics/CacheMetricsCollectorInterface.php b/src/Cache/Metrics/CacheMetricsCollectorInterface.php index 8759be7..116ed76 100644 --- a/src/Cache/Metrics/CacheMetricsCollectorInterface.php +++ b/src/Cache/Metrics/CacheMetricsCollectorInterface.php @@ -10,5 +10,6 @@ interface CacheMetricsCollectorInterface * @return array> */ public function export(): array; + public function increment(string $adapterClass, string $metric): void; } diff --git a/src/Cache/Tiering/TieredPoolFactory.php b/src/Cache/Tiering/TieredPoolFactory.php new file mode 100644 index 0000000..35c5c86 --- /dev/null +++ b/src/Cache/Tiering/TieredPoolFactory.php @@ -0,0 +1,421 @@ + $tiers + * @return array + */ + public static function fromArray(array $tiers): array + { + if ($tiers === []) { + throw new CacheInvalidArgumentException('Cache::tiered() requires at least one tier.'); + } + + $pools = []; + foreach ($tiers as $index => $tier) { + $pools[] = self::resolvePool($tier, $index); + } + + return $pools; + } + + /** + * @param array $descriptor + */ + private static function bool(array $descriptor, string $key, bool $default): bool + { + if (!array_key_exists($key, $descriptor)) { + return $default; + } + + $value = $descriptor[$key]; + if (!is_bool($value)) { + throw new CacheInvalidArgumentException( + sprintf('Tier descriptor key `%s` must be bool, got %s.', $key, get_debug_type($value)), + ); + } + + return $value; + } + + private static function buildScyllaSession(string $keyspace): object + { + if (!class_exists(\Cassandra::class)) { + throw new CacheInvalidArgumentException( + 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', + ); + } + + /** @var object */ + return \Cassandra::cluster()->build()->connect($keyspace); + } + + /** + * @param array $descriptor + */ + private static function descriptorToPool(array $descriptor, int|string $index): CacheItemPoolInterface + { + $driverValue = $descriptor['driver'] ?? $descriptor['type'] ?? null; + if (!is_string($driverValue) || $driverValue === '') { + throw new CacheInvalidArgumentException( + sprintf("Tier descriptor at index '%s' requires string key `driver`.", (string) $index), + ); + } + $driver = strtolower($driverValue); + + $namespace = isset($descriptor['namespace']) && is_string($descriptor['namespace']) + ? $descriptor['namespace'] + : 'default'; + $client = $descriptor['client'] ?? $descriptor['session'] ?? null; + + return match ($driver) { + 'apcu' => new Adapter\ApcuCacheAdapter($namespace), + 'array', 'memory' => new Adapter\ArrayCacheAdapter($namespace), + 'file' => new Adapter\FileCacheAdapter($namespace, self::nullableString($descriptor, 'dir', 'base_dir')), + 'php_files' => new Adapter\PhpFilesCacheAdapter($namespace, self::nullableString($descriptor, 'dir', 'base_dir')), + 'memcache', 'memcached' => new Adapter\MemCacheAdapter( + $namespace, + self::servers($descriptor['servers'] ?? null), + self::memcachedClient($client, $index), + ), + 'redis' => new Adapter\RedisCacheAdapter( + $namespace, + self::string($descriptor, 'dsn', 'redis://127.0.0.1:6379'), + self::redisClient($client, $index, 'redis'), + ), + 'valkey' => new Adapter\ValkeyCacheAdapter( + $namespace, + self::string($descriptor, 'dsn', 'valkey://127.0.0.1:6379'), + self::redisClient($client, $index, 'valkey'), + ), + 'redis_cluster' => new Adapter\RedisClusterCacheAdapter( + $namespace, + self::seeds($descriptor['seeds'] ?? null), + self::float($descriptor, 'timeout', 1.0), + self::float($descriptor, 'read_timeout', 1.0), + self::bool($descriptor, 'persistent', false), + is_object($client) ? $client : null, + ), + 'pdo' => new Adapter\PdoCacheAdapter( + $namespace, + self::nullableString($descriptor, 'dsn'), + self::nullableString($descriptor, 'username'), + self::nullableString($descriptor, 'password'), + self::pdoClient($client, $index), + self::string($descriptor, 'table', 'cachelayer_entries'), + ), + 'sqlite' => new Adapter\PdoCacheAdapter( + $namespace, + self::sqliteDsn($descriptor), + null, + null, + null, + self::string($descriptor, 'table', 'cachelayer_entries'), + ), + 'mongodb' => self::mongoPool($descriptor, $namespace, $client, $index), + 'scylladb', 'scylla' => new Adapter\ScyllaDbCacheAdapter( + is_object($client) ? $client : self::buildScyllaSession(self::string($descriptor, 'keyspace', 'cachelayer')), + self::string($descriptor, 'keyspace', 'cachelayer'), + self::string($descriptor, 'table', 'cachelayer_entries'), + $namespace, + ), + 'shared_memory' => new Adapter\SharedMemoryCacheAdapter( + $namespace, + self::int($descriptor, 'segment_size', 16_777_216), + ), + 'weak_map' => new Adapter\WeakMapCacheAdapter($namespace), + 'null', 'null_store' => new Adapter\NullCacheAdapter(), + default => throw new CacheInvalidArgumentException( + sprintf("Unsupported tier driver '%s' at index '%s'.", $driver, (string) $index), + ), + }; + } + + /** + * @param array $descriptor + */ + private static function float(array $descriptor, string $key, float $default): float + { + if (!array_key_exists($key, $descriptor)) { + return $default; + } + + $value = $descriptor[$key]; + if (!is_int($value) && !is_float($value)) { + throw new CacheInvalidArgumentException( + sprintf('Tier descriptor key `%s` must be float, got %s.', $key, get_debug_type($value)), + ); + } + + return (float) $value; + } + + /** + * @param array $descriptor + */ + private static function int(array $descriptor, string $key, int $default): int + { + if (!array_key_exists($key, $descriptor)) { + return $default; + } + + $value = $descriptor[$key]; + if (!is_int($value)) { + throw new CacheInvalidArgumentException( + sprintf('Tier descriptor key `%s` must be int, got %s.', $key, get_debug_type($value)), + ); + } + + return $value; + } + + private static function memcachedClient(mixed $client, int|string $index): ?\Memcached + { + if ($client === null) { + return null; + } + + if (!$client instanceof \Memcached) { + throw new CacheInvalidArgumentException( + sprintf("Memcached tier at index '%s' requires `client` instance of Memcached.", (string) $index), + ); + } + + return $client; + } + + /** + * @param array $descriptor + */ + private static function mongoPool(array $descriptor, string $namespace, mixed $client, int|string $index): CacheItemPoolInterface + { + $collection = $descriptor['collection'] ?? null; + if (is_object($collection)) { + return new Adapter\MongoDbCacheAdapter($collection, $namespace); + } + + if (!is_object($client)) { + throw new CacheInvalidArgumentException( + sprintf("MongoDB tier at index '%s' requires object `collection` or `client`.", (string) $index), + ); + } + + return Adapter\MongoDbCacheAdapter::fromClient( + $client, + self::string($descriptor, 'database', 'cachelayer'), + self::string($descriptor, 'collection_name', 'entries'), + $namespace, + ); + } + + /** + * @param array $tier + * @return array + */ + private static function normalizeDescriptor(array $tier): array + { + $descriptor = []; + foreach ($tier as $key => $value) { + if (!is_string($key)) { + throw new CacheInvalidArgumentException( + sprintf( + 'Tier descriptor keys must be strings; got key type %s.', + get_debug_type($key), + ), + ); + } + + $descriptor[$key] = $value; + } + + return $descriptor; + } + + /** + * @param array $descriptor + */ + private static function nullableString(array $descriptor, string ...$keys): ?string + { + foreach ($keys as $key) { + if (!array_key_exists($key, $descriptor)) { + continue; + } + + $value = $descriptor[$key]; + if ($value === null) { + return null; + } + + if (!is_string($value)) { + throw new CacheInvalidArgumentException( + sprintf('Tier descriptor key `%s` must be string|null, got %s.', $key, get_debug_type($value)), + ); + } + + return $value; + } + + return null; + } + + private static function pdoClient(mixed $client, int|string $index): ?\PDO + { + if ($client === null) { + return null; + } + + if (!$client instanceof \PDO) { + throw new CacheInvalidArgumentException( + sprintf("PDO tier at index '%s' requires `client` instance of PDO.", (string) $index), + ); + } + + return $client; + } + + private static function redisClient(mixed $client, int|string $index, string $driver): ?\Redis + { + if ($client === null) { + return null; + } + + if (!$client instanceof \Redis) { + throw new CacheInvalidArgumentException( + sprintf( + "%s tier at index '%s' requires `client` instance of Redis.", + ucfirst($driver), + (string) $index, + ), + ); + } + + return $client; + } + + private static function resolvePool(mixed $tier, int|string $index): CacheItemPoolInterface + { + if ($tier instanceof CacheItemPoolInterface) { + return $tier; + } + + if (!is_array($tier)) { + throw new CacheInvalidArgumentException( + sprintf( + "Invalid tier at index '%s': expected CacheItemPoolInterface or descriptor array, got %s.", + (string) $index, + get_debug_type($tier), + ), + ); + } + + return self::descriptorToPool(self::normalizeDescriptor($tier), $index); + } + + /** + * @return array + */ + private static function seeds(mixed $value): array + { + if ($value === null) { + return ['127.0.0.1:6379']; + } + + if (!is_array($value)) { + throw new CacheInvalidArgumentException( + sprintf('Tier descriptor key `seeds` must be array, got %s.', get_debug_type($value)), + ); + } + + $seeds = []; + foreach ($value as $seed) { + if (!is_string($seed)) { + throw new CacheInvalidArgumentException( + sprintf( + 'Tier descriptor key `seeds` must contain only strings, got %s.', + get_debug_type($seed), + ), + ); + } + $seeds[] = $seed; + } + + return $seeds; + } + + /** + * @return array + */ + private static function servers(mixed $value): array + { + if ($value === null) { + return [['127.0.0.1', 11211, 0]]; + } + + if (!is_array($value)) { + throw new CacheInvalidArgumentException( + sprintf( + 'Tier descriptor key `servers` must be array, got %s.', + get_debug_type($value), + ), + ); + } + + $servers = []; + foreach ($value as $server) { + if (!is_array($server) || !is_string($server[0] ?? null) || !is_int($server[1] ?? null)) { + throw new CacheInvalidArgumentException( + 'Tier descriptor key `servers` entries must be [string host, int port, int weight].', + ); + } + + $weight = $server[2] ?? 0; + if (!is_int($weight)) { + throw new CacheInvalidArgumentException( + 'Tier descriptor key `servers` entries weight must be int.', + ); + } + + $servers[] = [$server[0], $server[1], $weight]; + } + + return $servers; + } + + /** + * @param array $descriptor + */ + private static function sqliteDsn(array $descriptor): ?string + { + $file = self::nullableString($descriptor, 'file'); + + return $file === null ? null : 'sqlite:' . $file; + } + + /** + * @param array $descriptor + */ + private static function string(array $descriptor, string $key, string $default): string + { + if (!array_key_exists($key, $descriptor)) { + return $default; + } + + $value = $descriptor[$key]; + if (!is_string($value)) { + throw new CacheInvalidArgumentException( + sprintf('Tier descriptor key `%s` must be string, got %s.', $key, get_debug_type($value)), + ); + } + + return $value; + } +} diff --git a/src/Exceptions/CacheInvalidArgumentException.php b/src/Exceptions/CacheInvalidArgumentException.php index a9d19ab..0d0c9a6 100644 --- a/src/Exceptions/CacheInvalidArgumentException.php +++ b/src/Exceptions/CacheInvalidArgumentException.php @@ -1,5 +1,7 @@ __memo = []; + return; } diff --git a/src/Memoize/Memoizer.php b/src/Memoize/Memoizer.php index 6a002bc..7c22d37 100644 --- a/src/Memoize/Memoizer.php +++ b/src/Memoize/Memoizer.php @@ -11,9 +11,12 @@ final class Memoizer { + use MemoizeTrait; + private static ?self $instance = null; private int $hits = 0; + private int $misses = 0; /** @var WeakMap> */ @@ -40,6 +43,8 @@ public function flush(): void } /** + * @param array $params + * * @throws ReflectionException */ public function get(callable $callable, array $params = []): mixed @@ -51,16 +56,20 @@ public function get(callable $callable, array $params = []): mixed if (array_key_exists($cacheKey, $this->staticCache)) { $this->hits++; + return $this->staticCache[$cacheKey]; } $this->misses++; $value = $callable(...$params); $this->staticCache[$cacheKey] = $value; + return $value; } /** + * @param array $params + * * @throws ReflectionException */ public function getFor(object $object, callable $callable, array $params = []): mixed @@ -73,6 +82,7 @@ public function getFor(object $object, callable $callable, array $params = []): $bucket = $this->objectCache[$object] ?? []; if (array_key_exists($cacheKey, $bucket)) { $this->hits++; + return $bucket[$cacheKey]; } @@ -80,6 +90,7 @@ public function getFor(object $object, callable $callable, array $params = []): $value = $callable(...$params); $bucket[$cacheKey] = $value; $this->objectCache[$object] = $bucket; + return $value; } @@ -95,6 +106,9 @@ public function stats(): array ]; } + /** + * @param array $params + */ private static function buildCacheKey(string $signature, array $params): string { if ($params === []) { @@ -102,6 +116,7 @@ private static function buildCacheKey(string $signature, array $params): string } $normalized = array_map(self::normalizeParam(...), $params); + return $signature . '|' . hash('xxh3', serialize($normalized)); } @@ -113,6 +128,7 @@ 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(); } @@ -121,7 +137,8 @@ private static function callableSignature(callable $callable): string } if (is_array($callable)) { - $target = is_object($callable[0]) ? $callable[0]::class : (string) $callable[0]; + $target = is_object($callable[0]) ? $callable[0]::class : $callable[0]; + return 'array:' . $target . '::' . $callable[1]; } @@ -131,6 +148,7 @@ private static function callableSignature(callable $callable): string $rf = new ReflectionFunction(Closure::fromCallable($callable)); $file = $rf->getFileName() ?: 'internal'; + return 'callable:' . $file . ':' . $rf->getStartLine() . '-' . $rf->getEndLine(); } diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index 96ad8e1..bed544f 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -66,7 +66,7 @@ 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_array($callback) => 'array:' . (is_object($callback[0]) ? $callback[0]::class : $callback[0]) . '::' . $callback[1], is_object($callback) => 'invokable:' . $callback::class, default => 'callable:' . get_debug_type($callback), }; @@ -83,6 +83,9 @@ private function closureFingerprint(Closure $closure): string if (!is_string($file) || $file === '') { return $lineFingerprint; } + if (!is_readable($file)) { + return $lineFingerprint; + } $sourceKey = $file . ':' . $start . '-' . $end; $cached = $this->closureSourceMemo[$sourceKey] ?? null; @@ -90,7 +93,7 @@ private function closureFingerprint(Closure $closure): string return $cached; } - $lines = @file($file, FILE_IGNORE_NEW_LINES); + $lines = file($file, FILE_IGNORE_NEW_LINES); if (!is_array($lines)) { return $lineFingerprint; } @@ -124,10 +127,6 @@ private function trackCacheKey(string $key): void } $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 index 90f1a57..5aefd32 100644 --- a/src/Serializer/ValueSerializer.php +++ b/src/Serializer/ValueSerializer.php @@ -7,13 +7,17 @@ use Closure; use InvalidArgumentException; -use function Opis\Closure\{serialize as oc_serialize, unserialize as oc_unserialize}; +use function Opis\Closure\serialize as oc_serialize; +use function Opis\Closure\unserialize as oc_unserialize; final class ValueSerializer { private const array NATIVE_SERIALIZED_PREFIXES = ['N', 'b', 'i', 'd', 's', 'a']; + private const int SERIALIZED_CLOSURE_MEMO_LIMIT = 2048; + private static bool $allowClosurePayloads = true; + private static bool $allowObjectPayloads = true; /** @var array */ @@ -47,7 +51,8 @@ public static function configureSecurity( * * @param string $payload The encoded string * @param bool $base64 True ⇒ expect base64; false ⇒ raw - * @return mixed Original value + * @return mixed Original value + * * @throws InvalidArgumentException Forwarded from ::unserialize() */ public static function decode(string $payload, bool $base64 = true): mixed @@ -71,12 +76,14 @@ public static function decode(string $payload, bool $base64 = true): mixed * * @param mixed $value Any PHP value * @param bool $base64 True ⇒ wrap with base64; false ⇒ raw - * @return string Encoded payload + * @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; } @@ -88,7 +95,6 @@ public static function encode(mixed $value, bool $base64 = true): string * 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 @@ -145,7 +151,6 @@ public static function registerResourceHandler( * 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. @@ -162,7 +167,6 @@ public static function serialize(mixed $value): string return oc_serialize($wrapped); } - /** * Unserializes a given string into its original value. * @@ -172,7 +176,6 @@ public static function serialize(mixed $value): string * 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 @@ -196,14 +199,12 @@ public static function unserialize(string $blob): mixed 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 @@ -227,7 +228,6 @@ public static function useStrictSecurity(): void ); } - /** * Wraps resources within a given value. * @@ -236,7 +236,6 @@ public static function useStrictSecurity(): void * 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 @@ -248,6 +247,7 @@ private static function assertAllowedBySecurityPolicy(mixed $value): void { if (!is_array($value)) { self::assertAllowedScalarOrNode($value); + return; } @@ -279,6 +279,7 @@ private static function containsOpisPayloadMarker(string $blob): bool private static function isNativeSerializedPayload(string $blob): bool { $first = $blob[0] ?? ''; + return in_array($first, self::NATIVE_SERIALIZED_PREFIXES, true); } @@ -287,12 +288,11 @@ private static function rememberSerializedClosureMemo(string $key, bool $value): 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]); - } + unset(self::$serializedClosureMemo[$oldest]); } self::$serializedClosureMemo[$key] = $value; + return $value; } @@ -320,7 +320,6 @@ private static function requiresOpisSerialization(mixed $value): bool * 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 @@ -328,6 +327,8 @@ private static function unwrapRecursive(mixed $resource): mixed if ( is_array($resource) && ($resource['__wrapped_resource'] ?? false) + && is_string($resource['type'] ?? null) + && array_key_exists('data', $resource) && isset(self::$resourceHandlers[$resource['type']]) ) { return (self::$resourceHandlers[$resource['type']]['restore'])($resource['data']); @@ -338,6 +339,7 @@ private static function unwrapRecursive(mixed $resource): mixed $resource[$key] = self::unwrapRecursive($item); } } + return $resource; } @@ -354,7 +356,6 @@ private static function unwrapRecursive(mixed $resource): mixed * 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. @@ -367,6 +368,7 @@ private static function wrapRecursive(mixed $resource): mixed if (!$arr) { throw new InvalidArgumentException("No handler for resource type '$type'"); } + return [ '__wrapped_resource' => true, 'type' => $type, @@ -379,6 +381,7 @@ private static function wrapRecursive(mixed $resource): mixed $resource[$key] = self::wrapRecursive($value); } } + return $resource; } } diff --git a/src/functions.php b/src/functions.php index f9e716c..c8d9fa6 100644 --- a/src/functions.php +++ b/src/functions.php @@ -5,20 +5,25 @@ use Infocyph\CacheLayer\Memoize\Memoizer; use Infocyph\CacheLayer\Memoize\OnceMemoizer; -if (! function_exists('sanitize_cache_ns')) { +if (!function_exists('sanitize_cache_ns')) { /** * Normalize cache namespaces into safe key prefixes. */ function sanitize_cache_ns(string $ns): string { + /** @var array $cache */ static $cache = []; - return $cache[$ns] ??= (preg_replace('/[^A-Za-z0-9_\-]/', '_', $ns) ?? ''); + $sanitized = preg_replace('/[^A-Za-z0-9_\-]/', '_', $ns); + + return $cache[$ns] ??= is_string($sanitized) ? $sanitized : ''; } } -if (! function_exists('memoize')) { +if (!function_exists('memoize')) { /** + * @param array $params + * * @throws ReflectionException */ function memoize(?callable $callable = null, array $params = []): mixed @@ -32,8 +37,10 @@ function memoize(?callable $callable = null, array $params = []): mixed } } -if (! function_exists('remember')) { +if (!function_exists('remember')) { /** + * @param array $params + * * @throws ReflectionException */ function remember(?object $object = null, ?callable $callable = null, array $params = []): mixed @@ -52,7 +59,7 @@ function remember(?object $object = null, ?callable $callable = null, array $par } } -if (! function_exists('once')) { +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 index 64c66d4..a00c4f9 100644 --- a/tests/Cache/ApcuCachePoolTest.php +++ b/tests/Cache/ApcuCachePoolTest.php @@ -10,18 +10,19 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Item\ApcuCacheItem; -use Infocyph\CacheLayer\Serializer\ValueSerializer; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; +use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── skip entirely if APCu unavailable ─────────────────────────────── */ -if (!extension_loaded('apcu')) { +if (! extension_loaded('apcu')) { test('APCu not loaded – skipping adapter tests')->skip(); + return; } ini_set('apcu.enable_cli', 1); -if (!apcu_enabled()) { +if (! apcu_enabled()) { test('APCu not enabled – skipping adapter tests')->skip(); + return; } @@ -36,13 +37,14 @@ 'stream', // ----- wrap ---------------------------------------------------- function (mixed $res): array { - if (!is_resource($res)) { + if (! is_resource($res)) { throw new InvalidArgumentException('Expected resource'); } $meta = stream_get_meta_data($res); rewind($res); + return [ - 'mode' => $meta['mode'], + 'mode' => $meta['mode'], 'content' => stream_get_contents($res), ]; }, @@ -51,6 +53,7 @@ function (array $data): mixed { $s = fopen('php://memory', $data['mode']); fwrite($s, $data['content']); rewind($s); + return $s; // <- real resource } ); @@ -75,6 +78,7 @@ function (array $data): mixed { // Callable default without prior set $computed = $this->cache->get('dyn', function (ApcuCacheItem $item) { $item->expiresAfter(1); + return 'computed'; }); expect($computed)->toBe('computed'); @@ -170,5 +174,3 @@ function (array $data): mixed { ->and($items['y']->get())->toBe('Y') ->and($items['z']->isHit())->toBeFalse(); }); - - diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index 7087068..10efc68 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -7,12 +7,12 @@ use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; beforeEach(function () { - $this->cacheDir = sys_get_temp_dir() . '/pest_cache_features_' . uniqid(); + $this->cacheDir = sys_get_temp_dir().'/pest_cache_features_'.uniqid(); $this->cache = Cache::file('features', $this->cacheDir); }); afterEach(function () { - if (!is_dir($this->cacheDir)) { + if (! is_dir($this->cacheDir)) { return; } @@ -20,7 +20,7 @@ $rim = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); foreach ($rim as $file) { $path = $file->getRealPath(); - if ($path === false || !file_exists($path)) { + if ($path === false || ! file_exists($path)) { continue; } $file->isDir() ? rmdir($path) : unlink($path); @@ -49,6 +49,7 @@ function ($item) use (&$count) { $count++; $item->expiresAfter(30); + return 'payload'; }, null, @@ -59,6 +60,7 @@ function ($item) use (&$count) { 'hot', function () use (&$count) { $count++; + return 'should-not-run'; }, ); @@ -76,10 +78,12 @@ function () use (&$count) { $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; }); @@ -104,7 +108,11 @@ function () use (&$count) { }); test('remember respects ttl argument expiry', function () { - $this->cache->remember('short', fn ($item) => 'value', 1); + $this->cache->remember('short', function ($item) { + $item->expiresAfter(1); + + return 'value'; + }, 1); usleep(2_000_000); @@ -138,17 +146,25 @@ function () use (&$count) { test('remember uses configured lock provider', function () { $calls = ['acquire' => 0, 'release' => 0]; - $provider = new class ($calls) implements LockProviderInterface { + $provider = new class($calls) implements LockProviderInterface + { public function __construct(private array &$calls) {} public function acquire(string $key, float $waitSeconds): ?LockHandle { + if ($waitSeconds < 0) { + return null; + } $this->calls['acquire']++; + return new LockHandle($key, 'tkn'); } public function release(?LockHandle $handle): void { + if (! $handle instanceof LockHandle) { + return; + } $this->calls['release']++; } }; @@ -161,7 +177,7 @@ public function release(?LockHandle $handle): void }); test('metrics collector exports hit and miss counters', function () { - $collector = new InMemoryCacheMetricsCollector(); + $collector = new InMemoryCacheMetricsCollector; $this->cache->setMetricsCollector($collector); $this->cache->get('x'); @@ -201,4 +217,3 @@ public function release(?LockHandle $handle): void $this->cache->configurePayloadCompression(null); }); - diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 4f99409..0708b4e 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -25,7 +25,7 @@ CachePayloadCodec::configureSecurity('secret-key-123', 8_388_608); $blob = CachePayloadCodec::encode('value', null); - $tampered = $blob . 'x'; + $tampered = $blob.'x'; expect(CachePayloadCodec::decode($tampered))->toBeNull(); }); diff --git a/tests/Cache/ChainCachePoolTest.php b/tests/Cache/ChainCachePoolTest.php index 054dc9e..38160ae 100644 --- a/tests/Cache/ChainCachePoolTest.php +++ b/tests/Cache/ChainCachePoolTest.php @@ -2,6 +2,7 @@ use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; beforeEach(function () { $this->l1 = new ArrayCacheAdapter('l1'); @@ -25,3 +26,32 @@ expect($this->cache->get('promote'))->toBe('from-l2') ->and($this->l1->getItem('promote')->isHit())->toBeTrue(); }); + +test('tiered cache supports descriptor array tiers', function () { + $cache = Cache::tiered([ + ['driver' => 'memory', 'namespace' => 'tiered-l1'], + ['driver' => 'memory', 'namespace' => 'tiered-l2'], + ]); + + expect($cache->set('k', 'v'))->toBeTrue() + ->and($cache->get('k'))->toBe('v'); +}); + +test('tiered cache can skip L1 write-through on save', function () { + $l1 = new ArrayCacheAdapter('skip-l1'); + $l2 = new ArrayCacheAdapter('skip-l2'); + $cache = Cache::tiered([$l1, $l2], writeToL1: false); + + $cache->set('x', 'X'); + + expect($l1->getItem('x')->isHit())->toBeFalse() + ->and($l2->getItem('x')->isHit())->toBeTrue(); + + expect($cache->get('x'))->toBe('X') + ->and($l1->getItem('x')->isHit())->toBeTrue(); +}); + +test('tiered cache rejects unsupported driver descriptors', function () { + expect(fn() => Cache::tiered([['driver' => 'unknown-tier']])) + ->toThrow(CacheInvalidArgumentException::class); +}); diff --git a/tests/Cache/DynamoDbCachePoolTest.php b/tests/Cache/DynamoDbCachePoolTest.php deleted file mode 100644 index ff634dd..0000000 --- a/tests/Cache/DynamoDbCachePoolTest.php +++ /dev/null @@ -1,103 +0,0 @@ -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 index c077c10..7580e3e 100644 --- a/tests/Cache/FileCachePoolTest.php +++ b/tests/Cache/FileCachePoolTest.php @@ -6,13 +6,12 @@ use Infocyph\CacheLayer\Cache\Item\FileCacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Serializer\ValueSerializer; -use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; beforeEach(function () { ValueSerializer::clearResourceHandlers(); /* fresh temp directory for each run */ - $this->cacheDir = sys_get_temp_dir() . '/pest_cache_' . uniqid(); + $this->cacheDir = sys_get_temp_dir().'/pest_cache_'.uniqid(); /* build a file-backed cachepool via static factory */ $this->cache = Cache::file('tests', $this->cacheDir); @@ -21,13 +20,14 @@ 'stream', // ----- wrap ---------------------------------------------------- function (mixed $res): array { - if (!is_resource($res)) { + if (! is_resource($res)) { throw new InvalidArgumentException('Expected resource'); } $meta = stream_get_meta_data($res); rewind($res); + return [ - 'mode' => $meta['mode'], + 'mode' => $meta['mode'], 'content' => stream_get_contents($res), ]; }, @@ -36,6 +36,7 @@ function (array $data): mixed { $s = fopen('php://memory', $data['mode']); fwrite($s, $data['content']); rewind($s); + return $s; // <- real resource } ); @@ -43,11 +44,11 @@ function (array $data): mixed { afterEach(function () { /* recursive dir cleanup */ - if (!is_dir($this->cacheDir)) { + if (! is_dir($this->cacheDir)) { return; } - $it = new RecursiveDirectoryIterator($this->cacheDir, FilesystemIterator::SKIP_DOTS); + $it = new RecursiveDirectoryIterator($this->cacheDir, FilesystemIterator::SKIP_DOTS); $rim = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); foreach ($rim as $file) { @@ -73,6 +74,7 @@ function (array $data): mixed { // Callable default $computed = $this->cache->get('x', function (FileCacheItem $item) { $item->expiresAfter(1); + return 'xyz'; }); expect($computed)->toBe('xyz'); @@ -136,24 +138,30 @@ function (array $data): mixed { expect(isset($this->cache->alpha))->toBeFalse(); }); test('runtime re-namespace and directory swap', function () { - $newDir = sys_get_temp_dir() . '/pest_cache_new_' . uniqid(); + $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'; + $namespaceDir = $newDir.'/cache_newns'; expect(is_dir($namespaceDir)) ->toBeTrue() - ->and(glob($namespaceDir . '/*.cache'))->not->toBeEmpty(); + ->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); + foreach (glob($namespaceDir.'/*') as $f) { + if (is_file($f)) { + unlink($f); + } + } + if (is_dir($namespaceDir)) { + rmdir($namespaceDir); + } + if (is_dir($newDir)) { + rmdir($newDir); } - @rmdir($namespaceDir); - @rmdir($newDir); }); test('expiration honours TTL', function () { @@ -180,16 +188,16 @@ function (array $data): mixed { }); test('custom resource handler works', function () { - $dirPath = __DIR__; // path we will open/restore - $dirRes = opendir($dirPath); - $resType = get_resource_type($dirRes); // "stream" + $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 + fn ($r) => ['path' => is_resource($r) ? $dirPath : $dirPath], // wrap + fn (array $data) => opendir($data['path']) // restore ); $this->cache->getItem('dirRes')->set($dirRes)->save(); @@ -232,5 +240,3 @@ function (array $data): mixed { ->and($items['b']->get())->toBe(2) ->and($items['c']->isHit())->toBeFalse(); }); - - diff --git a/tests/Cache/MemCachePoolTest.php b/tests/Cache/MemCachePoolTest.php index 8c0a0a0..c9921c7 100644 --- a/tests/Cache/MemCachePoolTest.php +++ b/tests/Cache/MemCachePoolTest.php @@ -7,38 +7,43 @@ * a Memcached daemon is reachable at 127.0.0.1:11211. */ +use Infocyph\CacheLayer\Cache\Adapter\MemCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Item\MemCacheItem; -use Infocyph\CacheLayer\Serializer\ValueSerializer; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; +use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── Skip suite if Memcached unavailable ─────────────────────────── */ -if (!class_exists(\Memcached::class)) { +if (! class_exists(Memcached::class)) { test('Memcached ext not loaded – skipping')->skip(); + return; } -$probe = new Memcached(); -$probe->addServer('127.0.0.1', 11211); +$memcachedHost = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; +$memcachedPort = (int) (getenv('IC_MEMCACHED_PORT') ?: getenv('CACHELAYER_MEMCACHED_PORT') ?: '11211'); + +$probe = new Memcached; +$probe->addServer($memcachedHost, $memcachedPort); $probe->set('ping', 'pong'); if ($probe->getResultCode() !== Memcached::RES_SUCCESS) { - test('No Memcached server at 127.0.0.1:11211 – skipping')->skip(); + test('No Memcached server available – skipping')->skip(); + return; } /* ── Test bootstrap / teardown ───────────────────────────────────── */ -beforeEach(function () { - $client = new Memcached(); - $client->addServer('127.0.0.1', 11211); +beforeEach(function () use ($memcachedHost, $memcachedPort) { + $client = new Memcached; + $client->addServer($memcachedHost, $memcachedPort); $client->flush(); // fresh slate ValueSerializer::clearResourceHandlers(); $this->cache = Cache::memcache( 'tests', - [['127.0.0.1', 11211, 0]], + [[$memcachedHost, $memcachedPort, 0]], $client ); @@ -47,13 +52,14 @@ 'stream', // ----- wrap ---------------------------------------------------- function (mixed $res): array { - if (!is_resource($res)) { + if (! is_resource($res)) { throw new InvalidArgumentException('Expected resource'); } $meta = stream_get_meta_data($res); rewind($res); + return [ - 'mode' => $meta['mode'], + 'mode' => $meta['mode'], 'content' => stream_get_contents($res), ]; }, @@ -62,13 +68,14 @@ 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 */ + /** @var MemCacheAdapter $adapt */ $adapt = (new ReflectionObject($this->cache)) ->getProperty('adapter')->getValue($this->cache); (new ReflectionProperty($adapt, 'mc')) @@ -93,6 +100,7 @@ function (array $data): mixed { // Callable $val = $this->cache->get('call', function (MemCacheItem $item) { $item->expiresAfter(1); + return 'hello'; }); expect($val)->toBe('hello'); @@ -176,5 +184,3 @@ function (array $data): mixed { ->and($items['m2']->get())->toBe('bar') ->and($items['missing']->isHit())->toBeFalse(); }); - - diff --git a/tests/Cache/MongoDbCachePoolTest.php b/tests/Cache/MongoDbCachePoolTest.php index 93f5587..c3e231a 100644 --- a/tests/Cache/MongoDbCachePoolTest.php +++ b/tests/Cache/MongoDbCachePoolTest.php @@ -4,7 +4,8 @@ use Infocyph\CacheLayer\Cache\Cache; beforeEach(function () { - $this->collection = new class { + $this->collection = new class + { /** @var array> */ public array $docs = []; @@ -50,6 +51,7 @@ public function findOne(array $filter): ?array public function updateOne(array $filter, array $update, array $options = []): void { + unset($options); $this->docs[$filter['_id']] = $update['$set']; } }; diff --git a/tests/Cache/NullCachePoolTest.php b/tests/Cache/NullCachePoolTest.php index caa9a13..3cc390b 100644 --- a/tests/Cache/NullCachePoolTest.php +++ b/tests/Cache/NullCachePoolTest.php @@ -17,10 +17,12 @@ $a = $this->cache->remember('k', function () use (&$calls) { $calls++; + return 'v'; }); $b = $this->cache->remember('k', function () use (&$calls) { $calls++; + return 'v'; }); diff --git a/tests/Cache/PdoCachePoolTest.php b/tests/Cache/PdoCachePoolTest.php index 686ca27..48b2902 100644 --- a/tests/Cache/PdoCachePoolTest.php +++ b/tests/Cache/PdoCachePoolTest.php @@ -1,11 +1,12 @@ skip(); + return; } @@ -38,7 +39,7 @@ }); test('pdo defaults to sqlite driver when no dsn is provided', function () { - $namespace = 'pdo-default-' . uniqid(); + $namespace = 'pdo-default-'.uniqid(); $cache = Cache::pdo($namespace); $cache->set('x', 'X'); @@ -47,7 +48,9 @@ $dbFile = PdoCacheAdapter::defaultSqliteFileForNamespace($namespace); $cache->clear(); - @unlink($dbFile); + if (is_file($dbFile)) { + unlink($dbFile); + } }); test('pdo factory configures pdo lock provider', function () { @@ -58,6 +61,7 @@ if (class_exists($pdoLockProviderClass)) { expect($provider)->toBeInstanceOf($pdoLockProviderClass); + return; } diff --git a/tests/Cache/PdoMysqlCachePoolTest.php b/tests/Cache/PdoMysqlCachePoolTest.php index 95d88ce..d02c60e 100644 --- a/tests/Cache/PdoMysqlCachePoolTest.php +++ b/tests/Cache/PdoMysqlCachePoolTest.php @@ -2,14 +2,15 @@ use Infocyph\CacheLayer\Cache\Cache; -if (!in_array('mysql', PDO::getAvailableDrivers(), true)) { +if (! in_array('mysql', PDO::getAvailableDrivers(), true)) { test('MySQL PDO driver not present')->skip(); + return; } -$dsn = getenv('CACHELAYER_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=cachelayer'; -$user = getenv('CACHELAYER_MYSQL_USER') ?: 'root'; -$pass = getenv('CACHELAYER_MYSQL_PASS'); +$dsn = getenv('IC_MYSQL_DSN') ?: getenv('CACHELAYER_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=cachelayer'; +$user = getenv('IC_SERVICE_USERNAME') ?: getenv('IC_MYSQL_USER') ?: getenv('CACHELAYER_MYSQL_USER') ?: 'root'; +$pass = getenv('IC_SERVICE_PASSWORD') ?: getenv('IC_MYSQL_PASSWORD') ?: getenv('CACHELAYER_MYSQL_PASS'); if ($pass === false) { $pass = ''; } @@ -20,6 +21,7 @@ $probe->query('SELECT 1'); } catch (Throwable) { test('MySQL server unreachable')->skip(); + return; } diff --git a/tests/Cache/PdoPgsqlCachePoolTest.php b/tests/Cache/PdoPgsqlCachePoolTest.php index 4862fbf..7b94359 100644 --- a/tests/Cache/PdoPgsqlCachePoolTest.php +++ b/tests/Cache/PdoPgsqlCachePoolTest.php @@ -2,14 +2,15 @@ use Infocyph\CacheLayer\Cache\Cache; -if (!in_array('pgsql', PDO::getAvailableDrivers(), true)) { +if (! in_array('pgsql', PDO::getAvailableDrivers(), true)) { test('PostgreSQL PDO driver not present')->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'; +$dsn = getenv('IC_POSTGRES_DSN') ?: getenv('CACHELAYER_PG_DSN') ?: 'pgsql:host=127.0.0.1;port=5432;dbname=cachelayer'; +$user = getenv('IC_SERVICE_USERNAME') ?: getenv('IC_POSTGRES_USER') ?: getenv('CACHELAYER_PG_USER') ?: 'postgres'; +$pass = getenv('IC_SERVICE_PASSWORD') ?: getenv('IC_POSTGRES_PASSWORD') ?: getenv('CACHELAYER_PG_PASS') ?: 'postgres'; try { $probe = new PDO($dsn, $user, $pass); @@ -17,6 +18,7 @@ $probe->query('SELECT 1'); } catch (Throwable) { test('PostgreSQL server unreachable')->skip(); + return; } diff --git a/tests/Cache/PhpFilesCachePoolTest.php b/tests/Cache/PhpFilesCachePoolTest.php index c12b5a6..0379b48 100644 --- a/tests/Cache/PhpFilesCachePoolTest.php +++ b/tests/Cache/PhpFilesCachePoolTest.php @@ -3,12 +3,12 @@ use Infocyph\CacheLayer\Cache\Cache; beforeEach(function () { - $this->cacheDir = sys_get_temp_dir() . '/pest_phpfiles_cache_' . uniqid(); + $this->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)) { + if (! is_dir($this->cacheDir)) { return; } @@ -16,7 +16,7 @@ $rim = new RecursiveIteratorIterator($it, RecursiveIteratorIterator::CHILD_FIRST); foreach ($rim as $file) { $path = $file->getRealPath(); - if ($path === false || !file_exists($path)) { + if ($path === false || ! file_exists($path)) { continue; } $file->isDir() ? rmdir($path) : unlink($path); diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index ed2e599..7a60364 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -11,34 +11,55 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Item\RedisCacheItem; -use Infocyph\CacheLayer\Serializer\ValueSerializer; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; +use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── skip whole file when Redis unavailable ───────────────────────── */ -if (!class_exists(Redis::class)) { +if (! class_exists(Redis::class)) { test('phpredis ext not loaded – skipping')->skip(); + return; } + +$redisHost = getenv('IC_REDIS_HOST') ?: getenv('CACHELAYER_REDIS_HOST') ?: '127.0.0.1'; +$redisPort = (int) (getenv('IC_REDIS_PORT') ?: getenv('CACHELAYER_REDIS_PORT') ?: '6379'); +$redisPassword = getenv('IC_REDIS_PASSWORD'); +if ($redisPassword === false) { + $redisPassword = getenv('IC_SERVICE_PASSWORD'); +} +if ($redisPassword === false) { + $redisPassword = getenv('CACHELAYER_REDIS_PASSWORD'); +} +if ($redisPassword === false) { + $redisPassword = ''; +} + try { - $probe = new Redis(); - $probe->connect('127.0.0.1', 6379, 0.5); + $probe = new Redis; + $probe->connect($redisHost, $redisPort, 0.5); + if ($redisPassword !== '') { + $probe->auth($redisPassword); + } $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); +beforeEach(function () use ($redisHost, $redisPort, $redisPassword) { + $client = new Redis; + $client->connect($redisHost, $redisPort); + if ($redisPassword !== '') { + $client->auth($redisPassword); + } $client->flushDB(); // fresh DB 0 ValueSerializer::clearResourceHandlers(); $this->cache = Cache::redis( 'tests', - 'redis://127.0.0.1:6379', + sprintf('redis://%s:%d', $redisHost, $redisPort), $client ); @@ -46,13 +67,14 @@ 'stream', // ----- wrap ---------------------------------------------------- function (mixed $res): array { - if (!is_resource($res)) { + if (! is_resource($res)) { throw new InvalidArgumentException('Expected resource'); } $meta = stream_get_meta_data($res); rewind($res); + return [ - 'mode' => $meta['mode'], + 'mode' => $meta['mode'], 'content' => stream_get_contents($res), ]; }, @@ -61,6 +83,7 @@ function (array $data): mixed { $s = fopen('php://memory', $data['mode']); fwrite($s, $data['content']); rewind($s); + return $s; // <- real resource } ); @@ -83,6 +106,7 @@ function (array $data): mixed { $val = $this->cache->get('dynamic', function (RedisCacheItem $item) { $item->expiresAfter(1); + return 'xyz'; }); expect($val)->toBe('xyz'); @@ -173,5 +197,3 @@ function (array $data): mixed { ->and($items['r2']->get())->toBe(20) ->and($items['none']->isHit())->toBeFalse(); }); - - diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index 529bae3..79a0b15 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -3,9 +3,11 @@ use Infocyph\CacheLayer\Cache\Cache; beforeEach(function () { - $this->cluster = new class { + $this->cluster = new class + { /** @var array */ private array $kv = []; + /** @var array> */ private array $sets = []; @@ -30,12 +32,14 @@ public function del(string|array $keys): int 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; } @@ -43,6 +47,7 @@ public function sAdd(string $key, string $member): int { $exists = isset($this->sets[$key][$member]); $this->sets[$key][$member] = true; + return $exists ? 0 : 1; } @@ -58,29 +63,32 @@ public function sMembers(string $key): array public function sRem(string $key, string $member): int { - if (!isset($this->sets[$key][$member])) { + 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])) { + if (! isset($this->kv[$key])) { return; } diff --git a/tests/Cache/S3CachePoolTest.php b/tests/Cache/S3CachePoolTest.php deleted file mode 100644 index 6186f63..0000000 --- a/tests/Cache/S3CachePoolTest.php +++ /dev/null @@ -1,79 +0,0 @@ -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/ScyllaDbCachePoolTest.php b/tests/Cache/ScyllaDbCachePoolTest.php new file mode 100644 index 0000000..564da01 --- /dev/null +++ b/tests/Cache/ScyllaDbCachePoolTest.php @@ -0,0 +1,211 @@ +session = new class + { + /** @var array> */ + private array $rows = []; + + public function prepare(string $cql): string + { + return $cql; + } + + /** + * @return array> + */ + public function execute(mixed $statement, mixed $options = []): array + { + $cql = trim((string) $statement); + $args = $this->extractArguments($options); + + if (str_starts_with($cql, 'CREATE TABLE')) { + return []; + } + + if (str_starts_with($cql, 'DELETE FROM') && str_contains($cql, 'AND ckey = ?')) { + $ns = (string) ($args[0] ?? ''); + $key = (string) ($args[1] ?? ''); + unset($this->rows[$ns][$key]); + + return []; + } + + if (str_starts_with($cql, 'DELETE FROM')) { + $ns = (string) ($args[0] ?? ''); + unset($this->rows[$ns]); + + return []; + } + + if (str_starts_with($cql, 'SELECT expires')) { + $ns = (string) ($args[0] ?? ''); + + return array_map( + static fn (array $row): array => ['expires' => $row['expires']], + array_values($this->rows[$ns] ?? []), + ); + } + + if (str_starts_with($cql, 'SELECT payload, expires')) { + $ns = (string) ($args[0] ?? ''); + $key = (string) ($args[1] ?? ''); + $row = $this->rows[$ns][$key] ?? null; + + return is_array($row) ? [$row] : []; + } + + if (str_starts_with($cql, 'INSERT INTO')) { + $ns = (string) ($args[0] ?? ''); + $key = (string) ($args[1] ?? ''); + $payload = (string) ($args[2] ?? ''); + $expires = $args[3] ?? null; + + $this->rows[$ns][$key] = [ + 'payload' => $payload, + 'expires' => is_numeric($expires) ? (int) $expires : null, + ]; + + return []; + } + + return []; + } + + /** + * @return array + */ + private function extractArguments(mixed $options): array + { + if (is_array($options) && is_array($options['arguments'] ?? null)) { + return array_values($options['arguments']); + } + + return []; + } + }; + + $this->cache = new Cache(new ScyllaDbCacheAdapter( + $this->session, + 'cachelayer', + 'cachelayer_entries', + 'scylla-tests', + )); +}); + +test('scylladb 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('scylladb 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('scylladb cache factory accepts injected session', function () { + $cache = Cache::scyllaDb('scylla-tests', $this->session, 'cachelayer', 'cachelayer_entries'); + $cache->set('x', 'X'); + + expect($cache->get('x'))->toBe('X'); +}); + +test('scylladb cache factory requires extension when session is missing', function () { + if (class_exists(Cassandra::class)) { + $this->markTestSkipped('Cassandra extension loaded in this environment.'); + } + + expect(fn () => Cache::scyllaDb('scylla-tests')) + ->toThrow(CacheInvalidArgumentException::class); +}); + +/** + * @return array{endpoint:string}|null + */ +function scylladbAlternatorIntegrationContext(): ?array +{ + $endpoint = getenv('IC_SCYLLADB_ENDPOINT') ?: getenv('CACHELAYER_SCYLLADB_ENDPOINT') ?: 'http://127.0.0.1:8000'; + if (!is_string($endpoint) || $endpoint === '') { + return null; + } + + $base = rtrim($endpoint, '/'); + $context = stream_context_create([ + 'http' => [ + 'timeout' => 1.5, + 'ignore_errors' => true, + ], + ]); + + $health = scylladbHttpGet($base . '/', $context); + if (!is_string($health) || $health === '') { + return null; + } + + return ['endpoint' => $base]; +} + +function scylladbHttpGet(string $url, mixed $context): ?string +{ + $previous = set_error_handler(static fn (): bool => true); + + try { + $result = file_get_contents($url, false, $context); + } finally { + restore_error_handler(); + } + + if (!is_string($result) || $result === '') { + return null; + } + + return $result; +} + +test('scylladb alternator health endpoint is reachable', function () { + $integration = scylladbAlternatorIntegrationContext(); + if ($integration === null) { + $this->markTestSkipped('ScyllaDB Alternator integration unavailable (service missing).'); + } + + $context = stream_context_create([ + 'http' => [ + 'timeout' => 1.5, + 'ignore_errors' => true, + ], + ]); + + $response = scylladbHttpGet($integration['endpoint'] . '/', $context); + + expect(is_string($response))->toBeTrue() + ->and(str_contains(strtolower((string) $response), 'healthy'))->toBeTrue(); +}); + +test('scylladb alternator localnodes endpoint returns json list', function () { + $integration = scylladbAlternatorIntegrationContext(); + if ($integration === null) { + $this->markTestSkipped('ScyllaDB Alternator integration unavailable (service missing).'); + } + + $context = stream_context_create([ + 'http' => [ + 'timeout' => 1.5, + 'ignore_errors' => true, + ], + ]); + + $response = scylladbHttpGet($integration['endpoint'] . '/localnodes', $context); + $decoded = is_string($response) ? json_decode($response, true) : null; + + expect(is_array($decoded))->toBeTrue(); +}); diff --git a/tests/Cache/SharedMemoryCachePoolTest.php b/tests/Cache/SharedMemoryCachePoolTest.php index 4d04b80..8d1001c 100644 --- a/tests/Cache/SharedMemoryCachePoolTest.php +++ b/tests/Cache/SharedMemoryCachePoolTest.php @@ -2,8 +2,9 @@ use Infocyph\CacheLayer\Cache\Cache; -if (!function_exists('shm_attach')) { +if (! function_exists('shm_attach')) { test('shared memory extension not loaded')->skip(); + return; } diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index e0394ee..75d2b77 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -8,20 +8,20 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; -use Infocyph\CacheLayer\Serializer\ValueSerializer; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; +use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── Skip entire suite if SQLite missing ─────────────────────────── */ -if (!in_array('sqlite', PDO::getAvailableDrivers(), true)) { +if (! in_array('sqlite', PDO::getAvailableDrivers(), true)) { test('SQLite PDO driver not present – skipping')->skip(); + return; } /* ── bootstrap / teardown ────────────────────────────────────────── */ beforeEach(function () { - $this->dbFile = sys_get_temp_dir() . '/pest_sqlite_' . uniqid() . '.sqlite'; - $this->cache = Cache::sqlite('tests', $this->dbFile); + $this->dbFile = sys_get_temp_dir().'/pest_sqlite_'.uniqid().'.sqlite'; + $this->cache = Cache::sqlite('tests', $this->dbFile); ValueSerializer::clearResourceHandlers(); /* stream handler for resource test */ @@ -29,13 +29,14 @@ 'stream', // ----- wrap ---------------------------------------------------- function (mixed $res): array { - if (!is_resource($res)) { + if (! is_resource($res)) { throw new InvalidArgumentException('Expected resource'); } $meta = stream_get_meta_data($res); rewind($res); + return [ - 'mode' => $meta['mode'], + 'mode' => $meta['mode'], 'content' => stream_get_contents($res), ]; }, @@ -44,6 +45,7 @@ function (array $data): mixed { $s = fopen('php://memory', $data['mode']); fwrite($s, $data['content']); rewind($s); + return $s; // <- real resource } ); @@ -53,7 +55,9 @@ function (array $data): mixed { // Release SQLite handles before unlink on Windows. $this->cache = null; gc_collect_cycles(); - @unlink($this->dbFile); + if (is_file($this->dbFile)) { + unlink($this->dbFile); + } }); /* ── 1. convenience set / get ───────────────────────────────────── */ @@ -69,6 +73,7 @@ function (array $data): mixed { $val = $this->cache->get('compute', function (GenericCacheItem $item) { $item->expiresAfter(1); + return 'val'; }); expect($val) @@ -159,4 +164,3 @@ function (array $data): mixed { ->and($items['s2']->get())->toBe('B') ->and($items['void']->isHit())->toBeFalse(); }); - diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php new file mode 100644 index 0000000..14963d5 --- /dev/null +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -0,0 +1,66 @@ +skip(); + + return; +} + +$valkeyHost = getenv('IC_VALKEY_HOST') ?: getenv('CACHELAYER_VALKEY_HOST') ?: getenv('IC_REDIS_HOST') ?: getenv('CACHELAYER_REDIS_HOST') ?: '127.0.0.1'; +$valkeyPort = (int) (getenv('IC_VALKEY_PORT') ?: getenv('CACHELAYER_VALKEY_PORT') ?: getenv('IC_REDIS_PORT') ?: getenv('CACHELAYER_REDIS_PORT') ?: '6379'); +$valkeyPassword = getenv('IC_VALKEY_PASSWORD') ?: getenv('CACHELAYER_VALKEY_PASSWORD') ?: getenv('IC_REDIS_PASSWORD') ?: getenv('CACHELAYER_REDIS_PASSWORD') ?: ''; + +try { + $probe = new Redis; + $probe->connect($valkeyHost, $valkeyPort, 0.5); + if ($valkeyPassword !== '') { + $probe->auth($valkeyPassword); + } + $probe->ping(); +} catch (Throwable) { + test('Valkey server unreachable - skipping')->skip(); + + return; +} + +beforeEach(function () use ($valkeyHost, $valkeyPort, $valkeyPassword) { + $client = new Redis; + $client->connect($valkeyHost, $valkeyPort); + if ($valkeyPassword !== '') { + $client->auth($valkeyPassword); + } + $client->flushDB(); + + $this->cache = Cache::valkey( + 'valkey-tests', + sprintf('valkey://%s:%d', $valkeyHost, $valkeyPort), + $client, + ); +}); + +afterEach(function () { + $this->cache->clear(); +}); + +test('valkey adapter stores and retrieves values', function () { + expect($this->cache->set('foo', 'bar'))->toBeTrue() + ->and($this->cache->get('foo'))->toBe('bar'); +}); + +test('valkey adapter supports remember lock path', function () { + $runs = 0; + + $v1 = $this->cache->remember('once', function ($item) use (&$runs) { + $runs++; + $item->expiresAfter(30); + + return 'value'; + }); + $v2 = $this->cache->remember('once', fn () => 'new-value'); + + expect($v1)->toBe('value') + ->and($v2)->toBe('value') + ->and($runs)->toBe(1); +}); diff --git a/tests/Cache/WeakMapCachePoolTest.php b/tests/Cache/WeakMapCachePoolTest.php index c3c1700..edb094c 100644 --- a/tests/Cache/WeakMapCachePoolTest.php +++ b/tests/Cache/WeakMapCachePoolTest.php @@ -14,7 +14,7 @@ }); test('weak map adapter returns same object while strongly referenced', function () { - $obj = new stdClass(); + $obj = new stdClass; $obj->name = 'cache-object'; $this->cache->set('obj', $obj); diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php index d8cf03b..7c9e4c6 100644 --- a/tests/Memoize/MemoizeTest.php +++ b/tests/Memoize/MemoizeTest.php @@ -36,7 +36,7 @@ }); it('remember() caches per-instance callables', function () { - $obj = new stdClass(); + $obj = new stdClass; $counter = 0; $fn = function () use (&$counter) { return ++$counter; @@ -71,8 +71,10 @@ }); it('memoize trait caches values within object', function () { - $inst = new class () { + $inst = new class + { use MemoizeTrait; + public int $count = 0; public function next(): int diff --git a/tests/Memoize/OnceMemoizerTest.php b/tests/Memoize/OnceMemoizerTest.php index 701be89..93839ee 100644 --- a/tests/Memoize/OnceMemoizerTest.php +++ b/tests/Memoize/OnceMemoizerTest.php @@ -27,7 +27,8 @@ it('isolates cache entries by caller context', function () { $counter = 0; - $caller = new class () { + $caller = new class + { public function one(int &$counter): int { return OnceMemoizer::instance()->once(function () use (&$counter) { @@ -64,7 +65,7 @@ public function two(int &$counter): int $seedCache = []; $seedOrder = []; for ($i = 0; $i < 2048; $i++) { - $key = 'seed-' . $i; + $key = 'seed-'.$i; $seedCache[$key] = $i; $seedOrder[] = $key; } diff --git a/tests/Serializer/ValueSerializerTest.php b/tests/Serializer/ValueSerializerTest.php index 96a11d5..490c082 100644 --- a/tests/Serializer/ValueSerializerTest.php +++ b/tests/Serializer/ValueSerializerTest.php @@ -19,13 +19,13 @@ foreach ($values as $v) { $blob = ValueSerializer::serialize($v); - $out = ValueSerializer::unserialize($blob); + $out = ValueSerializer::unserialize($blob); expect($out)->toBe($v); } }); it('round-trips closures', function () { - $fn = fn (int $x): int => $x + 2; + $fn = fn (int $x): int => $x + 2; $blob = ValueSerializer::serialize($fn); $rest = ValueSerializer::unserialize($blob); @@ -35,7 +35,7 @@ }); it('wraps and unwraps without full serialization', function () { - $data = ['foo' => 'bar', 'baz' => [1, 2, 3]]; + $data = ['foo' => 'bar', 'baz' => [1, 2, 3]]; $wrapped = ValueSerializer::wrap($data); expect($wrapped)->toBe($data); @@ -63,7 +63,7 @@ it('keeps serialized closure memo cache bounded', function () { for ($i = 0; $i < 2200; $i++) { - ValueSerializer::isSerializedClosure('x' . $i); + ValueSerializer::isSerializedClosure('x'.$i); } $ref = new ReflectionClass(ValueSerializer::class); @@ -80,4 +80,3 @@ ValueSerializer::useCompatibilitySecurity(); }); -