From 42386191cd2c56e80c3c0f2be1646b88c11828a8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 10 May 2026 16:58:37 +0600 Subject: [PATCH 01/10] init ic regulations --- .codex | 0 .github/scripts/composer-audit-guard.php | 85 ------ .github/scripts/phpstan-sarif.php | 178 ------------ .github/scripts/syntax.php | 109 -------- .github/workflows/security-standards.yml | 28 ++ README.md | 2 +- benchmarks/CacheFileBench.php | 1 + benchmarks/MemoizeBench.php | 1 + captainhook.json | 8 +- composer.json | 114 ++------ docs/adapters/index.rst | 3 +- docs/adapters/s3.rst | 44 --- docs/cache.rst | 1 - pest.xml | 22 -- phpbench.json | 26 -- phpcs.xml.dist | 52 ---- phpstan.neon.dist | 14 - phpunit.xml | 22 -- pint.json | 73 ----- psalm.xml | 40 --- rector.php | 14 - src/Cache/Adapter/AbstractCacheAdapter.php | 153 +++++++++++ src/Cache/Adapter/AdapterValueNormalizer.php | 71 +++++ src/Cache/Adapter/ApcuCacheAdapter.php | 71 +++-- src/Cache/Adapter/ArrayCacheAdapter.php | 57 ++-- src/Cache/Adapter/CachePayloadCodec.php | 21 +- src/Cache/Adapter/ChainCacheAdapter.php | 47 ++-- src/Cache/Adapter/DynamoDbCacheAdapter.php | 134 +++------ src/Cache/Adapter/FileCacheAdapter.php | 36 +-- src/Cache/Adapter/MemCacheAdapter.php | 163 ++++++++--- src/Cache/Adapter/MongoDbCacheAdapter.php | 126 +++------ src/Cache/Adapter/NullCacheAdapter.php | 10 +- src/Cache/Adapter/PdoCacheAdapter.php | 69 +++-- src/Cache/Adapter/PhpFilesCacheAdapter.php | 68 ++--- src/Cache/Adapter/RedisCacheAdapter.php | 41 ++- .../Adapter/RedisClusterCacheAdapter.php | 92 +++---- src/Cache/Adapter/S3CacheAdapter.php | 256 ------------------ .../Adapter/SecuresFilesystemDirectories.php | 27 ++ .../Adapter/SharedMemoryCacheAdapter.php | 80 +++--- src/Cache/Adapter/WeakMapCacheAdapter.php | 78 +++--- src/Cache/Cache.php | 173 +++++++----- src/Cache/CacheInterface.php | 4 +- src/Cache/Item/AbstractCacheItem.php | 8 + src/Cache/Lock/FileLockProvider.php | 5 + src/Cache/Lock/GeneratesLockTokens.php | 19 ++ src/Cache/Lock/MemcachedLockProvider.php | 54 +--- src/Cache/Lock/PdoLockProvider.php | 24 +- src/Cache/Lock/PollingLockProviderHelpers.php | 60 ++++ src/Cache/Lock/RedisLockProvider.php | 57 ++-- .../CacheMetricsCollectorInterface.php | 1 + .../CacheInvalidArgumentException.php | 2 + src/Memoize/MemoizeTrait.php | 1 + src/Memoize/Memoizer.php | 20 +- src/Memoize/OnceMemoizer.php | 6 +- src/Serializer/ValueSerializer.php | 35 +-- src/functions.php | 17 +- tests/Cache/ApcuCachePoolTest.php | 18 +- tests/Cache/CacheFeaturesTest.php | 17 +- tests/Cache/CachePayloadCodecSecurityTest.php | 2 +- tests/Cache/DynamoDbCachePoolTest.php | 6 +- tests/Cache/FileCachePoolTest.php | 34 +-- tests/Cache/MemCachePoolTest.php | 23 +- tests/Cache/MongoDbCachePoolTest.php | 3 +- tests/Cache/NullCachePoolTest.php | 2 + tests/Cache/PdoCachePoolTest.php | 8 +- tests/Cache/PdoMysqlCachePoolTest.php | 4 +- tests/Cache/PdoPgsqlCachePoolTest.php | 4 +- tests/Cache/PhpFilesCachePoolTest.php | 6 +- tests/Cache/RedisCachePoolTest.php | 20 +- tests/Cache/RedisClusterCachePoolTest.php | 14 +- tests/Cache/S3CachePoolTest.php | 79 ------ tests/Cache/SharedMemoryCachePoolTest.php | 3 +- tests/Cache/SqliteCachePoolTest.php | 18 +- tests/Cache/WeakMapCachePoolTest.php | 2 +- tests/Memoize/MemoizeTest.php | 6 +- tests/Memoize/OnceMemoizerTest.php | 5 +- tests/Serializer/ValueSerializerTest.php | 9 +- 77 files changed, 1335 insertions(+), 1871 deletions(-) delete mode 100644 .codex delete mode 100644 .github/scripts/composer-audit-guard.php delete mode 100644 .github/scripts/phpstan-sarif.php delete mode 100644 .github/scripts/syntax.php create mode 100644 .github/workflows/security-standards.yml delete mode 100644 docs/adapters/s3.rst delete mode 100644 pest.xml delete mode 100644 phpbench.json delete mode 100644 phpcs.xml.dist delete mode 100644 phpstan.neon.dist delete mode 100644 phpunit.xml delete mode 100644 pint.json delete mode 100644 psalm.xml delete mode 100644 rector.php create mode 100644 src/Cache/Adapter/AdapterValueNormalizer.php delete mode 100644 src/Cache/Adapter/S3CacheAdapter.php create mode 100644 src/Cache/Adapter/SecuresFilesystemDirectories.php create mode 100644 src/Cache/Lock/GeneratesLockTokens.php create mode 100644 src/Cache/Lock/PollingLockProviderHelpers.php delete mode 100644 tests/Cache/S3CachePoolTest.php 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/security-standards.yml b/.github/workflows/security-standards.yml new file mode 100644 index 0000000..d815bf7 --- /dev/null +++ b/.github/workflows/security-standards.yml @@ -0,0 +1,28 @@ +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, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" + coverage: "xdebug" + composer_flags: "" + phpstan_memory_limit: "1G" + psalm_threads: "1" + run_analysis: true + run_svg_report: true + artifact_retention_days: 61 diff --git a/README.md b/README.md index ee21b0b..9d3632d 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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, Redis Cluster, PDO (SQLite default), Shared Memory, MongoDB, and DynamoDB - Tagged invalidation with versioned tags: `setTagged()`, `invalidateTag()`, `invalidateTags()` - Stampede-safe `remember()` with pluggable lock providers - Per-adapter metrics counters and export hooks diff --git a/benchmarks/CacheFileBench.php b/benchmarks/CacheFileBench.php index 63181ab..2fd3ee9 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 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..843e5a5 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", @@ -19,7 +19,6 @@ "postgres", "mongodb", "dynamodb", - "s3", "weakmap", "chain-cache" ], @@ -29,108 +28,51 @@ "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" + }, "suggest": { "ext-apcu": "For APCu-based caching (in-memory, per-process)", - "ext-redis": "For Redis-based caching (persistent, networked)", + "ext-mbstring": "Recommended for development tools output formatting (Pest/Termwind)", "ext-memcached": "For Memcached-based caching (distributed, RAM)", "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)" + "aws/aws-sdk-php": "For DynamoDB adapter", + "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/index.rst b/docs/adapters/index.rst index 1c65de3..326d0ae 100644 --- a/docs/adapters/index.rst +++ b/docs/adapters/index.rst @@ -12,7 +12,7 @@ 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 cloud adapters (``mongodb``, ``dynamoDb``) when cache must live outside app hosts. .. toctree:: :maxdepth: 1 @@ -32,5 +32,4 @@ Choosing quickly: shared-memory mongodb dynamodb - s3 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/cache.rst b/docs/cache.rst index 7b6a3be..b12c424 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -62,7 +62,6 @@ The facade exposes factory methods for all bundled adapters: * ``Cache::chain(array $pools)`` * ``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')`` ``local()`` chooses APCu when available (``extension_loaded('apcu')`` and ``apcu_enabled()``), otherwise File cache. 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..0015ee4 --- /dev/null +++ b/src/Cache/Adapter/AdapterValueNormalizer.php @@ -0,0 +1,71 @@ +|null + */ + public static function fromArrayLikeOrToArray(mixed $value): ?array + { + if ($value === null) { + return null; + } + + if (is_array($value)) { + return self::normalizeAssoc($value); + } + + if ($value instanceof \ArrayAccess && $value instanceof \Traversable) { + $out = []; + foreach ($value as $k => $v) { + if (is_string($k)) { + $out[$k] = $v; + } + } + + return $out; + } + + if (is_object($value) && method_exists($value, 'toArray')) { + $arr = $value->toArray(); + + return is_array($arr) ? self::normalizeAssoc($arr) : null; + } + + return 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; + } +} diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 2dce444..654597b 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,6 +98,10 @@ 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 === []) { @@ -103,24 +109,25 @@ public function multiFetch(array $keys): array } $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; + if (is_string($raw[$p])) { + $item = $this->hitItemFromBlob($k, $raw[$p]); + if ($item instanceof ApcuCacheItem) { + $items[$k] = $item; + + continue; + } + + $stale[] = $p; } - $stale[] = $p; } $items[$k] = new ApcuCacheItem($this, $k); } @@ -141,10 +148,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 +162,27 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof ApcuCacheItem; } + 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 +193,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..d9cf70f 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; @@ -29,12 +30,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 +51,9 @@ public function deleteItem(string $key): bool return $ok; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { $ok = true; @@ -67,7 +73,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 +85,7 @@ public function getItem(string $key): GenericCacheItem $out = new GenericCacheItem($this, $key); $out->set($value); $out->expiresAfter($ttl); + return $out; } @@ -90,36 +97,28 @@ 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; + foreach ($this->pools as $pool) { + $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 index 78fa03f..0133d94 100644 --- a/src/Cache/Adapter/DynamoDbCacheAdapter.php +++ b/src/Cache/Adapter/DynamoDbCacheAdapter.php @@ -51,8 +51,13 @@ public function clear(): bool $params['ExclusiveStartKey'] = $lastKey; } - $result = $this->toArray($this->client->scan($params)) ?? []; - foreach ($result['Items'] ?? [] as $item) { + $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->client->scan($params)) ?? []; + $items = is_array($result['Items'] ?? null) ? $result['Items'] : []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + if (is_array($item['ckey'] ?? null) && is_string($item['ckey']['S'] ?? null)) { $keys[] = $item['ckey']['S']; } @@ -72,12 +77,13 @@ public function clear(): bool } $this->deferred = []; + return true; } public function count(): int { - $result = $this->toArray($this->client->scan([ + $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->client->scan([ 'TableName' => $this->table, 'FilterExpression' => '#ns = :ns AND (attribute_not_exists(#exp) OR #exp > :now)', 'Select' => 'COUNT', @@ -91,7 +97,9 @@ public function count(): int ], ])); - return (int) ($result['Count'] ?? 0); + $count = $result['Count'] ?? 0; + + return is_numeric($count) ? max(0, (int) $count) : 0; } public function deleteItem(string $key): bool @@ -104,6 +112,9 @@ public function deleteItem(string $key): bool return true; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { foreach ($keys as $key) { @@ -115,7 +126,7 @@ public function deleteItems(array $keys): bool public function getItem(string $key): GenericCacheItem { - $result = $this->toArray($this->client->getItem([ + $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->client->getItem([ 'TableName' => $this->table, 'Key' => ['ckey' => ['S' => $this->map($key)]], 'ConsistentRead' => true, @@ -126,39 +137,10 @@ public function getItem(string $key): GenericCacheItem 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); - } + $payloadAttr = is_array($row['payload'] ?? null) ? $row['payload'] : null; + $payload = is_array($payloadAttr) ? ($payloadAttr['S'] ?? null) : null; - $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; + return $this->genericFromBase64($key, is_string($payload) ? $payload : null); } public function hasItem(string $key): bool @@ -166,42 +148,34 @@ 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()); - } - - $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']]; - } + return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { + $itemMap = [ + 'ckey' => ['S' => $this->map($saveItem->getKey())], + 'ns' => ['S' => $this->ns], + 'payload' => ['S' => base64_encode(CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']))], + ]; + if ($expires['expiresAt'] !== null) { + $itemMap['expires'] = ['N' => (string) $expires['expiresAt']]; + } - $this->client->putItem([ - 'TableName' => $this->table, - 'Item' => $itemMap, - ]); + $this->client->putItem([ + 'TableName' => $this->table, + 'Item' => $itemMap, + ]); - return true; + return true; + }); } protected function supportsItem(CacheItemInterface $item): bool @@ -213,34 +187,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 \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..9ae6fc1 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) { + $files = glob("$this->dir*.cache"); + if ($files === false) { + $files = []; + } + foreach ($files as $f) { $ok = $ok && @unlink($f); } $this->deferred = []; + return $ok; } @@ -56,12 +65,16 @@ public function deleteItem(string $key): bool 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; } @@ -114,11 +127,13 @@ public function save(CacheItemInterface $item): bool if (file_put_contents($tmp, $blob) === false) { @unlink($tmp); + return false; } if (!@rename($tmp, $this->fileFor($item->getKey()))) { @unlink($tmp); + return false; } @@ -136,23 +151,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 +167,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; } @@ -227,6 +226,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..6e44cc4 100644 --- a/src/Cache/Adapter/MemCacheAdapter.php +++ b/src/Cache/Adapter/MemCacheAdapter.php @@ -13,9 +13,15 @@ class MemCacheAdapter extends AbstractCacheAdapter { 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]], @@ -37,6 +43,7 @@ public function clear(): bool $this->mc->flush(); $this->deferred = []; $this->knownKeys = []; + return true; } @@ -49,14 +56,19 @@ 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; } @@ -67,30 +79,32 @@ public function getClient(): Memcached public function getItem(string $key): MemCacheItem { - $raw = $this->mc->get($this->map($key)); + $mappedKey = $this->map($key); + $raw = $this->mc->get($mappedKey); 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']), - ); + $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; + + return $this->mc->getResultCode() === Memcached::RES_SUCCESS; } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { if ($keys === []) { @@ -98,29 +112,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 +150,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 +159,7 @@ public function save(CacheItemInterface $item): bool if ($ok) { $this->knownKeys[$item->getKey()] = true; } + return $ok; } @@ -160,6 +168,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 +215,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 +232,6 @@ private function collectDumpedKeys( /** * @param array $items - * * @return list */ private function extractSlabIds(array $items): array @@ -209,11 +249,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 +270,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 +334,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 +346,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..583c857 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; } @@ -25,6 +26,9 @@ public function deleteItem(string $key): bool return true; } + /** + * @param list $keys + */ public function deleteItems(array $keys): bool { return true; @@ -40,11 +44,15 @@ public function hasItem(string $key): bool 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..7b23ee1 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -13,9 +13,13 @@ 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 string $table; public function __construct( @@ -37,9 +41,19 @@ public function __construct( $resolvedDsn = 'sqlite:' . self::defaultSqliteFileForNamespace($this->ns); } - $this->pdo = $pdo ?? new PDO((string) $resolvedDsn, $username, $password); + 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); - $this->driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $this->driver = is_string($driver) ? $driver : ''; $this->configureDriverDefaults(); $this->createSchemaIfMissing(); @@ -62,6 +76,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,24 +92,31 @@ 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); } @@ -118,18 +140,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 +179,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 +191,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; } @@ -230,10 +265,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}"); @@ -260,6 +291,7 @@ 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; } @@ -323,8 +355,12 @@ private function fetchRowsByMappedKeys(array $mappedKeys): array $rows = []; foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { - $key = (string) ($row['ckey'] ?? ''); - if ($key === '' || !is_string($row['payload'] ?? null)) { + if (!is_array($row)) { + continue; + } + + $key = $row['ckey'] ?? null; + if (!is_string($key) || $key === '' || !is_string($row['payload'] ?? null)) { continue; } @@ -392,6 +428,7 @@ private function upsert(array $params, string $mappedKey): bool $nativeSql = $this->nativeUpsertSql(); if ($nativeSql !== null) { $stmt = $this->pdo->prepare($nativeSql); + return $stmt->execute($params); } diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 62b3e60..816c1ed 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) @@ -27,6 +30,7 @@ public function clear(): bool } $this->deferred = []; + return $ok; } @@ -58,9 +62,13 @@ public function deleteItem(string $key): bool $file = $this->fileFor($key); $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); - } - - $blob = base64_decode($row['p'], true); - if (!is_string($blob)) { - $this->deleteItem($key); - return new GenericCacheItem($this, $key); + $payload = is_array($row) && is_string($row['p'] ?? null) + ? $row['p'] + : null; + if (!is_string($payload)) { + return $this->genericDeleteAndMiss($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; @@ -143,15 +143,18 @@ public function save(CacheItemInterface $item): bool if (file_put_contents($tmp, $code) === false) { @unlink($tmp); + return false; } if (!@rename($tmp, $file)) { @unlink($tmp); + return false; } $this->invalidateOpcache($file); + return true; } @@ -166,23 +169,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); diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index cb8690c..fa74076 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -22,6 +22,7 @@ class RedisCacheAdapter extends AbstractCacheAdapter { private readonly string $ns; + private readonly Redis $redis; /** @@ -30,6 +31,7 @@ class RedisCacheAdapter extends AbstractCacheAdapter * @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. + * * @throws RuntimeException If the phpredis extension is not loaded. */ public function __construct( @@ -55,6 +57,7 @@ public function clear(): bool } } while ($cursor); $this->deferred = []; + return true; } @@ -65,6 +68,7 @@ public function count(): int while ($keys = $this->redis->scan($iter, $this->ns . ':*', 1000)) { $count += count($keys); } + return $count; } @@ -73,6 +77,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,6 +87,7 @@ public function deleteItems(array $keys): bool } $full = array_map($this->map(...), $keys); + return $this->redis->del($full) !== false; } @@ -104,6 +112,7 @@ public function getItem(string $key): RedisCacheItem } $this->redis->del($this->map($key)); } + return new RedisCacheItem($this, $key); } @@ -112,6 +121,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 +133,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 +158,7 @@ public function multiFetch(array $keys): array true, CachePayloadCodec::toDateTime($record['expires']), ); + continue; } $stale[] = $this->map($k); @@ -159,10 +183,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); @@ -180,16 +206,17 @@ private function connect(string $dsn): Redis 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/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..dcbbd8f 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( @@ -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/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index f87233e..7822ca8 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -12,12 +12,17 @@ final class WeakMapCacheAdapter extends AbstractCacheAdapter { private readonly string $ns; + /** @var array */ 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..cb4a165 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -4,19 +4,23 @@ namespace Infocyph\CacheLayer\Cache; +use Aws\DynamoDb\DynamoDbClient; use BadMethodCallException; use Closure; 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\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Serializer\ValueSerializer; +use MongoDB\Client; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; @@ -25,11 +29,17 @@ final class Cache implements CacheInterface { 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; /** @@ -54,6 +64,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,6 +127,9 @@ public static function chain(array $pools): self return new self(new Adapter\ChainCacheAdapter($pools)); } + /** + * @param array $config + */ public static function dynamoDb( string $namespace = 'default', string $table = 'cachelayer_entries', @@ -123,13 +137,13 @@ public static function dynamoDb( array $config = [], ): self { if ($client === null) { - if (!class_exists(\Aws\DynamoDb\DynamoDbClient::class)) { + if (!class_exists(DynamoDbClient::class)) { throw new CacheInvalidArgumentException( 'aws/aws-sdk-php is required unless a DynamoDB client is provided.', ); } - $client = new \Aws\DynamoDb\DynamoDbClient($config + [ + $client = new DynamoDbClient($config + [ 'version' => 'latest', 'region' => 'us-east-1', ]); @@ -149,7 +163,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 +188,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 +219,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 +256,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 +276,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 +291,9 @@ public static function redis( ); } + /** + * @param array $seeds + */ public static function redisCluster( string $namespace = 'default', array $seeds = ['127.0.0.1:6379'], @@ -299,29 +314,6 @@ public static function redisCluster( ); } - public static function s3( - string $namespace = 'default', - string $bucket = 'cachelayer', - ?object $client = null, - array $config = [], - string $prefix = 'cachelayer', - ): self { - if ($client === null) { - if (!class_exists(\Aws\S3\S3Client::class)) { - throw new CacheInvalidArgumentException( - 'aws/aws-sdk-php is required unless an S3 client is provided.', - ); - } - - $client = new \Aws\S3\S3Client($config + [ - 'version' => 'latest', - 'region' => 'us-east-1', - ]); - } - - return new self(new Adapter\S3CacheAdapter($client, $bucket, $prefix, $namespace)); - } - public static function sharedMemory(string $namespace = 'default', int $segmentSize = 16_777_216): self { return new self(new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize)); @@ -352,7 +344,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 +377,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 +444,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 +456,7 @@ public function deleteItem(string $key): bool $deleted = $this->adapter->deleteItem($key); $this->clearTagMeta($key); $this->metric('delete'); + return $deleted; } @@ -469,8 +464,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 +478,7 @@ public function deleteItems(array $keys): bool $this->clearTagMeta((string) $key); } $this->metric('delete_batch'); + return $deleted; } @@ -490,6 +486,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 +499,7 @@ public function deleteMultiple(iterable $keys): bool $allSucceeded = false; } } + return $allSucceeded; } @@ -542,16 +540,19 @@ public function get(string $key, mixed $default = null): mixed 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(); } @@ -561,14 +562,13 @@ 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 { @@ -580,6 +580,7 @@ public function getItem(string $key): CacheItemInterface if (!$this->isTagMetaValid($key)) { $this->purgeKeyAndTagMeta($key); + return $this->adapter->getItem($key); } @@ -597,10 +598,9 @@ 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 { @@ -617,14 +617,17 @@ public function getItems(array $keys = []): iterable ? $this->adapter->multiFetch($keys) : iterator_to_array($this->adapter->getItems($keys), true); + /** @var array $out */ $out = []; foreach ($keys as $key) { $k = (string) $key; - $item = $fetched[$k] ?? $this->adapter->getItem($k); + $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; } @@ -632,6 +635,7 @@ public function getItems(array $keys = []): iterable $this->purgeKeyAndTagMeta($k); $this->metric('miss'); $out[$k] = $this->adapter->getItem($k); + continue; } @@ -642,7 +646,6 @@ public function getItems(array $keys = []): iterable return $out; } - /** * Returns an iterable of {@see CacheItemInterface} objects for the given * keys. @@ -653,10 +656,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 +670,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 +681,7 @@ public function getMultiple(iterable $keys, mixed $default = null): iterable $this->validateKey($k); $result[$k] = $this->get($k, $default); } + return $result; } @@ -695,10 +699,10 @@ 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 @@ -707,16 +711,19 @@ public function hasItem(string $key): bool $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; } @@ -728,6 +735,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 +756,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 +784,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 +801,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 +818,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); } /** @@ -851,15 +864,18 @@ public function remember( 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(); } @@ -877,6 +893,7 @@ public function remember( } $this->metric('remember_miss'); + return $computed; } finally { $this->lockProvider->release($lockHandle); @@ -890,12 +907,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 +937,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 +967,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 +996,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 +1020,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 +1031,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 +1052,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 +1065,7 @@ public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = n } $ttlSeconds = $this->normalizeTtl($ttl); + return $this->writeTagMeta($key, $normalizedTags, $ttlSeconds); } @@ -1077,16 +1103,16 @@ public function useRedisLock(?\Redis $client = null, string $prefix = 'cachelaye 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 +1132,7 @@ private function currentTagVersion(string $normalizedTag): int $item->set(1)->expiresAfter(null); $this->adapter->save($item); + return 1; } @@ -1178,6 +1205,7 @@ private function normalizeTtl(mixed $ttl): ?int if ($ttl instanceof DateInterval) { $now = new DateTime(); + return max(0, $now->add($ttl)->getTimestamp() - (new DateTime())->getTimestamp()); } @@ -1275,6 +1303,7 @@ private function writeTagMeta(string $key, array $tags, ?int $ttl): bool { if ($tags === []) { $this->clearTagMeta($key); + return true; } @@ -1287,6 +1316,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 +1324,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..febd689 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; 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..3490097 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( @@ -41,6 +42,7 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle while (!@flock($handle, LOCK_EX | LOCK_NB)) { if (microtime(true) >= $deadline) { @fclose($handle); + return null; } @@ -51,6 +53,7 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle if ($token === null) { @flock($handle, LOCK_UN); @fclose($handle); + return null; } $activeLocks[$key] = true; @@ -79,7 +82,9 @@ public function release(?LockHandle $handle): void */ private static function &activeRegistry(): array { + /** @var array $registry */ static $registry = []; + return $registry; } 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, float $unusedWait): 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..ddd62fd 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -5,13 +5,16 @@ 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( @@ -21,7 +24,8 @@ public function __construct( ?FileLockProvider $fallback = null, ) { $this->retrySleepMicros = max(1_000, $retrySleepMicros); - $this->driver = (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $this->driver = is_string($driver) ? $driver : ''; $this->fallback = $fallback ?? new FileLockProvider(); } @@ -47,23 +51,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 +102,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..4864534 --- /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..02bb76b 100644 --- a/src/Cache/Lock/RedisLockProvider.php +++ b/src/Cache/Lock/RedisLockProvider.php @@ -6,10 +6,12 @@ use Redis; use RuntimeException; -use Throwable; final readonly class RedisLockProvider implements LockProviderInterface { + use GeneratesLockTokens; + use PollingLockProviderHelpers; + private int $retrySleepMicros; public function __construct( @@ -17,42 +19,24 @@ public function __construct( 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, float $unusedWait): 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 +44,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/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..215924c 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), }; @@ -124,10 +124,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..9327808 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; }); @@ -138,12 +142,14 @@ 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 { $this->calls['acquire']++; + return new LockHandle($key, 'tkn'); } @@ -161,7 +167,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 +207,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/DynamoDbCachePoolTest.php b/tests/Cache/DynamoDbCachePoolTest.php index ff634dd..aebb63b 100644 --- a/tests/Cache/DynamoDbCachePoolTest.php +++ b/tests/Cache/DynamoDbCachePoolTest.php @@ -4,7 +4,8 @@ use Infocyph\CacheLayer\Cache\Cache; beforeEach(function () { - $this->client = new class { + $this->client = new class + { /** @var array> */ private array $items = []; @@ -24,12 +25,14 @@ 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]] : []; } @@ -37,6 +40,7 @@ public function putItem(array $params): array { $key = $params['Item']['ckey']['S']; $this->items[$key] = $params['Item']; + return []; } diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php index c077c10..c2b8bdc 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,20 +138,20 @@ 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) { + foreach (glob($namespaceDir.'/*') as $f) { @unlink($f); } @rmdir($namespaceDir); @@ -180,16 +182,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' => $dirPath], // wrap + fn (array $data) => opendir($data['path']) // restore ); $this->cache->getItem('dirRes')->set($dirRes)->save(); @@ -232,5 +234,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..cd7ca78 100644 --- a/tests/Cache/MemCachePoolTest.php +++ b/tests/Cache/MemCachePoolTest.php @@ -7,31 +7,33 @@ * 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 = new Memcached; $probe->addServer('127.0.0.1', 11211); $probe->set('ping', 'pong'); if ($probe->getResultCode() !== Memcached::RES_SUCCESS) { test('No Memcached server at 127.0.0.1:11211 – skipping')->skip(); + return; } /* ── Test bootstrap / teardown ───────────────────────────────────── */ beforeEach(function () { - $client = new Memcached(); + $client = new Memcached; $client->addServer('127.0.0.1', 11211); $client->flush(); // fresh slate ValueSerializer::clearResourceHandlers(); @@ -47,13 +49,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 +65,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 +97,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 +181,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..1ad428d 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 = []; 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..a36f288 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'); @@ -58,6 +59,7 @@ if (class_exists($pdoLockProviderClass)) { expect($provider)->toBeInstanceOf($pdoLockProviderClass); + return; } diff --git a/tests/Cache/PdoMysqlCachePoolTest.php b/tests/Cache/PdoMysqlCachePoolTest.php index 95d88ce..582d925 100644 --- a/tests/Cache/PdoMysqlCachePoolTest.php +++ b/tests/Cache/PdoMysqlCachePoolTest.php @@ -2,8 +2,9 @@ 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; } @@ -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..77bbe6c 100644 --- a/tests/Cache/PdoPgsqlCachePoolTest.php +++ b/tests/Cache/PdoPgsqlCachePoolTest.php @@ -2,8 +2,9 @@ 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; } @@ -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..be06bec 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -11,27 +11,28 @@ 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; } try { - $probe = new Redis(); + $probe = new Redis; $probe->connect('127.0.0.1', 6379, 0.5); $probe->ping(); } catch (Throwable) { test('Redis server unreachable – skipping')->skip(); + return; } /* ── bootstrap / teardown ────────────────────────────────────────── */ beforeEach(function () { - $client = new Redis(); + $client = new Redis; $client->connect('127.0.0.1', 6379); $client->flushDB(); // fresh DB 0 ValueSerializer::clearResourceHandlers(); @@ -46,13 +47,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 +63,7 @@ function (array $data): mixed { $s = fopen('php://memory', $data['mode']); fwrite($s, $data['content']); rewind($s); + return $s; // <- real resource } ); @@ -83,6 +86,7 @@ function (array $data): mixed { $val = $this->cache->get('dynamic', function (RedisCacheItem $item) { $item->expiresAfter(1); + return 'xyz'; }); expect($val)->toBe('xyz'); @@ -173,5 +177,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/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..209056f 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 } ); @@ -69,6 +71,7 @@ function (array $data): mixed { $val = $this->cache->get('compute', function (GenericCacheItem $item) { $item->expiresAfter(1); + return 'val'; }); expect($val) @@ -159,4 +162,3 @@ function (array $data): mixed { ->and($items['s2']->get())->toBe('B') ->and($items['void']->isHit())->toBeFalse(); }); - 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(); }); - From ab1ac273f172e594e99ddd881c46a6e77a775451 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 10 May 2026 17:03:46 +0600 Subject: [PATCH 02/10] init ic regulations --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f633d38..7bfc7cd 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,4 +1,4 @@ -name: "Security & Standards" +name: "Security & Standards (v1)" on: schedule: From 7176050aafb5b1a70494cf85a6a9348e0cae9155 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 10 May 2026 19:15:30 +0600 Subject: [PATCH 03/10] ic changes applied --- .github/workflows/security-standards.yml | 2 +- benchmarks/CacheFileBench.php | 10 +- src/Cache/Adapter/AdapterValueNormalizer.php | 48 +++-- src/Cache/Adapter/ApcuCacheAdapter.php | 40 ++-- src/Cache/Adapter/DynamoDbCacheAdapter.php | 138 ++++++++----- src/Cache/Adapter/FileCacheAdapter.php | 20 +- src/Cache/Adapter/NullCacheAdapter.php | 6 + src/Cache/Adapter/PdoCacheAdapter.php | 2 +- src/Cache/Adapter/PhpFilesCacheAdapter.php | 26 ++- .../Adapter/SharedMemoryCacheAdapter.php | 4 +- src/Cache/Cache.php | 159 ++------------- src/Cache/CacheReadRememberTrait.php | 186 ++++++++++++++++++ src/Cache/Lock/FileLockProvider.php | 32 ++- src/Cache/Lock/MemcachedLockProvider.php | 2 +- src/Cache/Lock/PollingLockProviderHelpers.php | 4 +- src/Cache/Lock/RedisLockProvider.php | 2 +- src/Memoize/OnceMemoizer.php | 5 +- tests/Cache/CacheFeaturesTest.php | 12 +- tests/Cache/FileCachePoolTest.php | 14 +- tests/Cache/MongoDbCachePoolTest.php | 1 + tests/Cache/PdoCachePoolTest.php | 4 +- tests/Cache/SqliteCachePoolTest.php | 4 +- 22 files changed, 447 insertions(+), 274 deletions(-) create mode 100644 src/Cache/CacheReadRememberTrait.php diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index d815bf7..0a0df0f 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -19,10 +19,10 @@ jobs: php_versions: '["8.4","8.5"]' dependency_versions: '["prefer-lowest","prefer-stable"]' php_extensions: "apcu, mbstring, memcached, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" - coverage: "xdebug" composer_flags: "" phpstan_memory_limit: "1G" psalm_threads: "1" run_analysis: true run_svg_report: true artifact_retention_days: 61 + diff --git a/benchmarks/CacheFileBench.php b/benchmarks/CacheFileBench.php index 2fd3ee9..c32b895 100644 --- a/benchmarks/CacheFileBench.php +++ b/benchmarks/CacheFileBench.php @@ -36,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/src/Cache/Adapter/AdapterValueNormalizer.php b/src/Cache/Adapter/AdapterValueNormalizer.php index 0015ee4..baef5aa 100644 --- a/src/Cache/Adapter/AdapterValueNormalizer.php +++ b/src/Cache/Adapter/AdapterValueNormalizer.php @@ -11,32 +11,13 @@ final class AdapterValueNormalizer */ public static function fromArrayLikeOrToArray(mixed $value): ?array { - if ($value === null) { - return null; - } - - if (is_array($value)) { - return self::normalizeAssoc($value); - } - - if ($value instanceof \ArrayAccess && $value instanceof \Traversable) { - $out = []; - foreach ($value as $k => $v) { - if (is_string($k)) { - $out[$k] = $v; - } - } - - return $out; - } - - if (is_object($value) && method_exists($value, 'toArray')) { - $arr = $value->toArray(); - - return is_array($arr) ? self::normalizeAssoc($arr) : null; - } - - return null; + 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, + }; } /** @@ -68,4 +49,19 @@ public static function normalizeAssoc(array $value): array 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 654597b..83cba3d 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -107,6 +107,7 @@ public function multiFetch(array $keys): array if ($keys === []) { return []; } + $prefixed = array_map($this->map(...), $keys); $raw = apcu_fetch($prefixed); if (!is_array($raw)) { @@ -116,19 +117,10 @@ public function multiFetch(array $keys): array $items = []; $stale = []; foreach ($keys as $k) { - $p = $this->map($k); - if (array_key_exists($p, $raw)) { - if (is_string($raw[$p])) { - $item = $this->hitItemFromBlob($k, $raw[$p]); - if ($item instanceof ApcuCacheItem) { - $items[$k] = $item; - - continue; - } - - $stale[] = $p; - } + if ($this->appendFetchedHit($items, $stale, $k, $raw)) { + continue; } + $items[$k] = new ApcuCacheItem($this, $k); } @@ -162,6 +154,30 @@ 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); diff --git a/src/Cache/Adapter/DynamoDbCacheAdapter.php b/src/Cache/Adapter/DynamoDbCacheAdapter.php index 0133d94..cf1f4d6 100644 --- a/src/Cache/Adapter/DynamoDbCacheAdapter.php +++ b/src/Cache/Adapter/DynamoDbCacheAdapter.php @@ -30,51 +30,8 @@ public function __construct( 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 = AdapterValueNormalizer::fromArrayLikeOrToArray($this->client->scan($params)) ?? []; - $items = is_array($result['Items'] ?? null) ? $result['Items'] : []; - foreach ($items as $item) { - if (!is_array($item)) { - continue; - } - - 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]]); - } + $keys = $this->scanAllKeysByNamespace(); + $this->deleteBatches($keys); $this->deferred = []; @@ -183,8 +140,99 @@ protected function supportsItem(CacheItemInterface $item): bool return $item instanceof GenericCacheItem; } + /** + * @param list $keys + * @param array $result + */ + private function appendScanResultKeys(array &$keys, array $result): void + { + $items = is_array($result['Items'] ?? null) ? $result['Items'] : []; + foreach ($items as $item) { + if (!is_array($item)) { + continue; + } + + if (is_array($item['ckey'] ?? null) && is_string($item['ckey']['S'] ?? null)) { + $keys[] = $item['ckey']['S']; + } + } + } + + /** + * @param list $keys + */ + private function deleteBatches(array $keys): void + { + 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]]); + } + } + + /** + * @param array $result + * @return array|null + */ + private function extractLastEvaluatedKey(array $result): ?array + { + $lastEvaluatedKey = $result['LastEvaluatedKey'] ?? null; + if (!is_array($lastEvaluatedKey)) { + return null; + } + + return AdapterValueNormalizer::normalizeAssoc($lastEvaluatedKey); + } + private function map(string $key): string { return $this->ns . ':' . $key; } + + /** + * @return list + */ + private function scanAllKeysByNamespace(): array + { + $keys = []; + $lastKey = null; + + do { + $result = AdapterValueNormalizer::fromArrayLikeOrToArray( + $this->client->scan($this->scanParams($lastKey)), + ) ?? []; + $this->appendScanResultKeys($keys, $result); + $lastKey = $this->extractLastEvaluatedKey($result); + } while ($lastKey !== null); + + return $keys; + } + + /** + * @param array|null $lastKey + * @return array + */ + private function scanParams(?array $lastKey): array + { + $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; + } + + return $params; + } } diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 9ae6fc1..5378ca0 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -46,7 +46,7 @@ public function clear(): bool $files = []; } foreach ($files as $f) { - $ok = $ok && @unlink($f); + $ok = $ok && (!is_file($f) || unlink($f)); } $this->deferred = []; @@ -62,7 +62,7 @@ public function deleteItem(string $key): bool { $file = $this->fileFor($key); - return !is_file($file) || @unlink($file); + return !is_file($file) || unlink($file); } /** @@ -96,7 +96,7 @@ public function getItem(string $key): FileCacheItem ); } } - @unlink($file); + unlink($file); } return new FileCacheItem($this, $key); @@ -126,13 +126,17 @@ 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; } @@ -194,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); } @@ -211,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); } diff --git a/src/Cache/Adapter/NullCacheAdapter.php b/src/Cache/Adapter/NullCacheAdapter.php index 583c857..51deb1e 100644 --- a/src/Cache/Adapter/NullCacheAdapter.php +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -23,6 +23,8 @@ public function count(): int public function deleteItem(string $key): bool { + unset($key); + return true; } @@ -31,6 +33,8 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + unset($keys); + return true; } @@ -41,6 +45,8 @@ public function getItem(string $key): GenericCacheItem public function hasItem(string $key): bool { + unset($key); + return false; } diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 7b23ee1..3c4ac85 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -257,7 +257,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}"); } diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 816c1ed..74edd54 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -25,7 +25,7 @@ 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); } @@ -38,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; } @@ -60,7 +60,7 @@ 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; @@ -86,7 +86,7 @@ public function getItem(string $key): GenericCacheItem return $this->genericMiss($key); } - $row = @require $file; + $row = require $file; $payload = is_array($row) && is_string($row['p'] ?? null) ? $row['p'] : null; @@ -142,13 +142,17 @@ 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; } @@ -178,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}"); } @@ -209,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/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index dcbbd8f..de94af6 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -29,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; @@ -37,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'); } diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index cb4a165..87caf9f 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -28,6 +28,14 @@ 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; @@ -525,35 +533,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); } /** @@ -572,19 +552,7 @@ public function get(string $key, mixed $default = null): mixed */ 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); } /** @@ -604,46 +572,7 @@ public function getItem(string $key): CacheItemInterface */ 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); - - /** @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; + return $this->traitGetItems($keys); } /** @@ -707,24 +636,7 @@ public function has(string $key): bool */ 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); } /** @@ -852,52 +764,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); } /** 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/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index 3490097..d8f95b4 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -28,20 +28,20 @@ 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; } @@ -51,8 +51,8 @@ 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; } @@ -70,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]); @@ -96,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/MemcachedLockProvider.php b/src/Cache/Lock/MemcachedLockProvider.php index d8164f4..f7d4c1f 100644 --- a/src/Cache/Lock/MemcachedLockProvider.php +++ b/src/Cache/Lock/MemcachedLockProvider.php @@ -33,7 +33,7 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle $this->prefix, $key, $waitSeconds, - fn(string $lockKey, string $token, float $unusedWait): bool => $this->memcached->add($lockKey, $token, $ttlSeconds), + fn(string $lockKey, string $token): bool => $this->memcached->add($lockKey, $token, $ttlSeconds), ); } diff --git a/src/Cache/Lock/PollingLockProviderHelpers.php b/src/Cache/Lock/PollingLockProviderHelpers.php index 4864534..51f2f66 100644 --- a/src/Cache/Lock/PollingLockProviderHelpers.php +++ b/src/Cache/Lock/PollingLockProviderHelpers.php @@ -14,7 +14,7 @@ protected static function normalizeRetrySleepMicros(int $retrySleepMicros): int } /** - * @param callable(string,string,float):bool $attemptAcquire + * @param callable(string,string):bool $attemptAcquire */ protected function acquireWithRetry( string $prefix, @@ -30,7 +30,7 @@ protected function acquireWithRetry( } do { - if ($attemptAcquire($lockKey, $token, $waitSeconds)) { + if ($attemptAcquire($lockKey, $token)) { return new LockHandle($lockKey, $token); } diff --git a/src/Cache/Lock/RedisLockProvider.php b/src/Cache/Lock/RedisLockProvider.php index 02bb76b..2f7dfc0 100644 --- a/src/Cache/Lock/RedisLockProvider.php +++ b/src/Cache/Lock/RedisLockProvider.php @@ -31,7 +31,7 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle $this->prefix, $key, $waitSeconds, - fn(string $lockKey, string $token, float $unusedWait): bool => (bool) $this->redis->set($lockKey, $token, ['nx', 'px' => $ttlMs]), + fn(string $lockKey, string $token): bool => (bool) $this->redis->set($lockKey, $token, ['nx', 'px' => $ttlMs]), ); } diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index 215924c..bed544f 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -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; } diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index 9327808..e1a3b9c 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -108,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); @@ -148,6 +152,9 @@ 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'); @@ -155,6 +162,9 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle public function release(?LockHandle $handle): void { + if (!$handle instanceof LockHandle) { + return; + } $this->calls['release']++; } }; diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php index c2b8bdc..7580e3e 100644 --- a/tests/Cache/FileCachePoolTest.php +++ b/tests/Cache/FileCachePoolTest.php @@ -152,10 +152,16 @@ function (array $data): mixed { /* manual clean-up of this secondary dir (afterEach cleans only first dir) */ foreach (glob($namespaceDir.'/*') as $f) { - @unlink($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 () { @@ -190,7 +196,7 @@ function (array $data): mixed { // register handler *capturing* $dirPath ValueSerializer::registerResourceHandler( $resType, - fn ($r) => ['path' => $dirPath], // wrap + fn ($r) => ['path' => is_resource($r) ? $dirPath : $dirPath], // wrap fn (array $data) => opendir($data['path']) // restore ); diff --git a/tests/Cache/MongoDbCachePoolTest.php b/tests/Cache/MongoDbCachePoolTest.php index 1ad428d..c3e231a 100644 --- a/tests/Cache/MongoDbCachePoolTest.php +++ b/tests/Cache/MongoDbCachePoolTest.php @@ -51,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/PdoCachePoolTest.php b/tests/Cache/PdoCachePoolTest.php index a36f288..48b2902 100644 --- a/tests/Cache/PdoCachePoolTest.php +++ b/tests/Cache/PdoCachePoolTest.php @@ -48,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 () { diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index 209056f..75d2b77 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -55,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 ───────────────────────────────────── */ From 50531097515e8d5fe7f153c9b5b7fe40d1f030dc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Sun, 10 May 2026 19:26:53 +0600 Subject: [PATCH 04/10] ic changes applied --- src/Cache/Cache.php | 13 +++---------- src/Cache/Lock/PdoLockProvider.php | 5 +---- 2 files changed, 4 insertions(+), 14 deletions(-) diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 87caf9f..283d133 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -44,10 +44,6 @@ final class Cache implements CacheInterface private const string TAG_VERSION_PREFIX = '__im_tagv_'; - private LockProviderInterface $lockProvider; - - private CacheMetricsCollectorInterface $metrics; - private ?Closure $metricsExportHook = null; /** @@ -57,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. diff --git a/src/Cache/Lock/PdoLockProvider.php b/src/Cache/Lock/PdoLockProvider.php index ddd62fd..ee87f29 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -13,20 +13,17 @@ private string $driver; - private FileLockProvider $fallback; - private int $retrySleepMicros; public function __construct( 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); $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $this->driver = is_string($driver) ? $driver : ''; - $this->fallback = $fallback ?? new FileLockProvider(); } public function acquire(string $key, float $waitSeconds): ?LockHandle From 2e9a483b8c36bff18a5b6e17491c4044b0e4551e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 11 May 2026 11:15:16 +0600 Subject: [PATCH 05/10] updating tests --- .github/workflows/security-standards.yml | 10 +- tests/Cache/DynamoDbCachePoolTest.php | 128 +++++++++++++++++++++++ tests/Cache/MemCachePoolTest.php | 13 ++- tests/Cache/PdoMysqlCachePoolTest.php | 6 +- tests/Cache/PdoPgsqlCachePoolTest.php | 6 +- tests/Cache/RedisCachePoolTest.php | 28 ++++- 6 files changed, 175 insertions(+), 16 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 0a0df0f..763c2ff 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -24,5 +24,13 @@ jobs: psalm_threads: "1" run_analysis: true run_svg_report: true + enable_redis_service: true + enable_memcached_service: true + enable_postgres_service: true + enable_mysql_service: true + enable_dynamodb_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/tests/Cache/DynamoDbCachePoolTest.php b/tests/Cache/DynamoDbCachePoolTest.php index aebb63b..e2c99ef 100644 --- a/tests/Cache/DynamoDbCachePoolTest.php +++ b/tests/Cache/DynamoDbCachePoolTest.php @@ -1,8 +1,107 @@ 'latest', + 'region' => $region, + 'endpoint' => $endpoint, + 'credentials' => [ + 'key' => $key, + 'secret' => $secret, + ], + 'http' => [ + 'connect_timeout' => 1.5, + 'timeout' => 3.0, + ], + ]); + + try { + $client->listTables(['Limit' => 1]); + } catch (Throwable) { + $context = null; + + return null; + } + + try { + $client->describeTable(['TableName' => $table]); + } catch (Throwable) { + try { + $client->createTable([ + 'TableName' => $table, + 'AttributeDefinitions' => [ + [ + 'AttributeName' => 'ckey', + 'AttributeType' => 'S', + ], + ], + 'KeySchema' => [ + [ + 'AttributeName' => 'ckey', + 'KeyType' => 'HASH', + ], + ], + // Works with DynamoDB Local across broader versions than PAY_PER_REQUEST. + 'ProvisionedThroughput' => [ + 'ReadCapacityUnits' => 1, + 'WriteCapacityUnits' => 1, + ], + ]); + } catch (Throwable) { + $context = null; + + return null; + } + } + + for ($attempt = 0; $attempt < 20; $attempt++) { + try { + $status = $client->describeTable(['TableName' => $table])['Table']['TableStatus'] ?? null; + if ($status === 'ACTIVE') { + break; + } + } catch (Throwable) { + // Continue polling until timeout. + } + + usleep(250_000); + } + + $context = [ + 'client' => $client, + 'table' => $table, + 'namespace' => 'ddb-live-tests', + ]; + + return $context; +} + beforeEach(function () { $this->client = new class { @@ -105,3 +204,32 @@ public function scan(array $params): array expect($cache->get('x'))->toBe('X'); }); + +test('dynamodb local integration stores and retrieves values', function () { + $context = dynamodbLocalIntegrationContext(); + + if ($context === null) { + $this->markTestSkipped('DynamoDB Local integration unavailable (sdk or service missing).'); + } + + $cache = Cache::dynamoDb($context['namespace'], $context['table'], $context['client']); + $cache->clear(); + + expect($cache->set('live-key', 'live-value'))->toBeTrue() + ->and($cache->get('live-key'))->toBe('live-value'); +}); + +test('dynamodb local integration clears namespace entries', function () { + $context = dynamodbLocalIntegrationContext(); + + if ($context === null) { + $this->markTestSkipped('DynamoDB Local integration unavailable (sdk or service missing).'); + } + + $cache = Cache::dynamoDb($context['namespace'], $context['table'], $context['client']); + $cache->set('live-a', 1); + $cache->set('live-b', 2); + $cache->clear(); + + expect($cache->count())->toBe(0); +}); diff --git a/tests/Cache/MemCachePoolTest.php b/tests/Cache/MemCachePoolTest.php index cd7ca78..c9921c7 100644 --- a/tests/Cache/MemCachePoolTest.php +++ b/tests/Cache/MemCachePoolTest.php @@ -21,26 +21,29 @@ return; } +$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('127.0.0.1', 11211); +$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 () { +beforeEach(function () use ($memcachedHost, $memcachedPort) { $client = new Memcached; - $client->addServer('127.0.0.1', 11211); + $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 ); diff --git a/tests/Cache/PdoMysqlCachePoolTest.php b/tests/Cache/PdoMysqlCachePoolTest.php index 582d925..d02c60e 100644 --- a/tests/Cache/PdoMysqlCachePoolTest.php +++ b/tests/Cache/PdoMysqlCachePoolTest.php @@ -8,9 +8,9 @@ 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 = ''; } diff --git a/tests/Cache/PdoPgsqlCachePoolTest.php b/tests/Cache/PdoPgsqlCachePoolTest.php index 77bbe6c..7b94359 100644 --- a/tests/Cache/PdoPgsqlCachePoolTest.php +++ b/tests/Cache/PdoPgsqlCachePoolTest.php @@ -8,9 +8,9 @@ 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); diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index be06bec..7a60364 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -20,9 +20,26 @@ 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->connect($redisHost, $redisPort, 0.5); + if ($redisPassword !== '') { + $probe->auth($redisPassword); + } $probe->ping(); } catch (Throwable) { test('Redis server unreachable – skipping')->skip(); @@ -31,15 +48,18 @@ } /* ── bootstrap / teardown ────────────────────────────────────────── */ -beforeEach(function () { +beforeEach(function () use ($redisHost, $redisPort, $redisPassword) { $client = new Redis; - $client->connect('127.0.0.1', 6379); + $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 ); From 9b355e3255ba3d8ef4971b87432ccbd7f8e3a2cc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 11 May 2026 12:03:48 +0600 Subject: [PATCH 06/10] tests update --- .github/workflows/security-standards.yml | 3 ++- composer.json | 5 ++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 763c2ff..a21f49d 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -18,12 +18,13 @@ jobs: with: php_versions: '["8.4","8.5"]' dependency_versions: '["prefer-lowest","prefer-stable"]' - php_extensions: "apcu, mbstring, memcached, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" + 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_memcached_service: true enable_postgres_service: true diff --git a/composer.json b/composer.json index 843e5a5..c609005 100644 --- a/composer.json +++ b/composer.json @@ -35,12 +35,15 @@ "psr/simple-cache": "^3.0" }, "require-dev": { - "infocyph/phpforge": "dev-main" + "aws/aws-sdk-php": "^3.0", + "infocyph/phpforge": "dev-main", + "mongodb/mongodb": "^1.20 || ^2.0" }, "suggest": { "ext-apcu": "For APCu-based caching (in-memory, per-process)", "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_mysql": "For MySQL/MariaDB usage via Cache::pdo(...)", "ext-pdo_pgsql": "For PostgreSQL usage via Cache::pdo(...)", From 114b3fd9667cdf28cf4e7124c93dbab526b0dbcc Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 11 May 2026 12:10:41 +0600 Subject: [PATCH 07/10] tests update --- src/Cache/Adapter/DynamoDbCacheAdapter.php | 29 ++++++++++++++++------ 1 file changed, 22 insertions(+), 7 deletions(-) diff --git a/src/Cache/Adapter/DynamoDbCacheAdapter.php b/src/Cache/Adapter/DynamoDbCacheAdapter.php index cf1f4d6..fa4e120 100644 --- a/src/Cache/Adapter/DynamoDbCacheAdapter.php +++ b/src/Cache/Adapter/DynamoDbCacheAdapter.php @@ -20,7 +20,7 @@ public function __construct( $this->ns = sanitize_cache_ns($namespace); foreach (['getItem', 'putItem', 'deleteItem', 'scan', 'batchWriteItem'] as $method) { - if (!method_exists($this->client, $method)) { + if (!$this->supportsClientMethod($method)) { throw new RuntimeException( sprintf('DynamoDbCacheAdapter requires client method `%s()`.', $method), ); @@ -40,7 +40,7 @@ public function clear(): bool public function count(): int { - $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->client->scan([ + $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->call('scan', [ 'TableName' => $this->table, 'FilterExpression' => '#ns = :ns AND (attribute_not_exists(#exp) OR #exp > :now)', 'Select' => 'COUNT', @@ -61,7 +61,7 @@ public function count(): int public function deleteItem(string $key): bool { - $this->client->deleteItem([ + $this->call('deleteItem', [ 'TableName' => $this->table, 'Key' => ['ckey' => ['S' => $this->map($key)]], ]); @@ -83,7 +83,7 @@ public function deleteItems(array $keys): bool public function getItem(string $key): GenericCacheItem { - $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->client->getItem([ + $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->call('getItem', [ 'TableName' => $this->table, 'Key' => ['ckey' => ['S' => $this->map($key)]], 'ConsistentRead' => true, @@ -126,7 +126,7 @@ public function save(CacheItemInterface $item): bool $itemMap['expires'] = ['N' => (string) $expires['expiresAt']]; } - $this->client->putItem([ + $this->call('putItem', [ 'TableName' => $this->table, 'Item' => $itemMap, ]); @@ -158,6 +158,16 @@ private function appendScanResultKeys(array &$keys, array $result): void } } + /** + * @param array $params + */ + private function call(string $method, array $params): mixed + { + $callable = [$this->client, $method]; + + return is_callable($callable) ? $callable($params) : null; + } + /** * @param list $keys */ @@ -168,7 +178,7 @@ private function deleteBatches(array $keys): void fn(string $key): array => ['DeleteRequest' => ['Key' => ['ckey' => ['S' => $key]]]], $batch, ); - $this->client->batchWriteItem(['RequestItems' => [$this->table => $requests]]); + $this->call('batchWriteItem', ['RequestItems' => [$this->table => $requests]]); } } @@ -201,7 +211,7 @@ private function scanAllKeysByNamespace(): array do { $result = AdapterValueNormalizer::fromArrayLikeOrToArray( - $this->client->scan($this->scanParams($lastKey)), + $this->call('scan', $this->scanParams($lastKey)), ) ?? []; $this->appendScanResultKeys($keys, $result); $lastKey = $this->extractLastEvaluatedKey($result); @@ -235,4 +245,9 @@ private function scanParams(?array $lastKey): array return $params; } + + private function supportsClientMethod(string $method): bool + { + return method_exists($this->client, $method) || is_callable([$this->client, $method]); + } } From 7c34da6a78772ae4a86829f26ca090d38e82fb1e Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 11 May 2026 13:52:40 +0600 Subject: [PATCH 08/10] adapter update --- .github/workflows/build.yml | 191 ------------ .github/workflows/security-standards.yml | 3 +- README.md | 4 +- composer.json | 6 +- docs/adapters/dynamodb.rst | 43 --- docs/adapters/index.rst | 7 +- docs/adapters/scylladb.rst | 40 +++ docs/adapters/valkey.rst | 40 +++ docs/cache.rst | 5 +- src/Cache/Adapter/DynamoDbCacheAdapter.php | 253 ---------------- src/Cache/Adapter/MemCacheAdapter.php | 17 +- src/Cache/Adapter/PdoCacheAdapter.php | 26 +- src/Cache/Adapter/RedisCacheAdapter.php | 15 +- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 325 +++++++++++++++++++++ src/Cache/Adapter/ValkeyCacheAdapter.php | 16 + src/Cache/Cache.php | 73 +++-- src/Cache/CacheInterface.php | 2 + src/Cache/Lock/MemcachedLockProvider.php | 7 +- src/Cache/Lock/PdoLockProvider.php | 5 +- src/Cache/Lock/RedisLockProvider.php | 5 +- tests/Cache/CacheFeaturesTest.php | 2 +- tests/Cache/DynamoDbCachePoolTest.php | 235 --------------- tests/Cache/ScyllaDbCachePoolTest.php | 211 +++++++++++++ tests/Cache/ValkeyCachePoolTest.php | 66 +++++ 24 files changed, 796 insertions(+), 801 deletions(-) delete mode 100644 .github/workflows/build.yml delete mode 100644 docs/adapters/dynamodb.rst create mode 100644 docs/adapters/scylladb.rst create mode 100644 docs/adapters/valkey.rst delete mode 100644 src/Cache/Adapter/DynamoDbCacheAdapter.php create mode 100644 src/Cache/Adapter/ScyllaDbCacheAdapter.php create mode 100644 src/Cache/Adapter/ValkeyCacheAdapter.php delete mode 100644 tests/Cache/DynamoDbCachePoolTest.php create mode 100644 tests/Cache/ScyllaDbCachePoolTest.php create mode 100644 tests/Cache/ValkeyCachePoolTest.php diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index 7bfc7cd..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,191 +0,0 @@ -name: "Security & Standards (v1)" - -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 index a21f49d..61b240c 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -26,10 +26,11 @@ jobs: 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_dynamodb_service: true + enable_scylladb_service: true enable_mongodb_service: true service_db_name: "cachelayer" service_db_user: "cachelayer" diff --git a/README.md b/README.md index 9d3632d..9b9d1f7 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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, and DynamoDB +- Adapter support for APCu, File, PHP Files, Memcached, Redis, Valkey, Redis Cluster, PDO (SQLite default), Shared Memory, MongoDB, and ScyllaDB - Tagged invalidation with versioned tags: `setTagged()`, `invalidateTag()`, `invalidateTags()` - Stampede-safe `remember()` with pluggable lock providers - Per-adapter metrics counters and export hooks @@ -41,7 +41,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 diff --git a/composer.json b/composer.json index c609005..b3eaea4 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,8 @@ "mariadb", "postgres", "mongodb", - "dynamodb", + "scylladb", + "valkey", "weakmap", "chain-cache" ], @@ -35,12 +36,12 @@ "psr/simple-cache": "^3.0" }, "require-dev": { - "aws/aws-sdk-php": "^3.0", "infocyph/phpforge": "dev-main", "mongodb/mongodb": "^1.20 || ^2.0" }, "suggest": { "ext-apcu": "For APCu-based caching (in-memory, per-process)", + "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", @@ -50,7 +51,6 @@ "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", - "aws/aws-sdk-php": "For DynamoDB adapter", "mongodb/mongodb": "For MongoDB caching via MongoDbCacheAdapter" }, "minimum-stability": "stable", 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 326d0ae..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``) 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,10 +26,11 @@ Choosing quickly: apcu memcached redis + valkey redis-cluster sqlite pdo shared-memory mongodb - dynamodb + scylladb serialization 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 b12c424..3318489 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')`` @@ -61,7 +62,7 @@ The facade exposes factory methods for all bundled adapters: * ``Cache::nullStore()`` * ``Cache::chain(array $pools)`` * ``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::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. @@ -172,11 +173,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/src/Cache/Adapter/DynamoDbCacheAdapter.php b/src/Cache/Adapter/DynamoDbCacheAdapter.php deleted file mode 100644 index fa4e120..0000000 --- a/src/Cache/Adapter/DynamoDbCacheAdapter.php +++ /dev/null @@ -1,253 +0,0 @@ -ns = sanitize_cache_ns($namespace); - - foreach (['getItem', 'putItem', 'deleteItem', 'scan', 'batchWriteItem'] as $method) { - if (!$this->supportsClientMethod($method)) { - throw new RuntimeException( - sprintf('DynamoDbCacheAdapter requires client method `%s()`.', $method), - ); - } - } - } - - public function clear(): bool - { - $keys = $this->scanAllKeysByNamespace(); - $this->deleteBatches($keys); - - $this->deferred = []; - - return true; - } - - public function count(): int - { - $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->call('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()], - ], - ])); - - $count = $result['Count'] ?? 0; - - return is_numeric($count) ? max(0, (int) $count) : 0; - } - - public function deleteItem(string $key): bool - { - $this->call('deleteItem', [ - 'TableName' => $this->table, - 'Key' => ['ckey' => ['S' => $this->map($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 - { - $result = AdapterValueNormalizer::fromArrayLikeOrToArray($this->call('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); - } - - $payloadAttr = is_array($row['payload'] ?? null) ? $row['payload'] : null; - $payload = is_array($payloadAttr) ? ($payloadAttr['S'] ?? null) : null; - - return $this->genericFromBase64($key, is_string($payload) ? $payload : null); - } - - 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 { - $itemMap = [ - 'ckey' => ['S' => $this->map($saveItem->getKey())], - 'ns' => ['S' => $this->ns], - 'payload' => ['S' => base64_encode(CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']))], - ]; - if ($expires['expiresAt'] !== null) { - $itemMap['expires'] = ['N' => (string) $expires['expiresAt']]; - } - - $this->call('putItem', [ - 'TableName' => $this->table, - 'Item' => $itemMap, - ]); - - return true; - }); - } - - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof GenericCacheItem; - } - - /** - * @param list $keys - * @param array $result - */ - private function appendScanResultKeys(array &$keys, array $result): void - { - $items = is_array($result['Items'] ?? null) ? $result['Items'] : []; - foreach ($items as $item) { - if (!is_array($item)) { - continue; - } - - if (is_array($item['ckey'] ?? null) && is_string($item['ckey']['S'] ?? null)) { - $keys[] = $item['ckey']['S']; - } - } - } - - /** - * @param array $params - */ - private function call(string $method, array $params): mixed - { - $callable = [$this->client, $method]; - - return is_callable($callable) ? $callable($params) : null; - } - - /** - * @param list $keys - */ - private function deleteBatches(array $keys): void - { - foreach (array_chunk($keys, 25) as $batch) { - $requests = array_map( - fn(string $key): array => ['DeleteRequest' => ['Key' => ['ckey' => ['S' => $key]]]], - $batch, - ); - $this->call('batchWriteItem', ['RequestItems' => [$this->table => $requests]]); - } - } - - /** - * @param array $result - * @return array|null - */ - private function extractLastEvaluatedKey(array $result): ?array - { - $lastEvaluatedKey = $result['LastEvaluatedKey'] ?? null; - if (!is_array($lastEvaluatedKey)) { - return null; - } - - return AdapterValueNormalizer::normalizeAssoc($lastEvaluatedKey); - } - - private function map(string $key): string - { - return $this->ns . ':' . $key; - } - - /** - * @return list - */ - private function scanAllKeysByNamespace(): array - { - $keys = []; - $lastKey = null; - - do { - $result = AdapterValueNormalizer::fromArrayLikeOrToArray( - $this->call('scan', $this->scanParams($lastKey)), - ) ?? []; - $this->appendScanResultKeys($keys, $result); - $lastKey = $this->extractLastEvaluatedKey($result); - } while ($lastKey !== null); - - return $keys; - } - - /** - * @param array|null $lastKey - * @return array - */ - private function scanParams(?array $lastKey): array - { - $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; - } - - return $params; - } - - private function supportsClientMethod(string $method): bool - { - return method_exists($this->client, $method) || is_callable([$this->client, $method]); - } -} diff --git a/src/Cache/Adapter/MemCacheAdapter.php b/src/Cache/Adapter/MemCacheAdapter.php index 6e44cc4..6750178 100644 --- a/src/Cache/Adapter/MemCacheAdapter.php +++ b/src/Cache/Adapter/MemCacheAdapter.php @@ -6,13 +6,12 @@ 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; @@ -25,14 +24,14 @@ class MemCacheAdapter extends AbstractCacheAdapter 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); } @@ -72,7 +71,7 @@ public function deleteItems(array $keys): bool return true; } - public function getClient(): Memcached + public function getClient(): \Memcached { return $this->mc; } @@ -81,7 +80,7 @@ public function getItem(string $key): MemCacheItem { $mappedKey = $this->map($key); $raw = $this->mc->get($mappedKey); - if ($this->mc->getResultCode() === Memcached::RES_SUCCESS && is_string($raw)) { + if ($this->mc->getResultCode() === \Memcached::RES_SUCCESS && is_string($raw)) { $item = $this->hitItemFromBlob($key, $raw); if ($item instanceof MemCacheItem) { return $item; @@ -98,7 +97,7 @@ public function hasItem(string $key): bool { $this->mc->get($this->map($key)); - return $this->mc->getResultCode() === Memcached::RES_SUCCESS; + return $this->mc->getResultCode() === \Memcached::RES_SUCCESS; } /** @@ -112,7 +111,7 @@ 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 = []; } diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 3c4ac85..ea5b40a 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -5,8 +5,6 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; -use PDO; -use PDOException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -18,7 +16,7 @@ final class PdoCacheAdapter extends AbstractCacheAdapter private readonly string $ns; - private readonly PDO $pdo; + private readonly \PDO $pdo; private readonly string $table; @@ -27,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)) { @@ -48,11 +46,11 @@ public function __construct( throw new RuntimeException('Unable to resolve PDO DSN.'); } - $this->pdo = new PDO($resolvedDsn, $username, $password); + $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->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); $this->driver = is_string($driver) ? $driver : ''; $this->configureDriverDefaults(); @@ -120,7 +118,7 @@ public function deleteItems(array $keys): bool return $stmt->execute($mapped); } - public function getClient(): PDO + public function getClient(): \PDO { return $this->pdo; } @@ -131,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); @@ -279,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. } } @@ -296,11 +294,11 @@ private function createExpiresIndexIfMissing(): void } $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. } } @@ -354,7 +352,7 @@ private function fetchRowsByMappedKeys(array $mappedKeys): array $stmt->execute($mappedKeys); $rows = []; - foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $row) { + foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { if (!is_array($row)) { continue; } @@ -453,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/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index fa74076..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; /** @@ -23,23 +22,23 @@ 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'); } @@ -91,7 +90,7 @@ public function deleteItems(array $keys): bool return $this->redis->del($full) !== false; } - public function getClient(): Redis + public function getClient(): \Redis { return $this->redis; } @@ -199,9 +198,9 @@ 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"); 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/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 @@ + $config - */ - public static function dynamoDb( - string $namespace = 'default', - string $table = 'cachelayer_entries', - ?object $client = null, - array $config = [], - ): self { - if ($client === null) { - if (!class_exists(DynamoDbClient::class)) { - throw new CacheInvalidArgumentException( - 'aws/aws-sdk-php is required unless a DynamoDB client is provided.', - ); - } - - $client = new DynamoDbClient($config + [ - 'version' => 'latest', - 'region' => 'us-east-1', - ]); - } - - return new self(new Adapter\DynamoDbCacheAdapter($client, $table, $namespace)); - } - /** * Static factory for file-based cache. * @@ -315,6 +289,26 @@ public static function redisCluster( ); } + public static function scyllaDb( + string $namespace = 'default', + ?object $session = null, + string $keyspace = 'cachelayer', + string $table = 'cachelayer_entries', + ): self { + if ($session === null) { + if (!class_exists(\Cassandra::class)) { + throw new CacheInvalidArgumentException( + 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', + ); + } + + /** @var object $session */ + $session = \Cassandra::cluster()->build()->connect($keyspace); + } + + return new self(new Adapter\ScyllaDbCacheAdapter($session, $keyspace, $table, $namespace)); + } + public static function sharedMemory(string $namespace = 'default', int $segmentSize = 16_777_216): self { return new self(new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize)); @@ -336,6 +330,25 @@ public static function sqlite(string $namespace = 'default', ?string $file = nul ); } + /** + * 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)); @@ -961,6 +974,11 @@ 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 (!$item instanceof AbstractCacheItem) { @@ -1103,7 +1121,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('/(?retrySleepMicros = self::normalizeRetrySleepMicros($retrySleepMicros); @@ -41,7 +40,7 @@ public function release(?LockHandle $handle): void { $this->releaseWithGuard($handle, function (LockHandle $lock): void { $current = $this->memcached->get($lock->key); - if ($this->memcached->getResultCode() === Memcached::RES_SUCCESS && $current === $lock->token) { + if ($this->memcached->getResultCode() === \Memcached::RES_SUCCESS && $current === $lock->token) { $this->memcached->delete($lock->key); } }); diff --git a/src/Cache/Lock/PdoLockProvider.php b/src/Cache/Lock/PdoLockProvider.php index ee87f29..ae5f104 100644 --- a/src/Cache/Lock/PdoLockProvider.php +++ b/src/Cache/Lock/PdoLockProvider.php @@ -4,7 +4,6 @@ namespace Infocyph\CacheLayer\Cache\Lock; -use PDO; use Throwable; final readonly class PdoLockProvider implements LockProviderInterface @@ -16,13 +15,13 @@ private int $retrySleepMicros; public function __construct( - private PDO $pdo, + private \PDO $pdo, private string $prefix = 'cachelayer:lock:', int $retrySleepMicros = 50_000, private FileLockProvider $fallback = new FileLockProvider(), ) { $this->retrySleepMicros = max(1_000, $retrySleepMicros); - $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); $this->driver = is_string($driver) ? $driver : ''; } diff --git a/src/Cache/Lock/RedisLockProvider.php b/src/Cache/Lock/RedisLockProvider.php index 2f7dfc0..27c4be5 100644 --- a/src/Cache/Lock/RedisLockProvider.php +++ b/src/Cache/Lock/RedisLockProvider.php @@ -4,7 +4,6 @@ namespace Infocyph\CacheLayer\Cache\Lock; -use Redis; use RuntimeException; final readonly class RedisLockProvider implements LockProviderInterface @@ -15,7 +14,7 @@ private int $retrySleepMicros; public function __construct( - private Redis $redis, + private \Redis $redis, private string $prefix = 'cachelayer:lock:', int $retrySleepMicros = 50_000, ) { @@ -54,7 +53,7 @@ function (LockHandle $lock) use ($script): void { private function assertRedisExtensionLoaded(): void { - if (!class_exists(Redis::class)) { + if (!class_exists(\Redis::class)) { throw new RuntimeException('phpredis extension not loaded'); } } diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index e1a3b9c..10efc68 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -162,7 +162,7 @@ public function acquire(string $key, float $waitSeconds): ?LockHandle public function release(?LockHandle $handle): void { - if (!$handle instanceof LockHandle) { + if (! $handle instanceof LockHandle) { return; } $this->calls['release']++; diff --git a/tests/Cache/DynamoDbCachePoolTest.php b/tests/Cache/DynamoDbCachePoolTest.php deleted file mode 100644 index e2c99ef..0000000 --- a/tests/Cache/DynamoDbCachePoolTest.php +++ /dev/null @@ -1,235 +0,0 @@ - 'latest', - 'region' => $region, - 'endpoint' => $endpoint, - 'credentials' => [ - 'key' => $key, - 'secret' => $secret, - ], - 'http' => [ - 'connect_timeout' => 1.5, - 'timeout' => 3.0, - ], - ]); - - try { - $client->listTables(['Limit' => 1]); - } catch (Throwable) { - $context = null; - - return null; - } - - try { - $client->describeTable(['TableName' => $table]); - } catch (Throwable) { - try { - $client->createTable([ - 'TableName' => $table, - 'AttributeDefinitions' => [ - [ - 'AttributeName' => 'ckey', - 'AttributeType' => 'S', - ], - ], - 'KeySchema' => [ - [ - 'AttributeName' => 'ckey', - 'KeyType' => 'HASH', - ], - ], - // Works with DynamoDB Local across broader versions than PAY_PER_REQUEST. - 'ProvisionedThroughput' => [ - 'ReadCapacityUnits' => 1, - 'WriteCapacityUnits' => 1, - ], - ]); - } catch (Throwable) { - $context = null; - - return null; - } - } - - for ($attempt = 0; $attempt < 20; $attempt++) { - try { - $status = $client->describeTable(['TableName' => $table])['Table']['TableStatus'] ?? null; - if ($status === 'ACTIVE') { - break; - } - } catch (Throwable) { - // Continue polling until timeout. - } - - usleep(250_000); - } - - $context = [ - 'client' => $client, - 'table' => $table, - 'namespace' => 'ddb-live-tests', - ]; - - return $context; -} - -beforeEach(function () { - $this->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'); -}); - -test('dynamodb local integration stores and retrieves values', function () { - $context = dynamodbLocalIntegrationContext(); - - if ($context === null) { - $this->markTestSkipped('DynamoDB Local integration unavailable (sdk or service missing).'); - } - - $cache = Cache::dynamoDb($context['namespace'], $context['table'], $context['client']); - $cache->clear(); - - expect($cache->set('live-key', 'live-value'))->toBeTrue() - ->and($cache->get('live-key'))->toBe('live-value'); -}); - -test('dynamodb local integration clears namespace entries', function () { - $context = dynamodbLocalIntegrationContext(); - - if ($context === null) { - $this->markTestSkipped('DynamoDB Local integration unavailable (sdk or service missing).'); - } - - $cache = Cache::dynamoDb($context['namespace'], $context['table'], $context['client']); - $cache->set('live-a', 1); - $cache->set('live-b', 2); - $cache->clear(); - - expect($cache->count())->toBe(0); -}); 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/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); +}); From febd2f80f0669aa6a0d7fe3f07bc7dd38752eb24 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 11 May 2026 15:28:18 +0600 Subject: [PATCH 09/10] chain cache --- README.md | 30 +- docs/cache.rst | 34 ++ docs/metrics-and-locking.rst | 4 +- src/Cache/Adapter/ChainCacheAdapter.php | 11 +- src/Cache/Cache.php | 13 + src/Cache/Tiering/TieredPoolFactory.php | 421 ++++++++++++++++++++++++ tests/Cache/ChainCachePoolTest.php | 30 ++ 7 files changed, 538 insertions(+), 5 deletions(-) create mode 100644 src/Cache/Tiering/TieredPoolFactory.php diff --git a/README.md b/README.md index 9b9d1f7..22d7421 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) @@ -21,6 +21,7 @@ visibility, maintenance focus, and faster feature enrichment for caching. - Unified `Cache` facade implementing PSR-6, PSR-16, `ArrayAccess`, and `Countable` - 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 @@ -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: diff --git a/docs/cache.rst b/docs/cache.rst index 3318489..a0cc14d 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -61,6 +61,7 @@ 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::scyllaDb(string $namespace = 'default', ?object $session = null, string $keyspace = 'cachelayer', string $table = 'cachelayer_entries')`` @@ -69,6 +70,39 @@ The facade exposes factory methods for all bundled adapters: ``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 ----------------- 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/src/Cache/Adapter/ChainCacheAdapter.php b/src/Cache/Adapter/ChainCacheAdapter.php index d9cf70f..47c081f 100644 --- a/src/Cache/Adapter/ChainCacheAdapter.php +++ b/src/Cache/Adapter/ChainCacheAdapter.php @@ -15,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.'); } @@ -110,7 +112,10 @@ public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { $ok = true; - foreach ($this->pools as $pool) { + $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']); diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 588e80a..bb1632b 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -17,6 +17,7 @@ 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; @@ -330,6 +331,18 @@ 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. * 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/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); +}); From 9eb45985e360f7641163819c40ebf38e126b83f3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 11 May 2026 16:30:50 +0600 Subject: [PATCH 10/10] updated docs --- CODE_OF_CONDUCT.md | 65 ++++++++++++++++++++++++++++++++++++++++++++++ README.md | 1 + SECURITY.md | 23 ++++++++++++++++ composer.json | 4 +++ 4 files changed, 93 insertions(+) create mode 100644 CODE_OF_CONDUCT.md 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 22d7421..037bc08 100644 --- a/README.md +++ b/README.md @@ -142,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/composer.json b/composer.json index b3eaea4..d4e8c61 100644 --- a/composer.json +++ b/composer.json @@ -24,6 +24,10 @@ "chain-cache" ], "authors": [ + { + "name": "Infocyph", + "email": "infocyph@gmail.com" + }, { "name": "abmmhasan", "email": "abmmhasan@gmail.com"