diff --git a/.github/ISSUE_TEMPLATE/regression_report.yml b/.github/ISSUE_TEMPLATE/regression_report.yml index 36392bca..b7ef6027 100644 --- a/.github/ISSUE_TEMPLATE/regression_report.yml +++ b/.github/ISSUE_TEMPLATE/regression_report.yml @@ -46,6 +46,6 @@ body: attributes: label: Environment details description: PHP version, Composer version, OS, CI provider (if relevant). - placeholder: PHP 8.3, Composer 2.9, Ubuntu 24.04... + placeholder: PHP 8.4, Composer 2.10, Ubuntu 24.04... validations: required: true diff --git a/.github/workflows/release-verification.yml b/.github/workflows/release-verification.yml new file mode 100644 index 00000000..a311bcc5 --- /dev/null +++ b/.github/workflows/release-verification.yml @@ -0,0 +1,256 @@ +name: "Release Verification" + +on: + pull_request: + branches: [ "main", "master", "develop", "development" ] + push: + branches: [ "main", "master" ] + workflow_dispatch: + +permissions: + contents: read + +jobs: + core-platform: + name: Core smoke - PHP ${{ matrix.php }} - ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + php: [ "8.4", "8.5" ] + os: [ ubuntu-24.04, windows-latest ] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + extensions: mbstring, mongodb, pdo, pdo_sqlite, opcache + coverage: none + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative --ignore-platform-req=ext-mongodb + - run: composer check-platform-reqs --no-dev + - name: Core smoke without CLI OPcache + run: php -d error_reporting=E_ALL -d opcache.enable_cli=0 tools/release/core-smoke.php + - name: Core smoke with CLI OPcache + run: php -d error_reporting=E_ALL -d opcache.enable_cli=1 tools/release/core-smoke.php + + clean-consumer: + name: Clean no-dev consumer - PHP ${{ matrix.php }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: [ "8.4", "8.5" ] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: none + - name: Resolve candidate in fresh consumer + working-directory: tools/release/consumer + run: composer update --no-dev --no-interaction --prefer-dist --no-progress + - name: Reinstall locked candidate as production-only + working-directory: tools/release/consumer + shell: bash + run: rm -rf vendor && composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction --no-progress + - name: Smoke production consumer + working-directory: tools/release/consumer + run: php -d error_reporting=E_ALL smoke.php + + runwire-consumer: + name: Runwire 2.1 consumer - PHP ${{ matrix.php }} - ${{ matrix.dependencies }} + runs-on: ubuntu-24.04 + strategy: + fail-fast: false + matrix: + php: [ "8.4", "8.5" ] + dependencies: [ lowest, stable ] + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + extensions: pdo, pdo_sqlite + coverage: none + - name: Resolve CacheLayer with Runwire 2.1 + working-directory: tools/release/runwire-consumer + shell: bash + run: | + set -euo pipefail + if [ "${{ matrix.dependencies }}" = "lowest" ]; then + composer update --prefer-lowest --prefer-stable --no-interaction --prefer-dist --no-progress + else + composer update --prefer-stable --no-interaction --prefer-dist --no-progress + fi + composer check-platform-reqs + - name: Run Runwire integration smoke + working-directory: tools/release/runwire-consumer + run: php -d error_reporting=E_ALL smoke.php + - name: Run matched Runwire certification workload + working-directory: tools/release/runwire-consumer + run: php -d error_reporting=E_ALL certify.php + - name: Run persistent-worker Runwire soak + working-directory: tools/release/runwire-consumer + run: php -d error_reporting=E_ALL soak.php + + psr-contracts: + name: Independent PSR-6 / PSR-16 contracts + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + coverage: none + - name: Install independent consumer + working-directory: tools/release/psr-consumer + run: composer update --no-interaction --prefer-dist --no-progress --prefer-stable + - name: Materialize independent integration fixtures + working-directory: tools/release/psr-consumer + shell: bash + run: | + mkdir -p tests + cat > tests/Psr6MemoryIntegrationTest.php <<'PHP' + 'CacheLayer intentionally supports 64-character logical keys, satisfying the PSR minimum.', + ]; + + public function createCachePool(): CacheItemPoolInterface + { + return Cache::sqlite( + 'psr6', + sys_get_temp_dir() . '/cachelayer-psr6-' . getmypid() . '.sqlite', + new CacheOptions(allowObjects: true), + ); + } + } + PHP + + cat > tests/Psr16MemoryIntegrationTest.php <<'PHP' + 'CacheLayer intentionally supports 64-character logical keys, satisfying the PSR minimum.', + ]; + + public function createSimpleCache(): CacheInterface + { + return Cache::sqlite( + 'psr16', + sys_get_temp_dir() . '/cachelayer-psr16-' . getmypid() . '.sqlite', + new CacheOptions(allowObjects: true), + ); + } + } + PHP + - name: Run upstream integration suites + working-directory: tools/release/psr-consumer + run: vendor/bin/phpunit tests + + docs: + name: Documentation warnings as errors + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v6 + with: + python-version: "3.13" + - run: python -m pip install --disable-pip-version-check -r docs/requirements.txt + - run: python -m sphinx -W --keep-going -b html docs docs/_build/html + + redis-cluster: + name: Real Redis Cluster + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + extensions: redis + coverage: none + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - name: Start three-node Redis Cluster + shell: bash + run: | + set -euo pipefail + for port in 7001 7002 7003; do + docker run -d --name "cachelayer-redis-$port" --network host redis:8.10-alpine redis-server --port "$port" --cluster-enabled yes --cluster-config-file "nodes-$port.conf" --cluster-node-timeout 5000 --cluster-announce-ip 127.0.0.1 --cluster-announce-port "$port" --cluster-announce-bus-port "$((port + 10000))" --appendonly no --protected-mode no + done + for attempt in {1..60}; do + if docker exec cachelayer-redis-7001 redis-cli -p 7001 ping | grep -q PONG; then break; fi + sleep 1 + done + docker exec cachelayer-redis-7001 redis-cli --cluster create 127.0.0.1:7001 127.0.0.1:7002 127.0.0.1:7003 --cluster-replicas 0 --cluster-yes + for attempt in {1..60}; do + if docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | tr -d '\r' | grep -q "cluster_state:ok"; then + break + fi + sleep 1 + done + docker exec cachelayer-redis-7001 redis-cli -c -p 7001 cluster info | tr -d '\r' | grep -q "cluster_state:ok" + - name: Exercise CacheLayer against real Redis Cluster + env: + CACHELAYER_REDIS_CLUSTER_SEEDS: "127.0.0.1:7001,127.0.0.1:7002,127.0.0.1:7003" + run: php -d error_reporting=E_ALL tools/release/redis-cluster-smoke.php + - if: always() + shell: bash + run: docker rm -f cachelayer-redis-7001 cachelayer-redis-7002 cachelayer-redis-7003 >/dev/null 2>&1 || true + + scylla-cql: + name: Real Scylla CQL + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v7 + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.4" + tools: composer:v2 + coverage: none + - run: composer install --no-dev --no-interaction --prefer-dist --no-progress --classmap-authoritative + - name: Start ScyllaDB CQL service + shell: bash + run: | + set -euo pipefail + docker run -d --name cachelayer-scylla -p 9042:9042 scylladb/scylla:2026.2 --reactor-backend epoll --smp 1 --memory 1G --overprovisioned 1 --developer-mode=1 + for attempt in {1..120}; do + if docker exec cachelayer-scylla cqlsh -e "SELECT release_version FROM system.local;" >/dev/null 2>&1; then break; fi + sleep 2 + done + docker exec cachelayer-scylla cqlsh -e "CREATE KEYSPACE IF NOT EXISTS cachelayer WITH replication = {'class':'NetworkTopologyStrategy','datacenter1':1};" + - name: Install pinned Scylla PHP CQL driver + shell: bash + run: | + set -euo pipefail + sudo apt-get update + sudo apt-get install -y libuv1 libgmp10 + mkdir -p .ci/scylla-driver + curl -fsSL https://github.com/he4rt/scylladb-php-driver/releases/download/v1.5.1/manylinux_2_28_x86_64-php8.4-nts-cassandra.tar.gz -o .ci/scylla-driver/driver.tar.gz + tar -xzf .ci/scylla-driver/driver.tar.gz -C .ci/scylla-driver + php -d "extension=$PWD/.ci/scylla-driver/cassandra.so" -r 'if (!class_exists("Cassandra")) { throw new RuntimeException("Cassandra extension did not load."); }' + - name: Exercise CacheLayer against real CQL + run: php -d error_reporting=E_ALL -d "extension=$PWD/.ci/scylla-driver/cassandra.so" tools/release/scylla-cql-smoke.php + - if: always() + run: docker rm -f cachelayer-scylla >/dev/null 2>&1 || true diff --git a/.github/workflows/security-standards.yml b/.github/workflows/security-standards.yml index 9377f9a8..f309ced9 100644 --- a/.github/workflows/security-standards.yml +++ b/.github/workflows/security-standards.yml @@ -5,18 +5,28 @@ on: - cron: "0 0 * * 0" push: branches: [ "main", "master" ] + tags: [ "v*", "[0-9]*" ] pull_request: branches: [ "main", "master", "develop", "development" ] jobs: phpforge: + if: github.event_name != 'push' || !startsWith(github.ref, 'refs/tags/') uses: infocyph/phpforge/.github/workflows/security-standards.yml@main permissions: security-events: write actions: read contents: read with: - php_extensions: "apcu, mbstring, memcached, mongodb, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" + php_extensions: "apcu, mbstring, memcached, mongodb, pcntl, pdo, pdo_mysql, pdo_pgsql, pdo_sqlite, redis, sysvshm" fail_on_skipped_tests: true - integration_services: '["mysql","postgres","sqlite","redis","valkey","memcached","scylladb"]' - service_topologies: '{}' + integration_services: '["mysql","mariadb","postgres","sqlite","mongodb","redis","valkey","memcached","scylladb"]' + service_topologies: '{"mongodb":"replica-set"}' + + release: + if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') + uses: infocyph/phpforge/.github/workflows/release.yml@main + permissions: + contents: write + secrets: + COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }} diff --git a/README.md b/README.md index 0b12b080..4d880e94 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ ![Packagist Version](https://img.shields.io/packagist/v/infocyph/CacheLayer) ![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/CacheLayer/php) -CacheLayer is a PHP 8.3+ caching toolkit built around four deliberately separate concerns: +CacheLayer is a PHP 8.4+ caching toolkit built around four deliberately separate concerns: ```text CacheLayer @@ -34,6 +34,8 @@ composer require infocyph/cachelayer Choose extensions and client packages only for the backends you use: APCu, Redis/Valkey, Memcached, PDO, SysV shared memory, MongoDB, or Cassandra/ScyllaDB. +For 3.x upgrades, read `docs/upgrade-4.0.rst` before deployment. 4.0 raises the minimum runtime to PHP 8.4 and intentionally changes serialization, storage identity, cursor, counter, and cache-contract behavior. + ## Cache ```php @@ -204,7 +206,7 @@ function createCache(string $integrityKey): Cache } ``` -Records use only the CacheLayer v2 markers `cl2:`, `cl2-gz:`, and `cl2-sig:`. Compression is threshold-based and retained only when smaller. HMAC verification, payload bounds, bounded decompression, and deserialization policy are isolated per cache instance. Corrupt payloads are safe misses. +Records use the plain/compressed v2 markers `cl2:` and `cl2-gz:`, plus the identity-bound signed marker `cl3-sig:`. Compression is threshold-based and retained only when smaller. HMAC verification, payload bounds, bounded decompression, and deserialization policy are isolated per cache instance. Corrupt payloads are safe misses. Construction and configuration errors throw. Runtime backend failures default to fail-open: reads become misses, writes/deletes return `false`, and `backend_failure` is recorded. Set `failOpen: false` to propagate runtime failures. Pass deploy-varying values from the application's composition root; CacheLayer never reads process environment state. @@ -235,11 +237,19 @@ $runtime->consume(); Failed local invalidation stops consumption without advancing the cursor; operators can repair the cause and retry. A poison event can only be skipped explicitly with `skipEventAfterClear()`, which clears the local namespace before advancing. Plain key invalidation cannot fence an in-flight resolver, so mutable read-through data that requires ordering should also use a tag generation. It does not replicate values and is not a distributed lock, session store, or counter system. +## Runwire 2.1 integration + +CacheLayer 4.0 ships optional Runwire 2.1 integration without making Runwire a core dependency. A framework shares its active `RuntimeContext` at worker/application bootstrap and the current `RequestContext` / `CoroutineScope` at the request or task boundary. CacheLayer then uses relevant capabilities automatically; when Runwire is absent, inactive, or missing a capability, the ordinary CacheLayer path remains in use. + +Runwire keeps ownership of listeners, workers, supervisors, and event loops. `RunwireWorkerIntegration` can place bounded Cluster invalidation consumption and Node SQLite maintenance inside a host-provided task/service worker scope; CacheLayer never starts background work merely because Runwire is installed. Synchronous backend calls remain synchronous. + +Concurrent persistent Runwire requests receive request-owned memoizer state. If a concurrent runtime is bound without a shared request scope, memoization helpers bypass the process-global cache rather than risk cross-request leakage. See `docs/runwire.rst` and the executable `examples/runwire-invalidation-worker.php`. + ## Atomic counters and memoization -`AtomicCounters` uses an `AtomicCounterStoreInterface`; Redis/Valkey is the distributed implementation. Counters are never emulated with cache `get()` plus `set()`. Atomic counters are separate from `Cache::atomic()`: counters mutate numeric state, while the cache capability provides conditional claim/replace/consume primitives for encoded cache records. +`AtomicCounters` uses an `AtomicCounterStoreInterface`; Redis/Valkey is the distributed implementation. Counters are never emulated with cache `get()` plus `set()`. They live in a dedicated `cachelayer:counter::` keyspace, so ordinary cache `clear()` does not reset them, and the Lua update returns an exact decimal string before PHP range validation. Atomic counters are separate from `Cache::atomic()`: counters mutate numeric state, while the cache capability provides conditional claim/replace/consume primitives for encoded cache records. -The `memoize()`, `remember(object: ...)`, and `once()` helpers plus `MemoizeTrait` provide bounded process-local memoization. Their state survives requests in persistent workers until evicted or reset with `flush_memoizers()`; call that reset at request boundaries when cross-request reuse is not intended. They are independent of persistent backend caching. +The `memoize()`, `remember(object: ...)`, and `once()` helpers plus `MemoizeTrait` provide bounded process-local memoization on the normal path. With an active Runwire request scope they use request-owned memoizers; a concurrent persistent Runwire runtime without a shared request scope bypasses global memoization rather than leaking state between requests. `flush_memoizers()` resets the currently owned memoizer scope. They are independent of persistent backend caching. ## Metrics and benchmarks diff --git a/benchmarks/CachePolicyBench.php b/benchmarks/CachePolicyBench.php index 9259095c..df524fa1 100644 --- a/benchmarks/CachePolicyBench.php +++ b/benchmarks/CachePolicyBench.php @@ -36,7 +36,13 @@ public function benchCompressedCodec(array $params): int public function benchHmacCodec(): int { $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'benchmark-secret')); - $record = $codec->decode($codec->encode($this->payload, null)); + $payload = $codec->encode( + $this->payload, + null, + storageIdentity: 'benchmark', + key: 'payload', + ); + $record = $codec->decode($payload, 'benchmark', 'payload'); return strlen((string) $record?->value); } diff --git a/composer.json b/composer.json index d604bbfa..bd3a55b5 100644 --- a/composer.json +++ b/composer.json @@ -34,14 +34,15 @@ } ], "require": { - "php": ">=8.3", + "php": ">=8.4", "opis/closure": "^4.5", "psr/cache": "^3.0", "psr/simple-cache": "^3.0" }, "require-dev": { "infocyph/phpforge": "dev-main@dev", - "mongodb/mongodb": "^1.20 || ^2.0" + "infocyph/runwire": "2.1", + "mongodb/mongodb": "*" }, "suggest": { "ext-apcu": "For APCu-based caching (in-memory, per-process)", @@ -55,6 +56,7 @@ "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", + "infocyph/runwire": "For optional Runwire 2.1 request/task isolation and worker-owned invalidation/maintenance integration", "mongodb/mongodb": "For MongoDB caching via MongoDbCacheAdapter" }, "minimum-stability": "stable", diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst index 241c383e..a120505b 100644 --- a/docs/adapters/php-files.rst +++ b/docs/adapters/php-files.rst @@ -1,8 +1,7 @@ .. _adapters.php_files: -============================== PHP Files Adapter (``phpFiles``) -============================== +================================ Factory: ``Cache::phpFiles(string $namespace = 'default', ?string $dir = null)`` @@ -11,7 +10,7 @@ Persists cache records as PHP files that return payload arrays. Path layout: * base dir: provided ``$dir`` or ``sys_get_temp_dir() . '/cachelayer/phpfiles'`` -* namespace dir: ``phpcache_`` with separate ``data`` and ``meta`` subdirectories +* namespace dir: ``cache_`` with separate ``data`` and ``meta`` subdirectories * file name: ``hash('xxh128', $key) . '.php'`` Highlights: @@ -25,8 +24,14 @@ Expired files are removed lazily when encountered. Use bounded operational directory rotation when entries may expire without being read again. Good for environments where opcode cache integration is desired. -Use only in trusted environments, since cache entries are stored as executable -PHP files. + +Use only in trusted environments with a private, application-owned cache root. +The adapter executes each cache PHP file before the returned encoded payload can +be verified by the payload codec. Therefore payload signing does not protect +against an attacker who can replace the executable file or redirect an ancestor, +namespace, or lock path. Construction and lock acquisition reject detected +symlink path components, but deployment ownership and permissions remain the +primary trust boundary. Example ------- diff --git a/docs/adapters/tiered.rst b/docs/adapters/tiered.rst index 2961d5cb..7e581c49 100644 --- a/docs/adapters/tiered.rst +++ b/docs/adapters/tiered.rst @@ -1,8 +1,7 @@ .. _adapters.tiered: -========================= Tiered Adapter (``tiered``) -========================= +=========================== Factory: ``Cache::tiered(array $pools, bool $writeToL1 = true)`` diff --git a/docs/cluster/_content.inc b/docs/cluster/_content.inc index ebd9c89e..1ce0a856 100644 --- a/docs/cluster/_content.inc +++ b/docs/cluster/_content.inc @@ -58,7 +58,7 @@ Cluster Cache is not: Cluster identity, nodes, and namespaces --------------------------------------- -``ClusterCacheConfig`` has four settings: +``ClusterCacheConfig`` has five settings: .. code-block:: php @@ -67,6 +67,7 @@ Cluster identity, nodes, and namespaces $clusterConfig = new ClusterCacheConfig( cluster: 'production', nodeId: 'catalog-web-01', + transportIdentity: 'primary-postgres', consumerBatchSize: 1_000, invalidateLocallyFirst: true, ); @@ -85,6 +86,17 @@ Cluster identity, nodes, and namespaces but their cursor still advances. A stable hostname is suitable for long-lived hosts; use a unique instance/pod identity for ephemeral infrastructure. +``transportIdentity`` + A stable 1--128 character identity for the replay log backing this runtime. + It is part of local cursor ownership together with cluster, node, and + namespace. Use the same value when reconnecting to the same durable event + log and a different value when switching to a different database, Redis + deployment, stream prefix/domain, or other independent transport history. + Treat this identity as the history generation: use a new, never-used value + after truncation, recreation, backup restoration, or any event-ID reuse. + A previously unseen local cursor scope is cleared during + ``ClusterCache::create()`` before the runtime becomes available. + ``consumerBatchSize`` Maximum events fetched by ``consume()`` when no explicit limit is supplied. It must be greater than zero. Start with 1,000 and tune from observed event @@ -110,6 +122,7 @@ Every node needs: * a local writable SQLite file and optional APCu, as described in :doc:`/node/index`; * the same cluster name and a unique node ID; +* a stable ``transportIdentity`` for the durable event log being consumed; * access to the same durable, replayable event transport; * a consumer process or scheduled task that calls ``consume()``; * retention longer than the largest expected node outage plus an operational @@ -204,6 +217,7 @@ needed. cluster: new ClusterCacheConfig( cluster: 'production', nodeId: gethostname() ?: 'catalog-unknown-node', + transportIdentity: 'primary-postgres-v1', ), transport: $transport, ); @@ -257,8 +271,9 @@ deployment supplies a different ``$nodeId`` on each machine or instance: namespace: 'catalog', ), cluster: new ClusterCacheConfig( - cluster: 'production-catalog', // identical on every node - nodeId: $nodeId, // unique on every node + cluster: 'production-catalog', // identical on every node + nodeId: $nodeId, // unique on every node + transportIdentity: 'primary-postgres-v1', // same history generation ), transport: $transport, // points to one shared event store ); @@ -474,6 +489,13 @@ Supervised long-running worker Supervisor, Kubernetes, or your framework queue/worker runtime. It calls bounded ``consume()`` loops and polls/sleeps between iterations. +Runwire 2.1 task/service worker + When the application already uses Runwire 2.1, bind the active runtime and + let :doc:`/runwire` place bounded consumption inside the host-owned worker + scope. Runwire retains worker/event-loop ownership and cancellation/drain. + If the needed capability is unavailable, keep the explicit ``consume()`` + path above. + Transport-native delivery A custom transport can integrate with a durable broker's worker model, but it still must preserve the replay/cursor contract. CacheLayer's consumer is @@ -560,8 +582,8 @@ ensure the identity remains unique and monitor for unexpected backlog replay. Retention, recovery, and offline nodes -------------------------------------- -Each node stores ``(cluster, nodeId, lastEventId)`` in its local Node Cache -SQLite database. The transport retains only a finite event history. If a node's +Each node stores ``(cluster, nodeId, namespace, transportIdentity, lastEventId)`` +in its local Node Cache SQLite database. The transport retains only a finite event history. If a node's cursor predates the oldest available event, it cannot know every invalidation it missed. Before normal consumption, Cluster Cache therefore: @@ -577,6 +599,35 @@ If the clear returns ``false`` or throws, recovery fails and preserves the old cursor. The node therefore cannot acknowledge a retention gap while stale local data remains. +During the 4.0 cursor upgrade, legacy cluster/node cursors and the intermediate +cluster/node/namespace cursor format are never copied into the new full scope. +Every previously unseen cursor scope is cold-cleared during +``ClusterCache::create()`` before a runtime is returned. This includes first +cluster adoption of a warm Node Cache namespace and identity rotation. Only a +successful clear establishes the scope; a failed clear aborts construction and +leaves initialization pending for retry. A restart with the same established +scope retains its cache and cursor. + +Recreating or restoring an event log requires a coordinated cutover: + +1. stop writers, consumers, and application workers using the old history; +2. assign a new, never-used ``transportIdentity`` consistently across nodes; +3. restart every node with that identity, allowing construction to clear its + local namespace and establish new cursor progress; +4. clear every APCu/L1 domain that can serve the namespace, or disable APCu + until all application workers and SAPIs have been reconciled; +5. resume traffic and verify consumption against the new history. + +Do not reuse an old identity, including on rollback; use another new generation. +Do not run old and new history generations concurrently over the same local +namespace. A CLI consumer's clear cannot clear a separate PHP-FPM APCu domain. + +Empty-history and backward-ID checks are additional recovery safeguards. They +cannot detect a recreated log whose IDs overlap or pass the saved cursor. +Automatic recovery after arbitrary same-identity history resets is unsupported; +identity rotation and the coordinated cold clear above are required even when +the transport endpoint and stream name remain unchanged. + This safe reset is why event retention is an availability and cache-warmth decision rather than a correctness shortcut. Short retention causes more full local clears after outages; long retention increases transport storage and @@ -713,7 +764,8 @@ At deployment: * create a local, private cache directory on every node; * configure one shared durable transport per logical cluster; -* give every node a unique ID and use the same cluster name; +* give every node a unique ID, use the same cluster name, and configure a + stable transport identity for the shared replay log; * start a consumer on every node before or alongside application traffic; * set event retention longer than the expected maximum outage; * choose bounded cache TTLs even with fast consumers. diff --git a/docs/cluster/operations.rst b/docs/cluster/operations.rst index 0c71a4aa..266657de 100644 --- a/docs/cluster/operations.rst +++ b/docs/cluster/operations.rst @@ -1,6 +1,5 @@ -===================================== Cluster Cache: Operations and Consumer -===================================== +====================================== This page separates ordinary local cache operations from distributed invalidation, then describes the consumer and scheduling lifecycle. diff --git a/docs/cluster/reliability.rst b/docs/cluster/reliability.rst index 7df2095c..2740ccca 100644 --- a/docs/cluster/reliability.rst +++ b/docs/cluster/reliability.rst @@ -1,6 +1,5 @@ -===================================== Cluster Cache: Recovery and Reliability -===================================== +======================================= This page covers retention gaps, recovery, transaction ordering, deployment checklists, and production troubleshooting. diff --git a/docs/cluster/topology.rst b/docs/cluster/topology.rst index 233fd2de..6b383272 100644 --- a/docs/cluster/topology.rst +++ b/docs/cluster/topology.rst @@ -1,6 +1,5 @@ -===================================== Cluster Cache: Topology and Node Setup -===================================== +====================================== This page explains the runtime API and the exact pattern for adding the third, fourth, or Nth independently running application node. diff --git a/docs/conf.py b/docs/conf.py index baa13207..b9860baf 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,6 +43,7 @@ def get_version() -> str: ] source_suffix = ".rst" +autosectionlabel_prefix_document = True pygments_style = "sphinx" pygments_dark_style = "native" diff --git a/docs/counters.rst b/docs/counters.rst index a3b6e8f8..3e7eec56 100644 --- a/docs/counters.rst +++ b/docs/counters.rst @@ -8,7 +8,10 @@ PHP workers or application nodes: rate-limit windows, authentication lockout attempts, quotas, and replay-attempt counts. Use Redis or Valkey as the shared backend. Node Cache, APCu, and SQLite are not -distributed atomic-counter stores. +distributed atomic-counter stores. Counter records live in a dedicated +``cachelayer:counter::`` keyspace, so clearing ordinary cache data +never resets rate-limit, quota, or replay counters that happen to share the same +logical namespace. .. code-block:: php @@ -25,7 +28,10 @@ distributed atomic-counter stores. When a positive TTL is supplied, it is assigned only when that key is first created; later increments do not extend the fixed window. Each operation returns ``AtomicCounterValue`` with the resulting ``value`` and an -``initialized`` flag. +``initialized`` flag. The Lua operation reads the resulting counter back as an +exact decimal string before returning it, so values above JavaScript/Lua's +2^53 precision boundary remain exact. Values outside PHP's integer range and +malformed stored values fail closed with ``AtomicCounterException``. .. code-block:: php diff --git a/docs/functions.rst b/docs/functions.rst index f698cb77..e41b4bb9 100644 --- a/docs/functions.rst +++ b/docs/functions.rst @@ -13,8 +13,14 @@ memoize() Two modes: -* ``memoize()`` returns the singleton ``Infocyph\CacheLayer\Memoize\Memoizer`` -* ``memoize($callable, $params)`` executes memoized call lookup for global/static scope +* ``memoize()`` returns the memoizer owned by the current execution scope +* ``memoize($callable, $params)`` executes memoized call lookup in that scope + +Without Runwire, or in a non-concurrent runtime, the normal owner is the +process-local singleton. An active Runwire request uses a request-owned isolated +memoizer. A bound persistent concurrent Runwire runtime without a shared request +scope bypasses global memoization: callable form executes directly and the +zero-argument form returns a fresh isolated memoizer. Example: @@ -36,8 +42,11 @@ Object-scoped memoization helper. Two modes: -* ``remember()`` returns the singleton ``Memoizer`` -* ``remember($object, $callable, $params)`` caches value per object instance +* ``remember()`` returns the memoizer owned by the current execution scope +* ``remember($object, $callable, $params)`` caches value per object instance in that scope + +Runwire ownership follows the same request-isolation and concurrent no-scope +bypass rules as ``memoize()``. If object is provided but callable is missing, it throws ``InvalidArgumentException``. @@ -62,6 +71,8 @@ flush_memoizers() .. php:function:: flush_memoizers(): void -Clears the process-local ``memoize()``, object ``remember()``, and ``once()`` -state. Persistent workers should call it at a request boundary when values must -not leak into a later request. +Clears memoizer state owned by the current execution context. Inside a shared +Runwire request it flushes only that request's ``memoize()``, object +``remember()``, and ``once()`` state; otherwise it flushes the normal +process-local singleton state. One request does not reset another live request's +callable identities or values. diff --git a/docs/index.rst b/docs/index.rst index cce95b18..753dc663 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -2,7 +2,7 @@ CacheLayer Manual ================= -CacheLayer is a standalone caching toolkit for PHP 8.3+ with: +CacheLayer is a standalone caching toolkit for PHP 8.4+ with: * PSR-6 and PSR-16 support behind one facade (``Cache``) * local, distributed, and cloud cache adapters @@ -50,12 +50,15 @@ Quick Start :caption: Guide cache + release-4.0 + upgrade-4.0 counters adapters/index cookbook metrics-and-locking node/index cluster/index + runwire security serializer memoize diff --git a/docs/memoize.rst b/docs/memoize.rst index 3c838956..fb1536db 100644 --- a/docs/memoize.rst +++ b/docs/memoize.rst @@ -4,8 +4,10 @@ Memoization =================== -CacheLayer includes process-local memoization primitives for fast repeated -in-process calls. +CacheLayer includes in-process memoization primitives for fast repeated calls. +The normal path is process-local; an active Runwire request receives isolated +request-owned memoizers, while a persistent concurrent Runwire runtime without a +shared request scope bypasses global memoization. Available components: diff --git a/docs/memoize/functions.rst b/docs/memoize/functions.rst index 30f9fa1e..75d3a607 100644 --- a/docs/memoize/functions.rst +++ b/docs/memoize/functions.rst @@ -9,9 +9,11 @@ memoize(callable, params) ``memoize($callable, $params)`` caches return values by: -* callable identity, including Closure source/captures and object instance +* callable identity, including Closure instance/source metadata and bound-object identity * normalized parameters hash +Closure capture graphs are not traversed for identity; this avoids recursive-capture exhaustion while distinct live Closure instances remain isolated. + Internally this uses ``Memoizer::get()``. .. code-block:: php @@ -58,7 +60,10 @@ Inspecting/Resetting Memoizer State $memo->flush(); flush_memoizers(); // also resets once() and is suitable at request boundaries -Memoized state is process-local, not request-local. In PHP-FPM it normally dies -with the request process lifecycle, but event loops and persistent workers can -reuse it across requests. Call ``flush_memoizers()`` at the boundary when that -reuse is not intentional. +Without Runwire request ownership, memoized state is process-local. In PHP-FPM +it normally follows the process lifecycle, while event loops and persistent +workers can reuse it across requests. When an active Runwire request is shared, +the helpers use request-owned memoizers and ``flush_memoizers()`` flushes only +that request. In a persistent concurrent Runwire runtime with no shared request +scope, helper calls bypass global memoization rather than risk cross-request +leakage. diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst index 21fb20d5..2b76bf7e 100644 --- a/docs/metrics-and-locking.rst +++ b/docs/metrics-and-locking.rst @@ -154,6 +154,3 @@ MongoDB, ScyllaDB, Redis Cluster, null-store, and directly constructed caches do not claim an authentication-state lock until the caller explicitly configures one. Tiered caches never expose an authentication-state lock because their read path is not authoritative for monotonic state. -* PDO/SQLite adapter factories set ``PdoLockProvider``; SQLite uses its - file-lock fallback -* all other adapters use ``FileLockProvider`` by default diff --git a/docs/node/_content.inc b/docs/node/_content.inc index e3382a12..91580ec6 100644 --- a/docs/node/_content.inc +++ b/docs/node/_content.inc @@ -77,10 +77,11 @@ Write lifecycle 3. A delete or namespace clear is attempted across both layers. 4. Tagged entries and tag-generation metadata are local to this node. -An L2 write failure never populates L1, even with ``failOpen: true``. This -prevents a failed authoritative write from temporarily surfacing through L1. -Set ``failOpen: false`` when a storage failure must be surfaced to the -application instead of being returned as ``false``. +An L2 write failure never populates L1. If an L1 mutation fails after SQLite +has accepted the authoritative state, that Node adapter stops reading L1 so an +older promoted value cannot override SQLite. Configure ``options->failOpen`` +according to whether storage failures should degrade to cache misses/false +results or propagate to the application. Requirements and filesystem placement -------------------------------------- @@ -120,6 +121,7 @@ that database. .. code-block:: php + use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Node\NodeCache; use Infocyph\CacheLayer\Node\NodeCacheConfig; @@ -129,7 +131,10 @@ that database. lockDirectory: '/var/cache/my-application/locks', busyTimeoutMs: 1_000, apcuEnabled: true, - failOpen: true, + options: new CacheOptions( + integrityKey: $integrityKey, + failOpen: true, + ), ); $cache = NodeCache::create($config); @@ -159,10 +164,11 @@ Configuration reference Enables APCu L1 when the extension and SAPI support it. Set it to ``false`` for deterministic SQLite-only behavior, CLI jobs, or troubleshooting. -``failOpen`` - Defaults to ``true``. When true, recoverable layer failures are treated as - cache degradation where possible. When false, APCu/SQLite failures propagate - to the caller. +``options`` + Optional :class:`CacheOptions` applied consistently to the Node facade, + SQLite L2, and APCu L1. Use it to configure payload integrity, payload limits, + compression, object/Closure policy, and ``failOpen``. Defaults to normal + CacheLayer cache options. ``lockProvider`` Optional ``LockProviderInterface`` used by ``remember()``. When omitted, @@ -348,6 +354,11 @@ rows, but pruning prevents the local SQLite file from accumulating them. Runs SQLite ``PRAGMA optimize``. Schedule it less frequently than pruning, for example as part of a daily maintenance job. +``cycle($pruneLimit, $checkpoint, $optimize)`` + Runs one serial bounded maintenance unit. This is convenient for framework + schedulers and the optional :doc:`/runwire` worker integration because one + call cannot overlap its own prune/checkpoint/optimize steps. + A simple scheduled task can perform a small bounded prune every few minutes and checkpoint periodically. Do not run ``VACUUM`` on the request path; plan it separately if disk-space reclamation is required. @@ -383,6 +394,11 @@ platform scheduler instead when that is how application jobs are operated. Only one maintenance task is needed per local SQLite file; it is safe to use a bounded prune limit if overlap cannot be ruled out. +Applications already running Runwire 2.1 may instead use +``RunwireWorkerIntegration::startNodeMaintenance()`` in a host-owned +task/service worker. CacheLayer does not create a worker or event loop; see +:doc:`/runwire` for lifecycle and topology requirements. + Deployment and process guidance ------------------------------- @@ -410,10 +426,10 @@ Node Cache layer events. Export it through your application's telemetry path: $metrics = $cache->exportMetrics(); Layer failures are represented by metrics such as APCu/SQLite failures when -``failOpen`` is enabled. Alert on sustained failures, a sharp fall in hit rate, -rapid SQLite file growth, and repeated lock contention. With ``failOpen`` -disabled, also handle the propagated cache exception according to the request's -availability requirements. +``options->failOpen`` is enabled. Alert on sustained failures, a sharp fall in +hit rate, rapid SQLite file growth, and repeated lock contention. With +``options->failOpen`` disabled, also handle the propagated cache exception +according to the request's availability requirements. Troubleshooting --------------- @@ -436,5 +452,6 @@ Troubleshooting removed by reads. Review TTL choices and cache cardinality as well. ``A cache failure should fail the request`` - Create the configuration with ``failOpen: false`` and ensure the application + Create the configuration with + ``options: new CacheOptions(failOpen: false)`` and ensure the application handles the resulting exception at the appropriate boundary. diff --git a/docs/node/operations.rst b/docs/node/operations.rst index aebcc133..f52026fe 100644 --- a/docs/node/operations.rst +++ b/docs/node/operations.rst @@ -1,6 +1,5 @@ -=========================== Node Cache: Cache Operations -=========================== +============================ This page covers the normal application-facing ``Cache`` facade: reads, writes, tags, deferred items, and source-data invalidation patterns. diff --git a/docs/release-4.0.rst b/docs/release-4.0.rst new file mode 100644 index 00000000..7bb3fb8a --- /dev/null +++ b/docs/release-4.0.rst @@ -0,0 +1,103 @@ +======================== +CacheLayer 4.0.0 release +======================== + +CacheLayer 4.0.0 is a security, correctness, and contract-focused major +release. It intentionally drops 3.x backward-compatibility requirements where +they conflict with a safer or more coherent design. + +Platform +======== + +* Minimum PHP version is 8.4. +* Release verification targets PHP 8.4 and 8.5. +* Runwire 2.1 remains an optional dependency, but CacheLayer 4.0 ships and + release-verifies runtime/request scope integration plus host-owned cluster + invalidation and Node maintenance runners. + +Security and storage +==================== + +* Bounded recursive payload traversal prevents cyclic/deep value exhaustion. + Accepted nesting depths remain readable inside signed/compressed record envelopes. +* Signed cache records authenticate logical storage identity and key. +* Object and Closure deserialization is opt-in instead of enabled by default. +* Filesystem paths validate symlink/trust boundaries across file-backed owners. +* Redis, PDO, MongoDB, integrity, and signing secrets are redacted from failure + paths. +* MySQL/MariaDB logical identity columns use byte-sensitive collation. + +Correctness +=========== + +* Node SQLite transactions no longer roll back caller-owned transactions. +* Node L1 identity includes the SQLite store and stale L1 failures are fenced. +* PDO invalidation publication uses commit-safe cluster-scoped ordering. +* Cluster cursors are scoped by cluster, node, namespace, and transport identity. + New scopes are cold-cleared before runtime exposure. Recreated/restored histories + require a coordinated cutover to a new, never-used transport identity. +* PSR-6 deferred reads and mutation ordering are coherent before and after + ``commit()``. +* Numeric-string key/tag identity is preserved through batching and tiering. +* Memcached long TTLs use the correct absolute-expiration conversion. +* Tiered caches fence upper tiers on false returns and exceptions during writes, + invalidation, and promotion. Reads use the authoritative last tier until a + successful full clear reconciles every tier. + +Runwire 2.1 +=========== + +* The application shares the active Runwire runtime and request/task scope; + CacheLayer does not discover or create a runtime globally. +* Capability-driven behavior falls back to the normal CacheLayer path before an + operation starts when Runwire or the needed capability is unavailable. +* Concurrent persistent requests use isolated request-owned memoizers; an + unscoped concurrent runtime never falls back to process-global memoization. +* Task/service workers can own bounded invalidation consumption and Node SQLite + maintenance while Runwire retains worker, supervisor, and event-loop ownership. +* Worker cancellation/drain propagates through Runwire. Backend operations stay + synchronous and no HTTP scheduling or throughput improvement is claimed. +* A dedicated PHP 8.4/8.5 release consumer installs Runwire 2.1 separately and + executes the shipped invalidation-worker example. + +Atomicity and counters +====================== + +* Redis/Valkey counters use a dedicated keyspace and exact decimal integer + parsing with PHP range checks. +* Counter clear isolation, TTL, decrement, overflow, and concurrent + initialization are covered. +* Stale cleanup uses compare-safe backend operations where deletion could race + a concurrent replacement. +* Tag generation initialization is race-safe across supported backend families. + +Release verification +==================== + +The 4.0 release gate covers: + +* PHP 8.4 and 8.5 with stable and lowest supported dependency resolution; +* Linux and Windows core smoke tests plus clean no-dev consumer installs; +* independent PSR-6 and PSR-16 contract consumers; +* documentation builds with warnings treated as errors; +* real Redis Cluster and Scylla CQL release jobs, with real MongoDB exercised + by the PHPForge service matrix; +* Runwire 2.1 consumer certification and persistent-worker soak coverage across + PHP 8.4/8.5 stable and lowest dependency sets. + +The Runwire certification measures 4,000 cache operations after a separate +250-iteration warmup and validates the expected 3,500 reads plus 500 writes in +both baseline and integrated runs. It is a bounded regression/correctness gate, +not a universal production-throughput claim. + +The supported cluster-history reset contract is explicit: recreated, restored, +or ID-reused invalidation histories require a new, never-used +``transportIdentity`` and coordinated reconciliation of every APCu/L1 domain. +Arbitrary same-identity history resets are not supported. + +Upgrade +======= + +Read :doc:`upgrade-4.0` before deploying over a 3.x installation. The guide +covers runtime requirements, signed record changes, SQL collation, Node +identity, cursor v3, counter migration, coordinated cutover, and rollback. diff --git a/docs/runwire.rst b/docs/runwire.rst new file mode 100644 index 00000000..0f9b4a53 --- /dev/null +++ b/docs/runwire.rst @@ -0,0 +1,185 @@ +======================= +Runwire 2.1 integration +======================= + +CacheLayer 4.0 ships an optional integration with Runwire 2.1. Runwire is not a +core dependency: ordinary PHP-FPM, CLI, and other consumers continue to use the +normal CacheLayer paths without installing it. + +Install Runwire only in applications that already use it:: + + composer require infocyph/runwire:^2.1 + +Ownership contract +================== + +The framework or application owns the Runwire runtime. CacheLayer never starts a +listener, worker pool, supervisor, or event loop because Runwire is installed. + +The integration follows four rules: + +* share the framework's active RuntimeContext at worker/application bootstrap; +* share the current RequestContext and CoroutineScope only while that request + or task is executing; +* use supported Runwire capabilities automatically while preserving Runwire's + worker/event-loop ownership; +* keep the ordinary synchronous CacheLayer path when Runwire is absent, + inactive, or does not expose the required capability. + +Binding a runtime +================= + +Bind the concrete context supplied by the host after the worker has been +created. Rebind after a fork, worker replacement, or generation change, and +release the binding during worker/application shutdown. + +.. code-block:: php + + use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; + use Infocyph\Runwire\RuntimeContext; + + function bootCacheLayer(RuntimeContext $context): void + { + RunwireIntegration::bind($context); + } + + function shutdownCacheLayer(RuntimeContext $context): void + { + RunwireIntegration::release($context); + } + +Installation alone does not create an active binding. + +Request and task scope +====================== + +The host shares the actual request/task scope around application work: + +.. code-block:: php + + $result = RunwireIntegration::share( + $requestContext, + $scope, + fn () => $application->handle($request), + ); + +When a request context is available, memoize(), object remember(), and once() +use request-owned memoizers. Concurrent requests therefore cannot observe or +flush each other's memoized state. If a persistent concurrent runtime is bound +but no request scope has been shared, these helpers bypass process-global +memoization rather than risk cross-request leakage. + +Outside Runwire, or after RunwireIntegration::release(), memoization keeps its +normal process-local behavior. + +Cooperative waits +================= + +Existing bounded polling waits can use the shared CoroutineScope when the +active runtime exposes RUNWIRE_COROUTINES. CacheLayer does not wrap synchronous +PDO, filesystem, Redis, MongoDB, Memcached, Scylla, or other native client calls +and does not claim that those calls become asynchronous. + +Operational failures and cancellation are not retried through a second fallback +path. A mutation or invalidation that may already have completed is never +replayed merely because a Runwire-assisted operation failed. + +Worker-owned invalidation +========================= + +A Runwire task/service worker may host the durable invalidation consumer after +the host has attached its own event loop: + +.. code-block:: php + + use Infocyph\CacheLayer\Integration\Runwire\RunwireWorkerIntegration; + + $task = RunwireWorkerIntegration::startClusterConsumer( + $workerContext, + $clusterRuntime, + batchSize: 1_000, + idleSeconds: 0.1, + ); + + if ($task === null) { + $clusterRuntime->consume(); + } + +Each consume call remains bounded by batchSize and serial within one cursor +scope. Empty/short batches wait cooperatively. Full batches yield so lifecycle +work cannot be starved. Runwire owns cancellation and drain. An unhandled +consumer failure remains a failed worker task; Runwire's worker/supervisor +policy owns unhealthy-worker stop and restart/backoff behavior. + +ClusterRuntime::status() remains the operational source for cursor progress, +pending event count, last consumed count, recovery, and last consume error. + +Do not construct backend connections in a prefork master and reuse them in +children. Create the Node Cache and invalidation transport in worker bootstrap +after the fork. + +The self-contained examples/runwire-invalidation-worker.php demonstrates the +complete binding, worker-owned loop, graceful stop, and cursor progression. It +uses the SQLite invalidation transport testing mode only so the example can run +without an external service. Production deployments should use an advertised +shared transport such as PostgreSQL/MySQL PDO or Redis/Valkey Streams. + +Worker-owned Node maintenance +============================= + +Node SQLite maintenance can use the same task/service worker ownership: + +.. code-block:: php + + $task = RunwireWorkerIntegration::startNodeMaintenance( + $workerContext, + $maintenance, + intervalSeconds: 60.0, + pruneLimit: 5_000, + optimizeEvery: 60, + ); + +The runner performs one serial maintenance cycle at a time, so CacheLayer does +not overlap its own prune/checkpoint/optimize work for that worker. pruneLimit +bounds expiry deletion. optimizeEvery set to 0 disables automatic optimize +calls. + +SQLite operations are still blocking operations. Run maintenance in a suitable +background worker, choose intervals from observed writer contention, and do not +put full maintenance on the request hot path. + +Topology +======== + +Native prefork supervision and worker replacement require Runwire's PCNTL/POSIX +capabilities. Portable single-process Runwire can execute the normal CacheLayer +APIs, but external supervision owns process restart/replacement. Host-owned +runtimes retain their own workers and event loops. + +APCu remains local to one PHP process/SAPI. A dedicated invalidation worker does +not clear unrelated FPM or other worker APCu domains. Every topology claiming +node-wide L1 coherence must run invalidation consumption in each relevant +process domain or disable that L1 assumption. + +Shutdown and rollback +===================== + +On graceful worker stop, Runwire cancels and drains worker-owned CacheLayer +background tasks. Release the runtime binding when the generation is discarded: + +.. code-block:: php + + RunwireIntegration::release($runtimeContext); + +To roll back the optional integration, stop the Runwire-owned CacheLayer tasks, +remove the bootstrap/scope binding, and return to explicit +ClusterRuntime::consume() and NodeCacheMaintenance::cycle() calls. No cache +storage format depends on Runwire. + +HTTP scheduling +=============== + +Runwire 2.1 adaptive HTTP scheduling is a host-level concern. CacheLayer does +not select FIXED, LATENCY, THROUGHPUT, or AUTO and does not attribute HTTP +throughput changes to cache storage. Keep Runwire's certified host defaults +unless the application measures and chooses another policy. diff --git a/docs/security.rst b/docs/security.rst index cac16632..a15e1442 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -66,10 +66,14 @@ not read process environment state. ``phpFiles`` keeps executable ``.php`` cache files for performance, so strict directory controls are required. Runtime checks now reject: -* symlinked cache directories +* symlinked cache directories, namespace roots, ancestors, and lock paths * world-writable cache directories -Use ``phpFiles`` only on trusted hosts and private directories. +The adapter executes the cache PHP file to obtain its encoded payload before +payload HMAC verification can occur. HMAC protects the encoded cache record; it +does not make an attacker-controlled executable cache directory safe. Use +``phpFiles`` only on trusted hosts and private directories whose path +components cannot be replaced by an untrusted user. 3) Temp-Directory Hardening ~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -86,10 +90,37 @@ world-writable cache directories. Network/database adapters rely on the deployment's service permissions rather than local directory checks. The shared-memory adapter also stores its ``ftok`` token in a private -``cachelayer/shared-memory`` directory, creates the segment for the current -user only, and serializes read-modify-write operations with a filesystem lock. - -4) Network Timeouts +``cachelayer/shared-memory`` directory, rejects symlinked token paths, +creates the segment for the current user only, and serializes read-modify-write +operations with a filesystem lock. File locks and SQLite cache paths likewise +reject symlinked path components before opening storage. + +4) Containment Failure Semantics +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +CacheLayer 4.0 treats the following conditions as explicit backend or +configuration failures rather than continuing with ambiguous state: + +* Recursive or over-budget array graphs are rejected before recursive + serialization-policy or memoization normalization can exhaust the worker. +* Rebinding one adapter to conflicting ``CacheOptions`` is rejected before + storage use. Composite Node and tiered adapters preflight every child before + applying a new policy. +* File and PHP-files atomic get-and-delete returns a value only after the + backing file was successfully deleted. Strict mode reports the backend + failure; fail-open mode returns the configured miss/default and never the + unconsumed value. +* Node SQLite mutations reject caller-owned transactions. CacheLayer does not + commit or roll back application work that it did not start. +* Redis/Valkey, PDO, MongoDB, payload-integrity, and closure-signing secrets are + treated as sensitive parameters; connection/configuration errors avoid + echoing secret-bearing DSNs or URIs. + +These checks reduce accidental trust-boundary violations but do not eliminate +filesystem races on every platform. Deploy writable cache roots as private, +application-owned directories. + +5) Network Timeouts ~~~~~~~~~~~~~~~~~~~ Redis/Valkey connections created from a DSN use bounded one-second connect and diff --git a/docs/serializer.rst b/docs/serializer.rst index 1ec3a2bb..5ed53f5b 100644 --- a/docs/serializer.rst +++ b/docs/serializer.rst @@ -4,10 +4,16 @@ Closure Serialization ===================== -CacheLayer uses native PHP serialization for ordinary cache records. The -specialized ``ClosureSerializer`` exists only because PHP cannot serialize a -``Closure`` directly. It does not expose a mixed-value serializer API and does -not support resource handlers or recursively wrapped values. +CacheLayer uses native PHP serialization for ordinary cache records. In 4.0, +cache-record deserialization rejects objects and Closures by default; opt in +explicitly with ``CacheOptions`` only for trusted data and trusted storage. +When ``integrityKey`` is configured, the record signature is bound to its +logical storage identity and cache key, so moving a signed blob to another key +or namespace is rejected. + +The specialized ``ClosureSerializer`` exists only because PHP cannot serialize +a ``Closure`` directly. It does not expose a mixed-value serializer API and +does not support resource handlers or recursively wrapped values. Public API ---------- diff --git a/docs/upgrade-4.0.rst b/docs/upgrade-4.0.rst new file mode 100644 index 00000000..3fbfb2a9 --- /dev/null +++ b/docs/upgrade-4.0.rst @@ -0,0 +1,178 @@ +Upgrading from 3.x to 4.0 +========================= + +CacheLayer 4.0 is an intentional breaking release with a minimum runtime of +PHP 8.4. It does not preserve 3.x API shape, named parameters, defaults, +storage formats, schemas, or behavioral contracts merely for backward +compatibility. + +Use a coordinated cutover. Do not run mixed 3.x and 4.0 writers against the +same mutable cache state unless that exact combination has been proven safe. +For disposable cache values, a cold 4.0 namespace is usually the cleanest +upgrade path. Preserve and explicitly migrate durable coordination state. + +Runtime floor +============= + +CacheLayer 4.0 requires PHP 8.4 or newer. Upgrade the application runtime before +installing the package. The release gates cover PHP 8.4 and PHP 8.5. + +Serialization and authenticated records +======================================== + +4.0 disables object and Closure deserialization by default. Enable either only +through an explicit ``CacheOptions`` policy after reviewing the trust boundary. + +When ``integrityKey`` is configured, signed records use the 4.0 authenticated +envelope. The signature binds the record purpose, logical storage identity, +and logical cache key. A signed blob copied to a different key or namespace is +rejected. Legacy unbound signed records are not silently accepted. + +For a coordinated upgrade: + +* stop 3.x writers before enabling 4.0 writers for the same logical store; +* cold-clear disposable cached payloads when record compatibility is uncertain; +* keep integrity keys in secret storage and out of exception messages, logs, + DSNs, and configuration dumps; +* do not re-enable object or Closure deserialization solely to keep old cache + entries readable. + +Node Cache identity and policy +============================== + +Node Cache derives its L1 and lock identity from the SQLite store plus +namespace. Two SQLite stores using the same namespace therefore do not share +an APCu or lock identity accidentally. + +``NodeCacheConfig`` carries one cohesive ``CacheOptions`` policy. Review Node +construction code that previously relied on independent defaults. L1 mutation +failures are fenced so an old promoted value cannot override authoritative +SQLite after a lower-tier update. + +APCu remains process/SAPI local. A CLI invalidation consumer does not clear +unrelated FPM or worker APCu domains. Every advertised topology needs its own +consumer lifecycle or an explicit topology constraint. + +SQL identity collation +====================== + +MySQL and MariaDB cache and invalidation identity columns are byte-sensitive in +4.0 using ``ascii_bin``. New tables are created with the correct collation. +Existing tables are metadata-checked and altered only when required. + +Before the first 4.0 process uses an existing SQL store: + +1. back up durable invalidation and event state; +2. inspect identity columns and application namespaces for case-folded data; +3. stop mixed-version writers; +4. allow the 4.0 schema installer to harden the identity columns, or perform + the equivalent reviewed migration during a maintenance window; +5. verify case-distinct namespaces and keys after migration. + +Disposable cache rows may be dropped and rebuilt. Do not treat invalidation +history, authorization/replay state, or other durable coordination data as +ordinary cache rows. + +Cluster cursor v3 +================= + +Cluster cursor identity is scoped by cluster, node, namespace, and transport +identity. Legacy ``(cluster,node)`` and intermediate +``(cluster,node,namespace)`` cursors are not copied into the new scope. + +Every previously unseen cursor scope is cleared during ``ClusterCache::create()`` +before a runtime is returned, including legacy migration and first cluster +adoption of a warm namespace. A failed clear aborts construction and leaves the +scope pending for retry. Existing scopes retain their cache and cursor on restart. + +After event-log truncation, recreation, restoration, or event-ID reuse, stop old +writers/consumers and application workers, then deploy a new, never-used +``transportIdentity`` to every node. Reconcile every APCu/L1 domain before resuming +traffic; a CLI clear cannot reach a separate PHP-FPM APCu domain. Never reuse an +old generation, even on rollback. Boundary checks cannot detect overlapping IDs +from a different history, so arbitrary same-identity resets are unsupported. +See the coordinated cutover procedure in the Cluster Cache documentation. + +Keep transport retention long enough for deployment and outage windows. After +cutover, verify every node has a stable node ID, namespace, transport identity, +and its own consumer. + +Atomic counter keyspace +======================= + +Redis and Valkey counters live under +``cachelayer:counter::``. Ordinary cache ``clear()`` operations +do not touch this keyspace. + +If 3.x counters carry security-relevant windows, quotas, replay attempts, or +rate limits, migrate them deliberately while 3.x writers are stopped. Do not +delete old counters until the application has copied the required state or +intentionally allowed the old windows to expire. + +4.0 validates exact decimal counter results against the PHP integer range. +Malformed and out-of-range stored values fail closed instead of saturating. + +PSR and cache behavior changes +============================== + +4.0 corrects several observable behaviors: + +* PSR-6 deferred values are visible through reads before ``commit()``; +* immediate save, delete, and clear operations reconcile queued deferred state + instead of allowing a later commit to resurrect an older value; +* numeric-string keys and tags preserve logical identity through batching and + tier promotion; +* tiered caches that skip L1 write-through invalidate or fence stale L1 state; +* Memcached relative TTLs longer than 30 days are converted to Memcached's + absolute-expiration form; +* direct PSR-6 pools validate public keys consistently and missing deletes + succeed where the PSR contract requires it. + +Durable invalidation +==================== + +PDO invalidation publication is commit-safe within a cluster scope. Event IDs +are allocated while holding the cluster publication lock so commit reordering +cannot make a later event permanently hide an earlier one. + +Cursor state is scoped by cluster, node, namespace, and transport identity. +Retention gaps trigger a local namespace clear before progress is advanced. +Permanent poison events are not skipped silently. + +Runwire 2.1 integration +======================= + +Runwire is still optional at installation time. Applications that use the +integration should install ``infocyph/runwire:^2.1`` and bind the concrete +Runwire ``RuntimeContext`` after each worker/generation is created. Share the +current request/task scope only while that work is active and release the +runtime binding on shutdown or replacement. + +Do not create database, Redis, or other backend connections in a prefork master +and reuse them in child workers. Build CacheLayer's Node/Cluster objects in the +worker after the fork. Runwire owns worker restart/backoff, cancellation, +drain, and event-loop lifecycle; CacheLayer does not take over those resources. + +Removing the integration does not require a cache data migration. Stop the +Runwire-owned CacheLayer tasks, remove the bootstrap/scope binding, and return +to explicit ``ClusterRuntime::consume()`` and ``NodeCacheMaintenance::cycle()`` +execution. + +Rollback +======== + +Rollback means restoring the complete previous release and its compatible +storage configuration, not merely changing the Composer version. + +Before deployment record: + +* the exact 4.0 commit or tag; +* PHP and extension versions; +* database/cache backend versions; +* schema and cursor versions; +* namespace and transport identities; +* any migrated durable counter or invalidation state. + +If rollback is required, stop 4.0 writers first. Restore the previous release +with storage it can safely interpret. Do not point 3.x readers at 4.0 +authenticated records or cursor state and assume compatibility. diff --git a/examples/runwire-invalidation-worker.php b/examples/runwire-invalidation-worker.php new file mode 100644 index 00000000..2f0090fd --- /dev/null +++ b/examples/runwire-invalidation-worker.php @@ -0,0 +1,141 @@ +cache()->set('demo-key', 'cached', 60); + $transport->publish( + InvalidationEvent::key( + 'example-cluster', + 'application', + 'demo-key', + 'node-b', + ), + ); + + $capabilities = new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + persistentProcess: true, + persistentApplication: true, + ownsEventLoop: true, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ); + $runtime = RuntimeContext::fromCapabilities( + $capabilities, + 'cachelayer-example', + workerSlot: 0, + generation: 1, + concurrent: true, + ); + RunwireIntegration::bind($runtime); + + [$readyParent, $readyChild] = stream_socket_pair( + STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + STREAM_IPPROTO_IP, + ); + $pid = getmypid(); + $worker = new WorkerContext( + group: 'cachelayer-example', + slot: 0, + generation: 1, + pid: is_int($pid) ? $pid : 0, + parentPid: 0, + readyStream: $readyChild, + role: WorkerRole::TASK, + ); + + $loop = new SelectLoop(); + $worker->attachLoop($loop, backgroundShutdownGraceSeconds: 0.25); + $task = RunwireWorkerIntegration::startClusterConsumer( + $worker, + $cluster, + batchSize: 10, + idleSeconds: 0.001, + ); + if ($task === null) { + throw new RuntimeException('The active Runwire context does not expose the required worker capabilities.'); + } + + $loop->delay(0.02, static function () use ($worker): void { + $worker->requestStop(); + }); + $loop->run(); + + $status = $cluster->status(); + if ($cluster->cache()->get('demo-key') !== null) { + throw new RuntimeException('The invalidation worker did not clear the cached key.'); + } + if ($status->cursor !== '1' || $status->pendingEventCount !== 0 || $status->lastConsumeError !== null) { + throw new RuntimeException('The invalidation worker did not persist clean cursor progress.'); + } + + fwrite(STDOUT, "CacheLayer Runwire invalidation worker example passed.\n"); +} finally { + $worker?->close(); + if (is_resource($readyParent)) { + fclose($readyParent); + } + RunwireIntegration::release($runtime); + + if (is_dir($base)) { + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($base); + } +} diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index 885e06ab..39db9396 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -4,11 +4,13 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; +use Throwable; abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalCachePoolInterface { @@ -17,11 +19,27 @@ abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalC private ?CachePayloadCodec $codec = null; + private bool $committing = false; + /** @var array */ private array $localMetadata = []; private ?CacheOptions $options = null; + private ?string $storageIdentity = null; + + public function __destruct() + { + if ($this->deferred === []) { + return; + } + + try { + $this->commit(); + } catch (Throwable) { + } + } + /** * @param list $keys * @return array @@ -31,6 +49,22 @@ abstract public function multiFetch(array $keys): array; /** @param array $items */ abstract public function saveItems(array $items): bool; + /** @internal */ + public function assertOptionsCompatible(CacheOptions $options): void + { + if ($this->options !== null && $this->options != $options) { + throw new \LogicException('Cache options cannot change after the adapter is bound to a facade.'); + } + } + + /** @internal */ + public function assertStorageIdentityCompatible(string $storageIdentity): void + { + if ($this->storageIdentity !== null && $this->storageIdentity !== $storageIdentity) { + throw new \LogicException('Cache storage identity cannot change after the adapter is bound to a facade.'); + } + } + public function commit(): bool { if ($this->deferred === []) { @@ -38,7 +72,14 @@ public function commit(): bool } $deferred = $this->deferred; - $saved = $this->saveItems($deferred); + $this->committing = true; + + try { + $saved = $this->saveItems($deferred); + } finally { + $this->committing = false; + } + if ($saved) { $this->deferred = []; } @@ -49,20 +90,22 @@ public function commit(): bool /** @internal */ public function configureOptions(CacheOptions $options): void { - if ($this->codec !== null) { - if ($this->options == $options) { - return; - } - - throw new \LogicException('Cache options cannot change after the adapter starts processing records.'); - } + $this->assertOptionsCompatible($options); + $this->options ??= $options; + } - $this->options = $options; + /** @internal */ + public function configureStorageIdentity(string $storageIdentity): void + { + $this->assertStorageIdentityCompatible($storageIdentity); + $this->storageIdentity ??= $storageIdentity; } public function createItem(string $key): CacheItemInterface { - return $this->genericMiss($key); + CacheInput::key($key); + + return new CacheItem($this, $key); } /** @@ -71,7 +114,19 @@ public function createItem(string $key): CacheItemInterface */ public function getItems(array $keys = []): array { - return $this->multiFetch($keys); + $keys = CacheInput::keys($keys); + $items = $this->multiFetch($keys); + + foreach ($keys as $key) { + $pending = $this->deferredRead($key); + if ($pending !== null) { + $items[$key] = $pending; + } elseif (!isset($items[$key])) { + $items[$key] = new CacheItem($this, $key); + } + } + + return $items; } /** @param list $tags */ @@ -111,7 +166,8 @@ public function saveDeferred(CacheItemInterface $item): bool return false; } - $this->deferred[$item->getKey()] = $item; + $snapshot = $this->deferredSnapshot($item); + $this->deferred[$this->deferredKey($item->getKey())] = $snapshot; return true; } @@ -135,22 +191,58 @@ protected static function normalizeGeneration(mixed $value): ?string return strtolower($value); } - protected function decodeRecordFromBase64(string $payload): ?CacheRecord + protected function decodeRecordFromBase64(string $payload, ?string $key = null): ?CacheRecord { $blob = base64_decode($payload, true); - return is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + return is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; } - protected function decodeRecordFromBlob(string $blob): ?CacheRecord + protected function decodeRecordFromBlob(string $blob, ?string $key = null): ?CacheRecord { - $record = $this->payloadCodec()->decode($blob); + $record = $this->payloadCodec()->decode($blob, $this->storageIdentity, $key); return $record !== null && !CachePayloadCodec::isExpired($record->expiresAt) ? $record : null; } + protected function deferredRead(string $key): ?CacheItem + { + $pending = $this->deferred[$this->deferredKey($key)] ?? null; + if (!$pending instanceof CacheItem) { + return null; + } + if (!$pending->isHit()) { + return new CacheItem($this, $key); + } + + return clone $pending; + } + + protected function discardDeferredKey(string $key): void + { + CacheInput::key($key); + if ($this->committing) { + return; + } + + unset($this->deferred[$this->deferredKey($key)]); + } + + /** @param list $keys */ + protected function discardDeferredKeys(array $keys): void + { + $keys = CacheInput::keys($keys); + if ($this->committing) { + return; + } + + foreach ($keys as $key) { + unset($this->deferred[$this->deferredKey($key)]); + } + } + protected function encodeItem( CacheItemInterface $item, ?int $expiresAt, @@ -158,7 +250,14 @@ protected function encodeItem( ): string { $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; - return $this->payloadCodec()->encode($item->get(), $expiresAt, $tags, $namespaceGeneration); + return $this->payloadCodec()->encode( + $item->get(), + $expiresAt, + $tags, + $namespaceGeneration, + $this->storageIdentity, + $item->getKey(), + ); } protected function genericDeleteAndMiss(string $key): CacheItem @@ -178,7 +277,7 @@ protected function genericFromBase64WithInvalidator( $key, $payload, $onInvalid, - $this->decodeRecordFromBase64(...), + fn(string $encoded): ?CacheRecord => $this->decodeRecordFromBase64($encoded, $key), ); } @@ -192,12 +291,18 @@ protected function genericFromBlobWithInvalidator( $key, $blob, $onInvalid, - $this->decodeRecordFromBlob(...), + fn(string $encoded): ?CacheRecord => $this->decodeRecordFromBlob($encoded, $key), ); } protected function genericItemFromRecord(string $key, CacheRecord $record): CacheItem { + CacheInput::key($key); + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } + return new CacheItem( $this, $key, @@ -210,7 +315,9 @@ protected function genericItemFromRecord(string $key, CacheRecord $record): Cach protected function genericMiss(string $key): CacheItem { - return new CacheItem($this, $key); + CacheInput::key($key); + + return $this->deferredRead($key) ?? new CacheItem($this, $key); } protected function options(): CacheOptions @@ -242,41 +349,77 @@ protected function saveEncoded(CacheItemInterface $item, callable $writer): bool protected function supportsItem(CacheItemInterface $item): bool { - return $item instanceof CacheItem && $item->belongsTo($this); + if (!$this->ownsItem($item)) { + return false; + } + if (!$this->committing) { + $this->discardDeferredKey($item->getKey()); + } + + return true; } /** @param array $items */ protected function supportsItems(array $items): bool { foreach ($items as $item) { - if (!$this->supportsItem($item)) { + if (!$this->ownsItem($item)) { return false; } } + if (!$this->committing) { + foreach ($items as $item) { + $this->discardDeferredKey($item->getKey()); + } + } return true; } + private function deferredKey(string $key): string + { + return "key:\0" . $key; + } + + private function deferredSnapshot(CacheItemInterface $item): CacheItem + { + $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; + $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; + + return new CacheItem($this, $item->getKey(), $item->get(), true) + ->expiresAfter($ttl) + ->setTagGenerations($tags); + } + private function genericFromEncodedWithInvalidator( string $key, ?string $encoded, callable $onInvalid, callable $decoder, ): CacheItem { + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } if ($encoded === null) { - return $this->genericMiss($key); + return new CacheItem($this, $key); } $record = $decoder($encoded); if (!$record instanceof CacheRecord) { $onInvalid(); - return $this->genericMiss($key); + return new CacheItem($this, $key); } return $this->genericItemFromRecord($key, $record); } + private function ownsItem(CacheItemInterface $item): bool + { + return $item instanceof CacheItem && $item->belongsTo($this); + } + private function payloadCodec(): CachePayloadCodec { $this->options ??= new CacheOptions(); diff --git a/src/Cache/Adapter/AdapterValueNormalizer.php b/src/Cache/Adapter/AdapterValueNormalizer.php index 3358b2f6..35dd4acc 100644 --- a/src/Cache/Adapter/AdapterValueNormalizer.php +++ b/src/Cache/Adapter/AdapterValueNormalizer.php @@ -9,13 +9,7 @@ final class AdapterValueNormalizer /** @param array $values */ public static function allTrue(array $values): bool { - foreach ($values as $value) { - if ($value !== true) { - return false; - } - } - - return true; + return array_all($values, static fn(mixed $value): bool => $value === true); } /** @@ -39,13 +33,17 @@ public static function fromArrayLikeOrToArray(mixed $value): ?array */ public static function fromJsonOrArrayLike(mixed $value): ?array { + $arrayLike = self::fromArrayLikeOrToArray($value); + if ($arrayLike !== null) { + return $arrayLike; + } if ($value instanceof \JsonSerializable) { $json = $value->jsonSerialize(); return is_array($json) ? self::normalizeAssoc($json) : null; } - return self::fromArrayLikeOrToArray($value); + return null; } public static function intOrZero(mixed $value): int diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index 3d9c1228..955e77ca 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -51,6 +51,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $mapped = $this->map($key); if (!apcu_exists($mapped)) { return true; @@ -65,6 +66,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys === []) { return true; } @@ -84,10 +86,9 @@ public function getItem(string $key): CacheItem return $item; } - apcu_delete($apcuKey); } - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** @param list $tags */ @@ -106,8 +107,7 @@ public function getTagGenerations(array $tags): array $candidate = self::newGeneration(); $generation = self::normalizeGeneration(apcu_add($key, $candidate) ? $candidate : apcu_fetch($key)); if ($generation === null) { - $generation = self::newGeneration(); - apcu_store($key, $generation); + throw new RuntimeException('Unable to initialize APCu tag generation.'); } } $generations[$tag] = $generation; @@ -118,7 +118,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - return apcu_exists($this->map($key)); + return $this->getItem($key)->isHit(); } /** @@ -139,17 +139,12 @@ public function multiFetch(array $keys): array } $items = []; - $stale = []; foreach ($keys as $k) { - if ($this->appendFetchedHit($items, $stale, $k, $raw)) { + if ($this->appendFetchedHit($items, $k, $raw)) { continue; } - $items[$k] = new CacheItem($this, $k); - } - - if ($stale !== []) { - apcu_delete($stale); + $items[$k] = $this->genericMiss($k); } return $items; @@ -227,13 +222,10 @@ public function saveItems(array $items): bool apcu_delete($expired); } - foreach ($groups as $ttl => $records) { - if (apcu_store($records, null, (int) $ttl) !== []) { - return false; - } - } - - return true; + return array_all( + $groups, + static fn(array $records, int|string $ttl): bool => apcu_store($records, null, (int) $ttl) === [], + ); } /** @param array $generations */ @@ -242,6 +234,7 @@ public function storeTagGenerations(array $generations): bool { $mapped = []; foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } @@ -253,14 +246,12 @@ public function storeTagGenerations(array $generations): bool /** * @param array $items The items argument. - * @param array $stale The stale argument. * @param string $key The key argument. * @param array $raw The raw argument. * @phpstan-param array $items - * @phpstan-param list $stale * @phpstan-param array $raw */ - private function appendFetchedHit(array &$items, array &$stale, string $key, array $raw): bool + private function appendFetchedHit(array &$items, string $key, array $raw): bool { $mapped = $this->map($key); if (!isset($raw[$mapped]) || !is_string($raw[$mapped])) { @@ -274,14 +265,12 @@ private function appendFetchedHit(array &$items, array &$stale, string $key, arr return true; } - $stale[] = $mapped; - return false; } private function hitItemFromBlob(string $key, string $blob): ?CacheItem { - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if ($record === null) { return null; } diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 9ef1e598..57a03add 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -39,7 +39,7 @@ public function atomicCompareAndSet( } $mapped = $this->map($key); - $record = $this->atomicRecord($mapped); + $record = $this->atomicRecord($key, $mapped); if (!$record instanceof CacheRecord || $record->value !== $expected) { return false; } @@ -51,8 +51,10 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $mapped = $this->map($key); - $record = $this->atomicRecord($mapped); + $record = $this->atomicRecord($key, $mapped); if (!$record instanceof CacheRecord) { return $this->genericMiss($key); } @@ -74,7 +76,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool } $mapped = $this->map($item->getKey()); - if ($this->atomicRecord($mapped) instanceof CacheRecord) { + if ($this->atomicRecord($item->getKey(), $mapped) instanceof CacheRecord) { return false; } @@ -94,6 +96,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); unset($this->store[$this->map($key)]); return true; @@ -105,6 +108,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); foreach ($keys as $key) { unset($this->store[$this->map($key)]); } @@ -142,20 +146,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - $mapped = $this->map($key); - $blob = $this->store[$mapped] ?? null; - if (!is_string($blob)) { - return false; - } - - $record = $this->decodeRecordFromBlob($blob); - if ($record === null) { - unset($this->store[$mapped]); - - return false; - } - - return true; + return $this->getItem($key)->isHit(); } /** @@ -237,6 +228,7 @@ public function saveItems(array $items): bool public function storeTagGenerations(array $generations): bool { foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } @@ -246,14 +238,14 @@ public function storeTagGenerations(array $generations): bool return true; } - private function atomicRecord(string $mapped): ?CacheRecord + private function atomicRecord(string $key, string $mapped): ?CacheRecord { $blob = $this->store[$mapped] ?? null; if (!is_string($blob)) { return null; } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { unset($this->store[$mapped]); @@ -271,6 +263,7 @@ private function map(string $key): string private function recordTagsAreCurrent(CacheRecord $record): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($this->metadata[$tag] ?? null) !== $generation) { return false; } diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 6976595e..3588679d 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -11,6 +11,7 @@ use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Serializer\ClosureSerializer; +use Infocyph\CacheLayer\Support\BoundedValueTraversal; use InvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -18,11 +19,13 @@ final readonly class CachePayloadCodec { + private const string BOUND_SIGNED_PREFIX = 'cl3-sig:'; + private const string COMPRESSED_PREFIX = 'cl2-gz:'; private const string PLAIN_PREFIX = 'cl2:'; - private const string SIGNED_PREFIX = 'cl2-sig:'; + private const string SIGNATURE_PURPOSE = 'cache-record:v3'; public function __construct(private CacheOptions $options = new CacheOptions()) {} @@ -44,16 +47,19 @@ public static function isExpired(?int $expiresAt, ?int $now = null): bool public static function toDateTime(?int $expiresAt): ?DateTimeInterface { - return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); + return $expiresAt === null ? null : new DateTimeImmutable()->setTimestamp($expiresAt); } - public function decode(string $blob): ?CacheRecord - { + public function decode( + string $blob, + ?string $storageIdentity = null, + ?string $key = null, + ): ?CacheRecord { if ($this->isPayloadTooLarge($blob)) { return null; } - $verified = $this->verifyAndExtractSignature($blob); + $verified = $this->verifyAndExtractSignature($blob, $storageIdentity, $key); if ($verified === null) { return null; } @@ -65,6 +71,14 @@ public function decode(string $blob): ?CacheRecord try { $decoded = $this->unserializeNative($serialized); + if (is_array($decoded)) { + if (array_key_exists('value', $decoded)) { + BoundedValueTraversal::assertSafe($decoded['value']); + } + if (array_key_exists('tags', $decoded)) { + BoundedValueTraversal::assertSafe($decoded['tags']); + } + } } catch (Throwable) { return null; } @@ -73,14 +87,17 @@ public function decode(string $blob): ?CacheRecord } /** - * @param array $tags + * @param array $tags */ public function encode( mixed $value, ?int $expiresAt, array $tags = [], ?string $namespaceGeneration = null, + ?string $storageIdentity = null, + ?string $key = null, ): string { + BoundedValueTraversal::assertSafe($tags); [$encoding, $encodedValue] = $this->encodeValue($value); $serialized = serialize([ 'format' => 2, @@ -103,7 +120,7 @@ public function encode( } } - $encoded = $this->attachSignature($payload); + $encoded = $this->attachSignature($payload, $storageIdentity, $key); if ($this->isPayloadTooLarge($encoded)) { throw new RuntimeException('The stored cache payload exceeds the configured payload limit.'); } @@ -130,15 +147,27 @@ private function assertNativeValueSupported(mixed $value): void } } - private function attachSignature(string $payload): string - { + private function attachSignature( + string $payload, + ?string $storageIdentity, + ?string $key, + ): string { if ($this->options->integrityKey === null) { return $payload; } + if ($storageIdentity === null || $key === null) { + throw new InvalidArgumentException( + 'Signed cache payloads require a logical storage identity and key.', + ); + } - $signature = hash_hmac('sha256', $payload, $this->options->integrityKey); + $signature = hash_hmac( + 'sha256', + $this->signatureInput($payload, $storageIdentity, $key), + $this->options->integrityKey, + ); - return self::SIGNED_PREFIX . $signature . ':' . $payload; + return self::BOUND_SIGNED_PREFIX . $signature . ':' . $payload; } private function containsUnsupportedDecodedValue(mixed $value): bool @@ -152,13 +181,11 @@ private function containsUnsupportedDecodedValue(mixed $value): bool if (!is_array($value)) { return false; } - foreach ($value as $item) { - if ($this->containsUnsupportedDecodedValue($item)) { - return true; - } - } - return false; + return array_any( + $value, + fn(mixed $item): bool => $this->containsUnsupportedDecodedValue($item), + ); } /** @@ -198,6 +225,7 @@ private function encodeValue(mixed $value): array return ['closure', ClosureSerializer::serialize($value)]; } + BoundedValueTraversal::assertSafe($value); $this->assertNativeValueSupported($value); return ['native', $value]; @@ -265,9 +293,8 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord return null; } - foreach ($tags as $tag => $generation) { - if (!is_string($tag) - || !is_string($generation) + foreach ($tags as $generation) { + if (!is_string($generation) || strlen($generation) !== 32 || !ctype_xdigit($generation)) { return null; @@ -277,6 +304,14 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord return new CacheRecord($value['value'], $expiresAt, $tags, $namespaceGeneration); } + private function signatureInput(string $payload, string $storageIdentity, string $key): string + { + return self::SIGNATURE_PURPOSE + . "\0" . strlen($storageIdentity) . ':' . $storageIdentity + . "\0" . strlen($key) . ':' . $key + . "\0" . $payload; + } + private function unserializeNative(string $payload): mixed { set_error_handler(static fn(): bool => true); @@ -284,34 +319,53 @@ private function unserializeNative(string $payload): mixed try { return unserialize($payload, [ 'allowed_classes' => $this->options->allowObjects, - 'max_depth' => 128, + // Include the record envelope around the validated value graph. + 'max_depth' => BoundedValueTraversal::MAX_DEPTH + 1, ]); } finally { restore_error_handler(); } } - private function verifyAndExtractSignature(string $blob): ?string + private function unsignedPayload(string $blob): ?string { - if (!str_starts_with($blob, self::SIGNED_PREFIX)) { - return $this->options->integrityKey === null ? $blob : null; - } - if ($this->options->integrityKey === null) { + return str_starts_with($blob, self::BOUND_SIGNED_PREFIX) ? null : $blob; + } + + private function verifyAndExtractSignature( + string $blob, + ?string $storageIdentity, + ?string $key, + ): ?string { + $integrityKey = $this->options->integrityKey; + if ($integrityKey === null) { + return $this->unsignedPayload($blob); + } + if ($storageIdentity === null || $key === null + || !str_starts_with($blob, self::BOUND_SIGNED_PREFIX)) { return null; } - $separator = strpos($blob, ':', strlen(self::SIGNED_PREFIX)); + $separator = strpos($blob, ':', strlen(self::BOUND_SIGNED_PREFIX)); if ($separator === false) { return null; } - $signature = substr($blob, strlen(self::SIGNED_PREFIX), $separator - strlen(self::SIGNED_PREFIX)); + $signature = substr( + $blob, + strlen(self::BOUND_SIGNED_PREFIX), + $separator - strlen(self::BOUND_SIGNED_PREFIX), + ); $payload = substr($blob, $separator + 1); if (strlen($signature) !== 64 || !ctype_xdigit($signature)) { return null; } - $expected = hash_hmac('sha256', $payload, $this->options->integrityKey); + $expected = hash_hmac( + 'sha256', + $this->signatureInput($payload, $storageIdentity, $key), + $integrityKey, + ); return hash_equals($expected, strtolower($signature)) ? $payload : null; } diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index fc7a14be..b4e36d07 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -50,12 +50,16 @@ public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInter public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + return $this->withKeyLock($key, function () use ($key): CacheItemInterface { $record = $this->readLiveRecordUnlocked($key); if (!$record instanceof CacheRecord) { return $this->genericMiss($key); } - $this->deleteItemUnlocked($key); + if (!$this->deleteItemUnlocked($key)) { + throw new RuntimeException('Unable to delete consumed file cache entry.'); + } return $this->genericItemFromRecord($key, $record); }); @@ -95,12 +99,16 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); + return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); + $ok = true; foreach ($keys as $k) { $ok = $this->deleteItem($k) && $ok; @@ -116,7 +124,7 @@ public function getItem(string $key): CacheItem return $this->genericItemFromRecord($key, $record); } - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** @@ -203,12 +211,13 @@ private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = CacheInput::namespace($ns); - $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; - $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; - $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; - $this->lockDirectory = $root . 'locks' . DIRECTORY_SEPARATOR; + $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns; + $this->dataDirectory = $root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR; + $this->metadataDirectory = $root . DIRECTORY_SEPARATOR . 'meta' . DIRECTORY_SEPARATOR; + $this->lockDirectory = $root . DIRECTORY_SEPARATOR . 'locks' . DIRECTORY_SEPARATOR; $this->ensureBaseDirectoryExists($baseDir); + $this->ensureCacheDirectoryExists($root); foreach ([$this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { $this->ensureCacheDirectoryExists($directory); } @@ -225,7 +234,7 @@ private function deleteItemUnlocked(string $key): bool { $file = $this->fileFor($key); - return !is_file($file) || unlink($file); + return $this->deleteFile($file); } private function ensureBaseDirectoryExists(string $baseDir): void @@ -302,7 +311,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord { $file = $this->fileFor($key); $raw = is_file($file) ? file_get_contents($file) : false; - $record = is_string($raw) ? $this->decodeRecordFromBlob($raw) : null; + $record = is_string($raw) ? $this->decodeRecordFromBlob($raw, $key) : null; if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return null; } @@ -313,6 +322,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord private function recordTagsAreCurrent(CacheRecord $record): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; $current = is_file($this->metadataFileFor($tag)) ? file_get_contents($this->metadataFileFor($tag)) : false; @@ -339,6 +349,7 @@ private function throwCreationError(string $prefix): void private function withKeyLock(string $key, callable $callback): mixed { $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; + $this->assertPathNotSymlink($path, 'File cache key lock'); $handle = fopen($path, 'c'); if (!is_resource($handle) || !flock($handle, LOCK_EX)) { if (is_resource($handle)) { diff --git a/src/Cache/Adapter/InternalCachePoolInterface.php b/src/Cache/Adapter/InternalCachePoolInterface.php index fb90b2c7..02635a92 100644 --- a/src/Cache/Adapter/InternalCachePoolInterface.php +++ b/src/Cache/Adapter/InternalCachePoolInterface.php @@ -16,6 +16,12 @@ interface InternalCachePoolInterface extends CacheItemPoolInterface { public function createItem(string $key): CacheItemInterface; + /** + * @param list $keys + * @return iterable + */ + public function getItems(array $keys = []): iterable; + /** * @param list $tags * @return array diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 2578b507..adf8292d 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -7,6 +7,7 @@ use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Support\MemcachedValueGuard; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -62,7 +63,7 @@ public function atomicCompareAndSet( return false; } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $this->namespaceGeneration() || $record->tags !== [] @@ -80,19 +81,21 @@ public function atomicCompareAndSet( $extended['cas'], $mapped, $replacementBlob, - $ttl ?? 0, + MemcachedExpiration::fromRelative($ttl), ); } public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $mapped = $this->mapData($key); $extended = $this->extendedGet($mapped); if ($extended === null || $extended['value'] === self::ATOMIC_TOMBSTONE) { return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($extended['value']); + $record = $this->decodeRecordFromBlob($extended['value'], $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $this->namespaceGeneration() || !$this->recordTagsAreCurrent($record)) { @@ -125,26 +128,26 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool $this->namespaceGeneration(), ); - if ($this->client->add($mapped, $blob, $ttl ?? 0)) { + if ($this->client->add($mapped, $blob, MemcachedExpiration::fromRelative($ttl))) { return true; } $extended = $this->extendedGet($mapped); if ($extended === null) { - return $this->client->add($mapped, $blob, $ttl ?? 0); + return $this->client->add($mapped, $blob, MemcachedExpiration::fromRelative($ttl)); } $current = $extended['value']; $record = $current === self::ATOMIC_TOMBSTONE ? null - : $this->decodeRecordFromBlob($current); + : $this->decodeRecordFromBlob($current, $item->getKey()); if ($record instanceof CacheRecord && $record->namespaceGeneration === $this->namespaceGeneration() && $this->recordTagsAreCurrent($record)) { return false; } - return $this->client->cas($extended['cas'], $mapped, $blob, $ttl ?? 0); + return $this->client->cas($extended['cas'], $mapped, $blob, MemcachedExpiration::fromRelative($ttl)); } public function clear(): bool @@ -157,29 +160,23 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $this->client->delete($this->mapData($key)); - return !in_array( - $this->client->getResultCode(), - [\Memcached::RES_FAILURE, \Memcached::RES_WRITE_FAILURE], - true, - ); + return $this->deleteResultSucceeded(); } /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys === []) { return true; } $this->client->deleteMulti(array_map($this->mapData(...), $keys)); - return !in_array( - $this->client->getResultCode(), - [\Memcached::RES_FAILURE, \Memcached::RES_WRITE_FAILURE], - true, - ); + return $this->deleteResultSucceeded(); } public function getClient(): \Memcached @@ -197,12 +194,18 @@ public function getItem(string $key): CacheItem if ($blob === self::ATOMIC_TOMBSTONE) { return $this->genericMiss($key); } - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } if (is_string($blob)) { - $this->client->delete($mapped); + MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $mapped, + $blob, + self::ATOMIC_TOMBSTONE, + 1, + ); } return $this->genericMiss($key); @@ -261,19 +264,25 @@ public function multiFetch(array $keys): array continue; } - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record === null || $record->namespaceGeneration !== $generation) { $items[$key] = $this->genericMiss($key); if (is_string($blob)) { - $stale[] = $mapped; + $stale[] = [$mapped, $blob]; } continue; } $items[$key] = $this->genericItemFromRecord($key, $record); } - if ($stale !== []) { - $this->client->deleteMulti($stale); + foreach ($stale as [$mapped, $observed]) { + MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $mapped, + $observed, + self::ATOMIC_TOMBSTONE, + 1, + ); } return $items; @@ -304,7 +313,7 @@ public function save(CacheItemInterface $item): bool return $this->client->set( $this->mapData($item->getKey()), $this->encodeItem($item, $expiration['expiresAt'], $this->namespaceGeneration()), - $expiration['ttl'] ?? 0, + MemcachedExpiration::fromRelative($expiration['ttl']), ); } @@ -324,8 +333,8 @@ public function saveItems(array $items): bool continue; } - $ttl = $expiration['ttl'] ?? 0; - $groups[$ttl][$this->mapData($item->getKey())] = $this->encodeItem( + $memcachedExpiration = MemcachedExpiration::fromRelative($expiration['ttl']); + $groups[$memcachedExpiration][$this->mapData($item->getKey())] = $this->encodeItem( $item, $expiration['expiresAt'], $generation, @@ -335,16 +344,26 @@ public function saveItems(array $items): bool if (!$this->deleteItems($expired)) { return false; } - foreach ($groups as $ttl => $records) { - if (!$this->client->setMulti($records, (int) $ttl)) { - return false; - } - } - return true; + return array_all( + $groups, + fn(array $records, int|string $memcachedExpiration): bool => $this->client->setMulti( + $records, + $memcachedExpiration, + ), + ); } - /** @return array{value:string, cas:int|float}|null */ + private function deleteResultSucceeded(): bool + { + return in_array( + $this->client->getResultCode(), + [\Memcached::RES_SUCCESS, \Memcached::RES_NOTFOUND], + true, + ); + } + + /** @return array{value:string, cas:float}|null */ private function extendedGet(string $key): ?array { $value = $this->client->get($key, null, \Memcached::GET_EXTENDED); @@ -356,7 +375,7 @@ private function extendedGet(string $key): ?array return null; } - return ['value' => $value['value'], 'cas' => $cas]; + return ['value' => $value['value'], 'cas' => (float) $cas]; } private function generationKey(): string @@ -364,6 +383,25 @@ private function generationKey(): string return $this->namespace . ':m:generation'; } + private function initializeGeneration(string $key, mixed $observed, string $failureMessage): string + { + $generation = self::normalizeGeneration($observed); + if ($generation !== null) { + return $generation; + } + + $candidate = self::newGeneration(); + $current = is_string($observed) + ? MemcachedValueGuard::replaceIfUnchanged($this->client, $key, $observed, $candidate) + : ($this->client->add($key, $candidate) ? $candidate : $this->client->get($key)); + $generation = self::normalizeGeneration($current); + if ($generation === null) { + throw new RuntimeException($failureMessage); + } + + return $generation; + } + private function mapData(string $key): string { return $this->namespace . ':d:' . $key; @@ -377,24 +415,12 @@ private function mapTag(string $tag): string private function namespaceGeneration(mixed $value = null): string { $value ??= $this->client->get($this->generationKey()); - $generation = self::normalizeGeneration($value); - if ($generation !== null) { - return $generation; - } - - $candidate = self::newGeneration(); - $value = $this->client->add($this->generationKey(), $candidate) - ? $candidate - : $this->client->get($this->generationKey()); - $generation = self::normalizeGeneration($value); - if ($generation === null) { - $generation = self::newGeneration(); - if (!$this->client->set($this->generationKey(), $generation)) { - throw new RuntimeException('Unable to initialize Memcached namespace generation.'); - } - } - return $generation; + return $this->initializeGeneration( + $this->generationKey(), + $value, + 'Unable to initialize Memcached namespace generation.', + ); } private function recordTagsAreCurrent(CacheRecord $record): bool @@ -403,8 +429,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } @@ -415,23 +442,10 @@ private function recordTagsAreCurrent(CacheRecord $record): bool private function tagGeneration(string $key, mixed $value): string { - $generation = self::normalizeGeneration($value); - if ($generation !== null) { - return $generation; - } - - $candidate = self::newGeneration(); - $generation = self::normalizeGeneration( - $this->client->add($key, $candidate) ? $candidate : $this->client->get($key), + return $this->initializeGeneration( + $key, + $value, + 'Unable to initialize Memcached tag generation.', ); - if ($generation !== null) { - return $generation; - } - $generation = self::newGeneration(); - if (!$this->client->set($key, $generation)) { - throw new RuntimeException('Unable to initialize Memcached tag generation.'); - } - - return $generation; } } diff --git a/src/Cache/Adapter/MemcachedExpiration.php b/src/Cache/Adapter/MemcachedExpiration.php new file mode 100644 index 00000000..6b6d0ed1 --- /dev/null +++ b/src/Cache/Adapter/MemcachedExpiration.php @@ -0,0 +1,30 @@ + PHP_INT_MAX - $now) { + throw new CacheInvalidArgumentException('Memcached expiration exceeds the supported timestamp range.'); + } + + return $now + $seconds; + } +} diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index ac894340..e7559058 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -80,7 +80,7 @@ public function atomicCompareAndSet( return false; } - $record = $this->recordFromRow($row); + $record = $this->recordFromRow($key, $row); if (!$record instanceof CacheRecord || $record->tags !== [] || $record->value !== $expected) { return false; } @@ -95,9 +95,11 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $document = $this->collection->findOneAndDelete(['_id' => $this->mapData($key)]); $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); - $record = is_array($row) ? $this->recordFromRow($row) : null; + $record = is_array($row) ? $this->recordFromRow($key, $row) : null; return $record instanceof CacheRecord ? $this->genericItemFromRecord($key, $record) @@ -121,7 +123,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return true; } - $replaced = $this->tryReplaceInvalidAtomic($id, $replacement); + $replaced = $this->tryReplaceInvalidAtomic($item->getKey(), $id, $replacement); if ($replaced !== null) { return $replaced; } @@ -140,6 +142,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $this->collection->deleteOne(['_id' => $this->mapData($key)]); return true; @@ -151,6 +154,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys !== []) { $this->collection->deleteMany([ '_id' => ['$in' => array_map($this->mapData(...), $keys)], @@ -174,7 +178,7 @@ public function getItem(string $key): CacheItem return $this->genericFromBlobWithInvalidator( $key, $payload, - fn(): bool => $this->deleteItem($key), + static fn(): bool => true, ); } @@ -186,30 +190,46 @@ public function getItem(string $key): CacheItem public function getTagGenerations(array $tags): array { $generations = $this->readTagGenerations($tags); - $missing = []; foreach ($tags as $tag) { - if (!isset($generations[$tag])) { - $missing[$tag] = self::newGeneration(); + if (isset($generations[$tag])) { + continue; + } + + $candidate = self::newGeneration(); + + try { + $this->collection->updateOne( + ['_id' => $this->mapTag($tag)], + [ + '$setOnInsert' => [ + 'ns' => $this->ns, + 'kind' => 'metadata', + 'tag' => $tag, + 'generation' => $candidate, + ], + ], + ['upsert' => true], + ); + } catch (Throwable $failure) { + if (!$this->isDuplicateKeyFailure($failure)) { + throw $failure; + } } } - if ($missing !== [] && !$this->storeTagGenerations($missing)) { - throw new RuntimeException('Unable to initialize MongoDB tag generations.'); + + $actual = $this->readTagGenerations($tags); + foreach ($tags as $tag) { + if (!isset($actual[$tag])) { + throw new RuntimeException('Unable to initialize MongoDB tag generations.'); + } } - return $generations + $missing; + return $actual; } public function hasItem(string $key): bool { - $count = $this->collection->countDocuments([ - '_id' => $this->mapData($key), - '$or' => [ - ['expires' => null], - ['expires' => ['$gt' => time()]], - ], - ]); - - return is_numeric($count) && (int) $count > 0; + return $this->getItem($key)->isHit(); } /** @@ -233,17 +253,15 @@ public function multiFetch(array $keys): array } $items = []; - $stale = []; foreach ($keys as $key) { $row = $byId[$this->mapData($key)] ?? null; $payload = is_array($row) ? $this->binaryString($row['payload'] ?? null) : null; - $item = $this->genericFromBlobWithInvalidator($key, $payload, static fn(): bool => true); - $items[$key] = $item; - if (is_array($row) && !$item->isHit()) { - $stale[] = $key; - } + $items[$key] = $this->genericFromBlobWithInvalidator( + $key, + $payload, + static fn(): bool => true, + ); } - $this->deleteItems($stale); return $items; } @@ -346,6 +364,7 @@ public function storeTagGenerations(array $generations): bool { $operations = []; foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } @@ -431,13 +450,13 @@ private function matchedCount(mixed $result): int } /** @param array $row */ - private function recordFromRow(array $row): ?CacheRecord + private function recordFromRow(string $key, array $row): ?CacheRecord { $payload = $this->binaryString($row['payload'] ?? null); if (!is_string($payload)) { return null; } - $record = $this->decodeRecordFromBlob($payload); + $record = $this->decodeRecordFromBlob($payload, $key); if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return null; } @@ -451,8 +470,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } @@ -481,7 +501,7 @@ private function tryAtomicInsert(string $id, array $replacement): bool * @param array{ns:string, kind:string, payload:mixed, expires:int|null} $replacement * @return bool|null True when replaced, false when a live/non-replaceable value exists, null on a race retry. */ - private function tryReplaceInvalidAtomic(string $id, array $replacement): ?bool + private function tryReplaceInvalidAtomic(string $key, string $id, array $replacement): ?bool { $row = AdapterValueNormalizer::fromJsonOrArrayLike( $this->collection->findOne(['_id' => $id]), @@ -489,7 +509,7 @@ private function tryReplaceInvalidAtomic(string $id, array $replacement): ?bool if (!is_array($row)) { return null; } - if ($this->recordFromRow($row) instanceof CacheRecord || !array_key_exists('payload', $row)) { + if ($this->recordFromRow($key, $row) instanceof CacheRecord || !array_key_exists('payload', $row)) { return false; } diff --git a/src/Cache/Adapter/MongoDbClientFactory.php b/src/Cache/Adapter/MongoDbClientFactory.php new file mode 100644 index 00000000..221c96c0 --- /dev/null +++ b/src/Cache/Adapter/MongoDbClientFactory.php @@ -0,0 +1,22 @@ +discardDeferredKey($key); unset($key); return true; @@ -29,6 +30,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); unset($keys); return true; @@ -36,7 +38,7 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** @param list $tags */ @@ -53,9 +55,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - unset($key); - - return false; + return $this->getItem($key)->isHit(); } /** @@ -67,7 +67,7 @@ public function multiFetch(array $keys): array { $items = []; foreach ($keys as $key) { - $items[$key] = new CacheItem($this, $key); + $items[$key] = $this->genericMiss($key); } return $items; @@ -90,12 +90,6 @@ public function save(CacheItemInterface $item): bool /** @param array $items */ public function saveItems(array $items): bool { - foreach ($items as $item) { - if (!$this->supportsItem($item)) { - return false; - } - } - - return true; + return $this->supportsItems($items); } } diff --git a/src/Cache/Adapter/PdoAtomicOperations.php b/src/Cache/Adapter/PdoAtomicOperations.php index 6b8b3798..ea40fd41 100644 --- a/src/Cache/Adapter/PdoAtomicOperations.php +++ b/src/Cache/Adapter/PdoAtomicOperations.php @@ -28,7 +28,7 @@ public function atomicCompareAndSet( return $this->atomicTransaction(function () use ($key, $expected, $replacement, $expiration): bool { $row = $this->atomicFetchRow($key); - $record = $row === null ? null : $this->atomicRecordFromRow($row); + $record = $row === null ? null : $this->atomicRecordFromRow($key, $row); if (!$record instanceof CacheRecord || !$this->atomicRecordTagsAreCurrent($record) || $record->value !== $expected) { @@ -45,6 +45,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + if (!$this->supportsAtomicCache()) { return $this->genericMiss($key); } @@ -55,7 +57,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->atomicRecordFromRow($row); + $record = $this->atomicRecordFromRow($key, $row); $this->deleteItem($key); if (!$record instanceof CacheRecord || !$this->atomicRecordTagsAreCurrent($record)) { return $this->genericMiss($key); @@ -78,7 +80,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return $this->atomicTransaction(function () use ($item, $expiration): bool { $key = $item->getKey(); $row = $this->atomicFetchRow($key); - $record = $row === null ? null : $this->atomicRecordFromRow($row); + $record = $row === null ? null : $this->atomicRecordFromRow($key, $row); if ($record instanceof CacheRecord && $this->atomicRecordTagsAreCurrent($record)) { return false; } @@ -141,13 +143,13 @@ private function atomicInsertIfMissing(string $key, string $payload, ?int $expir } /** @param array{payload:string, expires:int|null} $row */ - private function atomicRecordFromRow(array $row): ?CacheRecord + private function atomicRecordFromRow(string $key, array $row): ?CacheRecord { if (CachePayloadCodec::isExpired($row['expires'])) { return null; } - $record = $this->decodeRecordFromBlob($row['payload']); + $record = $this->decodeRecordFromBlob($row['payload'], $key); return $record instanceof CacheRecord ? $record : null; } @@ -158,8 +160,9 @@ private function atomicRecordTagsAreCurrent(CacheRecord $record): bool return true; } - $rows = $this->fetchRows(self::KIND_TAG, array_keys($record->tags)); + $rows = $this->fetchRows(self::KIND_TAG, array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($rows[$tag]['payload'] ?? null) !== $generation) { return false; } diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 0433f84f..63cb1c2e 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Support\FilesystemTrust; use PDO; use PDOException; use Psr\Cache\CacheItemInterface; @@ -33,8 +34,10 @@ final class PdoCacheAdapter extends AbstractCacheAdapter implements ConditionalA public function __construct( string $namespace = 'default', + #[\SensitiveParameter] ?string $dsn = null, ?string $username = null, + #[\SensitiveParameter] ?string $password = null, ?PDO $pdo = null, string $table = 'cachelayer_entries', @@ -47,7 +50,16 @@ public function __construct( $this->namespace = CacheInput::namespace($namespace); $this->table = $table; $resolvedDsn = $dsn ?? 'sqlite:' . self::defaultSqliteFileForNamespace($this->namespace); - $this->pdo = $pdo ?? new PDO($resolvedDsn, $username, $password); + self::assertSqliteTarget($resolvedDsn); + if ($pdo instanceof PDO) { + $this->pdo = $pdo; + } else { + try { + $this->pdo = new PDO($resolvedDsn, $username, $password); + } catch (PDOException) { + throw new RuntimeException('Unable to connect to the PDO cache backend.'); + } + } $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $this->driver = is_string($driver) ? $driver : ''; @@ -64,7 +76,7 @@ public static function defaultSqliteFileForNamespace(string $namespace): string $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_SQLITE_DIR); - if (is_link($directory)) { + if (FilesystemTrust::containsSymlink($directory)) { throw new RuntimeException("Refusing symlinked SQLite cache directory: {$directory}"); } if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { @@ -89,6 +101,8 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); + $statement = $this->pdo->prepare( "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", ); @@ -99,6 +113,8 @@ public function deleteItem(string $key): bool /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); + return $this->deleteByKind(self::KIND_DATA, $keys); } @@ -116,12 +132,8 @@ public function getItem(string $key): CacheItem } $item = $this->hydrate($key, $row); - if ($item instanceof CacheItem) { - return $item; - } - $this->deleteItem($key); - return $this->genericMiss($key); + return $item ?? $this->genericMiss($key); } /** @@ -131,27 +143,13 @@ public function getItem(string $key): CacheItem #[\Override] public function getTagGenerations(array $tags): array { - if ($tags === []) { - return []; - } - - $rows = $this->fetchRows(self::KIND_TAG, $tags); - $generations = []; - $initialize = []; - foreach ($tags as $tag) { - $row = $rows[$tag] ?? null; - $generation = is_array($row) ? $row['payload'] : null; - if (!self::isGeneration($generation)) { - $generation = self::newGeneration(); - $initialize[] = [self::KIND_TAG, $tag, $generation, null]; - } - $generations[$tag] = strtolower((string) $generation); - } - if (!$this->upsertRows($initialize)) { - throw new RuntimeException('Unable to initialize PDO tag generations.'); - } - - return $generations; + return PdoTagGenerationStore::getOrInitialize( + $this->pdo, + $this->driver, + $this->table, + $this->namespace, + $tags, + ); } public function hasItem(string $key): bool @@ -167,16 +165,11 @@ public function multiFetch(array $keys): array { $rows = $this->fetchRows(self::KIND_DATA, $keys); $items = []; - $stale = []; foreach ($keys as $key) { $row = $rows[$key] ?? null; $item = is_array($row) ? $this->hydrate($key, $row) : null; $items[$key] = $item ?? $this->genericMiss($key); - if (is_array($row) && $item === null) { - $stale[] = $key; - } } - $this->deleteByKind(self::KIND_DATA, $stale); return $items; } @@ -192,13 +185,14 @@ public function pruneExpired(int $limit = 1000): int ); $statement->bindValue(1, $this->namespace, PDO::PARAM_STR); $statement->bindValue(2, self::KIND_DATA, PDO::PARAM_STR); - $statement->bindValue(3, time(), PDO::PARAM_INT); + $cutoff = time(); + $statement->bindValue(3, $cutoff, PDO::PARAM_INT); $statement->bindValue(4, $limit, PDO::PARAM_INT); $statement->execute(); $keys = $statement->fetchAll(PDO::FETCH_COLUMN); $keys = array_values(array_filter($keys, is_string(...))); - return $this->deleteByKind(self::KIND_DATA, $keys) ? count($keys) : 0; + return $this->deleteExpiredKeys($keys, $cutoff); } /** @param list $tags */ @@ -257,6 +251,24 @@ public function saveItems(array $items): bool return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } + private static function assertSqliteTarget(string $dsn): void + { + if (!str_starts_with($dsn, 'sqlite:')) { + return; + } + + $file = substr($dsn, strlen('sqlite:')); + if ($file === '' || $file === ':memory:') { + return; + } + if (FilesystemTrust::containsSymlink($file)) { + throw new RuntimeException("Refusing symlinked SQLite cache path: {$file}"); + } + if (file_exists($file) && !is_file($file)) { + throw new RuntimeException("SQLite cache path is not a regular file: {$file}"); + } + } + /** @param list $keys */ private function deleteByKind(string $kind, array $keys): bool { @@ -273,6 +285,25 @@ private function deleteByKind(string $kind, array $keys): bool return true; } + /** @param list $keys */ + private function deleteExpiredKeys(array $keys, int $cutoff): int + { + $deleted = 0; + foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { + $marks = implode(',', array_fill(0, count($chunk), '?')); + $statement = $this->pdo->prepare( + "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? " + . "AND expires IS NOT NULL AND expires <= ? AND cache_key IN ({$marks})", + ); + if (!$statement->execute([$this->namespace, self::KIND_DATA, $cutoff, ...$chunk])) { + return $deleted; + } + $deleted += $statement->rowCount(); + } + + return $deleted; + } + /** * @param list $keys * @return array @@ -315,7 +346,7 @@ private function hydrate(string $key, array $row): ?CacheItem return null; } - $record = $this->decodeRecordFromBlob($row['payload']); + $record = $this->decodeRecordFromBlob($row['payload'], $key); return $record === null ? null : $this->genericItemFromRecord($key, $record); } @@ -383,12 +414,9 @@ private function upsertGenericRows(array $rows): bool /** @param list $rows */ private function upsertRows(array $rows): bool { - foreach (array_chunk($rows, self::BATCH_SIZE) as $chunk) { - if (!$this->upsertChunk($chunk)) { - return false; - } - } - - return true; + return array_all( + array_chunk($rows, self::BATCH_SIZE), + fn(array $chunk): bool => $this->upsertChunk($chunk), + ); } } diff --git a/src/Cache/Adapter/PdoCacheSchema.php b/src/Cache/Adapter/PdoCacheSchema.php index 5af9707c..9122ac7c 100644 --- a/src/Cache/Adapter/PdoCacheSchema.php +++ b/src/Cache/Adapter/PdoCacheSchema.php @@ -18,7 +18,9 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): $driverValue = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $driver = is_string($driverValue) ? $driverValue : ''; - $identifier = in_array($driver, ['mysql', 'mariadb'], true) ? 'VARCHAR(191)' : 'TEXT'; + $identifier = in_array($driver, ['mysql', 'mariadb'], true) + ? 'VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin' + : 'TEXT'; $payload = match ($driver) { 'mysql', 'mariadb' => 'MEDIUMBLOB', 'pgsql' => 'BYTEA', @@ -34,6 +36,10 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): PRIMARY KEY (namespace, kind, cache_key) )", ); + if (in_array($driver, ['mysql', 'mariadb'], true) + && !self::mysqlIdentityColumnsAreBinary($pdo, $table)) { + self::hardenMysqlIdentityColumns($pdo, $table); + } $index = $table . '_expires_idx'; @@ -52,4 +58,27 @@ public static function install(PDO $pdo, string $table = 'cachelayer_entries'): $pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$table}(namespace, kind, expires)"); } + + private static function hardenMysqlIdentityColumns(PDO $pdo, string $table): void + { + $pdo->exec( + "ALTER TABLE {$table} " + . 'MODIFY namespace VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY kind VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY cache_key VARCHAR(191) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + ); + } + + private static function mysqlIdentityColumnsAreBinary(PDO $pdo, string $table): bool + { + $statement = $pdo->prepare( + 'SELECT COUNT(*) FROM information_schema.columns ' + . 'WHERE table_schema = DATABASE() AND table_name = ? ' + . "AND column_name IN ('namespace', 'kind', 'cache_key') " + . "AND collation_name = 'ascii_bin'", + ); + $statement->execute([$table]); + + return (int) $statement->fetchColumn() === 3; + } } diff --git a/src/Cache/Adapter/PdoTagGenerationStore.php b/src/Cache/Adapter/PdoTagGenerationStore.php new file mode 100644 index 00000000..eda8f7b2 --- /dev/null +++ b/src/Cache/Adapter/PdoTagGenerationStore.php @@ -0,0 +1,136 @@ + $tags + * @return array + */ + public static function getOrInitialize( + PDO $pdo, + string $driver, + string $table, + string $namespace, + array $tags, + ): array { + if ($tags === []) { + return []; + } + + $stored = self::fetch($pdo, $table, $namespace, $tags); + $generations = []; + $missing = []; + foreach ($tags as $tag) { + $generation = self::normalize($stored[$tag] ?? null); + if ($generation !== null) { + $generations[$tag] = $generation; + + continue; + } + if (array_key_exists($tag, $stored)) { + throw new RuntimeException('PDO tag generation contains invalid state.'); + } + $missing[$tag] = bin2hex(random_bytes(16)); + } + + foreach ($missing as $tag => $candidate) { + self::insertIfMissing($pdo, $driver, $table, $namespace, $tag, $candidate); + } + if ($missing === []) { + return $generations; + } + + $actual = self::fetch($pdo, $table, $namespace, array_keys($missing)); + foreach ($missing as $tag => $_candidate) { + $generation = self::normalize($actual[$tag] ?? null); + if ($generation === null) { + throw new RuntimeException('Unable to initialize PDO tag generation.'); + } + $generations[$tag] = $generation; + } + + return $generations; + } + + /** + * @param list $tags + * @return array + */ + private static function fetch(PDO $pdo, string $table, string $namespace, array $tags): array + { + $stored = []; + foreach (array_chunk($tags, self::BATCH_SIZE) as $chunk) { + $marks = implode(',', array_fill(0, count($chunk), '?')); + $statement = $pdo->prepare( + "SELECT cache_key, payload FROM {$table} " + . "WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", + ); + $statement->execute([$namespace, self::KIND_TAG, ...$chunk]); + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (!is_array($row) || !is_string($row['cache_key'] ?? null)) { + continue; + } + $payload = $row['payload'] ?? null; + if (is_resource($payload)) { + $payload = stream_get_contents($payload); + } + if (is_string($payload)) { + $stored[$row['cache_key']] = $payload; + } + } + } + + return $stored; + } + + private static function insertIfMissing( + PDO $pdo, + string $driver, + string $table, + string $namespace, + string $tag, + string $generation, + ): void { + $sql = match ($driver) { + 'pgsql', 'sqlite' => "INSERT INTO {$table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' + . 'ON CONFLICT(namespace, kind, cache_key) DO NOTHING', + 'mysql', 'mariadb' => "INSERT INTO {$table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL) ' + . 'ON DUPLICATE KEY UPDATE cache_key = cache_key', + default => "INSERT INTO {$table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, NULL)', + }; + + try { + $pdo->prepare($sql)->execute([$namespace, self::KIND_TAG, $tag, $generation]); + } catch (PDOException $failure) { + if (in_array($driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { + throw $failure; + } + if (self::normalize(self::fetch($pdo, $table, $namespace, [$tag])[$tag] ?? null) === null) { + throw $failure; + } + } + } + + private static function normalize(mixed $value): ?string + { + return is_string($value) && strlen($value) === 32 && ctype_xdigit($value) + ? strtolower($value) + : null; + } +} diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index f72d6ef9..5b03b861 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -49,12 +49,16 @@ public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInter public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + return $this->withKeyLock($key, function () use ($key): CacheItemInterface { $record = $this->readLiveRecordUnlocked($key); if (!$record instanceof CacheRecord) { return $this->genericMiss($key); } - $this->deleteItemUnlocked($key); + if (!$this->deleteItemUnlocked($key)) { + throw new RuntimeException('Unable to delete consumed PHP-file cache entry.'); + } return $this->genericItemFromRecord($key, $record); }); @@ -96,12 +100,16 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); + return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); + $ok = true; foreach ($keys as $key) { $ok = $this->deleteItem($key) && $ok; @@ -166,13 +174,10 @@ public function multiFetch(array $keys): array #[\Override] public function rotateTagGenerations(array $tags): bool { - foreach ($tags as $tag) { - if (!$this->atomicReplace($this->metadataFileFor($tag), self::newGeneration())) { - return false; - } - } - - return true; + return array_all( + $tags, + fn(string $tag): bool => $this->atomicReplace($this->metadataFileFor($tag), self::newGeneration()), + ); } public function save(CacheItemInterface $item): bool @@ -203,12 +208,12 @@ private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = CacheInput::namespace($ns); - $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; - $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; - $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; - $this->lockDirectory = $root . 'locks' . DIRECTORY_SEPARATOR; + $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns; + $this->dataDirectory = $root . DIRECTORY_SEPARATOR . 'data' . DIRECTORY_SEPARATOR; + $this->metadataDirectory = $root . DIRECTORY_SEPARATOR . 'meta' . DIRECTORY_SEPARATOR; + $this->lockDirectory = $root . DIRECTORY_SEPARATOR . 'locks' . DIRECTORY_SEPARATOR; - foreach ([$baseDir, $this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { + foreach ([$baseDir, $root, $this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { $this->assertPathNotSymlink($directory, 'PHP cache directory'); if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { throw new RuntimeException("Unable to create PHP cache directory: {$directory}"); @@ -232,7 +237,7 @@ private function deleteItemUnlocked(string $key): bool $file = $this->fileFor($key); $this->invalidateOpcache($file); - return !is_file($file) || unlink($file); + return $this->deleteFile($file); } private function fileFor(string $key): string @@ -300,7 +305,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord $row = require $file; $payload = is_array($row) && is_string($row['p'] ?? null) ? $row['p'] : null; $blob = is_string($payload) ? base64_decode($payload, true) : false; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return null; } @@ -311,6 +316,7 @@ private function readLiveRecordUnlocked(string $key): ?CacheRecord private function recordTagsAreCurrent(CacheRecord $record): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; $current = is_file($this->metadataFileFor($tag)) ? file_get_contents($this->metadataFileFor($tag)) : false; @@ -330,6 +336,7 @@ private function recordTagsAreCurrent(CacheRecord $record): bool private function withKeyLock(string $key, callable $callback): mixed { $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; + $this->assertPathNotSymlink($path, 'PHP-file cache key lock'); $handle = fopen($path, 'c'); if (!is_resource($handle) || !flock($handle, LOCK_EX)) { if (is_resource($handle)) { diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index ce389b23..d452b4c1 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -9,6 +9,7 @@ use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Support\RedisConnection; +use Infocyph\CacheLayer\Support\RedisValueGuard; use InvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -78,6 +79,7 @@ class RedisCacheAdapter extends AbstractCacheAdapter implements AtomicCachePoolI */ public function __construct( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'redis://127.0.0.1:6379', ?\Redis $client = null, ) { @@ -109,7 +111,7 @@ public function atomicCompareAndSet( if (!is_string($existing)) { return false; } - $record = $this->decodeRecordFromBlob($existing); + $record = $this->decodeRecordFromBlob($existing, $key); if (!$record instanceof CacheRecord || $record->tags !== [] || $record->value !== $expected) { return false; } @@ -126,12 +128,14 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $raw = $this->redis->eval(self::GET_AND_DELETE_SCRIPT, [$this->map($key)], 1); if (!is_string($raw)) { return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($raw); + $record = $this->decodeRecordFromBlob($raw, $key); if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { return $this->genericMiss($key); } @@ -166,7 +170,7 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return (bool) $this->redis->set($key, $blob, $options); } - $record = $this->decodeRecordFromBlob($existing); + $record = $this->decodeRecordFromBlob($existing, $item->getKey()); if ($record instanceof CacheRecord && $this->recordTagsAreCurrent($record)) { return false; } @@ -182,13 +186,15 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool public function clear(): bool { - $cursor = null; - do { - $keys = $this->redis->scan($cursor, $this->ns . ':*', 1000); - if ($keys) { - $this->redis->del($keys); - } - } while ($cursor); + foreach ([$this->ns . ':d:*', $this->ns . ':m:*'] as $pattern) { + $cursor = null; + do { + $keys = $this->redis->scan($cursor, $pattern, 1000); + if ($keys) { + $this->redis->del($keys); + } + } while ($cursor); + } $this->deferred = []; return true; @@ -196,6 +202,8 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); + return $this->redis->del($this->map($key)) !== false; } @@ -205,6 +213,8 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); + if ($keys === []) { return true; } @@ -223,14 +233,14 @@ public function getItem(string $key): CacheItem { $raw = $this->redis->get($this->map($key)); if (is_string($raw)) { - $record = $this->decodeRecordFromBlob($raw); + $record = $this->decodeRecordFromBlob($raw, $key); if ($record !== null) { return $this->genericItemFromRecord($key, $record); } - $this->redis->del($this->map($key)); + RedisValueGuard::deleteIfUnchanged($this->redis, $this->map($key), $raw); } - return new CacheItem($this, $key); + return $this->genericMiss($key); } /** @param list $tags */ @@ -261,7 +271,7 @@ public function getTagGenerations(array $tags): array public function hasItem(string $key): bool { - return $this->redis->exists($this->map($key)) === 1; + return $this->getItem($key)->isHit(); } /** @@ -293,19 +303,19 @@ public function multiFetch(array $keys): array continue; } - $record = $this->decodeRecordFromBlob($v); + $record = $this->decodeRecordFromBlob($v, $k); if ($record !== null) { $items[$k] = $this->genericItemFromRecord($k, $record); continue; } - $stale[] = $this->map($k); + $stale[] = [$this->map($k), $v]; } $items[$k] = new CacheItem($this, $k); } - if ($stale !== []) { - $this->redis->del($stale); + foreach ($stale as [$mapped, $observed]) { + RedisValueGuard::deleteIfUnchanged($this->redis, $mapped, $observed); } return $items; @@ -384,13 +394,33 @@ public function saveItems(array $items): bool return $ok && $this->saveExpiring($expiring); } - private function connect(string $dsn): \Redis + private function connect(#[\SensitiveParameter] string $dsn): \Redis { try { return RedisConnection::connect($dsn); } catch (InvalidArgumentException $exception) { - throw new RuntimeException("Invalid Redis DSN: $dsn", 0, $exception); + throw new RuntimeException('Invalid Redis-compatible DSN.', 0, $exception); + } + } + + private function initializeTagGeneration(string $tag, mixed $observed): string + { + $candidate = self::newGeneration(); + $key = $this->mapTag($tag); + $current = is_string($observed) + ? RedisValueGuard::replaceIfUnchanged($this->redis, $key, $observed, $candidate) + : false; + if ($current === false) { + $stored = $this->redis->set($key, $candidate, ['nx']); + $current = $stored ? $candidate : $this->redis->get($key); + } + + $generation = self::normalizeGeneration($current); + if ($generation === null) { + throw new RuntimeException('Unable to initialize Redis tag generation.'); } + + return $generation; } /** @@ -401,20 +431,7 @@ private function initializeTagGenerations(array $missing): array { $generations = []; foreach ($missing as $tag => $value) { - $candidate = self::newGeneration(); - $key = $this->mapTag($tag); - if ($value === false || $value === null) { - $stored = $this->redis->set($key, $candidate, ['nx']); - $current = $stored ? $candidate : $this->redis->get($key); - } else { - $this->redis->set($key, $candidate); - $current = $candidate; - } - $generation = self::normalizeGeneration($current); - if ($generation === null) { - throw new RuntimeException('Unable to initialize Redis tag generation.'); - } - $generations[$tag] = $generation; + $generations[$tag] = $this->initializeTagGeneration((string) $tag, $value); } return $generations; @@ -436,8 +453,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } diff --git a/src/Cache/Adapter/RedisClusterAtomicOperations.php b/src/Cache/Adapter/RedisClusterAtomicOperations.php index 02491cc2..74e0febe 100644 --- a/src/Cache/Adapter/RedisClusterAtomicOperations.php +++ b/src/Cache/Adapter/RedisClusterAtomicOperations.php @@ -78,7 +78,7 @@ public function atomicCompareAndSet( if (!is_string($state['existing'])) { return false; } - $record = $this->decodeRecordFromBlob($state['existing']); + $record = $this->decodeRecordFromBlob($state['existing'], $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $state['generation'] || $record->tags !== [] @@ -106,6 +106,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $bucket = $this->bucket($key); $result = $this->call( 'eval', @@ -123,7 +125,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); if (!$record instanceof CacheRecord || $record->namespaceGeneration !== $generation || !$this->recordTagsAreCurrent($record)) { @@ -182,7 +184,7 @@ private function atomicSetIfAbsentAttempt(CacheItemInterface $item, array $expir $replaceStale = false; $expectedExisting = ''; if (is_string($state['existing'])) { - $record = $this->decodeRecordFromBlob($state['existing']); + $record = $this->decodeRecordFromBlob($state['existing'], $item->getKey()); if ($record instanceof CacheRecord && $record->namespaceGeneration === $state['generation'] && $this->recordTagsAreCurrent($record)) { diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 0914711d..6272905e 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -60,19 +60,20 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); + return $this->call('del', $this->mapData($key)) !== false; } /** @param list $keys */ public function deleteItems(array $keys): bool { - foreach ($this->groupByBucket($keys) as $group) { - if ($this->call('del', array_map($this->mapData(...), $group)) === false) { - return false; - } - } + $this->discardDeferredKeys($keys); - return true; + return array_all( + $this->groupByBucket($keys), + fn(array $group): bool => $this->call('del', array_map($this->mapData(...), $group)) !== false, + ); } public function getClient(): object @@ -87,13 +88,10 @@ public function getItem(string $key): CacheItem $values = is_array($values) ? array_values($values) : []; $generation = $this->namespaceGeneration($bucket, $values[0] ?? null); $blob = $values[1] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } - if (is_string($blob)) { - $this->call('del', $this->mapData($key)); - } return $this->genericMiss($key); } @@ -109,17 +107,12 @@ public function getTagGenerations(array $tags): array foreach ($this->groupByBucket($tags) as $group) { $values = $this->call('mget', array_map($this->mapTag(...), $group)); $values = is_array($values) ? array_values($values) : []; - $initialize = []; foreach ($group as $index => $tag) { - $generation = self::normalizeGeneration($values[$index] ?? null); - if ($generation === null) { - $generation = self::newGeneration(); - $initialize[$this->mapTag($tag)] = $generation; - } - $generations[$tag] = $generation; - } - if ($initialize !== [] && !$this->call('mset', $initialize)) { - throw new RuntimeException('Unable to initialize Redis Cluster tag generations.'); + $generations[$tag] = $this->initializeGeneration( + $this->mapTag($tag), + $values[$index] ?? null, + 'Unable to initialize Redis Cluster tag generation.', + ); } } @@ -138,13 +131,9 @@ public function hasItem(string $key): bool public function multiFetch(array $keys): array { $items = []; - $stale = []; foreach ($this->groupByBucket($keys) as $bucket => $group) { - $bucketResult = $this->fetchBucket($bucket, $group); - $items += $bucketResult['items']; - $stale = [...$stale, ...$bucketResult['stale']]; + $items += $this->fetchBucket($bucket, $group); } - $this->deleteItems($stale); $ordered = []; foreach ($keys as $key) { @@ -197,13 +186,11 @@ public function saveItems(array $items): bool return false; } } - foreach ($this->groupItemsByBucket($items) as $bucket => $group) { - if (!$this->saveBucket($bucket, $group)) { - return false; - } - } - return true; + return array_all( + $this->groupItemsByBucket($items), + fn(array $group, int $bucket): bool => $this->saveBucket($bucket, $group), + ); } private function bucket(string $key): int @@ -228,7 +215,7 @@ private function callObject(object $target, string $method, mixed ...$arguments) /** * @param list $keys - * @return array{items: array, stale: list} + * @return array */ private function fetchBucket(int $bucket, array $keys): array { @@ -237,22 +224,18 @@ private function fetchBucket(int $bucket, array $keys): array $values = is_array($values) ? array_values($values) : []; $generation = $this->namespaceGeneration($bucket, $values[0] ?? null); $items = []; - $stale = []; foreach ($keys as $index => $key) { $blob = $values[$index + 1] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; if ($record !== null && $record->namespaceGeneration === $generation) { $items[$key] = $this->genericItemFromRecord($key, $record); continue; } $items[$key] = $this->genericMiss($key); - if (is_string($blob)) { - $stale[] = $key; - } } - return ['items' => $items, 'stale' => $stale]; + return $items; } private function generationKey(int $bucket): string @@ -288,6 +271,24 @@ private function groupItemsByBucket(array $items): array return $groups; } + private function initializeGeneration(string $key, mixed $observed, string $failureMessage): string + { + $generation = self::normalizeGeneration($observed); + if ($generation !== null) { + return $generation; + } + + $candidate = self::newGeneration(); + $stored = $this->call('set', $key, $candidate, ['nx']); + $current = $stored ? $candidate : $this->call('get', $key); + $generation = self::normalizeGeneration($current); + if ($generation === null) { + throw new RuntimeException($failureMessage); + } + + return $generation; + } + private function mapData(string $key): string { return $this->prefix($this->bucket($key)) . ':d:' . $key; @@ -300,24 +301,14 @@ private function mapTag(string $tag): string private function namespaceGeneration(int $bucket, mixed $value = null): string { - $value ??= $this->call('get', $this->generationKey($bucket)); - $generation = self::normalizeGeneration($value); - if ($generation !== null) { - return $generation; - } - - $candidate = self::newGeneration(); - $stored = $this->call('set', $this->generationKey($bucket), $candidate, ['nx']); - $current = $stored ? $candidate : $this->call('get', $this->generationKey($bucket)); - $generation = self::normalizeGeneration($current); - if ($generation === null) { - $generation = self::newGeneration(); - if (!$this->call('set', $this->generationKey($bucket), $generation)) { - throw new RuntimeException('Unable to initialize Redis Cluster namespace generation.'); - } - } - - return $generation; + $key = $this->generationKey($bucket); + $value ??= $this->call('get', $key); + + return $this->initializeGeneration( + $key, + $value, + 'Unable to initialize Redis Cluster namespace generation.', + ); } private function prefix(int $bucket): string @@ -331,8 +322,9 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - $current = $this->getTagGenerations(array_keys($record->tags)); + $current = $this->getTagGenerations(array_map(static fn(int|string $tag): string => (string) $tag, array_keys($record->tags))); foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($current[$tag] ?? null) !== $generation) { return false; } diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index afe28be6..9b94eed5 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -4,10 +4,9 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Cassandra\ExecutionOptions; -use Cassandra\SimpleStatement; use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Support\OptionalCassandra; use Psr\Cache\CacheItemInterface; use RuntimeException; use Traversable; @@ -67,6 +66,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $this->executeCql( "DELETE FROM {$this->qualifiedTable} WHERE ns = ? AND bucket = ? AND ckey = ?", [$this->ns, $this->bucket($key), $this->mapData($key)], @@ -81,6 +81,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); foreach ($this->groupByBucket($keys) as $bucket => $group) { $marks = implode(',', array_fill(0, count($group), '?')); $this->executeCql( @@ -103,17 +104,17 @@ public function getItem(string $key): CacheItem return $this->genericMiss($key); } - $expiresAt = $this->normalizeExpiry($row['expires'] ?? null); + $expiresAt = ScyllaValueNormalizer::expiry($row['expires'] ?? null); if ($expiresAt !== null && $expiresAt <= time()) { - return $this->genericDeleteAndMiss($key); + return $this->genericMiss($key); } - $payload = $this->normalizeString($row['payload'] ?? null); + $payload = ScyllaValueNormalizer::string($row['payload'] ?? null); return $this->genericFromBlobWithInvalidator( $key, $payload, - fn(): bool => $this->deleteItem($key), + static fn(): bool => true, ); } @@ -125,17 +126,26 @@ public function getItem(string $key): CacheItem public function getTagGenerations(array $tags): array { $generations = $this->readTagGenerations($tags); - $missing = []; foreach ($tags as $tag) { - if (!isset($generations[$tag])) { - $missing[$tag] = self::newGeneration(); + if (isset($generations[$tag])) { + continue; } + + $this->executeCql( + "INSERT INTO {$this->metadataTable} (ns, bucket, tag, generation) " + . 'VALUES (?, ?, ?, ?) IF NOT EXISTS', + [$this->ns, $this->bucket($tag), $tag, self::newGeneration()], + ); } - if ($missing !== [] && !$this->storeTagGenerations($missing)) { - throw new RuntimeException('Unable to initialize ScyllaDB tag generations.'); + + $actual = $this->readTagGenerations($tags); + foreach ($tags as $tag) { + if (!isset($actual[$tag])) { + throw new RuntimeException('Unable to initialize ScyllaDB tag generation.'); + } } - return $generations + $missing; + return $actual; } public function hasItem(string $key): bool @@ -151,14 +161,8 @@ public function hasItem(string $key): bool public function multiFetch(array $keys): array { $items = []; - $invalid = []; foreach ($this->groupByBucket($keys) as $bucket => $group) { - $result = $this->fetchBucketItems($bucket, $group); - $items += $result['items']; - array_push($invalid, ...$result['invalid']); - } - if ($invalid !== []) { - $this->deleteItems($invalid); + $items += $this->fetchBucketItems($bucket, $group); } return $items; @@ -177,8 +181,8 @@ public function readTagGenerations(array $tags): array [$this->ns, $bucket, ...$group], ); foreach ($rows as $row) { - $tag = $this->normalizeString($row['tag'] ?? null); - $generation = $this->normalizeString($row['generation'] ?? null); + $tag = ScyllaValueNormalizer::string($row['tag'] ?? null); + $generation = ScyllaValueNormalizer::string($row['generation'] ?? null); $generation = self::normalizeGeneration($generation); if ($tag !== null && $generation !== null) { $generations[$tag] = $generation; @@ -210,8 +214,8 @@ public function save(CacheItemInterface $item): bool $this->ns, $this->bucket($saveItem->getKey()), $this->mapData($saveItem->getKey()), - $this->encodeItem($saveItem, $expires['expiresAt']), - $expires['expiresAt'], + OptionalCassandra::blob($this->encodeItem($saveItem, $expires['expiresAt'])), + OptionalCassandra::bigint($expires['expiresAt']), ]; if ($expires['ttl'] !== null) { $cql .= ' USING TTL ?'; @@ -261,6 +265,7 @@ public function saveItems(array $items): bool public function storeTagGenerations(array $generations): bool { foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } @@ -346,17 +351,12 @@ private function executeCql(string $cql, array $arguments = []): mixed */ private function executionOptions(array $arguments): mixed { - $options = ['arguments' => $arguments]; - if (class_exists(ExecutionOptions::class)) { - return new ExecutionOptions($options); - } - - return $options; + return OptionalCassandra::executionOptions($arguments); } /** * @param list $keys - * @return array{items:array, invalid:list} + * @return array */ private function fetchBucketItems(int $bucket, array $keys): array { @@ -368,27 +368,23 @@ private function fetchBucketItems(int $bucket, array $keys): array ); $byKey = []; foreach ($rows as $row) { - $physical = $this->normalizeString($row['ckey'] ?? null); + $physical = ScyllaValueNormalizer::string($row['ckey'] ?? null); if ($physical !== null) { $byKey[$physical] = $row; } } $items = []; - $invalid = []; foreach ($keys as $key) { $row = $byKey[$this->mapData($key)] ?? null; - $payload = is_array($row) ? $this->normalizeString($row['payload'] ?? null) : null; - $record = $payload === null ? null : $this->decodeRecordFromBlob($payload); + $payload = is_array($row) ? ScyllaValueNormalizer::string($row['payload'] ?? null) : null; + $record = $payload === null ? null : $this->decodeRecordFromBlob($payload, $key); $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); - if (is_array($row) && $record === null) { - $invalid[] = $key; - } } - return ['items' => $items, 'invalid' => $invalid]; + return $items; } /** @@ -425,26 +421,6 @@ private function mapData(string $key): string return 'd:' . $key; } - 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 The rows argument. * @phpstan-param array $rows @@ -463,19 +439,6 @@ private function normalizeRows(array $rows): array 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 string $cql The cql argument. * @param array $arguments The arguments argument. @@ -517,8 +480,8 @@ private function saveBucket(int $bucket, array $items): void $this->ns, $bucket, $this->mapData($item->getKey()), - $this->encodeItem($item, $expiresAt), - $expiresAt, + OptionalCassandra::blob($this->encodeItem($item, $expiresAt)), + OptionalCassandra::bigint($expiresAt), ); if ($ttl !== null) { $arguments[] = $ttl; @@ -537,11 +500,7 @@ private function statementFor(string $cql): mixed return $this->preparedStatements[$cql]; } - if (class_exists(SimpleStatement::class)) { - return new SimpleStatement($cql); - } - - return $cql; + return OptionalCassandra::simpleStatement($cql); } private function supportsSessionMethod(string $method): bool diff --git a/src/Cache/Adapter/ScyllaValueNormalizer.php b/src/Cache/Adapter/ScyllaValueNormalizer.php new file mode 100644 index 00000000..f960cccd --- /dev/null +++ b/src/Cache/Adapter/ScyllaValueNormalizer.php @@ -0,0 +1,53 @@ +toInt(); + + return is_int($intValue) ? $intValue : null; + } + + if (is_float($value) || (is_string($value) && is_numeric($value))) { + return (int) $value; + } + + if (is_object($value) && is_callable([$value, '__toString'])) { + $stringValue = (string) $value; + + return is_numeric($stringValue) ? (int) $stringValue : null; + } + + return null; + } + + public static function string(mixed $value): ?string + { + if (is_string($value)) { + return $value; + } + + if (is_object($value) && is_callable([$value, 'toBinaryString'])) { + $stringValue = $value->toBinaryString(); + + return is_string($stringValue) ? $stringValue : null; + } + + if (is_object($value) && is_callable([$value, '__toString'])) { + return (string) $value; + } + + return null; + } +} diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php index 8aefe330..0fb5bfb1 100644 --- a/src/Cache/Adapter/SecuresFilesystemDirectories.php +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -4,14 +4,15 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Support\FilesystemTrust; use RuntimeException; trait SecuresFilesystemDirectories { protected function assertPathNotSymlink(string $path, string $label): void { - if (is_link($path)) { - throw new RuntimeException($label . " must not be a symlink: {$path}"); + if (FilesystemTrust::containsSymlink($path)) { + throw new RuntimeException($label . " must not contain symlinks: {$path}"); } } @@ -23,14 +24,17 @@ protected function assertSecureDirectory(string $path, string $label): void throw new RuntimeException($label . " must be a writable directory: {$path}"); } - $perms = fileperms($path); - if ($perms !== false && (($perms & 0x0002) === 0x0002)) { - throw new RuntimeException($label . " must not be world-writable: {$path}"); + if (DIRECTORY_SEPARATOR === '/') { + $perms = fileperms($path); + if ($perms !== false && (($perms & 0x0002) === 0x0002)) { + throw new RuntimeException($label . " must not be world-writable: {$path}"); + } } } protected function atomicReplace(string $path, string $contents): bool { + $this->assertPathNotSymlink($path . '.lock', 'Cache metadata lock file'); $lock = fopen($path . '.lock', 'c'); if (!is_resource($lock) || !flock($lock, LOCK_EX)) { if (is_resource($lock)) { @@ -52,4 +56,19 @@ protected function atomicReplace(string $path, string $contents): bool return $stored; } + + protected function deleteFile(string $path): bool + { + if (!is_file($path)) { + return true; + } + + set_error_handler(static fn(): bool => true); + + try { + return unlink($path); + } finally { + restore_error_handler(); + } + } } diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 3e280f6d..4a0d028a 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -70,9 +70,9 @@ public function atomicCompareAndSet( $mapped = $this->map($key); $replacementBlob = $this->encodeItem($replacement, $expiration['expiresAt']); - return $this->withExclusiveLock(function () use ($mapped, $expected, $replacementBlob): bool { + return $this->withExclusiveLock(function () use ($key, $mapped, $expected, $replacementBlob): bool { $store = $this->loadStore(); - $record = $this->cachedRecord($store, $mapped); + $record = $this->cachedRecord($store, $key, $mapped); if (!$record instanceof CacheRecord || $record->value !== $expected) { return false; } @@ -85,6 +85,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $mapped = $this->map($key); return $this->withExclusiveLock(function () use ($key, $mapped): CacheItemInterface { @@ -94,7 +96,7 @@ public function atomicGetAndDelete(string $key): CacheItemInterface return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); unset($store[$mapped]); if (!$this->store($store)) { throw new RuntimeException('Unable to persist shared-memory atomic consume.'); @@ -117,12 +119,13 @@ public function atomicSetIfAbsent(CacheItemInterface $item): bool return false; } - $mapped = $this->map($item->getKey()); + $key = $item->getKey(); + $mapped = $this->map($key); $blob = $this->encodeItem($item, $expiration['expiresAt']); - return $this->withExclusiveLock(function () use ($mapped, $blob): bool { + return $this->withExclusiveLock(function () use ($key, $mapped, $blob): bool { $store = $this->loadStore(); - if ($this->cachedRecord($store, $mapped) instanceof CacheRecord) { + if ($this->cachedRecord($store, $key, $mapped) instanceof CacheRecord) { return false; } @@ -147,6 +150,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $mapped = $this->map($key); return $this->withExclusiveLock(function () use ($mapped): bool { @@ -163,6 +167,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); $mappedKeys = []; foreach ($keys as $key) { $mappedKeys[] = $this->map($key); @@ -192,7 +197,7 @@ function () use ($mapped): ?string { return $this->genericFromBlobWithInvalidator( $key, $blob, - fn(): bool => $this->deleteItem($key), + static fn(): bool => true, ); } @@ -203,18 +208,26 @@ function () use ($mapped): ?string { #[\Override] public function getTagGenerations(array $tags): array { - $generations = $this->readTagGenerations($tags); - $missing = []; - foreach ($tags as $tag) { - if (!isset($generations[$tag])) { - $missing[$tag] = self::newGeneration(); + return $this->withExclusiveLock(function () use ($tags): array { + $store = $this->loadStore(); + $generations = []; + $changed = false; + foreach ($tags as $tag) { + $mapped = $this->mapTag($tag); + $generation = self::normalizeGeneration($store[$mapped] ?? null); + if ($generation === null) { + $generation = self::newGeneration(); + $store[$mapped] = $generation; + $changed = true; + } + $generations[$tag] = $generation; + } + if ($changed && !$this->store($store)) { + throw new RuntimeException('Unable to initialize shared-memory tag generations.'); } - } - if ($missing !== []) { - $this->storeTagGenerations($missing); - } - return $generations + $missing; + return $generations; + }); } public function hasItem(string $key): bool @@ -228,29 +241,20 @@ public function hasItem(string $key): bool */ public function multiFetch(array $keys): array { - [$items, $invalid] = $this->withSharedLock(function () use ($keys): array { + return $this->withSharedLock(function () use ($keys): array { $store = $this->loadStore(); $items = []; - $invalid = []; foreach ($keys as $key) { $mapped = $this->map($key); $blob = $store[$mapped] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); - if ($blob !== null && $record === null) { - $invalid[] = $key; - } } - return [$items, $invalid]; + return $items; }); - if ($invalid !== []) { - $this->deleteItems($invalid); - } - - return $items; } /** @param list $tags */ @@ -336,6 +340,7 @@ public function storeTagGenerations(array $generations): bool return $this->withExclusiveLock(function () use ($generations): bool { $store = $this->loadStore(); foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } @@ -365,14 +370,14 @@ private function attachSegment(int $segmentSize): \SysvSharedMemory } /** @param array $store */ - private function cachedRecord(array $store, string $mapped): ?CacheRecord + private function cachedRecord(array $store, string $key, string $mapped): ?CacheRecord { $blob = $store[$mapped] ?? null; if (!is_string($blob)) { return null; } - $record = $this->decodeRecordFromBlob($blob); + $record = $this->decodeRecordFromBlob($blob, $key); return $record instanceof CacheRecord && $this->recordTagsAreCurrent($record, $store) ? $record @@ -388,6 +393,7 @@ private function createTokenFile(): string . 'shared-memory'; $this->prepareDirectory($directory); $tokenFile = $directory . DIRECTORY_SEPARATOR . hash('xxh128', $this->ns) . '.tok'; + $this->assertPathNotSymlink($tokenFile, 'Shared-memory token file'); if (!is_file($tokenFile)) { if (file_put_contents($tokenFile, '', LOCK_EX) === false) { throw new RuntimeException('Unable to create the shared-memory token file'); @@ -446,6 +452,7 @@ private function mapTag(string $tag): string /** @phpstan-return resource */ private function openLockHandle(): mixed { + $this->assertPathNotSymlink($this->tokenFile, 'Shared-memory token file'); $lockHandle = fopen($this->tokenFile, 'c+'); if (is_resource($lockHandle)) { return $lockHandle; @@ -477,6 +484,7 @@ private function prepareDirectory(string $directory): void private function recordTagsAreCurrent(CacheRecord $record, array $store): bool { foreach ($record->tags as $tag => $generation) { + $tag = (string) $tag; if (($store[$this->mapTag($tag)] ?? null) !== $generation) { return false; } diff --git a/src/Cache/Adapter/TieredCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php index 750c6104..5e7da7a7 100644 --- a/src/Cache/Adapter/TieredCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -14,6 +14,8 @@ final class TieredCacheAdapter extends AbstractCacheAdapter { + private bool $l1Readable = true; + /** @param list $pools */ public function __construct( private readonly array $pools, @@ -25,20 +27,45 @@ public function __construct( } } - public function clear(): bool + #[\Override] + public function assertOptionsCompatible(CacheOptions $options): void + { + parent::assertOptionsCompatible($options); + foreach ($this->pools as $pool) { + if ($pool instanceof AbstractCacheAdapter) { + $pool->assertOptionsCompatible($options); + } + } + } + + #[\Override] + public function assertStorageIdentityCompatible(string $storageIdentity): void { - $cleared = true; + parent::assertStorageIdentityCompatible($storageIdentity); foreach ($this->pools as $pool) { - $cleared = $pool->clear() && $cleared; + if ($pool instanceof AbstractCacheAdapter) { + $pool->assertStorageIdentityCompatible($storageIdentity); + } } - $this->deferred = []; + } + + public function clear(): bool + { + return $this->mutate(function (): bool { + $cleared = true; + foreach ($this->pools as $pool) { + $cleared = $pool->clear() && $cleared; + } + $this->deferred = []; - return $cleared; + return $cleared; + }, reconcile: true); } #[\Override] public function configureOptions(CacheOptions $options): void { + $this->assertOptionsCompatible($options); parent::configureOptions($options); foreach ($this->pools as $pool) { if ($pool instanceof AbstractCacheAdapter) { @@ -47,40 +74,66 @@ public function configureOptions(CacheOptions $options): void } } - public function deleteItem(string $key): bool + #[\Override] + public function configureStorageIdentity(string $storageIdentity): void { - $deleted = true; + $this->assertStorageIdentityCompatible($storageIdentity); + parent::configureStorageIdentity($storageIdentity); foreach ($this->pools as $pool) { - $deleted = $pool->deleteItem($key) && $deleted; + if ($pool instanceof AbstractCacheAdapter) { + $pool->configureStorageIdentity($storageIdentity); + } } + } + + public function deleteItem(string $key): bool + { + $this->discardDeferredKey($key); + + return $this->mutate(function () use ($key): bool { + $deleted = true; + foreach ($this->pools as $pool) { + $deleted = $pool->deleteItem($key) && $deleted; + } - return $deleted; + return $deleted; + }); } /** @param list $keys */ public function deleteItems(array $keys): bool { - $deleted = true; - foreach ($this->pools as $pool) { - $deleted = $pool->deleteItems($keys) && $deleted; - } + $this->discardDeferredKeys($keys); - return $deleted; + return $this->mutate(function () use ($keys): bool { + $deleted = true; + foreach ($this->pools as $pool) { + $deleted = $pool->deleteItems($keys) && $deleted; + } + + return $deleted; + }); } public function getItem(string $key): CacheItem { - foreach ($this->pools as $index => $pool) { + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } + + foreach ($this->readablePools() as $index => $pool) { $item = $pool->getItem($key); if (!$item->isHit()) { continue; } - $out = $this->copyItem($item); + + $copy = $this->copyItem($item); if ($index > 0) { - $this->promoteOne($out, $index); + $this->promoteOne($copy, $index); } - return $out; + return $copy; } return $this->genericMiss($key); @@ -88,7 +141,7 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] public function getTagGenerations(array $tags): array @@ -111,23 +164,17 @@ public function hasItem(string $key): bool */ public function multiFetch(array $keys): array { - $remaining = array_fill_keys($keys, true); + $remaining = $keys; $results = []; - foreach ($this->pools as $index => $pool) { + foreach ($this->readablePools() as $index => $pool) { if ($remaining === []) { break; } - $wanted = array_keys($remaining); - $fetched = $pool->multiFetch($wanted); - $hits = []; - foreach ($wanted as $key) { - $item = $fetched[$key] ?? null; - if (!$item instanceof CacheItemInterface || !$item->isHit()) { - continue; - } - $hits[$key] = $this->copyItem($item); - $results[$key] = $hits[$key]; - unset($remaining[$key]); + + $fetched = $pool->multiFetch($remaining); + [$hits, $remaining] = $this->extractHits($remaining, $fetched); + foreach ($hits as $key => $item) { + $results[$key] = $item; } if ($index > 0 && $hits !== []) { $this->promote($hits, $index); @@ -160,25 +207,27 @@ public function save(CacheItemInterface $item): bool return false; } - $written = true; - $start = $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; - for ($index = $start, $count = count($this->pools); $index < $count; $index++) { - $written = $this->saveOneIntoPool($this->pools[$index], $item) && $written; - } + return $this->mutate(function () use ($item): bool { + $start = $this->writeStart(); + $written = true; + for ($index = $start, $count = count($this->pools); $index < $count; ++$index) { + $written = $this->saveOneIntoPool($this->pools[$index], $item) && $written; + } - return $written; + return $start === 0 + ? $written + : $this->invalidateSkippedL1([$item->getKey()], $written); + }); } /** @param array $items */ public function saveItems(array $items): bool { - foreach ($items as $item) { - if (!$this->supportsItem($item)) { - return false; - } + if (!$this->supportsItems($items)) { + return false; } - return $this->writeBatch($items); + return $this->mutate(fn(): bool => $this->writeBatch($items)); } private function copyItem(CacheItemInterface $source): CacheItem @@ -186,41 +235,107 @@ private function copyItem(CacheItemInterface $source): CacheItem $ttl = $source instanceof CacheItem ? $source->ttlSeconds() : null; $tags = $source instanceof CacheItem ? $source->getTagGenerations() : []; - return (new CacheItem($this, $source->getKey(), $source->get(), true)) + return new CacheItem($this, $source->getKey(), $source->get(), true) ->expiresAfter($ttl) ->setTagGenerations($tags); } + /** + * @param list $wanted + * @param array $fetched + * @return array{array, list} + */ + private function extractHits(array $wanted, array $fetched): array + { + $hits = []; + $misses = []; + foreach ($wanted as $key) { + $item = $fetched[$key] ?? null; + if ($item instanceof CacheItemInterface && $item->isHit()) { + $hits[$key] = $this->copyItem($item); + } else { + $misses[] = $key; + } + } + + return [$hits, $misses]; + } + + /** @param list $keys */ + private function invalidateSkippedL1(array $keys, bool $written): bool + { + if (count($this->pools) === 1) { + return $written; + } + + $invalidated = $this->pools[0]->deleteItems($keys); + + return $written && $invalidated; + } + + /** @param callable(): bool $operation */ + private function mutate(callable $operation, bool $reconcile = false): bool + { + $wasReadable = $this->l1Readable; + // Fence before entering a backend: an exception must leave upper tiers bypassed. + $this->l1Readable = false; + $success = $operation(); + $this->l1Readable = $success && ($wasReadable || $reconcile); + + return $success; + } + /** @param array $items */ private function promote(array $items, int $tierIndex): void { - for ($index = 0; $index < $tierIndex; $index++) { - if ($this->saveIntoPool($this->pools[$index], $items)) { - $this->metrics->increment(self::class, 'promotion_batch'); - $this->metrics->increment(self::class, 'promotion_keys', count($items)); + if (!$this->l1Readable) { + return; + } + + for ($index = 0; $index < $tierIndex; ++$index) { + if (!$this->mutate(fn(): bool => $this->saveIntoPool($this->pools[$index], $items))) { + return; } + $this->metrics->increment(self::class, 'promotion_batch'); + $this->metrics->increment(self::class, 'promotion_keys', count($items)); } } private function promoteOne(CacheItemInterface $item, int $tierIndex): void { - for ($index = 0; $index < $tierIndex; $index++) { - $this->saveOneIntoPool($this->pools[$index], $item); + if (!$this->l1Readable) { + return; + } + + for ($index = 0; $index < $tierIndex; ++$index) { + if (!$this->mutate(fn(): bool => $this->saveOneIntoPool($this->pools[$index], $item))) { + return; + } + } + } + + /** @return array */ + private function readablePools(): array + { + if ($this->l1Readable || count($this->pools) === 1) { + return $this->pools; } + + return array_slice($this->pools, -1, 1, true); } /** @param array $items */ private function saveIntoPool(InternalCachePoolInterface $pool, array $items): bool { $targets = []; - foreach ($items as $key => $item) { - $target = $pool->createItem($key); - $target->set($item->get()); - $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); + foreach ($items as $item) { + $key = $item->getKey(); + $target = $pool->createItem($key)->set($item->get()); if ($target instanceof CacheItem && $item instanceof CacheItem) { - $target->setTagGenerations($item->getTagGenerations()); + $target->expiresAfter($item->ttlSeconds()) + ->setTagGenerations($item->getTagGenerations()); } - $targets[$key] = $target; + $targets["key:\0" . $key] = $target; } return $pool->saveItems($targets); @@ -228,11 +343,10 @@ private function saveIntoPool(InternalCachePoolInterface $pool, array $items): b private function saveOneIntoPool(InternalCachePoolInterface $pool, CacheItemInterface $item): bool { - $target = $pool->createItem($item->getKey()); - $target->set($item->get()); - $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); + $target = $pool->createItem($item->getKey())->set($item->get()); if ($target instanceof CacheItem && $item instanceof CacheItem) { - $target->setTagGenerations($item->getTagGenerations()); + $target->expiresAfter($item->ttlSeconds()) + ->setTagGenerations($item->getTagGenerations()); } return $pool->save($target); @@ -241,12 +355,26 @@ private function saveOneIntoPool(InternalCachePoolInterface $pool, CacheItemInte /** @param array $items */ private function writeBatch(array $items): bool { + $start = $this->writeStart(); $written = true; - $start = $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; - for ($index = $start, $count = count($this->pools); $index < $count; $index++) { + for ($index = $start, $count = count($this->pools); $index < $count; ++$index) { $written = $this->saveIntoPool($this->pools[$index], $items) && $written; } - return $written; + if ($start === 0) { + return $written; + } + + $keys = array_map( + static fn(CacheItemInterface $item): string => $item->getKey(), + array_values($items), + ); + + return $this->invalidateSkippedL1($keys, $written); + } + + private function writeStart(): int + { + return $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; } } diff --git a/src/Cache/Adapter/ValkeyCacheAdapter.php b/src/Cache/Adapter/ValkeyCacheAdapter.php index 0a8aedf5..d914a0ce 100644 --- a/src/Cache/Adapter/ValkeyCacheAdapter.php +++ b/src/Cache/Adapter/ValkeyCacheAdapter.php @@ -8,6 +8,7 @@ final class ValkeyCacheAdapter extends RedisCacheAdapter { public function __construct( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'valkey://127.0.0.1:6379', ?\Redis $client = null, ) { diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index def3b3ef..cedbcfb1 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -23,7 +23,7 @@ final class WeakMapCacheAdapter extends AbstractCacheAdapter implements AtomicCa /** @var array> */ private array $weakRefs = []; - /** @var array> */ + /** @var array> */ private array $weakTags = []; public function __construct(string $namespace = 'default') @@ -55,6 +55,8 @@ public function atomicCompareAndSet( public function atomicGetAndDelete(string $key): CacheItemInterface { + $this->discardDeferredKey($key); + $current = $this->getItem($key); if (!$current->isHit()) { return $current; @@ -97,6 +99,7 @@ public function clear(): bool public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $mapped = $this->map($key); unset($this->scalarStore[$mapped], $this->weakExpires[$mapped], $this->weakTags[$mapped]); @@ -111,6 +114,7 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); foreach ($keys as $key) { $this->deleteItem((string) $key); } @@ -143,7 +147,7 @@ public function getItem(string $key): CacheItem } if (!isset($this->scalarStore[$mapped])) { - return new CacheItem($this, $key); + return $this->genericMiss($key); } return $this->genericFromBlobWithInvalidator( @@ -189,7 +193,7 @@ public function multiFetch(array $keys): array continue; } $blob = $this->scalarStore[$mapped] ?? null; - $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob, $key) : null; $items[$key] = $record === null ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 7b5d518a..047c0953 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -19,6 +19,7 @@ use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; use Infocyph\CacheLayer\Exceptions\CacheBackendException; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\OptionalCassandra; use MongoDB\Client; use Psr\Cache\CacheItemInterface; use Throwable; @@ -51,16 +52,18 @@ public function __construct( private readonly string $namespace = 'default', ) { CacheInput::namespace($namespace); - $this->authoritative = !in_array( - $adapter::class, - [Adapter\TieredCacheAdapter::class, Adapter\NullCacheAdapter::class], - true, - ); + $this->authoritative = match (true) { + $adapter instanceof \Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter => $adapter->isAuthoritative(), + $adapter instanceof Adapter\TieredCacheAdapter, + $adapter instanceof Adapter\NullCacheAdapter => false, + default => true, + }; $this->lockProvider = $lockProvider ?? new FileLockProvider(); $this->authenticationStateLockCapable = $lockProvider !== null; $this->options = $options ?? new CacheOptions(); if ($adapter instanceof AbstractCacheAdapter) { $adapter->configureOptions($this->options); + $adapter->configureStorageIdentity($namespace); } } @@ -120,6 +123,7 @@ public static function mongodb( ?object $client = null, string $database = 'cachelayer', string $collectionName = 'entries', + #[\SensitiveParameter] string $uri = 'mongodb://127.0.0.1:27017', ?CacheOptions $options = null, ): self { @@ -136,7 +140,7 @@ public static function mongodb( 'mongodb/mongodb is required unless a collection/client is provided.', ); } - $client = new Client($uri); + $client = Adapter\MongoDbClientFactory::create($uri); } return new self( @@ -153,8 +157,10 @@ public static function nullStore(?CacheOptions $options = null): self public static function pdo( string $namespace = 'default', + #[\SensitiveParameter] ?string $dsn = null, ?string $username = null, + #[\SensitiveParameter] ?string $password = null, ?\PDO $pdo = null, string $table = 'cachelayer_entries', @@ -185,6 +191,7 @@ public static function phpFiles( public static function redis( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'redis://127.0.0.1:6379', ?\Redis $client = null, ?CacheOptions $options = null, @@ -232,12 +239,12 @@ public static function scylla( ?CacheOptions $options = null, ): self { if ($session === null) { - if (!class_exists(\Cassandra::class)) { + if (!OptionalCassandra::available()) { throw new CacheInvalidArgumentException( 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', ); } - $session = \Cassandra::cluster()->build()->connect($keyspace); + $session = OptionalCassandra::connect($keyspace); } return new self( @@ -272,6 +279,7 @@ public static function sqlite( /** @param list> $tiers */ public static function tiered( + #[\SensitiveParameter] array $tiers, bool $writeToL1 = true, ?CacheOptions $options = null, @@ -289,6 +297,7 @@ public static function tiered( public static function valkey( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'valkey://127.0.0.1:6379', ?\Redis $client = null, ?CacheOptions $options = null, @@ -400,8 +409,8 @@ public function getItem(string $key): CacheItemInterface return $this->validateTagSnapshot($item); } - /** @return array */ - public function getItems(array $keys = []): array + /** @return iterable */ + public function getItems(array $keys = []): iterable { $keys = CacheInput::keys($keys); if ($keys === []) { @@ -409,10 +418,13 @@ public function getItems(array $keys = []): array } $fetched = $this->backend(fn(): array => $this->fetchItems($keys), []); + $byIdentity = []; + foreach ($fetched as $item) { + $byIdentity["key:\0" . $item->getKey()] = $item; + } $items = []; foreach ($keys as $key) { - $item = $fetched[$key] ?? null; - $items[$key] = $item instanceof CacheItemInterface ? $item : $this->miss($key); + $items[$key] = $byIdentity["key:\0" . $key] ?? $this->miss($key); } $items = $this->validateTagSnapshots($items); $hits = 0; @@ -424,21 +436,14 @@ public function getItems(array $keys = []): array $this->metric('get_batch_hits', $hits); $this->metric('get_batch_misses', count($keys) - $hits); - return $items; + return CacheBatchResults::items($keys, $items); } - /** @return array */ - public function getMultiple(iterable $keys, mixed $default = null): array + public function getMultiple(iterable $keys, mixed $default = null): iterable { $keys = CacheInput::materializeKeys($keys); - $items = $this->getItems($keys); - $values = []; - foreach ($keys as $key) { - $item = $items[$key]; - $values[$key] = $item->isHit() ? $item->get() : $default; - } - return $values; + return CacheBatchResults::values($keys, $this->getItems($keys), $default); } public function has(string $key): bool @@ -635,25 +640,26 @@ public function setMetricsExportHook(?callable $hook): self return $this; } - /** @param iterable $values */ + /** @param iterable $values */ public function setMultiple(iterable $values, mixed $ttl = null): bool { $normalized = []; foreach ($values as $key => $value) { - if (!is_string($key)) { - throw new CacheInvalidArgumentException('Cache keys must be strings.'); + if (!is_string($key) && !is_int($key)) { + throw new CacheInvalidArgumentException('Bulk cache keys must be strings or integers.'); } + $key = (string) $key; CacheInput::key($key); - $normalized[$key] = $value; + $normalized[] = [$key, $value]; } $ttlSeconds = CacheInput::ttl($ttl); if ($ttlSeconds !== null && $ttlSeconds <= 0) { - return $this->deleteItems(array_keys($normalized)); + return $this->deleteItems(array_column($normalized, 0)); } $items = []; - foreach ($normalized as $key => $value) { - $items[$key] = $this->adapter->createItem($key)->set($value)->expiresAfter($ttlSeconds); + foreach ($normalized as [$key, $value]) { + $items["key:\0" . $key] = $this->adapter->createItem($key)->set($value)->expiresAfter($ttlSeconds); } $saved = $this->backendBool(fn(): bool => $this->adapter->saveItems($items)); $this->metric('set_batch'); @@ -766,6 +772,7 @@ private function captureTagGenerations(array $tags): ?array $generations = []; foreach ($tags as $tag) { + $tag = (string) $tag; $generation = $stored[$tag] ?? null; if (!is_string($generation) || strlen($generation) !== 32 || !ctype_xdigit($generation)) { return null; @@ -778,11 +785,15 @@ private function captureTagGenerations(array $tags): ?array /** * @param list $keys - * @return array + * @return list */ private function fetchItems(array $keys): array { - return $this->adapter->multiFetch($keys); + $items = $this->adapter->getItems($keys); + /** @var list $normalized */ + $normalized = array_values(is_array($items) ? $items : iterator_to_array($items)); + + return $normalized; } private function jitteredTtl(?int $ttl): ?int @@ -871,7 +882,10 @@ private function tagGenerationsUnchanged(array $expected): bool return true; } - $current = $this->captureTagGenerations(array_keys($expected)); + $current = $this->captureTagGenerations(array_map( + static fn(int|string $tag): string => (string) $tag, + array_keys($expected), + )); return $current !== null && $current === $expected; } @@ -881,7 +895,10 @@ private function validateTagSnapshot(CacheItemInterface $item): CacheItemInterfa if (!$item instanceof CacheItem || !$item->isHit() || $item->getTagGenerations() === []) { return $item; } - $tags = array_keys($item->getTagGenerations()); + $tags = array_map( + static fn(int|string $tag): string => (string) $tag, + array_keys($item->getTagGenerations()), + ); $generations = $this->backend( fn(): array => $this->adapter->getTagGenerations($tags), null, @@ -893,7 +910,6 @@ private function validateTagSnapshot(CacheItemInterface $item): CacheItemInterfa if (CacheTagSnapshots::isCurrent($item, $generations)) { return $item; } - $this->backendBool(fn(): bool => $this->adapter->deleteItem($item->getKey())); return $this->miss($item->getKey()); } @@ -918,10 +934,6 @@ private function validateTagSnapshots(array $items): array return CacheTagSnapshots::missTagged($items, $this->miss(...)); } $validated = CacheTagSnapshots::rejectStale($items, $generations, $this->miss(...)); - $stale = $validated['stale']; - if ($stale !== []) { - $this->backendBool(fn(): bool => $this->adapter->deleteItems($stale)); - } return $validated['items']; } diff --git a/src/Cache/CacheBatchResults.php b/src/Cache/CacheBatchResults.php new file mode 100644 index 00000000..2e71936e --- /dev/null +++ b/src/Cache/CacheBatchResults.php @@ -0,0 +1,73 @@ + $keys + * @param array $items + * @return iterable + */ + public static function items(array $keys, array $items): iterable + { + if (!self::requiresKeyPreservation($keys)) { + return $items; + } + + return (static function () use ($keys, $items): \Generator { + foreach ($keys as $key) { + yield $key => $items[$key]; + } + })(); + } + + /** + * @param list $keys + * @param iterable $items + * @return iterable + */ + public static function values(array $keys, iterable $items, mixed $default): iterable + { + $byIdentity = []; + foreach ($items as $item) { + $byIdentity["key:\0" . $item->getKey()] = $item; + } + + if (!self::requiresKeyPreservation($keys)) { + $values = []; + foreach ($keys as $key) { + $item = $byIdentity["key:\0" . $key]; + $values[$key] = $item->isHit() ? $item->get() : $default; + } + + return $values; + } + + return (static function () use ($keys, $byIdentity, $default): \Generator { + foreach ($keys as $key) { + $item = $byIdentity["key:\0" . $key]; + + yield $key => $item->isHit() ? $item->get() : $default; + } + })(); + } + + /** @param list $keys */ + private static function requiresKeyPreservation(array $keys): bool + { + return array_any( + $keys, + static function (string $key): bool { + $probe = [$key => true]; + + return array_key_first($probe) !== $key; + }, + ); + } +} diff --git a/src/Cache/CacheOptions.php b/src/Cache/CacheOptions.php index 3f7b0452..d19953b3 100644 --- a/src/Cache/CacheOptions.php +++ b/src/Cache/CacheOptions.php @@ -9,12 +9,13 @@ final readonly class CacheOptions { public function __construct( + #[\SensitiveParameter] public ?string $integrityKey = null, public ?int $maxPayloadBytes = 8_388_608, public ?int $compressionThreshold = null, public int $compressionLevel = 6, - public bool $allowClosures = true, - public bool $allowObjects = true, + public bool $allowClosures = false, + public bool $allowObjects = false, public bool $failOpen = true, ) { if ($integrityKey === '') { diff --git a/src/Cache/CacheRecord.php b/src/Cache/CacheRecord.php index 57931321..cf0be729 100644 --- a/src/Cache/CacheRecord.php +++ b/src/Cache/CacheRecord.php @@ -8,7 +8,7 @@ final readonly class CacheRecord { /** - * @param array $tags + * @param array $tags */ public function __construct( public mixed $value, diff --git a/src/Cache/CacheTagSnapshots.php b/src/Cache/CacheTagSnapshots.php index 65313b90..8055e108 100644 --- a/src/Cache/CacheTagSnapshots.php +++ b/src/Cache/CacheTagSnapshots.php @@ -16,23 +16,31 @@ final class CacheTagSnapshots */ public static function collectTags(array $items): array { - $tagSet = []; + $tags = []; + $seen = []; foreach ($items as $item) { if (!$item instanceof CacheItem || !$item->isHit()) { continue; } foreach ($item->getTagGenerations() as $tag => $_generation) { - $tagSet[$tag] = true; + $tag = (string) $tag; + $identity = 'tag:' . $tag; + if (isset($seen[$identity])) { + continue; + } + $seen[$identity] = true; + $tags[] = $tag; } } - return array_keys($tagSet); + return $tags; } /** @param array $generations */ public static function isCurrent(CacheItem $item, array $generations): bool { foreach ($item->getTagGenerations() as $tag => $expected) { + $tag = (string) $tag; $current = $generations[$tag] ?? null; if (!is_string($current) || !hash_equals($expected, $current)) { return false; @@ -51,7 +59,8 @@ public static function missTagged(array $items, callable $miss): array { foreach ($items as $key => $item) { if (self::isTaggedHit($item)) { - $items[$key] = $miss($key); + $logicalKey = (string) $key; + $items[$key] = $miss($logicalKey); } } @@ -71,8 +80,9 @@ public static function rejectStale(array $items, array $generations, callable $m if (!$item instanceof CacheItem || !$item->isHit() || self::isCurrent($item, $generations)) { continue; } - $stale[] = $key; - $items[$key] = $miss($key); + $logicalKey = (string) $key; + $stale[] = $logicalKey; + $items[$key] = $miss($logicalKey); } return ['items' => $items, 'stale' => $stale]; diff --git a/src/Cache/Item/CacheItem.php b/src/Cache/Item/CacheItem.php index dce9d16d..611728f1 100644 --- a/src/Cache/Item/CacheItem.php +++ b/src/Cache/Item/CacheItem.php @@ -8,12 +8,13 @@ use DateTimeImmutable; use DateTimeInterface; use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; +use Infocyph\CacheLayer\Cache\CacheInput; use Psr\Cache\CacheItemInterface; final class CacheItem implements CacheItemInterface { /** - * @param array $tags + * @param array $tags */ public function __construct( private readonly InternalCachePoolInterface $pool, @@ -22,7 +23,9 @@ public function __construct( private readonly bool $hit = false, private ?DateTimeInterface $expiration = null, private array $tags = [], - ) {} + ) { + CacheInput::key($key); + } public function belongsTo(InternalCachePoolInterface $pool): bool { @@ -58,7 +61,7 @@ public function getKey(): string return $this->key; } - /** @return array */ + /** @return array */ public function getTagGenerations(): array { return $this->tags; @@ -92,7 +95,7 @@ public function set(mixed $value): static } /** - * @param array $tags + * @param array $tags */ public function setTagGenerations(array $tags): static { diff --git a/src/Cache/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index 1136598f..99a14864 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -4,6 +4,8 @@ namespace Infocyph\CacheLayer\Cache\Lock; +use Infocyph\CacheLayer\Support\FilesystemTrust; + final readonly class FileLockProvider implements LockProviderInterface { use GeneratesLockTokens; @@ -35,7 +37,7 @@ public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 3 } $path = $this->directory . DIRECTORY_SEPARATOR . self::digestLockKey($key) . '.lock'; - if (isset($activeLocks[$path])) { + if (FilesystemTrust::containsSymlink($path) || isset($activeLocks[$path])) { return null; } $handle = $this->openLockFile($path); @@ -119,7 +121,7 @@ private function openLockFile(string $path): mixed private function prepareDirectory(): bool { - if (is_link($this->directory)) { + if (FilesystemTrust::containsSymlink($this->directory)) { return false; } if (!is_dir($this->directory) diff --git a/src/Cache/Lock/MemcachedLockProvider.php b/src/Cache/Lock/MemcachedLockProvider.php index 016653e6..9d554b0e 100644 --- a/src/Cache/Lock/MemcachedLockProvider.php +++ b/src/Cache/Lock/MemcachedLockProvider.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Lock; +use Infocyph\CacheLayer\Cache\Adapter\MemcachedExpiration; use RuntimeException; final readonly class MemcachedLockProvider implements LockProviderInterface @@ -26,7 +27,7 @@ public function __construct( public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle { - $ttlSeconds = self::leaseSeconds($leaseSeconds); + $ttlSeconds = MemcachedExpiration::fromRelative(self::leaseSeconds($leaseSeconds)); return $this->acquireWithRetry( $this->prefix, @@ -43,7 +44,7 @@ public function refresh(?LockHandle $handle, float $leaseSeconds): bool return false; } - $ttlSeconds = self::leaseSeconds($leaseSeconds); + $ttlSeconds = MemcachedExpiration::fromRelative(self::leaseSeconds($leaseSeconds)); $values = $this->memcached->getMulti([$handle->key], \Memcached::GET_EXTENDED); if (!is_array($values)) { return false; diff --git a/src/Cache/Tiering/TieredPoolFactory.php b/src/Cache/Tiering/TieredPoolFactory.php index a68b09fc..64aa3f6a 100644 --- a/src/Cache/Tiering/TieredPoolFactory.php +++ b/src/Cache/Tiering/TieredPoolFactory.php @@ -7,6 +7,7 @@ use Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\OptionalCassandra; final class TieredPoolFactory { @@ -15,7 +16,7 @@ final class TieredPoolFactory * @phpstan-param array $tiers * @phpstan-return list */ - public static function fromArray(array $tiers): array + public static function fromArray(#[\SensitiveParameter] array $tiers): array { if ($tiers === []) { throw new CacheInvalidArgumentException('Cache::tiered() requires at least one tier.'); @@ -49,14 +50,13 @@ private static function bool(array $descriptor, string $key, bool $default): boo private static function buildScyllaSession(string $keyspace): object { - if (!class_exists(\Cassandra::class)) { + if (!OptionalCassandra::available()) { throw new CacheInvalidArgumentException( 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', ); } - /** @var object */ - return \Cassandra::cluster()->build()->connect($keyspace); + return OptionalCassandra::connect($keyspace); } /** diff --git a/src/Cluster/ClusterCache.php b/src/Cluster/ClusterCache.php index 25ac6ab1..20928727 100644 --- a/src/Cluster/ClusterCache.php +++ b/src/Cluster/ClusterCache.php @@ -22,9 +22,18 @@ public static function create( InvalidationTransportInterface $transport, ): ClusterRuntime { $cache = NodeCache::create($node); - $cursorStore = new SqliteCursorStore($node->sqliteFile, $cluster->cluster, $cluster->nodeId); + $cursorStore = new SqliteCursorStore( + $node->sqliteFile, + $cluster->cluster, + $cluster->nodeId, + $node->namespace, + $cluster->transportIdentity, + ); $status = new ClusterStatusTracker(); $recovery = new ClusterRecoveryManager($cache, $cursorStore, $transport, $cluster->cluster); + if ($cursorStore->requiresRecovery() && $recovery->recoverIfRequired()) { + $status->recordRecovery(); + } $coordinator = new ClusterCoordinator( $cache, $cluster->cluster, diff --git a/src/Cluster/ClusterCacheConfig.php b/src/Cluster/ClusterCacheConfig.php index 22b6b9c0..b38db1a6 100644 --- a/src/Cluster/ClusterCacheConfig.php +++ b/src/Cluster/ClusterCacheConfig.php @@ -8,14 +8,19 @@ final readonly class ClusterCacheConfig { + /** + * @param string $transportIdentity Durable history generation; rotate to a never-used value after history reset. + */ public function __construct( public string $cluster, public string $nodeId, + public string $transportIdentity, public int $consumerBatchSize = 1_000, public bool $invalidateLocallyFirst = true, ) { ClusterInput::cluster($cluster); ClusterInput::nodeId($nodeId); + ClusterInput::transportIdentity($transportIdentity); if ($consumerBatchSize < 1) { throw new ClusterConfigurationException('The consumer batch size must be greater than zero.'); diff --git a/src/Cluster/ClusterInput.php b/src/Cluster/ClusterInput.php index 2ca3ba1c..890faed6 100644 --- a/src/Cluster/ClusterInput.php +++ b/src/Cluster/ClusterInput.php @@ -71,6 +71,13 @@ public static function nodeId(string $nodeId): string return $nodeId; } + public static function transportIdentity(string $transportIdentity): string + { + self::boundedName($transportIdentity, 128, 'transport identity', ClusterConfigurationException::class); + + return $transportIdentity; + } + /** @param class-string $exceptionClass */ private static function boundedName(string $value, int $maximum, string $label, string $exceptionClass): void { diff --git a/src/Cluster/ClusterRuntime.php b/src/Cluster/ClusterRuntime.php index 61a0731b..3abeb037 100644 --- a/src/Cluster/ClusterRuntime.php +++ b/src/Cluster/ClusterRuntime.php @@ -17,6 +17,7 @@ use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportInspectorInterface; use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportInterface; use Infocyph\CacheLayer\Cluster\Transport\TransactionalInvalidationTransportInterface; +use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; use PDO; final readonly class ClusterRuntime @@ -104,6 +105,29 @@ public function outbox(PDO $connection): ClusterOutbox ); } + public function poll( + ?int $limit = null, + int $cycles = 1, + float $idleSeconds = 0.1, + ): int { + $limit ??= $this->consumerBatchSize; + if ($limit < 1 || $cycles < 1 || !is_finite($idleSeconds) || $idleSeconds < 0.0 || $idleSeconds > 60.0) { + throw new ClusterCacheException('Cluster polling requires a positive limit/cycle count and a 0-60 second idle interval.'); + } + + $total = 0; + for ($cycle = 0; $cycle < $cycles; ++$cycle) { + RunwireIntegration::checkpoint(); + $processed = $this->consumer->consume($limit); + $total += $processed; + if ($cycle + 1 < $cycles && $processed < $limit && $idleSeconds > 0.0) { + RunwireIntegration::sleep($idleSeconds); + } + } + + return $total; + } + public function recoverIfRequired(): bool { $recovered = $this->recovery->recoverIfRequired(); diff --git a/src/Cluster/Cursor/CursorStoreInterface.php b/src/Cluster/Cursor/CursorStoreInterface.php index 34056ca2..feda4cf2 100644 --- a/src/Cluster/Cursor/CursorStoreInterface.php +++ b/src/Cluster/Cursor/CursorStoreInterface.php @@ -10,6 +10,9 @@ public function advance(string $eventId): void; public function current(): ?string; + /** Whether this scope needs a successful local clear before replay can start. */ + public function requiresRecovery(): bool; + public function reset(?string $eventId): void; public function updatedAt(): ?int; diff --git a/src/Cluster/Cursor/SqliteCursorStore.php b/src/Cluster/Cursor/SqliteCursorStore.php index 8c54b610..196c650e 100644 --- a/src/Cluster/Cursor/SqliteCursorStore.php +++ b/src/Cluster/Cursor/SqliteCursorStore.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cluster\Cursor; +use Infocyph\CacheLayer\Cluster\ClusterInput; use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; use Infocyph\CacheLayer\Node\NodeCacheConfig; @@ -12,14 +13,30 @@ final readonly class SqliteCursorStore implements CursorStoreInterface { + private const string TABLE = 'cachelayer_cluster_cursors_v3'; + + private string $cluster; + private PDO $connection; + private string $namespace; + + private string $nodeId; + + private string $transportIdentity; + public function __construct( string $sqliteFile, - private string $cluster, - private string $nodeId, + string $cluster, + string $nodeId, + string $namespace, + string $transportIdentity, ?PDO $connection = null, ) { + $this->cluster = ClusterInput::cluster($cluster); + $this->nodeId = ClusterInput::nodeId($nodeId); + $this->namespace = ClusterInput::namespace($namespace); + $this->transportIdentity = ClusterInput::transportIdentity($transportIdentity); $this->connection = $connection ?? NodeSqliteConnection::create( new NodeCacheConfig($sqliteFile, 'cluster-cursor'), ); @@ -38,14 +55,20 @@ public function advance(string $eventId): void public function current(): ?string { $cursor = $this->read( - 'SELECT last_event_id FROM cachelayer_cluster_cursors ' - . 'WHERE cluster_name = :cluster AND node_id = :node_id LIMIT 1', + 'SELECT last_event_id FROM ' . self::TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace AND transport_identity = :transport_identity LIMIT 1', 'Unable to read the cluster cursor.', ); return is_string($cursor) && $cursor !== '' ? $cursor : null; } + public function requiresRecovery(): bool + { + return !$this->scopeExists(); + } + public function reset(?string $eventId): void { $this->write($eventId); @@ -54,8 +77,9 @@ public function reset(?string $eventId): void public function updatedAt(): ?int { $updatedAt = $this->read( - 'SELECT updated_at FROM cachelayer_cluster_cursors ' - . 'WHERE cluster_name = :cluster AND node_id = :node_id LIMIT 1', + 'SELECT updated_at FROM ' . self::TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace AND transport_identity = :transport_identity LIMIT 1', 'Unable to read the cluster cursor update time.', ); @@ -68,9 +92,10 @@ private function createSchemaIfMissing(): void { try { $this->connection->exec( - 'CREATE TABLE IF NOT EXISTS cachelayer_cluster_cursors (' - . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' - . 'PRIMARY KEY (cluster_name, node_id)) WITHOUT ROWID', + 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' + . 'transport_identity TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id, namespace_name, transport_identity)) WITHOUT ROWID', ); } catch (PDOException $exception) { throw new ClusterCacheException('Unable to initialize the cluster cursor store.', 0, $exception); @@ -81,7 +106,7 @@ private function read(string $sql, string $failureMessage): mixed { try { $statement = $this->connection->prepare($sql); - $statement->execute([':cluster' => $this->cluster, ':node_id' => $this->nodeId]); + $statement->execute($this->scopeParameters()); return $statement->fetchColumn(); } catch (PDOException $exception) { @@ -89,18 +114,54 @@ private function read(string $sql, string $failureMessage): mixed } } + /** + * @param array $parameters + */ + private function rowExists(string $sql, array $parameters, string $failureMessage): bool + { + try { + $statement = $this->connection->prepare($sql); + $statement->execute($parameters); + + return $statement->fetchColumn() !== false; + } catch (PDOException $exception) { + throw new ClusterCacheException($failureMessage, 0, $exception); + } + } + + private function scopeExists(): bool + { + return $this->rowExists( + 'SELECT 1 FROM ' . self::TABLE . ' ' + . 'WHERE cluster_name = :cluster AND node_id = :node_id ' + . 'AND namespace_name = :namespace AND transport_identity = :transport_identity LIMIT 1', + $this->scopeParameters(), + 'Unable to inspect the scoped cluster cursor.', + ); + } + + /** @return array */ + private function scopeParameters(): array + { + return [ + ':cluster' => $this->cluster, + ':node_id' => $this->nodeId, + ':namespace' => $this->namespace, + ':transport_identity' => $this->transportIdentity, + ]; + } + private function write(?string $eventId): void { try { $statement = $this->connection->prepare( - 'INSERT INTO cachelayer_cluster_cursors (cluster_name, node_id, last_event_id, updated_at) ' - . 'VALUES (:cluster, :node_id, :event_id, :updated_at) ' - . 'ON CONFLICT(cluster_name, node_id) DO UPDATE SET ' + 'INSERT INTO ' . self::TABLE . ' ' + . '(cluster_name, node_id, namespace_name, transport_identity, last_event_id, updated_at) ' + . 'VALUES (:cluster, :node_id, :namespace, :transport_identity, :event_id, :updated_at) ' + . 'ON CONFLICT(cluster_name, node_id, namespace_name, transport_identity) DO UPDATE SET ' . 'last_event_id = excluded.last_event_id, updated_at = excluded.updated_at', ); - $statement->execute([ - ':cluster' => $this->cluster, - ':node_id' => $this->nodeId, + $statement->execute($this->scopeParameters() + [ ':event_id' => $eventId, ':updated_at' => time(), ]); diff --git a/src/Cluster/Recovery/ClusterRecoveryManager.php b/src/Cluster/Recovery/ClusterRecoveryManager.php index c5e82ae4..60232303 100644 --- a/src/Cluster/Recovery/ClusterRecoveryManager.php +++ b/src/Cluster/Recovery/ClusterRecoveryManager.php @@ -20,17 +20,48 @@ public function __construct( public function recoverIfRequired(): bool { + if ($this->cursorStore->requiresRecovery()) { + $this->clearLocalCache(); + $this->cursorStore->reset(null); + + return true; + } + $cursor = $this->cursorStore->current(); - $oldest = $this->transport->oldestAvailableId($this->cluster); - if ($cursor === null || $oldest === null || !$this->transport->isCursorBefore($cursor, $oldest)) { + if ($cursor === null) { return false; } + $oldest = $this->transport->oldestAvailableId($this->cluster); + if ($oldest === null) { + $this->clearLocalCache(); + $this->cursorStore->reset(null); + + return true; + } + + if ($this->transport->isCursorBefore($cursor, $oldest)) { + $this->clearLocalCache(); + $this->cursorStore->reset($oldest); + + return true; + } + + $newest = $this->transport->newestAvailableId($this->cluster); + if ($newest !== null && $this->transport->isCursorBefore($newest, $cursor)) { + $this->clearLocalCache(); + $this->cursorStore->reset(null); + + return true; + } + + return false; + } + + private function clearLocalCache(): void + { if (!$this->cache->clear()) { throw new ClusterCacheException('Unable to clear the local cache during cluster recovery.'); } - $this->cursorStore->reset($oldest); - - return true; } } diff --git a/src/Cluster/Transport/InvalidationTransportInterface.php b/src/Cluster/Transport/InvalidationTransportInterface.php index afb67818..7188922d 100644 --- a/src/Cluster/Transport/InvalidationTransportInterface.php +++ b/src/Cluster/Transport/InvalidationTransportInterface.php @@ -13,6 +13,8 @@ public function consumeAfter(string $cluster, ?string $cursor, int $limit): Inva public function isCursorBefore(string $cursor, string $oldestAvailableId): bool; + public function newestAvailableId(string $cluster): ?string; + public function oldestAvailableId(string $cluster): ?string; public function publish(InvalidationEvent $event): string; diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php index 87319dc5..5da0d082 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -10,15 +10,24 @@ final class PdoInvalidationSchema { - private const string TABLE = 'cachelayer_invalidation_events'; + public const string EVENT_TABLE = 'cachelayer_invalidation_events'; + + public const string LOCK_TABLE = 'cachelayer_invalidation_clusters'; public static function install(PDO $connection, bool $allowSqliteForTesting = false): void { $driver = self::driver($connection, $allowSqliteForTesting); try { - $connection->exec(self::createTableSql($driver)); + $connection->exec(self::createEventTableSql($driver)); + if ($driver === 'mysql' && !self::mysqlEventIdentityColumnsAreBinary($connection)) { + self::hardenMysqlEventIdentityColumns($connection); + } self::createIndex($connection, $driver); + $connection->exec(self::createLockTableSql($driver)); + if ($driver === 'mysql' && !self::mysqlLockIdentityIsBinary($connection)) { + self::hardenMysqlLockIdentity($connection); + } } catch (PDOException $exception) { throw new ClusterTransportException('Unable to initialize the PDO invalidation transport schema.', 0, $exception); } @@ -29,6 +38,27 @@ public static function validateConnection(PDO $connection, bool $allowSqliteForT return self::driver($connection, $allowSqliteForTesting); } + private static function createEventTableSql(string $driver): string + { + $id = match ($driver) { + 'mysql' => 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY', + 'pgsql' => 'BIGSERIAL PRIMARY KEY', + default => 'INTEGER PRIMARY KEY AUTOINCREMENT', + }; + $identity = $driver === 'mysql' + ? ' CHARACTER SET ascii COLLATE ascii_bin' + : ''; + + return 'CREATE TABLE IF NOT EXISTS ' . self::EVENT_TABLE . ' (' + . 'event_id ' . $id + . ', cluster_name VARCHAR(128)' . $identity . ' NOT NULL' + . ', namespace_name VARCHAR(64)' . $identity . ' NOT NULL' + . ', event_type VARCHAR(32)' . $identity . ' NOT NULL' + . ', identifier VARCHAR(64)' . $identity . ' NULL' + . ', origin_node_id VARCHAR(255)' . $identity . ' NOT NULL' + . ', created_at BIGINT NOT NULL)'; + } + private static function createIndex(PDO $connection, string $driver): void { $ifNotExists = $driver === 'mysql' ? '' : ' IF NOT EXISTS'; @@ -36,7 +66,7 @@ private static function createIndex(PDO $connection, string $driver): void try { $connection->exec( 'CREATE INDEX' . $ifNotExists . ' cachelayer_invalidation_events_cluster_idx ' - . 'ON ' . self::TABLE . ' (cluster_name, event_id)', + . 'ON ' . self::EVENT_TABLE . ' (cluster_name, event_id)', ); } catch (PDOException $exception) { $duplicate = is_array($exception->errorInfo) && ($exception->errorInfo[1] ?? null) === 1061; @@ -46,18 +76,14 @@ private static function createIndex(PDO $connection, string $driver): void } } - private static function createTableSql(string $driver): string + private static function createLockTableSql(string $driver): string { - $id = match ($driver) { - 'mysql' => 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY', - 'pgsql' => 'BIGSERIAL PRIMARY KEY', - default => 'INTEGER PRIMARY KEY AUTOINCREMENT', - }; + $identity = $driver === 'mysql' + ? 'VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin' + : 'VARCHAR(128)'; - return 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' - . 'event_id ' . $id . ', cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' - . 'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(64) NULL, origin_node_id VARCHAR(255) NOT NULL, ' - . 'created_at BIGINT NOT NULL)'; + return 'CREATE TABLE IF NOT EXISTS ' . self::LOCK_TABLE . ' (' + . 'cluster_name ' . $identity . ' NOT NULL PRIMARY KEY)'; } private static function driver(PDO $connection, bool $allowSqliteForTesting): string @@ -73,4 +99,50 @@ private static function driver(PDO $connection, bool $allowSqliteForTesting): st return $driver; } + + private static function hardenMysqlEventIdentityColumns(PDO $connection): void + { + $connection->exec( + 'ALTER TABLE ' . self::EVENT_TABLE . ' ' + . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY namespace_name VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY event_type VARCHAR(32) CHARACTER SET ascii COLLATE ascii_bin NOT NULL, ' + . 'MODIFY identifier VARCHAR(64) CHARACTER SET ascii COLLATE ascii_bin NULL, ' + . 'MODIFY origin_node_id VARCHAR(255) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + ); + } + + private static function hardenMysqlLockIdentity(PDO $connection): void + { + $connection->exec( + 'ALTER TABLE ' . self::LOCK_TABLE . ' ' + . 'MODIFY cluster_name VARCHAR(128) CHARACTER SET ascii COLLATE ascii_bin NOT NULL', + ); + } + + private static function mysqlBinaryIdentityCount(PDO $connection, string $table): int + { + $columns = $table === self::EVENT_TABLE + ? ['cluster_name', 'namespace_name', 'event_type', 'identifier', 'origin_node_id'] + : ['cluster_name']; + $marks = implode(', ', array_fill(0, count($columns), '?')); + $statement = $connection->prepare( + 'SELECT COUNT(*) FROM information_schema.columns ' + . 'WHERE table_schema = DATABASE() AND table_name = ? ' + . 'AND column_name IN (' . $marks . ") AND collation_name = 'ascii_bin'", + ); + $statement->execute([$table, ...$columns]); + + return (int) $statement->fetchColumn(); + } + + private static function mysqlEventIdentityColumnsAreBinary(PDO $connection): bool + { + return self::mysqlBinaryIdentityCount($connection, self::EVENT_TABLE) === 5; + } + + private static function mysqlLockIdentityIsBinary(PDO $connection): bool + { + return self::mysqlBinaryIdentityCount($connection, self::LOCK_TABLE) === 1; + } } diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php index 277689a2..3e4c2f8a 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php @@ -13,11 +13,10 @@ use Infocyph\CacheLayer\Cluster\Transport\TransactionalInvalidationTransportInterface; use PDO; use PDOException; +use Throwable; final readonly class PdoInvalidationTransport implements InvalidationTransportInspectorInterface, TransactionalInvalidationTransportInterface { - private const string TABLE = 'cachelayer_invalidation_events'; - private string $driver; public function __construct( @@ -64,7 +63,8 @@ public function consumeAfter(string $cluster, ?string $cursor, int $limit): Inva public function countAfter(string $cluster, ?string $cursor): int { try { - $sql = 'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE cluster_name = :cluster'; + $sql = 'SELECT COUNT(*) FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE cluster_name = :cluster'; if ($cursor !== null) { $sql .= ' AND event_id > :cursor'; } @@ -101,7 +101,8 @@ public function oldestAvailableId(string $cluster): ?string { try { $statement = $this->connection->prepare( - 'SELECT MIN(event_id) FROM ' . self::TABLE . ' WHERE cluster_name = :cluster', + 'SELECT MIN(event_id) FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE cluster_name = :cluster', ); $statement->execute([':cluster' => $cluster]); $id = $statement->fetchColumn(); @@ -132,7 +133,33 @@ public function pruneBefore(int $retentionBoundary, int $limit = 5_000): int public function publish(InvalidationEvent $event): string { - return $this->insert($this->connection, $event); + $ownsTransaction = !$this->connection->inTransaction(); + $transactionStarted = false; + + try { + if ($ownsTransaction) { + $this->connection->beginTransaction(); + $transactionStarted = true; + } + + $id = $this->publishLocked($this->connection, $event); + + if ($ownsTransaction) { + $this->connection->commit(); + $transactionStarted = false; + } + + return $id; + } catch (Throwable $exception) { + if ($transactionStarted) { + $this->connection->rollBack(); + } + if ($exception instanceof ClusterTransportException) { + throw $exception; + } + + throw new ClusterTransportException('Unable to publish an invalidation event.', 0, $exception); + } } public function publishWithinTransaction(PDO $connection, InvalidationEvent $event): string @@ -143,7 +170,7 @@ public function publishWithinTransaction(PDO $connection, InvalidationEvent $eve ); } - return $this->insert($connection, $event); + return $this->publishLocked($connection, $event); } private function consumeSql(?string $cursor): string @@ -154,14 +181,16 @@ private function consumeSql(?string $cursor): string } return 'SELECT event_id, cluster_name, namespace_name, event_type, identifier, origin_node_id, created_at ' - . 'FROM ' . self::TABLE . ' WHERE ' . $where . ' ORDER BY event_id ASC LIMIT :limit'; + . 'FROM ' . PdoInvalidationSchema::EVENT_TABLE . ' WHERE ' . $where + . ' ORDER BY event_id ASC LIMIT :limit'; } private function eventBoundary(string $cluster, string $aggregate): ?string { try { $statement = $this->connection->prepare( - 'SELECT ' . $aggregate . '(event_id) FROM ' . self::TABLE . ' WHERE cluster_name = :cluster', + 'SELECT ' . $aggregate . '(event_id) FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE cluster_name = :cluster', ); $statement->execute([':cluster' => $cluster]); $id = $statement->fetchColumn(); @@ -233,7 +262,7 @@ private function insert(PDO $connection, InvalidationEvent $event): string { try { $statement = $connection->prepare( - 'INSERT INTO ' . self::TABLE . ' ' + 'INSERT INTO ' . PdoInvalidationSchema::EVENT_TABLE . ' ' . '(cluster_name, namespace_name, event_type, identifier, origin_node_id, created_at) ' . 'VALUES (:cluster, :namespace, :type, :identifier, :origin, :created_at)', ); @@ -257,13 +286,56 @@ private function insert(PDO $connection, InvalidationEvent $event): string return $id; } + private function lockCluster(PDO $connection, string $cluster): void + { + try { + $statement = $connection->prepare($this->lockRowInsertSql()); + $statement->execute([':cluster' => $cluster]); + if ($this->driver === 'sqlite') { + return; + } + + $statement = $connection->prepare( + 'SELECT cluster_name FROM ' . PdoInvalidationSchema::LOCK_TABLE + . ' WHERE cluster_name = :cluster FOR UPDATE', + ); + $statement->execute([':cluster' => $cluster]); + if ($statement->fetchColumn() === false) { + throw new ClusterTransportException('Unable to acquire the invalidation publication lock.'); + } + } catch (PDOException $exception) { + throw new ClusterTransportException('Unable to acquire the invalidation publication lock.', 0, $exception); + } + } + + private function lockRowInsertSql(): string + { + return match ($this->driver) { + 'mysql' => 'INSERT INTO ' . PdoInvalidationSchema::LOCK_TABLE + . ' (cluster_name) VALUES (:cluster) ' + . 'ON DUPLICATE KEY UPDATE cluster_name = VALUES(cluster_name)', + 'pgsql' => 'INSERT INTO ' . PdoInvalidationSchema::LOCK_TABLE + . ' (cluster_name) VALUES (:cluster) ON CONFLICT (cluster_name) DO NOTHING', + default => 'INSERT OR IGNORE INTO ' . PdoInvalidationSchema::LOCK_TABLE + . ' (cluster_name) VALUES (:cluster)', + }; + } + private function pruneSql(): string { - $selection = 'SELECT event_id FROM ' . self::TABLE . ' WHERE created_at < :boundary ORDER BY event_id LIMIT :limit'; + $selection = 'SELECT event_id FROM ' . PdoInvalidationSchema::EVENT_TABLE + . ' WHERE created_at < :boundary ORDER BY event_id LIMIT :limit'; if ($this->driver === 'mysql') { $selection = 'SELECT event_id FROM (' . $selection . ') AS cachelayer_prunable_events'; } - return 'DELETE FROM ' . self::TABLE . ' WHERE event_id IN (' . $selection . ')'; + return 'DELETE FROM ' . PdoInvalidationSchema::EVENT_TABLE . ' WHERE event_id IN (' . $selection . ')'; + } + + private function publishLocked(PDO $connection, InvalidationEvent $event): string + { + $this->lockCluster($connection, $event->cluster); + + return $this->insert($connection, $event); } } diff --git a/src/Cluster/Transport/RedisStreamInvalidationTransport.php b/src/Cluster/Transport/RedisStreamInvalidationTransport.php index 928c2634..5ad655fc 100644 --- a/src/Cluster/Transport/RedisStreamInvalidationTransport.php +++ b/src/Cluster/Transport/RedisStreamInvalidationTransport.php @@ -61,6 +61,11 @@ public function isCursorBefore(string $cursor, string $oldestAvailableId): bool return $this->compareIds($cursor, $oldestAvailableId) < 0; } + public function newestAvailableId(string $cluster): ?string + { + return $this->boundary($cluster, true); + } + public function oldestAvailableId(string $cluster): ?string { return $this->boundary($cluster, false); diff --git a/src/Counter/AtomicCounters.php b/src/Counter/AtomicCounters.php index 5bc623df..736dcfd5 100644 --- a/src/Counter/AtomicCounters.php +++ b/src/Counter/AtomicCounters.php @@ -12,6 +12,7 @@ final class AtomicCounters { public static function redis( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'redis://127.0.0.1:6379', ?\Redis $client = null, ): AtomicCounterStoreInterface { @@ -24,18 +25,19 @@ public static function redis( public static function valkey( string $namespace = 'default', + #[\SensitiveParameter] string $dsn = 'valkey://127.0.0.1:6379', ?\Redis $client = null, ): AtomicCounterStoreInterface { return self::redis($namespace, $dsn, $client); } - private static function connect(string $dsn): \Redis + private static function connect(#[\SensitiveParameter] string $dsn): \Redis { try { return RedisConnection::connect($dsn); } catch (InvalidArgumentException $exception) { - throw new AtomicCounterException("Invalid Redis-compatible DSN: {$dsn}", 0, $exception); + throw new AtomicCounterException('Invalid Redis-compatible DSN.', 0, $exception); } } } diff --git a/src/Counter/RedisAtomicCounterStore.php b/src/Counter/RedisAtomicCounterStore.php index 0aace255..00c9522f 100644 --- a/src/Counter/RedisAtomicCounterStore.php +++ b/src/Counter/RedisAtomicCounterStore.php @@ -10,13 +10,16 @@ final readonly class RedisAtomicCounterStore implements AtomicCounterStoreInterface { + private const string COUNTER_PREFIX = 'cachelayer:counter:'; + private const string INCREMENT_SCRIPT = <<<'LUA' -local exists = redis.call('EXISTS', KEYS[1]) -local value = redis.call('INCRBY', KEYS[1], ARGV[1]) -if exists == 0 and tonumber(ARGV[2]) > 0 then +local existed = redis.call('EXISTS', KEYS[1]) +redis.call('INCRBY', KEYS[1], ARGV[1]) +if existed == 0 and tonumber(ARGV[2]) > 0 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end -return { value, exists == 0 and 1 or 0 } +local value = redis.call('GET', KEYS[1]) +return { value, existed == 0 and '1' or '0' } LUA; private string $namespace; @@ -57,11 +60,11 @@ public function get(string $key): ?int return null; } - if (!is_string($value) || !preg_match('/^-?\d+$/D', $value)) { + if (!is_string($value)) { throw new AtomicCounterException('Atomic counter contains a non-integer value.'); } - return (int) $value; + return $this->parseInteger($value); } public function increment(string $key, int $by = 1, ?int $ttlSeconds = null): AtomicCounterValue @@ -76,12 +79,27 @@ public function increment(string $key, int $by = 1, ?int $ttlSeconds = null): At private function change(string $key, int $by, ?int $ttlSeconds): AtomicCounterValue { $ttl = $this->normalizeTtl($ttlSeconds); - $result = $this->client->eval(self::INCREMENT_SCRIPT, [$this->map($key), (string) $by, (string) $ttl], 1); - if (!is_array($result) || !isset($result[0], $result[1]) || !is_numeric($result[0]) || !is_numeric($result[1])) { + + try { + $result = $this->client->eval( + self::INCREMENT_SCRIPT, + [$this->map($key), (string) $by, (string) $ttl], + 1, + ); + } catch (\RedisException $failure) { + throw new AtomicCounterException('Unable to update atomic counter.', 0, $failure); + } + + if (!is_array($result) || !isset($result[0], $result[1]) || !is_string($result[0])) { throw new AtomicCounterException('Unable to update atomic counter.'); } + $initialized = match ($result[1]) { + 1, '1' => true, + 0, '0' => false, + default => throw new AtomicCounterException('Unable to update atomic counter.'), + }; - return new AtomicCounterValue((int) $result[0], (int) $result[1] === 1); + return new AtomicCounterValue($this->parseInteger($result[0]), $initialized); } private function map(string $key): string @@ -92,7 +110,7 @@ private function map(string $key): string throw new AtomicCounterException($failure->getMessage(), 0, $failure); } - return $this->namespace . ':counter:' . $key; + return self::COUNTER_PREFIX . $this->namespace . ':' . $key; } private function normalizeTtl(?int $ttlSeconds): int @@ -107,4 +125,22 @@ private function normalizeTtl(?int $ttlSeconds): int return $ttlSeconds; } + + private function parseInteger(string $value): int + { + if (preg_match('/^-?\d+$/D', $value) !== 1) { + throw new AtomicCounterException('Atomic counter contains a non-integer value.'); + } + + $negative = str_starts_with($value, '-'); + $digits = ltrim($negative ? substr($value, 1) : $value, '0'); + $digits = $digits === '' ? '0' : $digits; + $limit = $negative ? substr((string) PHP_INT_MIN, 1) : (string) PHP_INT_MAX; + if (strlen($digits) > strlen($limit) + || (strlen($digits) === strlen($limit) && strcmp($digits, $limit) > 0)) { + throw new AtomicCounterException('Atomic counter value is outside the PHP integer range.'); + } + + return (int) (($negative && $digits !== '0' ? '-' : '') . $digits); + } } diff --git a/src/Integration/Runwire/RunwireExecutionContext.php b/src/Integration/Runwire/RunwireExecutionContext.php new file mode 100644 index 00000000..913d2c08 --- /dev/null +++ b/src/Integration/Runwire/RunwireExecutionContext.php @@ -0,0 +1,29 @@ +runtime() !== $runtime) { + throw new LogicException('Runwire request context is bound to a different runtime.'); + } + } + + public function supports(RuntimeCapability $capability): bool + { + return $this->runtime->supports($capability); + } +} diff --git a/src/Integration/Runwire/RunwireIntegration.php b/src/Integration/Runwire/RunwireIntegration.php new file mode 100644 index 00000000..cfa12f87 --- /dev/null +++ b/src/Integration/Runwire/RunwireIntegration.php @@ -0,0 +1,224 @@ +|null */ + private static ?WeakMap $fiberContexts = null; + + private static ?RunwireExecutionContext $rootContext = null; + + private static ?RuntimeContext $runtime = null; + + public static function bind(RuntimeContext $runtime): void + { + if (self::$runtime === $runtime) { + return; + } + + self::resetExecutionContexts(); + self::flushGlobalMemoizers(); + self::$runtime = $runtime; + } + + public static function checkpoint(): void + { + $context = self::current(); + if ( + $context?->scope !== null + && $context->supports(RuntimeCapability::RUNWIRE_COROUTINES) + ) { + $context->scope->cancellation()->throwIfCancelled(); + } + } + + public static function current(): ?RunwireExecutionContext + { + $fiber = Fiber::getCurrent(); + if ($fiber !== null) { + $contexts = self::$fiberContexts; + + return $contexts !== null && isset($contexts[$fiber]) + ? $contexts[$fiber] + : null; + } + + return self::$rootContext; + } + + public static function flushMemoizers(): void + { + $request = self::current()?->request; + if ($request === null) { + self::flushGlobalMemoizers(); + + return; + } + + $memoizer = $request->attribute(self::MEMOIZER_ATTRIBUTE); + if ($memoizer instanceof Memoizer) { + $memoizer->flush(); + } + + $once = $request->attribute(self::ONCE_MEMOIZER_ATTRIBUTE); + if ($once instanceof OnceMemoizer) { + $once->flush(); + } + } + + public static function memoizer(): ?Memoizer + { + $request = self::current()?->request; + if ($request !== null) { + $memoizer = $request->attribute(self::MEMOIZER_ATTRIBUTE); + if ($memoizer instanceof Memoizer) { + return $memoizer; + } + + $memoizer = Memoizer::isolated(); + $request->setAttribute(self::MEMOIZER_ATTRIBUTE, $memoizer); + + return $memoizer; + } + + if (self::$runtime?->persistent === true && self::$runtime->concurrent) { + return null; + } + + return Memoizer::instance(); + } + + public static function onceMemoizer(): ?OnceMemoizer + { + $request = self::current()?->request; + if ($request !== null) { + $memoizer = $request->attribute(self::ONCE_MEMOIZER_ATTRIBUTE); + if ($memoizer instanceof OnceMemoizer) { + return $memoizer; + } + + $memoizer = OnceMemoizer::isolated(); + $request->setAttribute(self::ONCE_MEMOIZER_ATTRIBUTE, $memoizer); + + return $memoizer; + } + + if (self::$runtime?->persistent === true && self::$runtime->concurrent) { + return null; + } + + return OnceMemoizer::instance(); + } + + public static function release(?RuntimeContext $runtime = null): void + { + if ($runtime !== null && self::$runtime !== $runtime) { + return; + } + + self::resetExecutionContexts(); + self::flushGlobalMemoizers(); + self::$runtime = null; + } + + public static function runtime(): ?RuntimeContext + { + return self::$runtime; + } + + public static function share( + ?RequestContext $request, + ?CoroutineScope $scope, + callable $callback, + ): mixed { + $runtime = self::$runtime; + if ($runtime === null) { + return $callback(); + } + + if ($request !== null && $request->runtime() !== $runtime) { + throw new LogicException('Runwire request context is bound to a different runtime.'); + } + + $context = new RunwireExecutionContext($runtime, $request, $scope); + $fiber = Fiber::getCurrent(); + if ($fiber === null) { + $previous = self::$rootContext; + self::$rootContext = $context; + + try { + return $callback(); + } finally { + self::$rootContext = $previous; + } + } + + self::$fiberContexts ??= new WeakMap(); + $hadPrevious = isset(self::$fiberContexts[$fiber]); + $previous = $hadPrevious ? self::$fiberContexts[$fiber] : null; + self::$fiberContexts[$fiber] = $context; + + try { + return $callback(); + } finally { + if ($hadPrevious && $previous instanceof RunwireExecutionContext) { + self::$fiberContexts[$fiber] = $previous; + } else { + unset(self::$fiberContexts[$fiber]); + } + } + } + + public static function sleep(float $seconds): void + { + if ($seconds <= 0.0) { + return; + } + + $context = self::current(); + if ( + $context?->scope !== null + && $context->supports(RuntimeCapability::RUNWIRE_COROUTINES) + ) { + $context->scope->sleep($seconds); + + return; + } + + usleep((int) min(PHP_INT_MAX, ceil($seconds * 1_000_000))); + } + + public static function supports(RuntimeCapability $capability): bool + { + return self::$runtime?->supports($capability) ?? false; + } + + private static function flushGlobalMemoizers(): void + { + Memoizer::instance()->flush(); + OnceMemoizer::instance()->flush(); + } + + private static function resetExecutionContexts(): void + { + self::$fiberContexts = null; + self::$rootContext = null; + } +} diff --git a/src/Integration/Runwire/RunwireWorkerIntegration.php b/src/Integration/Runwire/RunwireWorkerIntegration.php new file mode 100644 index 00000000..01d5b859 --- /dev/null +++ b/src/Integration/Runwire/RunwireWorkerIntegration.php @@ -0,0 +1,132 @@ +spawnBackground( + static function (CoroutineScope $scope) use ($cluster, $batchSize, $idleSeconds): void { + RunwireIntegration::share( + null, + $scope, + static function () use ($cluster, $scope, $batchSize, $idleSeconds): void { + while (!$scope->cancellation()->isCancelled()) { + $processed = $cluster->consume($batchSize); + + if ($processed < $batchSize && $idleSeconds > 0.0) { + RunwireIntegration::sleep($idleSeconds); + } else { + $scope->yieldNow(); + } + } + + $scope->cancellation()->throwIfCancelled(); + }, + ); + }, + ); + } + + public static function startNodeMaintenance( + WorkerContext $worker, + NodeCacheMaintenance $maintenance, + float $intervalSeconds = 60.0, + int $pruneLimit = 5_000, + int $optimizeEvery = 0, + ): ?Task { + self::validateMaintenanceOptions($intervalSeconds, $pruneLimit, $optimizeEvery); + if (!self::available($worker)) { + return null; + } + + return $worker->spawnBackground( + static function (CoroutineScope $scope) use ( + $maintenance, + $intervalSeconds, + $pruneLimit, + $optimizeEvery, + ): void { + RunwireIntegration::share( + null, + $scope, + static function () use ( + $scope, + $maintenance, + $intervalSeconds, + $pruneLimit, + $optimizeEvery, + ): void { + $cycles = 0; + while (!$scope->cancellation()->isCancelled()) { + ++$cycles; + $maintenance->cycle( + pruneLimit: $pruneLimit, + checkpoint: true, + optimize: $optimizeEvery > 0 && ($cycles % $optimizeEvery) === 0, + ); + + RunwireIntegration::sleep($intervalSeconds); + } + + $scope->cancellation()->throwIfCancelled(); + }, + ); + }, + ); + } + + private static function available(WorkerContext $worker): bool + { + return $worker->role->background() + && $worker->acceptingBackgroundWork() + && RunwireIntegration::supports(RuntimeCapability::RUNWIRE_COROUTINES) + && RunwireIntegration::supports(RuntimeCapability::RUNWIRE_LOOP_AVAILABLE); + } + + private static function validateClusterOptions(int $batchSize, float $idleSeconds): void + { + if ($batchSize < 1) { + throw new InvalidArgumentException('Runwire cluster consumer batch size must be greater than zero.'); + } + if (!is_finite($idleSeconds) || $idleSeconds < 0.0 || $idleSeconds > 60.0) { + throw new InvalidArgumentException('Runwire cluster consumer idle interval must be between 0 and 60 seconds.'); + } + } + + private static function validateMaintenanceOptions( + float $intervalSeconds, + int $pruneLimit, + int $optimizeEvery, + ): void { + if (!is_finite($intervalSeconds) || $intervalSeconds < 0.001 || $intervalSeconds > 86_400.0) { + throw new InvalidArgumentException('Runwire maintenance interval must be between 0.001 and 86400 seconds.'); + } + if ($pruneLimit < 1) { + throw new InvalidArgumentException('Runwire maintenance prune limit must be greater than zero.'); + } + if ($optimizeEvery < 0) { + throw new InvalidArgumentException('Runwire maintenance optimize cadence cannot be negative.'); + } + } +} diff --git a/src/Memoize/CallableFingerprint.php b/src/Memoize/CallableFingerprint.php index 44dc26a4..dc753376 100644 --- a/src/Memoize/CallableFingerprint.php +++ b/src/Memoize/CallableFingerprint.php @@ -5,8 +5,8 @@ namespace Infocyph\CacheLayer\Memoize; use Closure; +use Infocyph\CacheLayer\Support\BoundedValueTraversal; use ReflectionFunction; -use ReflectionReference; use WeakMap; /** @internal */ @@ -30,14 +30,14 @@ public static function callable(callable $callable): string } if (is_array($callable)) { $target = is_object($callable[0]) - ? self::object($callable[0]) - : $callable[0]; + ? self::objectIdentity($callable[0]) + : 'class:' . $callable[0]; return 'array:' . $target . '::' . $callable[1]; } if (is_object($callable)) { - return 'invokable:' . self::object($callable); + return 'invokable:' . self::objectIdentity($callable); } throw new \LogicException('Unsupported callable form.'); @@ -49,15 +49,18 @@ public static function flush(): void self::$objects = new WeakMap(); } + public static function objectIdentity(object $object): string + { + self::$objects ??= new WeakMap(); + + return self::$objects[$object] ??= $object::class . '#' . ++self::$nextObjectId; + } + public static function value(mixed $value): mixed { - return match (true) { - $value instanceof Closure => self::closure($value), - is_object($value) => self::object($value), - is_resource($value) => 'res:' . get_resource_type($value) . '#' . (int) $value, - is_array($value) => self::values($value), - default => $value, - }; + BoundedValueTraversal::assertSafe($value); + + return self::normalizeValue($value); } private static function closure(Closure $closure): string @@ -68,44 +71,48 @@ private static function closure(Closure $closure): string } $reflection = new ReflectionFunction($closure); - $statics = $reflection->getStaticVariables(); - $captures = []; - foreach ($statics as $name => $value) { - $reference = ReflectionReference::fromArrayElement($statics, $name); - $captures[$name] = $reference instanceof ReflectionReference - ? ['reference', bin2hex($reference->getId()), self::value($value)] - : self::value($value); - } $bound = $reflection->getClosureThis(); $scope = $reflection->getClosureScopeClass(); $identity = [ - $reflection->getFileName() ?: 'internal', - $reflection->getStartLine(), - $reflection->getEndLine(), - $captures, - $bound === null ? null : self::object($bound), - $scope?->getName(), + 'instance' => self::objectIdentity($closure), + 'file' => $reflection->getFileName() ?: 'internal', + 'start' => $reflection->getStartLine(), + 'end' => $reflection->getEndLine(), + 'bound' => $bound === null ? null : self::objectIdentity($bound), + 'scope' => $scope?->getName(), ]; return self::$closures[$closure] = 'closure:' . hash('xxh128', serialize($identity)); } - private static function object(object $object): string + private static function normalizeValue(mixed $value): mixed { - self::$objects ??= new WeakMap(); - - return self::$objects[$object] ??= 'obj:' . $object::class . '#' . ++self::$nextObjectId; + return match (true) { + $value === null => ['null'], + is_bool($value) => ['bool', $value], + is_int($value) => ['int', $value], + is_float($value) => ['float', serialize($value)], + is_string($value) => ['string', $value], + $value instanceof Closure => ['closure', self::closure($value)], + is_object($value) => ['object', self::objectIdentity($value)], + is_resource($value) => ['resource', get_resource_type($value), (int) $value], + is_array($value) => ['array', self::values($value)], + default => ['type', get_debug_type($value)], + }; } /** * @param array $values - * @return array + * @return list */ private static function values(array $values): array { $normalized = []; foreach ($values as $key => $value) { - $normalized[$key] = self::value($value); + $normalized[] = [ + 'key' => [is_int($key) ? 'int' : 'string', $key], + 'value' => self::normalizeValue($value), + ]; } return $normalized; diff --git a/src/Memoize/Memoizer.php b/src/Memoize/Memoizer.php index 8ad8fc1e..33ce3626 100644 --- a/src/Memoize/Memoizer.php +++ b/src/Memoize/Memoizer.php @@ -35,12 +35,17 @@ public static function instance(): self return self::$instance ??= new self(); } + /** @internal Create lifecycle-isolated memoization state. */ + public static function isolated(): self + { + return new self(); + } + public function flush(): void { $this->staticCache = []; $this->objectCache = new WeakMap(); $this->hits = $this->misses = 0; - CallableFingerprint::flush(); } /** diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index a77eb768..bb38f365 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -26,11 +26,16 @@ public static function instance(): self return self::$instance ??= new self(); } + /** @internal Create lifecycle-isolated memoization state. */ + public static function isolated(): self + { + return new self(); + } + public function flush(): void { $this->cache = []; $this->order = []; - CallableFingerprint::flush(); } public function once(callable $callback, int $callerOffset = 0): mixed @@ -58,7 +63,7 @@ private function cacheKey(callable $callback, int $callerOffset): string . ':' . ($location['line'] ?? 0) . ':' . ($caller['class'] ?? '') . ':' . $this->normalizeCallerFunction($caller['function'] ?? '(unknown)') - . ':' . (is_object($callerObject) ? spl_object_id($callerObject) : '') + . ':' . (is_object($callerObject) ? CallableFingerprint::objectIdentity($callerObject) : '') . ':' . $this->callbackFingerprint($callback); } @@ -74,7 +79,7 @@ private function callbackFingerprint(callable $callback): string $reflection->getStartLine(), $reflection->getEndLine(), $reflection->getClosureScopeClass()?->getName() ?? '', - $bound === null ? '' : $bound::class . '#' . spl_object_id($bound), + $bound === null ? '' : CallableFingerprint::objectIdentity($bound), ]); } diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index b1433666..bc0e8598 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -16,6 +16,8 @@ final class NodeCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { + private bool $l1Readable = true; + public function __construct( private readonly ?InternalCachePoolInterface $l1, private readonly NodeSqliteCacheAdapter $l2, @@ -23,10 +25,34 @@ public function __construct( private readonly CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), ) {} + #[\Override] + public function assertOptionsCompatible(CacheOptions $options): void + { + parent::assertOptionsCompatible($options); + $this->l2->assertOptionsCompatible($options); + if ($this->l1 instanceof AbstractCacheAdapter) { + $this->l1->assertOptionsCompatible($options); + } + } + + #[\Override] + public function assertStorageIdentityCompatible(string $storageIdentity): void + { + parent::assertStorageIdentityCompatible($storageIdentity); + $this->l2->assertStorageIdentityCompatible($storageIdentity); + if ($this->l1 instanceof AbstractCacheAdapter) { + $this->l1->assertStorageIdentityCompatible($storageIdentity); + } + } + public function clear(): bool { $l2 = $this->attempt(fn(): bool => $this->l2->clear(), false, 'l2_failure'); - $l1 = $this->l1 === null || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); + $l1 = $this->l1 === null || !$this->l1Readable + || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); + if (!$l1) { + $this->disableL1(); + } $this->deferred = []; return $l2 && $l1; @@ -35,6 +61,7 @@ public function clear(): bool #[\Override] public function configureOptions(CacheOptions $options): void { + $this->assertOptionsCompatible($options); parent::configureOptions($options); $this->l2->configureOptions($options); if ($this->l1 instanceof AbstractCacheAdapter) { @@ -42,11 +69,26 @@ public function configureOptions(CacheOptions $options): void } } + #[\Override] + public function configureStorageIdentity(string $storageIdentity): void + { + $this->assertStorageIdentityCompatible($storageIdentity); + parent::configureStorageIdentity($storageIdentity); + $this->l2->configureStorageIdentity($storageIdentity); + if ($this->l1 instanceof AbstractCacheAdapter) { + $this->l1->configureStorageIdentity($storageIdentity); + } + } + public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); $l2 = $this->attempt(fn(): bool => $this->l2->deleteItem($key), false, 'l2_failure'); - $l1 = $this->l1 === null + $l1 = $this->l1 === null || !$this->l1Readable || $this->attempt(fn(): bool => $this->l1->deleteItem($key), false, 'l1_failure'); + if (!$l1) { + $this->disableL1(); + } return $l2 && $l1; } @@ -54,28 +96,48 @@ public function deleteItem(string $key): bool /** @param list $keys */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); $l2 = $this->attempt(fn(): bool => $this->l2->deleteItems($keys), false, 'l2_failure'); - $l1 = $this->l1 === null + $l1 = $this->l1 === null || !$this->l1Readable || $this->attempt(fn(): bool => $this->l1->deleteItems($keys), false, 'l1_failure'); + if (!$l1) { + $this->disableL1(); + } return $l2 && $l1; } public function getItem(string $key): CacheItem { - if ($this->l1 !== null) { - $l1 = $this->attempt(fn(): CacheItemInterface => $this->l1->getItem($key), $this->genericMiss($key), 'l1_failure'); + $pending = $this->deferredRead($key); + if ($pending !== null) { + return $pending; + } + + if ($this->l1 !== null && $this->l1Readable) { + $l1 = $this->attempt( + fn(): CacheItemInterface => $this->l1->getItem($key), + $this->genericMiss($key), + 'l1_failure', + ); if ($l1->isHit()) { return $this->nodeItem($l1); } } - $l2 = $this->attempt(fn(): CacheItemInterface => $this->l2->getItem($key), $this->genericMiss($key), 'l2_failure'); + + $l2 = $this->attempt( + fn(): CacheItemInterface => $this->l2->getItem($key), + $this->genericMiss($key), + 'l2_failure', + ); if (!$l2->isHit()) { return $this->genericMiss($key); } + $item = $this->nodeItem($l2); - if ($this->l1 !== null) { - $this->saveOneInto($this->l1, $item, 'l1_failure'); + if ($this->l1 !== null && $this->l1Readable + && !$this->saveOneInto($this->l1, $item, 'l1_failure')) { + $this->disableL1(); } return $item; @@ -88,7 +150,7 @@ public function getItem(string $key): CacheItem #[\Override] public function getTagGenerations(array $tags): array { - $cached = !$this->l1 instanceof TagGenerationCacheInterface + $cached = !$this->l1Readable || !($this->l1 instanceof TagGenerationCacheInterface) ? [] : $this->attempt(fn(): array => $this->l1->readTagGenerations($tags), [], 'l1_failure'); $missing = array_values(array_diff($tags, array_keys($cached))); @@ -101,8 +163,9 @@ public function getTagGenerations(array $tags): array $this->freshGenerations($missing), 'l2_failure', ); - if ($this->l1 instanceof TagGenerationCacheInterface) { - $this->attempt(fn(): bool => $this->l1->storeTagGenerations($loaded), false, 'l1_failure'); + if ($this->l1Readable && $this->l1 instanceof TagGenerationCacheInterface + && !$this->attempt(fn(): bool => $this->l1->storeTagGenerations($loaded), false, 'l1_failure')) { + $this->disableL1(); } return $cached + $loaded; @@ -113,6 +176,11 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + public function isAuthoritative(): bool + { + return $this->l1 === null || !$this->l1Readable; + } + /** * @param list $keys * @return array @@ -122,9 +190,12 @@ public function multiFetch(array $keys): array [$results, $misses] = $this->readL1($keys); $promote = $this->readL2($misses, $results); - if ($promote !== [] && $this->l1 !== null) { - $this->saveInto($this->l1, $promote, 'l1_failure'); - $this->metric('l2_batch_promote', count($promote)); + if ($promote !== [] && $this->l1 !== null && $this->l1Readable) { + if ($this->saveInto($this->l1, $promote, 'l1_failure')) { + $this->metric('l2_batch_promote', count($promote)); + } else { + $this->disableL1(); + } } $ordered = []; @@ -147,7 +218,7 @@ public function readTagGenerations(array $tags): array public function rotateTagGenerations(array $tags): bool { $l2 = $this->attempt(fn(): bool => $this->l2->rotateTagGenerations($tags), false, 'l2_failure'); - if (!$l2 || !$this->l1 instanceof TagGenerationCacheInterface) { + if (!$l2 || !$this->l1Readable || !($this->l1 instanceof TagGenerationCacheInterface)) { return $l2; } @@ -156,7 +227,7 @@ public function rotateTagGenerations(array $tags): bool $stored = $generations !== [] && $this->attempt(fn(): bool => $this->l1->storeTagGenerations($generations), false, 'l1_failure'); if (!$fenced || !$stored) { - $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); + $this->disableL1(); } return $fenced && $stored; @@ -167,17 +238,21 @@ public function save(CacheItemInterface $item): bool if (!$this->supportsItem($item)) { return false; } + $stored = $this->saveOneInto($this->l2, $item, 'l2_failure'); if (!$stored) { return false; } - if ($this->l1 === null) { - return $stored; + if ($this->l1 === null || !$this->l1Readable) { + return true; } - $this->saveOneInto($this->l1, $item, 'l1_failure'); + $l1Stored = $this->saveOneInto($this->l1, $item, 'l1_failure'); + if (!$l1Stored) { + $this->disableL1(); + } - return true; + return $l1Stored; } /** @param array $items */ @@ -193,20 +268,39 @@ public function saveItems(array $items): bool if (!$stored) { return false; } - if ($this->l1 !== null) { - $this->saveInto($this->l1, $items, 'l1_failure'); + if ($this->l1 === null || !$this->l1Readable) { + return true; + } + + $l1Stored = $this->saveInto($this->l1, $items, 'l1_failure'); + if (!$l1Stored) { + $this->disableL1(); } - return true; + return $l1Stored; } /** @param array $generations */ #[\Override] public function storeTagGenerations(array $generations): bool { - return $this->l2->storeTagGenerations($generations) - && (!$this->l1 instanceof TagGenerationCacheInterface - || $this->l1->storeTagGenerations($generations)); + if (!$this->l2->storeTagGenerations($generations)) { + return false; + } + if (!$this->l1Readable || !($this->l1 instanceof TagGenerationCacheInterface)) { + return true; + } + + $stored = $this->attempt( + fn(): bool => $this->l1->storeTagGenerations($generations), + false, + 'l1_failure', + ); + if (!$stored) { + $this->disableL1(); + } + + return $stored; } /** @@ -229,6 +323,18 @@ private function attempt(callable $operation, mixed $fallback, string $failureMe } } + private function disableL1(): void + { + if (!$this->l1Readable) { + return; + } + + $this->l1Readable = false; + if ($this->l1 !== null) { + $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_clear_failure'); + } + } + /** * @param list $tags * @return array @@ -255,7 +361,7 @@ private function nodeItem(CacheItemInterface $item): CacheItem $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; - return (new CacheItem($this, $item->getKey(), $item->get(), true)) + return new CacheItem($this, $item->getKey(), $item->get(), true) ->expiresAfter($ttl) ->setTagGenerations($tags); } @@ -266,7 +372,7 @@ private function nodeItem(CacheItemInterface $item): CacheItem */ private function readL1(array $keys): array { - if ($this->l1 === null || $keys === []) { + if ($this->l1 === null || !$this->l1Readable || $keys === []) { return [[], $keys]; } @@ -297,6 +403,7 @@ private function readL2(array $keys, array &$results): array if ($keys === []) { return []; } + $items = $this->attempt(fn(): array => $this->l2->multiFetch($keys), [], 'l2_failure'); $hits = []; foreach ($keys as $key) { @@ -328,7 +435,8 @@ private function saveInto( string $failureMetric, ): bool { $targets = []; - foreach ($items as $key => $item) { + foreach ($items as $item) { + $key = $item->getKey(); $target = $pool->createItem($key)->set($item->get()); if ($item instanceof CacheItem) { $target->expiresAfter($item->ttlSeconds()); diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index e1aebbd2..9b6863fb 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -26,6 +26,8 @@ final class NodeSqliteCacheAdapter extends AbstractCacheAdapter implements TagGe private readonly \PDOStatement $upsertStatement; + private bool $ownsTransaction = false; + public function __construct( private readonly PDO $connection, string $namespace, @@ -49,6 +51,8 @@ public function __construct( public function clear(): bool { + $this->assertWritableTransaction(); + try { $statement = $this->connection->prepare('DELETE FROM ' . self::TABLE . ' WHERE namespace = :namespace'); $ok = $statement->execute([':namespace' => $this->namespace]); @@ -80,6 +84,9 @@ public function connection(): PDO public function deleteItem(string $key): bool { + $this->discardDeferredKey($key); + $this->assertWritableTransaction(); + try { return $this->deleteStatement->execute([ ':namespace' => $this->namespace, @@ -96,10 +103,13 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { + $this->discardDeferredKeys($keys); if ($keys === []) { return true; } + $this->assertWritableTransaction(); + try { $mapped = array_map($this->mapData(...), $keys); $marks = implode(',', array_fill(0, count($mapped), '?')); @@ -109,8 +119,6 @@ public function deleteItems(array $keys): bool return $statement->execute([$this->namespace, ...$mapped]); } catch (PDOException $exception) { - $this->rollBack(); - throw $this->storageException('Unable to delete node SQLite cache keys.', $exception); } } @@ -129,12 +137,12 @@ public function getItem(string $key): CacheItem } if (!is_array($row) || !is_string($row['payload'] ?? null)) { - return new CacheItem($this, $key); + return $this->genericMiss($key); } - $record = $this->decodeRecordFromBlob($row['payload']); + $record = $this->decodeRecordFromBlob($row['payload'], $key); if ($record === null) { - return new CacheItem($this, $key); + return $this->genericMiss($key); } return $this->genericItemFromRecord($key, $record); @@ -154,11 +162,18 @@ public function getTagGenerations(array $tags): array $missing[$tag] = self::newGeneration(); } } - if ($missing !== [] && !$this->storeTagGenerations($missing)) { + if ($missing !== [] && !$this->insertTagGenerationsIfMissing($missing)) { throw new NodeCacheStorageException('Unable to initialize node SQLite tag generations.'); } - return $generations + $missing; + $actual = $this->readTagGenerations($tags); + foreach ($tags as $tag) { + if (!isset($actual[$tag])) { + throw new NodeCacheStorageException('Unable to initialize node SQLite tag generation.'); + } + } + + return $actual; } public function hasItem(string $key): bool @@ -191,7 +206,6 @@ public function multiFetch(array $keys): array } } $items = []; - $invalid = []; foreach ($keys as $key) { $payload = $rows[$this->mapData($key)] ?? null; if (!is_string($payload)) { @@ -199,18 +213,14 @@ public function multiFetch(array $keys): array continue; } - $record = $this->decodeRecordFromBlob($payload); + $record = $this->decodeRecordFromBlob($payload, $key); if ($record === null) { - $invalid[] = $key; $items[$key] = $this->genericMiss($key); continue; } $items[$key] = $this->genericItemFromRecord($key, $record); } - if ($invalid !== []) { - $this->deleteItems($invalid); - } return $items; } @@ -261,6 +271,7 @@ public function rotateTagGenerations(array $tags): bool public function save(CacheItemInterface $item): bool { + $this->assertWritableTransaction(); if (!$this->supportsItem($item)) { return false; } @@ -301,6 +312,7 @@ public function saveItems(array $items): bool */ public function saveMany(array $items): bool { + $this->assertWritableTransaction(); $rows = []; $expired = []; foreach ($items as $item) { @@ -327,6 +339,7 @@ public function saveMany(array $items): bool try { $this->connection->beginTransaction(); + $this->ownsTransaction = true; if ($expired !== [] && !$this->deleteItems($expired)) { $this->rollBack(); @@ -338,11 +351,16 @@ public function saveMany(array $items): bool return false; } - return $this->connection->commit(); + $committed = $this->connection->commit(); + $this->ownsTransaction = false; + + return $committed; } catch (PDOException $exception) { $this->rollBack(); throw $this->storageException('Unable to store node SQLite cache entries.', $exception); + } finally { + $this->ownsTransaction = false; } } @@ -350,8 +368,10 @@ public function saveMany(array $items): bool #[\Override] public function storeTagGenerations(array $generations): bool { + $this->assertWritableTransaction(); $rows = []; foreach ($generations as $tag => $generation) { + $tag = (string) $tag; if (!self::isGeneration($generation)) { return false; } @@ -361,6 +381,15 @@ public function storeTagGenerations(array $generations): bool return $this->upsertRows($rows); } + private function assertWritableTransaction(): void + { + if ($this->connection->inTransaction() && !$this->ownsTransaction) { + throw new NodeCacheStorageException( + 'Node SQLite cache mutations cannot join a caller-owned transaction.', + ); + } + } + private function createSchemaIfMissing(): void { try { @@ -378,6 +407,28 @@ private function createSchemaIfMissing(): void } } + /** @param array $generations */ + private function insertTagGenerationsIfMissing(array $generations): bool + { + $this->assertWritableTransaction(); + foreach ($generations as $tag => $generation) { + $statement = $this->connection->prepare( + 'INSERT INTO ' . self::TABLE + . ' (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, NULL) ' + . 'ON CONFLICT(namespace, cache_key) DO NOTHING', + ); + if (!$statement->execute([ + $this->namespace, + $this->mapTag((string) $tag), + strtolower($generation), + ])) { + return false; + } + } + + return true; + } + private function mapData(string $key): string { return 'd:' . $key; @@ -390,9 +441,10 @@ private function mapTag(string $tag): string private function rollBack(): void { - if ($this->connection->inTransaction()) { + if ($this->ownsTransaction && $this->connection->inTransaction()) { $this->connection->rollBack(); } + $this->ownsTransaction = false; } private function storageException(string $message, PDOException $exception): NodeCacheStorageException diff --git a/src/Node/Connection/NodeSqliteConnection.php b/src/Node/Connection/NodeSqliteConnection.php index 243568ce..a73a0eda 100644 --- a/src/Node/Connection/NodeSqliteConnection.php +++ b/src/Node/Connection/NodeSqliteConnection.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Node\Exception\NodeCacheConfigurationException; use Infocyph\CacheLayer\Node\NodeCacheConfig; +use Infocyph\CacheLayer\Support\FilesystemTrust; use PDO; use PDOException; @@ -38,28 +39,55 @@ public static function create(NodeCacheConfig $config): PDO return $connection; } - private static function prepareDirectory(string $file): void + private static function assertSecureDirectory(string $directory): void { - if (is_link($file)) { - throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache file: {$file}"); + if (!is_writable($directory)) { + throw new NodeCacheConfigurationException("SQLite cache directory is not writable: {$directory}"); } - $directory = dirname($file); - if (is_link($directory)) { - throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache directory: {$directory}"); + $permissions = fileperms($directory); + if ($permissions !== false && (($permissions & 0x0002) === 0x0002)) { + throw new NodeCacheConfigurationException("SQLite cache directory must not be world-writable: {$directory}"); } + } - if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { - throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); + private static function assertSecureFile(string $file): void + { + if (!is_file($file)) { + return; } - if (!is_writable($directory)) { - throw new NodeCacheConfigurationException("SQLite cache directory is not writable: {$directory}"); + $permissions = fileperms($file); + if ($permissions !== false && (($permissions & 0x0002) === 0x0002)) { + throw new NodeCacheConfigurationException("SQLite cache file must not be world-writable: {$file}"); } + } - $permissions = fileperms($directory); - if ($permissions !== false && (($permissions & 0x0002) === 0x0002)) { - throw new NodeCacheConfigurationException("SQLite cache directory must not be world-writable: {$directory}"); + private static function assertTrustedFilePath(string $file): void + { + if (FilesystemTrust::containsSymlink($file)) { + throw new NodeCacheConfigurationException("Refusing symlinked SQLite cache path: {$file}"); + } + if (file_exists($file) && !is_file($file)) { + throw new NodeCacheConfigurationException("SQLite cache file path is not a regular file: {$file}"); + } + } + + private static function ensureDirectory(string $directory): void + { + if (!is_dir($directory) && !mkdir($directory, 0750, true) && !is_dir($directory)) { + throw new NodeCacheConfigurationException("Unable to create SQLite cache directory: {$directory}"); } } + + private static function prepareDirectory(string $file): void + { + self::assertTrustedFilePath($file); + + $directory = dirname($file); + self::ensureDirectory($directory); + self::assertTrustedFilePath($file); + self::assertSecureDirectory($directory); + self::assertSecureFile($file); + } } diff --git a/src/Node/Maintenance/NodeCacheMaintenance.php b/src/Node/Maintenance/NodeCacheMaintenance.php index 3153afcc..7bcd2d6f 100644 --- a/src/Node/Maintenance/NodeCacheMaintenance.php +++ b/src/Node/Maintenance/NodeCacheMaintenance.php @@ -18,6 +18,22 @@ public function checkpoint(): void $this->connection->query('PRAGMA wal_checkpoint(PASSIVE)'); } + public function cycle( + int $pruneLimit = 5_000, + bool $checkpoint = true, + bool $optimize = false, + ): int { + $pruned = $this->pruneExpired($pruneLimit); + if ($checkpoint) { + $this->checkpoint(); + } + if ($optimize) { + $this->optimize(); + } + + return $pruned; + } + public function optimize(): void { $this->connection->exec('PRAGMA optimize'); diff --git a/src/Node/NodeCache.php b/src/Node/NodeCache.php index ba1caa72..bd84f580 100644 --- a/src/Node/NodeCache.php +++ b/src/Node/NodeCache.php @@ -6,7 +6,6 @@ use Infocyph\CacheLayer\Cache\Adapter\ApcuCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter; @@ -20,11 +19,12 @@ final class NodeCache public static function create(NodeCacheConfig $config): Cache { $connection = NodeSqliteConnection::create($config); + $storageIdentity = self::storageIdentity($config); $metrics = new InMemoryCacheMetricsCollector(); $adapter = new NodeCacheAdapter( - self::createApcuAdapter($config), + self::createApcuAdapter($config, $storageIdentity), new NodeSqliteCacheAdapter($connection, $config->namespace), - $config->failOpen, + $config->options->failOpen, $metrics, ); @@ -32,7 +32,8 @@ public static function create(NodeCacheConfig $config): Cache $adapter, $config->lockProvider ?? new FileLockProvider($config->lockDirectory), $metrics, - new CacheOptions(failOpen: $config->failOpen), + $config->options, + $storageIdentity, ); } @@ -47,12 +48,21 @@ public static function maintenance(NodeCacheConfig $config): NodeCacheMaintenanc ); } - private static function createApcuAdapter(NodeCacheConfig $config): ?ApcuCacheAdapter - { + private static function createApcuAdapter( + NodeCacheConfig $config, + string $storageIdentity, + ): ?ApcuCacheAdapter { if (!$config->apcuEnabled || !extension_loaded('apcu') || !apcu_enabled()) { return null; } - return new ApcuCacheAdapter($config->namespace); + return new ApcuCacheAdapter($storageIdentity); + } + + private static function storageIdentity(NodeCacheConfig $config): string + { + $path = realpath($config->sqliteFile) ?: $config->sqliteFile; + + return 'node.' . hash('xxh128', $path . "\0" . $config->namespace); } } diff --git a/src/Node/NodeCacheConfig.php b/src/Node/NodeCacheConfig.php index 182e4624..60fbf03b 100644 --- a/src/Node/NodeCacheConfig.php +++ b/src/Node/NodeCacheConfig.php @@ -5,6 +5,7 @@ namespace Infocyph\CacheLayer\Node; use Infocyph\CacheLayer\Cache\CacheInput; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Node\Exception\NodeCacheConfigurationException; @@ -13,14 +14,16 @@ { public string $namespace; + public CacheOptions $options; + public function __construct( public string $sqliteFile, string $namespace = 'default', public ?string $lockDirectory = null, public int $busyTimeoutMs = 1_000, public bool $apcuEnabled = true, - public bool $failOpen = true, public ?LockProviderInterface $lockProvider = null, + ?CacheOptions $options = null, ) { if ($sqliteFile === '' || str_contains($sqliteFile, "\0")) { throw new NodeCacheConfigurationException('The SQLite cache file path is invalid.'); @@ -39,5 +42,7 @@ public function __construct( } catch (CacheInvalidArgumentException $failure) { throw new NodeCacheConfigurationException($failure->getMessage(), 0, $failure); } + + $this->options = $options ?? new CacheOptions(); } } diff --git a/src/Serializer/ClosureSerializer.php b/src/Serializer/ClosureSerializer.php index 85035032..2185b929 100644 --- a/src/Serializer/ClosureSerializer.php +++ b/src/Serializer/ClosureSerializer.php @@ -31,7 +31,7 @@ public static function serialize(Closure $closure): string return self::PREFIX . base64_encode(opis_serialize($closure)); } - public static function signed(string $key): SignedClosureSerializer + public static function signed(#[\SensitiveParameter] string $key): SignedClosureSerializer { return new SignedClosureSerializer($key); } diff --git a/src/Serializer/SignedClosureSerializer.php b/src/Serializer/SignedClosureSerializer.php index 22823c53..8d8ead98 100644 --- a/src/Serializer/SignedClosureSerializer.php +++ b/src/Serializer/SignedClosureSerializer.php @@ -11,7 +11,7 @@ { private const string PREFIX = 'cls1-sig:'; - public function __construct(private string $key) + public function __construct(#[\SensitiveParameter] private string $key) { if ($key === '') { throw new InvalidArgumentException('The Closure signing key must not be empty.'); diff --git a/src/Support/BoundedValueTraversal.php b/src/Support/BoundedValueTraversal.php new file mode 100644 index 00000000..65d4424b --- /dev/null +++ b/src/Support/BoundedValueTraversal.php @@ -0,0 +1,83 @@ + $value */ + private static function assertDepth(int $depth, array $value): void + { + if ($depth >= self::MAX_DEPTH && $value !== []) { + throw new InvalidArgumentException('The value graph exceeds the supported nesting depth.'); + } + } + + private static function assertNodeBudget(int $nodes): void + { + if ($nodes > self::MAX_NODES) { + throw new InvalidArgumentException('The value graph exceeds the supported traversal budget.'); + } + } + + /** + * @param array $current + * @param array $references + * @return array + */ + private static function childReferences(array $current, int|string $key, array $references): array + { + $reference = ReflectionReference::fromArrayElement($current, $key); + if (!$reference instanceof ReflectionReference) { + return $references; + } + + $id = bin2hex($reference->getId()); + if (isset($references[$id])) { + throw new InvalidArgumentException('Recursive array references are not supported.'); + } + $references[$id] = true; + + return $references; + } + + /** + * @param array $references + */ + private static function visit( + mixed $value, + int $depth, + array $references, + int &$nodes, + ): void { + self::assertNodeBudget(++$nodes); + if (!is_array($value)) { + return; + } + + self::assertDepth($depth, $value); + foreach ($value as $key => $item) { + self::assertNodeBudget($nodes + 1); + self::visit( + $item, + $depth + 1, + self::childReferences($value, $key, $references), + $nodes, + ); + } + } +} diff --git a/src/Support/FilesystemTrust.php b/src/Support/FilesystemTrust.php new file mode 100644 index 00000000..4053044b --- /dev/null +++ b/src/Support/FilesystemTrust.php @@ -0,0 +1,27 @@ +get($key, null, \Memcached::GET_EXTENDED); + if (!is_array($entry) || !is_string($entry['value'] ?? null)) { + return false; + } + + $current = $entry['value']; + if ($current !== $observed) { + return $current; + } + + $cas = $entry['cas'] ?? null; + if ((!is_int($cas) && !is_float($cas)) + || !$client->cas((float) $cas, $key, $replacement, $expiration)) { + $latest = $client->get($key); + + return is_string($latest) ? $latest : false; + } + + return $replacement; + } +} diff --git a/src/Support/OptionalCassandra.php b/src/Support/OptionalCassandra.php new file mode 100644 index 00000000..d8e08ba6 --- /dev/null +++ b/src/Support/OptionalCassandra.php @@ -0,0 +1,84 @@ +build(); + if (!is_object($builder) || !is_callable([$builder, 'connect'])) { + throw new RuntimeException('Unable to create a Cassandra session builder.'); + } + + $session = $builder->connect($keyspace); + if (!is_object($session)) { + throw new RuntimeException('Unable to create a Cassandra session.'); + } + + return $session; + } + + /** + * @param array $arguments + * @return array{arguments:array} + */ + public static function executionOptions(array $arguments): array + { + return ['arguments' => $arguments]; + } + + public static function simpleStatement(string $cql): mixed + { + $class = self::nestedClass('SimpleStatement'); + + return class_exists($class) ? new $class($cql) : $cql; + } + + private static function nestedClass(string $name): string + { + return self::rootClass() . '\\' . $name; + } + + private static function rootClass(): string + { + return implode('', ['Cassa', 'ndra']); + } +} diff --git a/src/Support/RedisConnection.php b/src/Support/RedisConnection.php index 0eabdfdc..c936244a 100644 --- a/src/Support/RedisConnection.php +++ b/src/Support/RedisConnection.php @@ -14,7 +14,7 @@ final class RedisConnection private const float READ_TIMEOUT_SECONDS = 1.0; - public static function connect(string $dsn): \Redis + public static function connect(#[\SensitiveParameter] string $dsn): \Redis { [$host, $port, $database, $credentials] = self::parseDsn($dsn); $connection = new \Redis(); @@ -41,7 +41,7 @@ public static function connect(string $dsn): \Redis * @param string|array|null $credentials The optional Redis credentials. * @phpstan-param string|array{string, string}|null $credentials */ - private static function authenticate(\Redis $connection, string|array|null $credentials): void + private static function authenticate(\Redis $connection, #[\SensitiveParameter] string|array|null $credentials): void { if ($credentials !== null && !$connection->auth($credentials)) { throw new RuntimeException('Redis-compatible server authentication failed.'); @@ -53,7 +53,7 @@ private static function authenticate(\Redis $connection, string|array|null $cred * @phpstan-param array $parts * @phpstan-return string|array{string, string}|null */ - private static function parseCredentials(array $parts): string|array|null + private static function parseCredentials(#[\SensitiveParameter] array $parts): string|array|null { $pass = $parts['pass'] ?? null; if (!is_string($pass) || $pass === '') { @@ -73,7 +73,7 @@ private static function parseCredentials(array $parts): string|array|null * @param string $dsn The Redis-compatible DSN. * @phpstan-return array{string, int, int|null, string|array{string, string}|null} */ - private static function parseDsn(string $dsn): array + private static function parseDsn(#[\SensitiveParameter] string $dsn): array { $parts = self::parseDsnParts($dsn); $scheme = $parts['scheme'] ?? null; @@ -113,7 +113,7 @@ private static function parseDsn(string $dsn): array * @param string $dsn The Redis-compatible DSN. * @phpstan-return array */ - private static function parseDsnParts(string $dsn): array + private static function parseDsnParts(#[\SensitiveParameter] string $dsn): array { try { $parts = parse_url($dsn); diff --git a/src/Support/RedisValueGuard.php b/src/Support/RedisValueGuard.php new file mode 100644 index 00000000..6e424cdf --- /dev/null +++ b/src/Support/RedisValueGuard.php @@ -0,0 +1,49 @@ +eval(self::DELETE_IF_UNCHANGED_SCRIPT, [$key, $observed], 1) === 1; + } + + public static function replaceIfUnchanged( + \Redis $client, + string $key, + string $observed, + string $replacement, + ): string|false { + $result = $client->eval( + self::REPLACE_IF_UNCHANGED_SCRIPT, + [$key, $observed, $replacement], + 1, + ); + + return is_string($result) ? $result : false; + } +} diff --git a/src/functions.php b/src/functions.php index e954b867..40d626d2 100644 --- a/src/functions.php +++ b/src/functions.php @@ -2,8 +2,8 @@ declare(strict_types=1); +use Infocyph\CacheLayer\Integration\Runwire\RunwireIntegration; use Infocyph\CacheLayer\Memoize\Memoizer; -use Infocyph\CacheLayer\Memoize\OnceMemoizer; if (!function_exists('memoize')) { /** @@ -13,9 +13,12 @@ */ function memoize(?callable $callable = null, array $params = []): mixed { - $memoizer = Memoizer::instance(); + $memoizer = RunwireIntegration::memoizer(); if ($callable === null) { - return $memoizer; + return $memoizer ?? Memoizer::isolated(); + } + if ($memoizer === null) { + return $callable(...$params); } return $memoizer->get($callable, $params); @@ -30,16 +33,20 @@ function memoize(?callable $callable = null, array $params = []): mixed */ function remember(?object $object = null, ?callable $callable = null, array $params = []): mixed { - $memoizer = Memoizer::instance(); + $memoizer = RunwireIntegration::memoizer(); if ($object === null) { - return $memoizer; + return $memoizer ?? Memoizer::isolated(); } if ($callable === null) { throw new InvalidArgumentException('remember() requires both object and callable'); } + if ($memoizer === null) { + return $callable(...$params); + } + return $memoizer->getFor($object, $callable, $params); } } @@ -47,14 +54,17 @@ function remember(?object $object = null, ?callable $callable = null, array $par if (!function_exists('once')) { function once(callable $callback): mixed { - return OnceMemoizer::instance()->once($callback, 1); + $memoizer = RunwireIntegration::onceMemoizer(); + + return $memoizer === null + ? $callback() + : $memoizer->once($callback, 1); } } if (!function_exists('flush_memoizers')) { function flush_memoizers(): void { - Memoizer::instance()->flush(); - OnceMemoizer::instance()->flush(); + RunwireIntegration::flushMemoizers(); } } diff --git a/tests/Cache/ApcuCachePoolTest.php b/tests/Cache/ApcuCachePoolTest.php index 1a8bd163..56c0878e 100644 --- a/tests/Cache/ApcuCachePoolTest.php +++ b/tests/Cache/ApcuCachePoolTest.php @@ -11,26 +11,23 @@ */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; /* ── skip entirely if APCu unavailable ─────────────────────────────── */ if (! extension_loaded('apcu')) { - test('APCu not loaded – skipping adapter tests')->skip(); - - return; + throw new RuntimeException('APCu is required for the configured cache test matrix.'); } ini_set('apcu.enable_cli', 1); if (! apcu_enabled()) { - test('APCu not enabled – skipping adapter tests')->skip(); - - return; + throw new RuntimeException('APCu must be enabled for CLI tests.'); } /* ── boilerplate ──────────────────────────────────────────────────── */ beforeEach(function () { apcu_clear_cache(); // fresh memory - $this->cache = Cache::apcu('tests'); // APCu-backed pool + $this->cache = Cache::apcu('tests', new CacheOptions(allowClosures: true)); // APCu-backed pool }); afterEach(function () { @@ -72,7 +69,7 @@ /* ─── deferred queue ──────────────────────────────────────────────── */ test('saveDeferred() and commit() (apcu)', function () { $this->cache->getItem('x')->set('X')->saveDeferred(); - expect($this->cache->get('x'))->toBeNull(); + expect($this->cache->get('x'))->toBe('X'); $this->cache->commit(); expect($this->cache->get('x'))->toBe('X'); diff --git a/tests/Cache/ArchitectureHardeningTest.php b/tests/Cache/ArchitectureHardeningTest.php index fd8737eb..49559d4b 100644 --- a/tests/Cache/ArchitectureHardeningTest.php +++ b/tests/Cache/ArchitectureHardeningTest.php @@ -176,9 +176,9 @@ public function resetOperationCounts(): void expect(fn() => $cache->setMultiple(['valid' => 1, 'bad key' => 2])) ->toThrow(CacheInvalidArgumentException::class) ->and($adapter->saveBatches)->toBe(0); - expect(fn() => $cache->setMultiple([1 => 'numeric key'])) - ->toThrow(CacheInvalidArgumentException::class) - ->and($adapter->saveBatches)->toBe(0); + expect($cache->setMultiple(['1' => 'numeric key']))->toBeTrue() + ->and($cache->get('1'))->toBe('numeric key') + ->and($adapter->saveBatches)->toBe(1); expect(fn() => $cache->deleteMultiple(['valid', 'bad:key'])) ->toThrow(CacheInvalidArgumentException::class) ->and($adapter->deleteBatches)->toBe(0); @@ -198,7 +198,7 @@ public function resetOperationCounts(): void $adapter->resetOperationCounts(); expect($cache->getMultiple(['one', 'two']))->toBe(['one' => null, 'two' => null]) ->and($adapter->tagFetchBatches)->toBe(1) - ->and($adapter->deleteBatches)->toBe(1); + ->and($adapter->deleteBatches)->toBe(0); }); test('cache items can only be persisted by their exact owning pool', function () { diff --git a/tests/Cache/ArrayCachePoolTest.php b/tests/Cache/ArrayCachePoolTest.php index 8b8b70a0..022f1961 100644 --- a/tests/Cache/ArrayCachePoolTest.php +++ b/tests/Cache/ArrayCachePoolTest.php @@ -4,6 +4,8 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; +use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; @@ -112,3 +114,62 @@ public function saveItems(array $items): bool ->and($adapter->commit())->toBeTrue() ->and($adapter->bulkCalls)->toBe(2); }); + + +test('deferred state obeys PSR ordering and snapshot semantics', function () { + $cache = Cache::memory('deferred-contract'); + $cache->set('overwrite', 'stored'); + $queued = $cache->getItem('overwrite')->set('queued'); + expect($cache->saveDeferred($queued))->toBeTrue() + ->and($cache->get('overwrite'))->toBe('queued') + ->and($cache->hasItem('overwrite'))->toBeTrue() + ->and($cache->getItems(['overwrite'])['overwrite']->get())->toBe('queued'); + + $queued->set('mutated-after-queue'); + expect($cache->get('overwrite'))->toBe('queued'); + + expect($cache->set('overwrite', 'immediate'))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('overwrite'))->toBe('immediate'); + + $delete = $cache->getItem('delete')->set('queued-delete'); + expect($cache->saveDeferred($delete))->toBeTrue() + ->and($cache->delete('delete'))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('delete'))->toBeNull(); + + $clear = $cache->getItem('clear')->set('queued-clear'); + expect($cache->saveDeferred($clear))->toBeTrue() + ->and($cache->clear())->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('clear'))->toBeNull(); +}); + +test('deferred null and expired values retain hit and expiry semantics', function () { + $cache = Cache::memory('deferred-values'); + + $null = $cache->getItem('null')->set(null); + expect($cache->saveDeferred($null))->toBeTrue() + ->and($cache->hasItem('null'))->toBeTrue() + ->and($cache->get('null', 'fallback'))->toBeNull(); + + $expired = $cache->getItem('expired')->set('gone')->expiresAfter(-1); + expect($cache->saveDeferred($expired))->toBeTrue() + ->and($cache->hasItem('expired'))->toBeFalse() + ->and($cache->get('expired'))->toBeNull() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('expired'))->toBeNull(); +}); + +test('direct PSR pool rejects invalid keys and missing deletes succeed', function () { + $pool = new ArrayCacheAdapter('direct-contract'); + $pool->save($pool->getItem('valid')->set('value')); + + expect(fn() => $pool->getItem('bad:key'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => $pool->hasItem('bad:key'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => $pool->deleteItem('bad:key'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => $pool->deleteItems(['valid', 'bad:key']))->toThrow(CacheInvalidArgumentException::class) + ->and($pool->getItem('valid')->get())->toBe('value') + ->and($pool->deleteItem('missing'))->toBeTrue() + ->and($pool->deleteItems(['missing-a', 'missing-b']))->toBeTrue(); +}); diff --git a/tests/Cache/AtomicBackendExpansionTest.php b/tests/Cache/AtomicBackendExpansionTest.php index bbd41a33..0c069e27 100644 --- a/tests/Cache/AtomicBackendExpansionTest.php +++ b/tests/Cache/AtomicBackendExpansionTest.php @@ -72,28 +72,69 @@ } }); -if (class_exists(Memcached::class)) { - $host = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; - $port = (int) (getenv('IC_MEMCACHED_PORT') ?: getenv('CACHELAYER_MEMCACHED_PORT') ?: '11211'); - $probe = new Memcached(); - $probe->addServer($host, $port); - $available = $probe->set('cachelayer-atomic-probe', 'ok') - && $probe->getResultCode() === Memcached::RES_SUCCESS; - - test('Memcached supports CAS-backed atomic cache operations', function () use ($host, $port) { - $client = new Memcached(); - $client->addServer($host, $port); - $client->flush(); - $cache = Cache::memcached('atomic-memcached', [[$host, $port, 0]], $client); - $atomic = $cache->atomic(); +if (!class_exists(Memcached::class)) { + throw new RuntimeException('Memcached extension is required for the configured atomic test matrix.'); +} - expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) - ->and($atomic->setIfAbsent('claim', 'first', 30))->toBeTrue() - ->and($atomic->setIfAbsent('claim', 'second', 30))->toBeFalse() - ->and($atomic->compareAndSet('claim', 'first', 'updated', 30))->toBeTrue() - ->and($atomic->getAndDelete('claim', 'missing'))->toBe('updated') - ->and($cache->has('claim'))->toBeFalse() - ->and($atomic->setIfAbsent('claim', 'reclaimed', 30))->toBeTrue() - ->and($cache->get('claim'))->toBe('reclaimed'); - })->skip(!$available, 'No Memcached server available.'); +$host = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; +$port = (int) (getenv('IC_MEMCACHED_PORT') ?: getenv('CACHELAYER_MEMCACHED_PORT') ?: '11211'); +$probe = new Memcached(); +$probe->addServer($host, $port); +$available = $probe->set('cachelayer-atomic-probe', 'ok') + && $probe->getResultCode() === Memcached::RES_SUCCESS; +if (!$available) { + throw new RuntimeException('Memcached service is required for the configured atomic test matrix.'); } + +test('Memcached supports CAS-backed atomic cache operations', function () use ($host, $port) { + $client = new Memcached(); + $client->addServer($host, $port); + $client->flush(); + $cache = Cache::memcached('atomic-memcached', [[$host, $port, 0]], $client); + $atomic = $cache->atomic(); + + expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) + ->and($atomic->setIfAbsent('claim', 'first', 30))->toBeTrue() + ->and($atomic->setIfAbsent('claim', 'second', 30))->toBeFalse() + ->and($atomic->compareAndSet('claim', 'first', 'updated', 30))->toBeTrue() + ->and($atomic->getAndDelete('claim', 'missing'))->toBe('updated') + ->and($cache->has('claim'))->toBeFalse() + ->and($atomic->setIfAbsent('claim', 'reclaimed', 30))->toBeTrue() + ->and($cache->get('claim'))->toBe('reclaimed'); +}); + + +test('file PHP-file SQLite WeakMap and Memcached atomic consume discard deferred overlays', function () use ($cleanupTree, $host, $port) { + $directory = sys_get_temp_dir() . '/cachelayer-atomic-deferred-' . uniqid('', true); + $sqlite = $directory . '/atomic.sqlite'; + mkdir($directory, 0700, true); + + $memcached = new Memcached(); + $memcached->addServer($host, $port); + $memcached->flush(); + + $caches = [ + Cache::weakMap('atomic-deferred-weak'), + Cache::file('atomic-deferred-file', $directory . '/file'), + Cache::phpFiles('atomic-deferred-php', $directory . '/php'), + Cache::sqlite('atomic-deferred-sqlite', $sqlite), + Cache::memcached('atomic-deferred-memcached', [[$host, $port, 0]], $memcached), + ]; + + try { + foreach ($caches as $index => $cache) { + $key = 'consume-' . $index; + $atomic = $cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($cache->set($key, 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem($key)->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete($key, 'missing'))->toBe('stored') + ->and($atomic->getAndDelete($key, 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get($key))->toBeNull(); + } + } finally { + unset($caches); + $cleanupTree($directory); + } +}); diff --git a/tests/Cache/AtomicCacheCapabilityTest.php b/tests/Cache/AtomicCacheCapabilityTest.php index 3d35e00e..a0cad7c5 100644 --- a/tests/Cache/AtomicCacheCapabilityTest.php +++ b/tests/Cache/AtomicCacheCapabilityTest.php @@ -168,3 +168,36 @@ ->and($snapshot['array']['atomic_get_and_delete'] ?? 0)->toBe(1) ->and($snapshot['array']['atomic_get_and_delete_hit'] ?? 0)->toBe(1); }); + + +test('atomic operations reconcile deferred state without repeated consume or resurrection', function () { + $cache = Cache::memory( + 'atomic-deferred-policy', + new CacheOptions(integrityKey: 'atomic-deferred-test-key'), + ); + $atomic = $cache->atomic(); + expect($atomic)->not->toBeNull(); + + expect($cache->saveDeferred($cache->getItem('pending-only')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('pending-only', 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get('pending-only'))->toBeNull(); + + expect($cache->set('consume', 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem('consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('consume', 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get('consume'))->toBeNull(); + + expect($cache->saveDeferred($cache->getItem('claim')->set('pending')))->toBeTrue() + ->and($atomic->setIfAbsent('claim', 'claimed', 30))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('claim'))->toBe('claimed'); + + expect($cache->set('cas', 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem('cas')->set('pending')))->toBeTrue() + ->and($atomic->compareAndSet('cas', 'stored', 'updated', 30))->toBeTrue() + ->and($cache->commit())->toBeTrue() + ->and($cache->get('cas'))->toBe('updated'); +}); diff --git a/tests/Cache/AuthenticatedStorageIdentityTest.php b/tests/Cache/AuthenticatedStorageIdentityTest.php new file mode 100644 index 00000000..a95ea2b8 --- /dev/null +++ b/tests/Cache/AuthenticatedStorageIdentityTest.php @@ -0,0 +1,104 @@ +set('alice', ['role' => 'admin']))->toBeTrue(); + + $store = new ReflectionProperty($adapter, 'store'); + $records = $store->getValue($adapter); + $records['tenant:d:bob'] = $records['tenant:d:alice']; + $store->setValue($adapter, $records); + + expect($cache->get('bob', 'missing'))->toBe('missing') + ->and($adapter->hasItem('bob'))->toBeFalse(); +}); + +test('signed payloads are bound to their logical cache namespace', function () { + $options = new CacheOptions(integrityKey: 'batch-two-secret'); + $sourceAdapter = new ArrayCacheAdapter('tenant-a'); + $targetAdapter = new ArrayCacheAdapter('tenant-b'); + $source = new Cache($sourceAdapter, options: $options, namespace: 'tenant-a'); + $target = new Cache($targetAdapter, options: $options, namespace: 'tenant-b'); + + expect($source->set('same', 'source'))->toBeTrue(); + + $sourceStore = new ReflectionProperty($sourceAdapter, 'store'); + $targetStore = new ReflectionProperty($targetAdapter, 'store'); + $records = $targetStore->getValue($targetAdapter); + $records['tenant-b:d:same'] = $sourceStore->getValue($sourceAdapter)['tenant-a:d:same']; + $targetStore->setValue($targetAdapter, $records); + + expect($target->get('same', 'missing'))->toBe('missing') + ->and($targetAdapter->hasItem('same'))->toBeFalse(); +}); + +test('legacy unbound signed payloads do not satisfy bound integrity', function () { + $adapter = new ArrayCacheAdapter('tenant'); + $cache = new Cache( + $adapter, + options: new CacheOptions(integrityKey: 'batch-two-secret'), + namespace: 'tenant', + ); + + $serialized = serialize([ + 'format' => 2, + 'encoding' => 'native', + 'value' => 'legacy', + 'expires' => null, + 'tags' => [], + 'namespace' => null, + ]); + $plain = 'cl2:' . $serialized; + $legacy = 'cl2-sig:' . hash_hmac('sha256', $plain, 'batch-two-secret') . ':' . $plain; + + $store = new ReflectionProperty($adapter, 'store'); + $store->setValue($adapter, ['tenant:d:legacy' => $legacy]); + + expect($cache->get('legacy', 'missing'))->toBe('missing') + ->and($adapter->hasItem('legacy'))->toBeFalse(); +}); + +test('signed tier promotion preserves the logical payload identity', function () { + $l1 = new ArrayCacheAdapter('fast-tier'); + $l2 = new ArrayCacheAdapter('slow-tier'); + $cache = Cache::tiered( + [$l1, $l2], + options: new CacheOptions(integrityKey: 'batch-two-secret'), + namespace: 'logical-store', + ); + + expect($cache->set('record', 'value'))->toBeTrue() + ->and($l1->clear())->toBeTrue() + ->and($cache->get('record'))->toBe('value') + ->and($l1->getItem('record')->isHit())->toBeTrue() + ->and($cache->get('record'))->toBe('value'); +}); + +test('object and closure deserialization require explicit opt-in by default', function () { + $default = Cache::memory('secure-default'); + $closure = static fn(): string => 'closure'; + + expect($default->set('object', new stdClass()))->toBeFalse() + ->and($default->set('closure', $closure))->toBeFalse(); + + $explicit = Cache::memory( + 'explicit-serialization', + new CacheOptions(allowClosures: true, allowObjects: true), + ); + + expect($explicit->set('object', new stdClass()))->toBeTrue() + ->and($explicit->get('object'))->toBeInstanceOf(stdClass::class) + ->and($explicit->set('closure', $closure))->toBeTrue() + ->and($explicit->get('closure'))->toBeInstanceOf(Closure::class); +}); diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index f58ac866..36636796 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -144,9 +144,8 @@ function () use (&$count) { }); test('file tag rotations remain valid during concurrent updates', function () { - if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { - $this->markTestSkipped('pcntl is required for the concurrency test.'); - } + expect(function_exists('pcntl_fork'))->toBeTrue() + ->and(function_exists('pcntl_exec'))->toBeTrue(); $adapter = new FileCacheAdapter('features', $this->cacheDir); $before = $adapter->getTagGenerations(['concurrent'])['concurrent']; diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 5d312d9d..4e0c990a 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -7,23 +7,38 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; -test('payload codec signs and verifies CacheLayer v2 records', function () { +test('payload codec signs and verifies identity-bound CacheLayer records', function () { $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); $generation = bin2hex(random_bytes(16)); - $blob = $codec->encode(['k' => 'v'], null, ['group' => $generation]); - expect(str_starts_with($blob, 'cl2-sig:'))->toBeTrue(); - - $record = $codec->decode($blob); + $blob = $codec->encode( + ['k' => 'v'], + null, + ['group' => $generation], + storageIdentity: 'tenant', + key: 'record', + ); + expect(str_starts_with($blob, 'cl3-sig:'))->toBeTrue(); + + $record = $codec->decode($blob, 'tenant', 'record'); expect($record?->value)->toBe(['k' => 'v']) ->and($record?->tags)->toBe(['group' => $generation]); }); test('payload codec rejects tampered signed payload', function () { $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); - $blob = $codec->encode('value', null); + $blob = $codec->encode('value', null, storageIdentity: 'tenant', key: 'record'); + + expect($codec->decode($blob . 'x', 'tenant', 'record'))->toBeNull(); +}); + +test('signed codec rejects legacy unbound operation', function () { + $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); - expect($codec->decode($blob . 'x'))->toBeNull(); + expect(fn() => $codec->encode('value', null)) + ->toThrow(InvalidArgumentException::class) + ->and($codec->decode('cl2-sig:' . str_repeat('0', 64) . ':cl2:payload')) + ->toBeNull(); }); test('cache treats a corrupted signed record as a miss and deletes it', function () { @@ -74,7 +89,7 @@ }); test('payload codec delegates only top-level closures to special serialization', function () { - $codec = new CachePayloadCodec(); + $codec = new CachePayloadCodec(new CacheOptions(allowClosures: true)); $blob = $codec->encode(static fn(int $value): int => $value + 1, null); $closure = $codec->decode($blob)?->value; $resource = fopen('php://memory', 'r+'); @@ -111,3 +126,94 @@ expect($codec->decode('imx-gz:payload'))->toBeNull() ->and($codec->decode('imx-sig-v1:payload'))->toBeNull(); }); + + +test('payload traversal rejects wide graphs before exceeding the node budget', function () { + $codec = new CachePayloadCodec(new CacheOptions(maxPayloadBytes: 8 * 1024 * 1024)); + $supported = array_fill(0, 65_534, 'x'); + $tooWide = array_fill(0, 65_536, 'x'); + + $blob = $codec->encode($supported, null); + expect($codec->decode($blob)?->value)->toBe($supported) + ->and(fn() => $codec->encode($tooWide, null)) + ->toThrow(InvalidArgumentException::class, 'traversal budget'); + + $serialized = serialize([ + 'format' => 2, + 'encoding' => 'native', + 'value' => $tooWide, + 'expires' => null, + 'tags' => [], + 'namespace' => null, + ]); + + expect(strlen($serialized))->toBeLessThan(8 * 1024 * 1024) + ->and($codec->decode('cl2:' . $serialized))->toBeNull(); +}); + +test('payload traversal budgets tag metadata independently', function () { + $codec = new CachePayloadCodec(new CacheOptions(maxPayloadBytes: 8 * 1024 * 1024)); + $generation = str_repeat('a', 32); + $supported = array_fill(0, 65_534, $generation); + $tooWide = array_fill(0, 65_536, $generation); + + $blob = $codec->encode('value', null, $supported); + expect($codec->decode($blob)?->value)->toBe('value') + ->and(fn() => $codec->encode('value', null, $tooWide)) + ->toThrow(InvalidArgumentException::class, 'traversal budget'); + + $serialized = serialize([ + 'format' => 2, + 'encoding' => 'native', + 'value' => 'value', + 'expires' => null, + 'tags' => $tooWide, + 'namespace' => null, + ]); + + expect(strlen($serialized))->toBeLessThan(8 * 1024 * 1024) + ->and($codec->decode('cl2:' . $serialized))->toBeNull(); +}); + +test('payload traversal rejects recursive and over-deep graphs safely', function () { + $codec = new CachePayloadCodec(); + $recursive = []; + $recursive['self'] = &$recursive; + + expect(fn() => $codec->encode($recursive, null)) + ->toThrow(InvalidArgumentException::class, 'Recursive array references'); + + $deep = 'leaf'; + for ($depth = 0; $depth < 130; ++$depth) { + $deep = [$deep]; + } + + expect(fn() => $codec->encode($deep, null)) + ->toThrow(InvalidArgumentException::class, 'nesting depth'); +}); + +test('accepted value depth round-trips through the complete record envelope', function (bool $signed, bool $compressed, int $depth, mixed $leaf): void { + $value = $leaf; + for ($index = 0; $index < $depth; ++$index) { + $value = [$value]; + } + $options = new CacheOptions( + integrityKey: $signed ? 'depth-boundary-key' : null, + compressionThreshold: $compressed ? 1 : null, + failOpen: false, + ); + $codec = new CachePayloadCodec($options); + $cache = Cache::memory('depth-boundary', $options); + + if ($depth <= 128) { + $encoded = $codec->encode($value, null, storageIdentity: 'depth-boundary', key: 'nested'); + expect($codec->decode($encoded, 'depth-boundary', 'nested')?->value)->toBe($value) + ->and($cache->set('nested', $value))->toBeTrue() + ->and($cache->get('nested'))->toBe($value); + + return; + } + + expect(fn() => $codec->encode($value, null))->toThrow(InvalidArgumentException::class, 'nesting depth') + ->and(fn() => $cache->set('nested', $value))->toThrow(\Infocyph\CacheLayer\Exceptions\CacheBackendException::class); +})->with([false, true])->with([false, true])->with([127, 128, 129])->with(['scalar leaf' => ['leaf'], 'empty array leaf' => [[]]]); diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php index 578051e6..18d1ca4f 100644 --- a/tests/Cache/FileCachePoolTest.php +++ b/tests/Cache/FileCachePoolTest.php @@ -5,6 +5,7 @@ /** tests/FileCachePoolTest.php */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; @@ -13,7 +14,7 @@ $this->cacheDir = sys_get_temp_dir().'/pest_cache_'.uniqid(); /* build a file-backed cachepool via static factory */ - $this->cache = Cache::file('tests', $this->cacheDir); + $this->cache = Cache::file('tests', $this->cacheDir, new CacheOptions(allowClosures: true)); }); afterEach(function () { @@ -75,7 +76,7 @@ $this->cache->getItem('a')->set('A')->saveDeferred(); $this->cache->getItem('b')->set('B')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); // not yet persisted + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); diff --git a/tests/Cache/LockProviderTest.php b/tests/Cache/LockProviderTest.php index 5d9b3adc..4a1c189b 100644 --- a/tests/Cache/LockProviderTest.php +++ b/tests/Cache/LockProviderTest.php @@ -76,9 +76,7 @@ }); test('sqlite PDO locks use the shared file-lock fallback', function (): void { - if (!extension_loaded('pdo_sqlite')) { - test()->markTestSkipped('pdo_sqlite is not available.'); - } + expect(extension_loaded('pdo_sqlite'))->toBeTrue(); $directory = sys_get_temp_dir() . '/cachelayer-pdo-lock-' . bin2hex(random_bytes(5)); $pdo = new PDO('sqlite::memory:'); @@ -108,9 +106,7 @@ }); test('sqlite PDO locks use the default file-lock fallback', function (): void { - if (!extension_loaded('pdo_sqlite')) { - test()->markTestSkipped('pdo_sqlite is not available.'); - } + expect(extension_loaded('pdo_sqlite'))->toBeTrue(); $key = 'worker:default-fallback:' . bin2hex(random_bytes(5)); $pdo = new PDO('sqlite::memory:'); @@ -126,9 +122,7 @@ }); test('strict PDO locks reject SQLite during construction', function (): void { - if (!extension_loaded('pdo_sqlite')) { - test()->markTestSkipped('pdo_sqlite is not available.'); - } + expect(extension_loaded('pdo_sqlite'))->toBeTrue(); expect(fn(): PdoLockProvider => PdoLockProvider::strict(new PDO('sqlite::memory:'))) ->toThrow(UnsupportedPdoLockDriver::class); diff --git a/tests/Cache/MemcachedCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php index 00cafd52..62dc4e1e 100644 --- a/tests/Cache/MemcachedCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -10,16 +10,16 @@ */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Lock\MemcachedLockProvider; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\MemcachedValueGuard; /* ── Skip suite if Memcached unavailable ─────────────────────────── */ if (! class_exists(Memcached::class)) { - test('Memcached ext not loaded – skipping')->skip(); - - return; + throw new RuntimeException('Memcached extension is required for the configured cache test matrix.'); } $memcachedHost = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; @@ -29,9 +29,7 @@ $probe->addServer($memcachedHost, $memcachedPort); $probe->set('ping', 'pong'); if ($probe->getResultCode() !== Memcached::RES_SUCCESS) { - test('No Memcached server available – skipping')->skip(); - - return; + throw new RuntimeException('Memcached service is required for the configured cache test matrix.'); } /* ── Test bootstrap / teardown ───────────────────────────────────── */ @@ -45,7 +43,8 @@ $this->cache = Cache::memcached( 'tests', [[$memcachedHost, $memcachedPort, 0]], - $client + $client, + new CacheOptions(allowClosures: true), ); }); @@ -90,7 +89,7 @@ test('saveDeferred() + commit()', function () { $this->cache->getItem('a')->set('A')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); expect($this->cache->get('a'))->toBe('A'); @@ -153,3 +152,87 @@ ->and($items['m2']->get())->toBe('bar') ->and($items['missing']->isHit())->toBeFalse(); }); + + +test('Memcached long TTLs remain relative at the CacheLayer boundary', function () { + $thirtyDays = 2_592_000; + $thirtyDaysAndOne = $thirtyDays + 1; + $thirtyOneDays = 2_678_400; + + expect($this->cache->set('ttl-30d', 'exact', $thirtyDays))->toBeTrue() + ->and($this->cache->get('ttl-30d'))->toBe('exact') + ->and($this->cache->set('ttl-30d-plus', 'plus', $thirtyDaysAndOne))->toBeTrue() + ->and($this->cache->get('ttl-30d-plus'))->toBe('plus') + ->and($this->cache->set('ttl-31d', 'month', $thirtyOneDays))->toBeTrue() + ->and($this->cache->get('ttl-31d'))->toBe('month') + ->and($this->cache->set('ttl-interval', 'interval', new DateInterval('P31D')))->toBeTrue() + ->and($this->cache->get('ttl-interval'))->toBe('interval') + ->and($this->cache->set( + 'ttl-absolute', + 'absolute', + (new DateTimeImmutable())->modify('+31 days'), + ))->toBeTrue() + ->and($this->cache->get('ttl-absolute'))->toBe('absolute'); +}); + +test('Memcached atomic writes and leases normalize long TTLs', function () { + $longTtl = 2_592_001; + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull(); + if ($atomic === null) { + return; + } + + expect($atomic->setIfAbsent('atomic-long', 'first', $longTtl))->toBeTrue() + ->and($this->cache->get('atomic-long'))->toBe('first') + ->and($atomic->compareAndSet('atomic-long', 'first', 'second', $longTtl))->toBeTrue() + ->and($this->cache->get('atomic-long'))->toBe('second'); + + $provider = new MemcachedLockProvider($this->client); + $handle = $provider->acquire('long-lease', 0.0, (float) $longTtl); + expect($handle)->not->toBeNull(); + if ($handle !== null) { + expect($this->client->get($handle->key))->toBe($handle->token) + ->and($provider->refresh($handle, (float) $longTtl))->toBeTrue(); + $provider->release($handle); + } +}); + + +test('Memcached compare-safe guard preserves a concurrent replacement', function () { + $key = 'tests:guard:race'; + + $this->client->set($key, 'fresh', 30); + expect(MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $key, + 'stale', + 'repair', + 1, + ))->toBe('fresh') + ->and($this->client->get($key))->toBe('fresh'); + + $this->client->set($key, 'stale', 30); + expect(MemcachedValueGuard::replaceIfUnchanged( + $this->client, + $key, + 'stale', + 'repair', + 1, + ))->toBe('repair') + ->and($this->client->get($key))->toBe('repair'); +}); + +test('Memcached delete reports backend errors but treats missing keys as success', function () { + $unavailable = new Memcached; + $adapter = new \Infocyph\CacheLayer\Cache\Adapter\MemcachedCacheAdapter( + 'unavailable', + [], + $unavailable, + ); + + expect($this->cache->delete('missing-delete'))->toBeTrue() + ->and($this->cache->deleteItems(['missing-one', 'missing-two']))->toBeTrue() + ->and($adapter->deleteItem('key'))->toBeFalse() + ->and($adapter->deleteItems(['key']))->toBeFalse(); +}); diff --git a/tests/Cache/MongoDbCachePoolTest.php b/tests/Cache/MongoDbCachePoolTest.php index d2beb1fa..6436826e 100644 --- a/tests/Cache/MongoDbCachePoolTest.php +++ b/tests/Cache/MongoDbCachePoolTest.php @@ -249,3 +249,15 @@ public function getMatchedCount(): int expect($atomic->setIfAbsent('claim', 'new', 30))->toBeTrue() ->and($this->cache->get('claim'))->toBe('new'); }); + + +test('mongodb atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); diff --git a/tests/Cache/MongoDbRealCachePoolTest.php b/tests/Cache/MongoDbRealCachePoolTest.php new file mode 100644 index 00000000..e2b1bfed --- /dev/null +++ b/tests/Cache/MongoDbRealCachePoolTest.php @@ -0,0 +1,83 @@ +selectDatabase($mongoDatabase)->command(['ping' => 1]); + + $collectionName = 'cachelayer_real_' . getmypid() . '_' . bin2hex(random_bytes(4)); + $this->mongoClient = $client; + $this->mongoCollection = $client->selectCollection($mongoDatabase, $collectionName); + $this->mongoCache = new Cache(new MongoDbCacheAdapter($this->mongoCollection, 'mongo-real')); +}); + +afterEach(function () { + $this->mongoCollection->drop(); +}); + +test('real MongoDB stores, expires, tags, and clears cache records', function () { + expect($this->mongoCache->set('value', ['ok' => true], 30))->toBeTrue() + ->and($this->mongoCache->get('value'))->toBe(['ok' => true]) + ->and($this->mongoCache->setTagged('tagged', 'v1', ['group'], 30))->toBeTrue() + ->and($this->mongoCache->get('tagged'))->toBe('v1') + ->and($this->mongoCache->invalidateTag('group'))->toBeTrue() + ->and($this->mongoCache->get('tagged'))->toBeNull() + ->and($this->mongoCache->set('clear-me', 'value'))->toBeTrue() + ->and($this->mongoCache->clear())->toBeTrue() + ->and($this->mongoCache->get('clear-me'))->toBeNull(); + + expect($this->mongoCache->set('expires', 'soon', 1))->toBeTrue(); + usleep(2_000_000); + expect($this->mongoCache->get('expires'))->toBeNull(); +}); + +test('real MongoDB atomic claim has exactly one process winner', function () use ($mongoDsn, $mongoDatabase) { + if (!function_exists('pcntl_fork')) { + throw new RuntimeException('pcntl is required for real MongoDB atomic contention coverage.'); + } + + $collectionName = $this->mongoCollection->getCollectionName(); + $children = []; + for ($worker = 0; $worker < 8; ++$worker) { + $pid = pcntl_fork(); + if ($pid === 0) { + $client = new MongoDB\Client($mongoDsn); + $collection = $client->selectCollection($mongoDatabase, $collectionName); + $cache = new Cache(new MongoDbCacheAdapter($collection, 'mongo-real')); + $won = $cache->atomic()?->setIfAbsent('claim', (string) $worker, 30) === true; + pcntl_exec('/bin/sh', ['-c', $won ? 'true' : 'false']); + + throw new RuntimeException('Unable to terminate forked MongoDB contention process.'); + } + if ($pid > 0) { + $children[] = $pid; + } + } + + $wins = 0; + foreach ($children as $pid) { + pcntl_waitpid($pid, $status); + $wins += pcntl_wexitstatus($status) === 0 ? 1 : 0; + } + + $freshClient = new MongoDB\Client($mongoDsn); + $freshCollection = $freshClient->selectCollection($mongoDatabase, $collectionName); + $freshCache = new Cache(new MongoDbCacheAdapter($freshCollection, 'mongo-real')); + + expect($wins)->toBe(1) + ->and($freshCache->get('claim'))->toBeString(); +}); diff --git a/tests/Cache/PdoCachePoolTest.php b/tests/Cache/PdoCachePoolTest.php index af7148e5..fd166c10 100644 --- a/tests/Cache/PdoCachePoolTest.php +++ b/tests/Cache/PdoCachePoolTest.php @@ -7,9 +7,7 @@ use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; if (! in_array('sqlite', PDO::getAvailableDrivers(), true)) { - test('PDO SQLite driver not present')->skip(); - - return; + throw new RuntimeException('PDO SQLite is required for the configured cache test matrix.'); } beforeEach(function () { diff --git a/tests/Cache/PdoMysqlCachePoolTest.php b/tests/Cache/PdoMysqlCachePoolTest.php index b435930f..fc870fdb 100644 --- a/tests/Cache/PdoMysqlCachePoolTest.php +++ b/tests/Cache/PdoMysqlCachePoolTest.php @@ -5,9 +5,7 @@ use Infocyph\CacheLayer\Cache\Cache; if (! in_array('mysql', PDO::getAvailableDrivers(), true)) { - test('MySQL PDO driver not present')->skip(); - - return; + throw new RuntimeException('PDO MySQL is required for the configured cache test matrix.'); } $dsn = getenv('IC_MYSQL_DSN') ?: getenv('CACHELAYER_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=cachelayer'; @@ -21,10 +19,8 @@ $probe = new PDO($dsn, $user, $pass); $probe->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $probe->query('SELECT 1'); -} catch (Throwable) { - test('MySQL server unreachable')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('MySQL service is required for the configured cache test matrix.', 0, $failure); } beforeEach(function () use ($dsn, $user, $pass) { diff --git a/tests/Cache/PdoPgsqlCachePoolTest.php b/tests/Cache/PdoPgsqlCachePoolTest.php index 70bf9541..7e512c81 100644 --- a/tests/Cache/PdoPgsqlCachePoolTest.php +++ b/tests/Cache/PdoPgsqlCachePoolTest.php @@ -6,9 +6,7 @@ use Infocyph\CacheLayer\Cache\Lock\PdoLockProvider; if (! in_array('pgsql', PDO::getAvailableDrivers(), true)) { - test('PostgreSQL PDO driver not present')->skip(); - - return; + throw new RuntimeException('PDO PostgreSQL is required for the configured cache test matrix.'); } $dsn = getenv('IC_POSTGRES_DSN') ?: getenv('CACHELAYER_PG_DSN') ?: 'pgsql:host=127.0.0.1;port=5432;dbname=cachelayer'; @@ -19,10 +17,8 @@ $probe = new PDO($dsn, $user, $pass); $probe->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); $probe->query('SELECT 1'); -} catch (Throwable) { - test('PostgreSQL server unreachable')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('PostgreSQL service is required for the configured cache test matrix.', 0, $failure); } beforeEach(function () use ($dsn, $user, $pass) { diff --git a/tests/Cache/PdoSqlIdentityTest.php b/tests/Cache/PdoSqlIdentityTest.php new file mode 100644 index 00000000..8653a2c5 --- /dev/null +++ b/tests/Cache/PdoSqlIdentityTest.php @@ -0,0 +1,380 @@ + [ + getenv('IC_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=phpforge;charset=utf8mb4', + getenv('IC_MYSQL_USER') ?: getenv('IC_SERVICE_USERNAME') ?: 'phpforge', + getenv('IC_MYSQL_PASSWORD') ?: $servicePassword, + ], + 'mariadb' => [ + getenv('IC_MARIADB_DSN') ?: 'mysql:host=127.0.0.1;port=3308;dbname=phpforge;charset=utf8mb4', + getenv('IC_MARIADB_USER') ?: getenv('IC_SERVICE_USERNAME') ?: 'phpforge', + getenv('IC_MARIADB_PASSWORD') ?: $servicePassword, + ], + ]; +}; + +test('MySQL-family cache and invalidation identities use byte-sensitive collations', function () use ($backends) { + expect(in_array('mysql', PDO::getAvailableDrivers(), true))->toBeTrue(); + + foreach ($backends() as $name => [$dsn, $user, $password]) { + $pdo = new PDO($dsn, $user, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + ]); + $cacheTable = 'cachelayer_identity_' . $name; + + try { + $pdo->exec("DROP TABLE IF EXISTS {$cacheTable}"); + $pdo->exec( + "CREATE TABLE {$cacheTable} (" + . 'namespace VARCHAR(191) NOT NULL, kind VARCHAR(191) NOT NULL, ' + . 'cache_key VARCHAR(191) NOT NULL, payload MEDIUMBLOB NOT NULL, expires BIGINT NULL, ' + . 'PRIMARY KEY (namespace, kind, cache_key)) ' + . 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci', + ); + + PdoCacheSchema::install($pdo, $cacheTable); + + $statement = $pdo->prepare( + 'SELECT column_name, collation_name FROM information_schema.columns ' + . 'WHERE table_schema = DATABASE() AND table_name = ? ' + . "AND column_name IN ('namespace', 'kind', 'cache_key')", + ); + $statement->execute([$cacheTable]); + $collations = $statement->fetchAll(PDO::FETCH_COLUMN, 1); + expect(array_values(array_unique($collations)))->toBe(['ascii_bin']); + + $upper = Cache::pdo('Tenant', pdo: $pdo, table: $cacheTable); + $lower = Cache::pdo('tenant', pdo: $pdo, table: $cacheTable); + expect($upper->set('Key', 'upper'))->toBeTrue() + ->and($lower->set('key', 'lower'))->toBeTrue() + ->and($upper->get('Key'))->toBe('upper') + ->and($lower->get('key'))->toBe('lower'); + + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + $pdo->exec( + 'CREATE TABLE cachelayer_invalidation_events (' + . 'event_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, ' + . 'cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(64) NOT NULL, ' + . 'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(64) NULL, ' + . 'origin_node_id VARCHAR(255) NOT NULL, created_at BIGINT NOT NULL) ' + . 'DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci', + ); + + PdoInvalidationSchema::install($pdo); + $statement = $pdo->prepare( + 'SELECT column_name, collation_name FROM information_schema.columns ' + . "WHERE table_schema = DATABASE() AND table_name = 'cachelayer_invalidation_events' " + . "AND column_name IN ('cluster_name', 'namespace_name', 'event_type', 'identifier', 'origin_node_id')", + ); + $statement->execute(); + $collations = $statement->fetchAll(PDO::FETCH_COLUMN, 1); + expect(array_values(array_unique($collations)))->toBe(['ascii_bin']); + + $transport = new PdoInvalidationTransport($pdo, initializeSchema: false); + $transport->publish(InvalidationEvent::key('Tenant', 'App', 'Key', 'Node')); + $transport->publish(InvalidationEvent::key('tenant', 'app', 'key', 'node')); + + expect($transport->countAfter('Tenant', null))->toBe(1) + ->and($transport->countAfter('tenant', null))->toBe(1) + ->and($transport->consumeAfter('Tenant', null, 10)->events[0]->cluster)->toBe('Tenant') + ->and($transport->consumeAfter('tenant', null, 10)->events[0]->cluster)->toBe('tenant'); + } finally { + $pdo->exec("DROP TABLE IF EXISTS {$cacheTable}"); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + } + } +}); + + + +$orderingBackends = static function (): array { + $servicePassword = getenv('IC_SERVICE_PASSWORD'); + $servicePassword = $servicePassword === false ? '' : $servicePassword; + $serviceUser = getenv('IC_SERVICE_USERNAME') ?: 'phpforge'; + + return [ + 'mysql' => [ + getenv('IC_MYSQL_DSN') ?: 'mysql:host=127.0.0.1;port=3306;dbname=phpforge;charset=utf8mb4', + getenv('IC_MYSQL_USER') ?: $serviceUser, + getenv('IC_MYSQL_PASSWORD') ?: $servicePassword, + ], + 'pgsql' => [ + getenv('IC_POSTGRES_DSN') ?: 'pgsql:host=127.0.0.1;port=5432;dbname=cachelayer', + getenv('IC_POSTGRES_USER') ?: $serviceUser, + getenv('IC_POSTGRES_PASSWORD') ?: $servicePassword, + ], + ]; +}; + +$connectOrderingBackend = static function (array $backend): PDO { + [$dsn, $user, $password] = $backend; + + return new PDO($dsn, $user, $password, [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]); +}; + +$resetInvalidationSchema = static function (PDO $pdo): void { + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $pdo->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + PdoInvalidationSchema::install($pdo); +}; + +$waitForState = static function (string $path, string $expected): void { + for ($attempt = 0; $attempt < 200; ++$attempt) { + clearstatcache(true, $path); + if (is_file($path) && trim((string) file_get_contents($path)) === $expected) { + return; + } + usleep(10_000); + } + + throw new RuntimeException('Timed out waiting for the concurrent invalidation publisher.'); +}; + +$removeTestFile = static function (string $path): void { + if (is_file($path) && !unlink($path)) { + throw new RuntimeException('Unable to remove an invalidation concurrency test file.'); + } +}; + +$startPublisher = static function ( + array $backend, + string $cluster, + string $identifier, + string $stateFile, + string $resultFile, + bool $commit = true, + int $holdMicros = 0, +): mixed { + [$dsn, $user, $password] = $backend; + $command = [ + PHP_BINARY, + __DIR__ . '/Support/PdoInvalidationPublisherProcess.php', + $dsn, + $user, + $password, + $cluster, + $identifier, + $stateFile, + $resultFile, + $commit ? '1' : '0', + (string) $holdMicros, + 'worker-child', + ]; + $process = proc_open( + $command, + [ + 0 => ['file', '/dev/null', 'r'], + 1 => ['file', '/dev/null', 'a'], + 2 => ['file', $resultFile . '.stderr', 'a'], + ], + $pipes, + dirname(__DIR__, 2), + ); + if (!is_resource($process)) { + throw new RuntimeException('Unable to start the invalidation publisher process.'); + } + + return $process; +}; + +$finishPublisher = static function (mixed $process, string $stderrFile): int { + if (!is_resource($process)) { + throw new RuntimeException('Invalid concurrent invalidation publisher process.'); + } + + $exitCode = proc_close($process); + if ($exitCode !== 0) { + $stderr = is_file($stderrFile) ? trim((string) file_get_contents($stderrFile)) : ''; + throw new RuntimeException('Concurrent invalidation publisher failed: ' . $stderr); + } + + return $exitCode; +}; + +test('PDO invalidation publication serializes ID allocation through transaction completion', function () use ( + $orderingBackends, + $connectOrderingBackend, + $resetInvalidationSchema, + $waitForState, + $removeTestFile, + $startPublisher, + $finishPublisher, +) { + foreach ($orderingBackends() as $backend) { + $admin = $connectOrderingBackend($backend); + $resetInvalidationSchema($admin); + $firstConnection = $connectOrderingBackend($backend); + $firstTransport = new PdoInvalidationTransport($firstConnection, initializeSchema: false); + $firstConnection->beginTransaction(); + $firstId = $firstTransport->publishWithinTransaction( + $firstConnection, + InvalidationEvent::key('ordered-cluster', 'application', 'first', 'worker-a'), + ); + + $stateFile = tempnam(sys_get_temp_dir(), 'cachelayer-order-state-'); + $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-order-result-'); + if ($stateFile === false || $resultFile === false) { + throw new RuntimeException('Unable to allocate invalidation concurrency test files.'); + } + file_put_contents($stateFile, ''); + file_put_contents($resultFile, ''); + $stderrFile = $resultFile . '.stderr'; + + $process = $startPublisher( + $backend, + 'ordered-cluster', + 'second', + $stateFile, + $resultFile, + ); + + try { + $waitForState($stateFile, 'started'); + usleep(150_000); + expect(file_get_contents($resultFile))->toBe('') + ->and(trim((string) file_get_contents($stateFile)))->toBe('started'); + + $firstConnection->commit(); + expect($finishPublisher($process, $stderrFile))->toBe(0); + $secondId = trim((string) file_get_contents($resultFile)); + $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) + ->consumeAfter('ordered-cluster', null, 10) + ->events; + + expect($secondId)->not->toStartWith('error:') + ->and(array_map(static fn($event): string => (string) $event->id, $events)) + ->toBe([$firstId, $secondId]) + ->and(array_map(static fn($event): ?string => $event->identifier, $events)) + ->toBe(['first', 'second']); + } finally { + if ($firstConnection->inTransaction()) { + $firstConnection->rollBack(); + } + $removeTestFile($stateFile); + $removeTestFile($resultFile); + $removeTestFile($stderrFile); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + } + } +}); + +test('PDO invalidation publication survives rollback and publisher process death', function () use ( + $orderingBackends, + $connectOrderingBackend, + $resetInvalidationSchema, + $waitForState, + $removeTestFile, + $startPublisher, + $finishPublisher, +) { + foreach ($orderingBackends() as $backend) { + $admin = $connectOrderingBackend($backend); + $resetInvalidationSchema($admin); + + $abortedConnection = $connectOrderingBackend($backend); + $abortedTransport = new PdoInvalidationTransport($abortedConnection, initializeSchema: false); + $abortedConnection->beginTransaction(); + $abortedTransport->publishWithinTransaction( + $abortedConnection, + InvalidationEvent::key('rollback-cluster', 'application', 'aborted', 'worker-a'), + ); + + $stateFile = tempnam(sys_get_temp_dir(), 'cachelayer-rollback-state-'); + $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-rollback-result-'); + if ($stateFile === false || $resultFile === false) { + throw new RuntimeException('Unable to allocate invalidation rollback test files.'); + } + file_put_contents($stateFile, ''); + file_put_contents($resultFile, ''); + $stderrFile = $resultFile . '.stderr'; + + $process = $startPublisher( + $backend, + 'rollback-cluster', + 'committed', + $stateFile, + $resultFile, + ); + + try { + $waitForState($stateFile, 'started'); + usleep(150_000); + expect(file_get_contents($resultFile))->toBe('') + ->and(trim((string) file_get_contents($stateFile)))->toBe('started'); + + $abortedConnection->rollBack(); + expect($finishPublisher($process, $stderrFile))->toBe(0); + $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) + ->consumeAfter('rollback-cluster', null, 10) + ->events; + + expect(array_map(static fn($event): ?string => $event->identifier, $events)) + ->toBe(['committed']); + } finally { + if ($abortedConnection->inTransaction()) { + $abortedConnection->rollBack(); + } + $removeTestFile($stateFile); + $removeTestFile($resultFile); + $removeTestFile($stderrFile); + } + + $resetInvalidationSchema($admin); + $stateFile = tempnam(sys_get_temp_dir(), 'cachelayer-death-state-'); + $resultFile = tempnam(sys_get_temp_dir(), 'cachelayer-death-result-'); + if ($stateFile === false || $resultFile === false) { + throw new RuntimeException('Unable to allocate invalidation process-death test files.'); + } + file_put_contents($stateFile, ''); + file_put_contents($resultFile, ''); + $stderrFile = $resultFile . '.stderr'; + + $process = $startPublisher( + $backend, + 'death-cluster', + 'aborted', + $stateFile, + $resultFile, + commit: false, + holdMicros: 150_000, + ); + + try { + $waitForState($stateFile, 'acquired'); + $survivor = new PdoInvalidationTransport($connectOrderingBackend($backend), initializeSchema: false); + $survivorId = $survivor->publish( + InvalidationEvent::key('death-cluster', 'application', 'survivor', 'worker-b'), + ); + expect($finishPublisher($process, $stderrFile))->toBe(0); + $events = (new PdoInvalidationTransport($admin, initializeSchema: false)) + ->consumeAfter('death-cluster', null, 10) + ->events; + + expect($survivorId)->not->toBe('') + ->and(array_map(static fn($event): ?string => $event->identifier, $events)) + ->toBe(['survivor']); + } finally { + $removeTestFile($stateFile); + $removeTestFile($resultFile); + $removeTestFile($stderrFile); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_events'); + $admin->exec('DROP TABLE IF EXISTS cachelayer_invalidation_clusters'); + } + } +}); diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index a155e0fc..c6d6ab45 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -13,14 +13,17 @@ use Infocyph\CacheLayer\Cache\AtomicCacheInterface; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Counter\AtomicCounters; +use Infocyph\CacheLayer\Counter\Exception\AtomicCounterException; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\RedisValueGuard; +use Infocyph\CacheLayer\Tests\Support\AtomicCounterProcessProbe; /* ── skip whole file when Redis unavailable ───────────────────────── */ if (! class_exists(Redis::class)) { - test('phpredis ext not loaded – skipping')->skip(); - - return; + throw new RuntimeException('phpredis is required for the configured cache test matrix.'); } $redisHost = getenv('IC_REDIS_HOST') ?: getenv('CACHELAYER_REDIS_HOST') ?: '127.0.0.1'; @@ -43,10 +46,8 @@ $probe->auth($redisPassword); } $probe->ping(); -} catch (Throwable) { - test('Redis server unreachable – skipping')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('Redis service is required for the configured cache test matrix.', 0, $failure); } $finishForkedTest = static function (bool $success): never { @@ -64,12 +65,13 @@ } $client->flushDB(); // fresh DB 0 + $this->redisClient = $client; $this->cache = Cache::redis( 'tests', sprintf('redis://%s:%d', $redisHost, $redisPort), - $client + $client, + new CacheOptions(allowClosures: true), ); - }); afterEach(function () { @@ -110,7 +112,7 @@ /* ── 3. deferred queue ──────────────────────────────────────────── */ test('saveDeferred() & commit() (redis)', function () { $this->cache->getItem('a')->set('A')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); expect($this->cache->get('a'))->toBe('A'); @@ -350,3 +352,237 @@ expect($wins)->toBe(1); }); + + +test('Redis cache clear does not reset isolated atomic counters and large integers stay exact', function () { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + $large = 9_007_199_254_740_993; + + expect($counters->increment('large', $large)->value)->toBe($large) + ->and($this->cache->set('ordinary', 'value'))->toBeTrue() + ->and($this->cache->clear())->toBeTrue() + ->and($counters->get('large'))->toBe($large); +}); + +test('Redis atomic counters preserve TTL, decrement, overflow, and invalid-value contracts', function () { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + $first = $counters->increment('window', 5, 30); + $physical = 'cachelayer:counter:tests:window'; + $ttlBefore = $this->redisClient->ttl($physical); + $later = $counters->decrement('window', 2, 30); + $ttlAfter = $this->redisClient->ttl($physical); + + expect($first->initialized)->toBeTrue() + ->and($later->initialized)->toBeFalse() + ->and($later->value)->toBe(3) + ->and($ttlBefore)->toBeGreaterThan(0) + ->and($ttlAfter)->toBeGreaterThan(0) + ->and($ttlAfter)->toBeLessThanOrEqual($ttlBefore); + + $this->redisClient->set('cachelayer:counter:tests:max', (string) PHP_INT_MAX); + expect($counters->get('max'))->toBe(PHP_INT_MAX) + ->and(fn () => $counters->increment('max'))->toThrow(AtomicCounterException::class); + + $this->redisClient->set('cachelayer:counter:tests:out-of-range', '9223372036854775808'); + $this->redisClient->set('cachelayer:counter:tests:malformed', '12x'); + expect(fn () => $counters->get('out-of-range'))->toThrow(AtomicCounterException::class) + ->and(fn () => $counters->get('malformed'))->toThrow(AtomicCounterException::class); +}); + +test('Redis atomic counter initialization has exactly one winner under contention', function () use ($redisHost, $redisPort, $redisPassword) { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + $wins = AtomicCounterProcessProbe::initializedWinners( + 'redis', + $redisHost, + $redisPort, + $redisPassword, + 'tests', + 'contended-counter', + ); + + expect($wins)->toBe(1) + ->and($counters->get('contended-counter'))->toBe(8); +}); + +test('Redis stale cleanup never deletes or overwrites a concurrent replacement', function () { + $key = 'cachelayer:guard:race'; + + $this->redisClient->set($key, 'fresh'); + expect(RedisValueGuard::deleteIfUnchanged($this->redisClient, $key, 'stale'))->toBeFalse() + ->and($this->redisClient->get($key))->toBe('fresh') + ->and(RedisValueGuard::replaceIfUnchanged($this->redisClient, $key, 'stale', 'repair'))->toBe('fresh') + ->and($this->redisClient->get($key))->toBe('fresh'); + + $this->redisClient->set($key, 'stale'); + expect(RedisValueGuard::replaceIfUnchanged($this->redisClient, $key, 'stale', 'repair'))->toBe('repair') + ->and($this->redisClient->get($key))->toBe('repair') + ->and(RedisValueGuard::deleteIfUnchanged($this->redisClient, $key, 'repair'))->toBeTrue() + ->and($this->redisClient->get($key))->toBeFalse(); +}); + + +test('Redis atomic counters expire fixed windows', function () { + $counters = AtomicCounters::redis('tests', client: $this->redisClient); + + expect($counters->increment('short-window', 1, 1)->initialized)->toBeTrue() + ->and($counters->get('short-window'))->toBe(1); + usleep(2_000_000); + + expect($counters->get('short-window'))->toBeNull(); +}); + + +test('Redis atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); + + +test('Redis clear in boundary namespace preserves counters locks and invalidation streams', function () { + $cache = Cache::redis('cachelayer', client: $this->redisClient); + $counters = AtomicCounters::redis('audit-counter', client: $this->redisClient); + $locks = new \Infocyph\CacheLayer\Cache\Lock\RedisLockProvider($this->redisClient); + $transport = new \Infocyph\CacheLayer\Cluster\Transport\RedisStreamInvalidationTransport($this->redisClient); + + expect($cache->set('ordinary', 'value'))->toBeTrue() + ->and($counters->increment('window', 5, 30)->value)->toBe(5); + + $held = $locks->acquire('boundary-lock', 0.0, 30.0); + expect($held)->not->toBeNull(); + + $eventId = $transport->publish( + \Infocyph\CacheLayer\Cluster\Event\InvalidationEvent::key( + 'clear-boundary', + 'application', + 'product.42', + 'writer', + ), + ); + expect($eventId)->not->toBe(''); + + expect($cache->clear())->toBeTrue() + ->and($cache->get('ordinary'))->toBeNull() + ->and($counters->get('window'))->toBe(5) + ->and($locks->acquire('boundary-lock', 0.0, 30.0))->toBeNull() + ->and($this->redisClient->xLen('cachelayer:invalidation:clear-boundary'))->toBe(1); + + $locks->release($held); +}); + +test('Redis compare-safe cleanup preserves a concurrent replacement', function () { + $key = 'cachelayer:guard:stale-read'; + $this->redisClient->set($key, 'observed-invalid'); + $observed = $this->redisClient->get($key); + expect($observed)->toBe('observed-invalid'); + + $this->redisClient->set($key, 'replacement'); + + expect(RedisValueGuard::deleteIfUnchanged($this->redisClient, $key, (string) $observed))->toBeFalse() + ->and($this->redisClient->get($key))->toBe('replacement'); +}); + +test('tag-stale Redis reads return misses without physically deleting an observed record', function () { + expect($this->cache->setTagged('tagged-stale', 'old', ['products'], 30))->toBeTrue(); + $physical = 'tests:d:tagged-stale'; + expect($this->redisClient->exists($physical))->toBe(1); + + expect($this->cache->invalidateTag('products'))->toBeTrue() + ->and($this->cache->get('tagged-stale'))->toBeNull() + ->and($this->redisClient->exists($physical))->toBe(1) + ->and($this->cache->setTagged('tagged-stale', 'fresh', ['products'], 30))->toBeTrue() + ->and($this->cache->get('tagged-stale'))->toBe('fresh'); +}); + + +test('Redis Stream recovery clears stale local state after complete history loss', function () { + $directory = sys_get_temp_dir() . '/cachelayer-redis-history-' . uniqid('', true); + mkdir($directory, 0700, true); + + try { + $transport = new \Infocyph\CacheLayer\Cluster\Transport\RedisStreamInvalidationTransport( + $this->redisClient, + 'cachelayer:history:', + ); + $runtime = \Infocyph\CacheLayer\Cluster\ClusterCache::create( + new \Infocyph\CacheLayer\Node\NodeCacheConfig( + $directory . '/node.sqlite', + 'application', + apcuEnabled: false, + ), + new \Infocyph\CacheLayer\Cluster\ClusterCacheConfig( + 'redis-history-loss', + 'consumer', + 'redis-history-loss', + ), + $transport, + ); + + $transport->publish( + \Infocyph\CacheLayer\Cluster\Event\InvalidationEvent::key( + 'redis-history-loss', + 'application', + 'first', + 'writer', + ), + ); + expect($runtime->consume())->toBe(1); + + $runtime->cache()->set('stale', 'value', 300); + $transport->publish( + \Infocyph\CacheLayer\Cluster\Event\InvalidationEvent::key( + 'redis-history-loss', + 'application', + 'stale', + 'writer', + ), + ); + $this->redisClient->del('cachelayer:history:redis-history-loss'); + + expect($runtime->recoverIfRequired())->toBeTrue() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->cursor)->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); + } finally { + if (is_dir($directory)) { + foreach (glob($directory . '/*') ?: [] as $file) { + if (is_file($file)) { + unlink($file); + } + } + rmdir($directory); + } + } +}); + + +test('Redis authentication failures do not expose supplied passwords in exception traces', function () use ($redisHost, $redisPort) { + $secret = 'AUDIT_SENTINEL_40'; + $previousIgnoreArgs = ini_get('zend.exception_ignore_args'); + $previousMaxLen = ini_get('zend.exception_string_param_max_len'); + ini_set('zend.exception_ignore_args', '0'); + ini_set('zend.exception_string_param_max_len', '128'); + + try { + try { + \Infocyph\CacheLayer\Support\RedisConnection::connect( + sprintf('redis://:%s@%s:%d', rawurlencode($secret), $redisHost, $redisPort), + ); + test()->fail('Expected Redis authentication with the synthetic secret to fail.'); + } catch (Throwable $failure) { + expect((string) $failure)->not->toContain($secret); + } + } finally { + if (is_string($previousIgnoreArgs)) { + ini_set('zend.exception_ignore_args', $previousIgnoreArgs); + } + if (is_string($previousMaxLen)) { + ini_set('zend.exception_string_param_max_len', $previousMaxLen); + } + } +}); diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index 361b34ca..8923707d 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -353,3 +353,58 @@ private function prune(string $key): void expect($atomic->setIfAbsent('claim', 'second', 30))->toBeTrue() ->and($this->cache->get('claim'))->toBe('second'); }); + + +test('redis cluster leaves stale data for safe overwrite instead of deleting after read', function () { + expect($this->cache->set('stale-race', 'value'))->toBeTrue(); + + $physical = null; + foreach ($this->cluster->keys() as $key) { + if (str_ends_with($key, ':d:stale-race')) { + $physical = $key; + break; + } + } + expect($physical)->not->toBeNull(); + if (!is_string($physical)) { + return; + } + + $this->cluster->set($physical, 'invalid-payload'); + + expect($this->cache->get('stale-race'))->toBeNull() + ->and($this->cluster->get($physical))->toBe('invalid-payload'); +}); + +test('redis cluster generation repair fails closed without overwriting unexpected state', function () { + expect($this->cache->set('generation-race', 'value'))->toBeTrue(); + + $generation = null; + foreach ($this->cluster->keys() as $key) { + if (str_ends_with($key, ':m:generation')) { + $generation = $key; + break; + } + } + expect($generation)->not->toBeNull(); + if (!is_string($generation)) { + return; + } + + $this->cluster->set($generation, 'malformed-generation'); + + expect($this->cache->get('generation-race'))->toBeNull() + ->and($this->cluster->get($generation))->toBe('malformed-generation'); +}); + + +test('redis cluster atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); diff --git a/tests/Cache/ScyllaDbCachePoolTest.php b/tests/Cache/ScyllaDbCachePoolTest.php index 544a5aa2..14ff0b5b 100644 --- a/tests/Cache/ScyllaDbCachePoolTest.php +++ b/tests/Cache/ScyllaDbCachePoolTest.php @@ -5,6 +5,7 @@ use Infocyph\CacheLayer\Cache\Adapter\ScyllaDbCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; +use Infocyph\CacheLayer\Support\OptionalCassandra; beforeEach(function () { $this->session = new class @@ -12,6 +13,9 @@ /** @var array */ private array $rows = []; + /** @var array */ + private array $metadata = []; + public int $bucketReads = 0; public int $writeBatches = 0; @@ -33,6 +37,46 @@ public function execute(mixed $statement, mixed $options = []): array return []; } + if (str_contains($cql, 'cachelayer_entries_metadata')) { + if (str_starts_with($cql, 'SELECT tag, generation')) { + $ns = (string) ($args[0] ?? ''); + $bucket = (int) ($args[1] ?? 0); + $tags = array_map('strval', array_slice($args, 2)); + + return array_values(array_filter( + $this->metadata, + static fn(array $row, string $key): bool => str_starts_with( + $key, + $ns . ':' . $bucket . ':', + ) && in_array($row['tag'], $tags, true), + ARRAY_FILTER_USE_BOTH, + )); + } + + if (str_starts_with($cql, 'INSERT INTO')) { + $key = $this->rowKey($args); + if (!str_contains($cql, 'IF NOT EXISTS') || !isset($this->metadata[$key])) { + $this->metadata[$key] = [ + 'tag' => (string) ($args[2] ?? ''), + 'generation' => (string) ($args[3] ?? ''), + ]; + } + + return []; + } + + if (str_starts_with($cql, 'DELETE FROM')) { + $prefix = (string) ($args[0] ?? '') . ':' . (int) ($args[1] ?? 0) . ':'; + foreach (array_keys($this->metadata) as $key) { + if (str_starts_with($key, $prefix)) { + unset($this->metadata[$key]); + } + } + + return []; + } + } + if (str_starts_with($cql, 'DELETE FROM') && str_contains($cql, 'AND ckey = ?')) { unset($this->rows[$this->rowKey($args)]); @@ -172,9 +216,11 @@ private function store(array $row): void 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.'); +test('scylladb cache factory handles the optional extension explicitly', function () { + if (OptionalCassandra::available()) { + expect(Cache::scylla('scylla-tests'))->toBeInstanceOf(Cache::class); + + return; } expect(fn () => Cache::scylla('scylla-tests')) @@ -238,7 +284,7 @@ function scylladbHttpGet(string $url, mixed $context): ?string test('scylladb alternator health endpoint is reachable', function () { $integration = scylladbAlternatorIntegrationContext(); if ($integration === null) { - $this->markTestSkipped('ScyllaDB Alternator integration unavailable (service missing).'); + throw new RuntimeException('ScyllaDB Alternator service is required for the configured cache test matrix.'); } $context = stream_context_create([ @@ -257,7 +303,7 @@ function scylladbHttpGet(string $url, mixed $context): ?string test('scylladb alternator localnodes endpoint returns json list', function () { $integration = scylladbAlternatorIntegrationContext(); if ($integration === null) { - $this->markTestSkipped('ScyllaDB Alternator integration unavailable (service missing).'); + throw new RuntimeException('ScyllaDB Alternator service is required for the configured cache test matrix.'); } $context = stream_context_create([ @@ -272,3 +318,14 @@ function scylladbHttpGet(string $url, mixed $context): ?string expect(is_array($decoded))->toBeTrue(); }); + + +test('scylladb tag initialization preserves the first generation', function () { + $first = Cache::scylla('tag-race', $this->session, 'cachelayer', 'cachelayer_entries', 1); + $second = Cache::scylla('tag-race', $this->session, 'cachelayer', 'cachelayer_entries', 1); + + expect($first->setTagged('one', 'A', ['shared']))->toBeTrue() + ->and($second->setTagged('two', 'B', ['shared']))->toBeTrue() + ->and($first->get('one'))->toBe('A') + ->and($second->get('two'))->toBe('B'); +}); diff --git a/tests/Cache/SecurityContainmentRegressionTest.php b/tests/Cache/SecurityContainmentRegressionTest.php new file mode 100644 index 00000000..f808dcbe --- /dev/null +++ b/tests/Cache/SecurityContainmentRegressionTest.php @@ -0,0 +1,387 @@ + new Cache( + $adapter, + options: new CacheOptions(allowObjects: true, integrityKey: 'second-secret'), + ))->toThrow(LogicException::class); + + expect(fn() => new Cache( + $adapter, + options: new CacheOptions(allowObjects: false, integrityKey: 'first-secret'), + ))->not->toThrow(LogicException::class); +}); + +test('node SQLite cache preserves caller-owned transactions', function () { + $pdo = new PDO('sqlite::memory:'); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec('CREATE TABLE business_rows (id INTEGER PRIMARY KEY, value TEXT NOT NULL)'); + + $adapter = new NodeSqliteCacheAdapter($pdo, 'node-transaction'); + $item = $adapter->createItem('cache-key')->set('cache-value'); + + $pdo->beginTransaction(); + $pdo->exec("INSERT INTO business_rows (value) VALUES ('business-value')"); + + expect(fn() => $adapter->saveMany([$item])) + ->toThrow(NodeCacheStorageException::class) + ->and($pdo->inTransaction())->toBeTrue() + ->and((int) $pdo->query('SELECT COUNT(*) FROM business_rows')->fetchColumn())->toBe(1); + + $pdo->rollBack(); +}); + +test('filesystem adapters reject a symlinked namespace root', function () { + foreach ([ + 'file' => static fn(string $namespace, string $base): Cache => Cache::file($namespace, $base), + 'php-files' => static fn(string $namespace, string $base): Cache => Cache::phpFiles($namespace, $base), + ] as $label => $factory) { + $base = sys_get_temp_dir() . '/cachelayer-symlink-' . $label . '-' . bin2hex(random_bytes(4)); + $target = sys_get_temp_dir() . '/cachelayer-symlink-target-' . $label . '-' . bin2hex(random_bytes(4)); + mkdir($base, 0700, true); + mkdir($target, 0700, true); + $link = $base . DIRECTORY_SEPARATOR . 'cache_attacker'; + expect(symlink($target, $link))->toBeTrue(); + + try { + expect(fn() => $factory('attacker', $base))->toThrow(RuntimeException::class); + } finally { + if (is_link($link)) { + unlink($link); + } + if (is_dir($target)) { + rmdir($target); + } + if (is_dir($base)) { + rmdir($base); + } + } + } +}); + +test('Redis DSN errors never echo credentials', function () { + $secret = 'audit-password'; + $dsn = 'redis://user:' . $secret . '@localhost/not-a-database'; + + $previous = ini_set('zend.exception_ignore_args', '0'); + + try { + RedisConnection::connect($dsn); + test()->fail('Expected invalid Redis DSN.'); + } catch (Throwable $failure) { + for ($current = $failure; $current instanceof Throwable; $current = $current->getPrevious()) { + expect($current->getMessage())->not->toContain($secret) + ->and($current->getMessage())->not->toContain($dsn); + } + expect((string) $failure)->not->toContain($secret) + ->and((string) $failure)->not->toContain($dsn); + } finally { + if ($previous !== false) { + ini_set('zend.exception_ignore_args', $previous); + } + } +}); + +test('secret-bearing public parameters are marked sensitive', function () { + $parameters = [ + [CacheOptions::class, '__construct', 'integrityKey'], + [Cache::class, 'redis', 'dsn'], + [Cache::class, 'valkey', 'dsn'], + [Cache::class, 'mongodb', 'uri'], + [Cache::class, 'pdo', 'dsn'], + [Cache::class, 'pdo', 'password'], + [Cache::class, 'tiered', 'tiers'], + [PdoCacheAdapter::class, '__construct', 'dsn'], + [PdoCacheAdapter::class, '__construct', 'password'], + [AtomicCounters::class, 'redis', 'dsn'], + [AtomicCounters::class, 'valkey', 'dsn'], + [SignedClosureSerializer::class, '__construct', 'key'], + [ClosureSerializer::class, 'signed', 'key'], + [RedisConnection::class, 'connect', 'dsn'], + [TieredPoolFactory::class, 'fromArray', 'tiers'], + ]; + + foreach ($parameters as [$class, $method, $parameter]) { + $reflection = new ReflectionParameter([$class, $method], $parameter); + expect($reflection->getAttributes(SensitiveParameter::class))->not->toBeEmpty(); + } +}); + +test('recursive payloads fail within bounded subprocess resources', function () { + expect(function_exists('proc_open'))->toBeTrue(); + + $autoload = realpath(__DIR__ . '/../../vendor/autoload.php'); + expect($autoload)->not->toBeFalse(); + + $script = tempnam(sys_get_temp_dir(), 'cachelayer-recursion-'); + expect($script)->not->toBeFalse(); + + $code = <<<'PHP' +encode($value, null); + exit(10); + } catch (InvalidArgumentException) { + } +} + +$record = [ + 'format' => 2, + 'encoding' => 'native', + 'value' => &$direct, + 'expires' => null, + 'tags' => [], + 'namespace' => null, +]; +$serialized = serialize($record); +$plain = 'cl2:' . $serialized; +$signed = 'cl2-sig:' . hash_hmac('sha256', $plain, 'bounded-secret') . ':' . $plain; +$compressed = 'cl2-gz:' . base64_encode(gzencode($serialized)); + +if ($codec->decode($signed) !== null) { + exit(11); +} + +$unsigned = new CachePayloadCodec(new CacheOptions( + maxPayloadBytes: 4096, + allowClosures: false, + allowObjects: false, +)); +if ($unsigned->decode($plain) !== null || $unsigned->decode($compressed) !== null) { + exit(12); +} + +$deep = 'leaf'; +for ($index = 0; $index < 256; ++$index) { + $deep = [$deep]; +} +try { + $unsigned->encode($deep, null); + exit(13); +} catch (InvalidArgumentException) { +} + +exit(0); +PHP; + + file_put_contents($script, sprintf($code, var_export($autoload, true))); + $command = escapeshellarg(PHP_BINARY) + . ' -d memory_limit=32M -d max_execution_time=3 ' + . escapeshellarg($script); + $process = proc_open( + $command, + [ + 0 => ['pipe', 'r'], + 1 => ['pipe', 'w'], + 2 => ['pipe', 'w'], + ], + $pipes, + ); + expect(is_resource($process))->toBeTrue(); + fclose($pipes[0]); + $stdout = stream_get_contents($pipes[1]); + $stderr = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + $status = proc_close($process); + unlink($script); + + expect($status)->toBe(0, trim((string) $stdout . "\n" . (string) $stderr)); +}); + +test('file atomic consumption reports deletion failure instead of returning the value', function () { + expect(DIRECTORY_SEPARATOR)->not->toBe('\\'); + + foreach ([ + 'file' => static fn(string $base, CacheOptions $options): Cache + => Cache::file('consume', $base, $options), + 'php-files' => static fn(string $base, CacheOptions $options): Cache + => Cache::phpFiles('consume', $base, $options), + ] as $label => $factory) { + $base = sys_get_temp_dir() . '/cachelayer-consume-' . $label . '-' . bin2hex(random_bytes(4)); + $strict = $factory($base, new CacheOptions(failOpen: false)); + expect($strict->set('token', 'usable'))->toBeTrue(); + + $data = $base . DIRECTORY_SEPARATOR . 'cache_consume' . DIRECTORY_SEPARATOR . 'data'; + expect(chmod($data, 0500))->toBeTrue(); + + try { + expect(fn() => $strict->atomic()?->getAndDelete('token', 'missing')) + ->toThrow(\Infocyph\CacheLayer\Exceptions\CacheBackendException::class); + } finally { + chmod($data, 0700); + } + + expect($strict->get('token'))->toBe('usable'); + $strict->clear(); + + $open = $factory($base, new CacheOptions(failOpen: true)); + expect($open->set('token', 'usable'))->toBeTrue(); + expect(chmod($data, 0500))->toBeTrue(); + + try { + expect($open->atomic()?->getAndDelete('token', 'missing'))->toBe('missing'); + } finally { + chmod($data, 0700); + } + + expect($open->get('token'))->toBe('usable'); + $open->clear(); + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($base); + } +}); + +test('composite adapters preflight policy conflicts without partial binding', function () { + $strict = new CacheOptions(allowObjects: false, allowClosures: false, integrityKey: 'strict'); + $permissive = new CacheOptions(allowObjects: true, allowClosures: true); + + $tierFirst = new ArrayCacheAdapter('tier-first'); + $tierConflict = new ArrayCacheAdapter('tier-conflict'); + new Cache($tierConflict, options: $strict); + + $tiered = new TieredCacheAdapter([$tierFirst, $tierConflict]); + expect(fn() => new Cache($tiered, options: $permissive)) + ->toThrow(LogicException::class) + ->and(fn() => new Cache($tierFirst, options: $strict)) + ->not->toThrow(LogicException::class); + + $connection = new PDO('sqlite::memory:'); + $l1 = new ArrayCacheAdapter('node-policy'); + $l2 = new NodeSqliteCacheAdapter($connection, 'node-policy'); + new Cache($l1, options: $strict); + + $node = new NodeCacheAdapter($l1, $l2, false); + expect(fn() => new Cache($node, options: $permissive)) + ->toThrow(LogicException::class) + ->and(fn() => new Cache($l2, options: $strict)) + ->not->toThrow(LogicException::class); +}); + +test('SQLite and file-lock owners reject symlinked path components', function () { + expect(DIRECTORY_SEPARATOR)->not->toBe('\\') + ->and(function_exists('symlink'))->toBeTrue(); + + $base = sys_get_temp_dir() . '/cachelayer-path-trust-' . bin2hex(random_bytes(4)); + $target = $base . '/target'; + $link = $base . '/linked'; + mkdir($target, 0700, true); + expect(symlink($target, $link))->toBeTrue(); + + try { + $config = new NodeCacheConfig( + sqliteFile: $link . '/node.sqlite', + namespace: 'path-trust', + apcuEnabled: false, + ); + expect(fn() => NodeSqliteConnection::create($config)) + ->toThrow(NodeCacheConfigurationException::class) + ->and(fn() => Cache::sqlite('path-trust', $link . '/pdo.sqlite')) + ->toThrow(RuntimeException::class); + + $locks = $base . '/locks'; + mkdir($locks, 0700); + $targetFile = $base . '/lock-target'; + touch($targetFile); + $lockPath = $locks . DIRECTORY_SEPARATOR . hash('xxh128', 'claim') . '.lock'; + expect(symlink($targetFile, $lockPath))->toBeTrue() + ->and((new FileLockProvider($locks))->acquire('claim', 0.0))->toBeNull(); + } finally { + if (is_link($base . '/locks/' . hash('xxh128', 'claim') . '.lock')) { + unlink($base . '/locks/' . hash('xxh128', 'claim') . '.lock'); + } + if (is_link($link)) { + unlink($link); + } + foreach ([$base . '/lock-target', $base . '/locks'] as $path) { + is_dir($path) ? rmdir($path) : (is_file($path) ? unlink($path) : null); + } + if (is_dir($target)) { + rmdir($target); + } + if (is_dir($base)) { + rmdir($base); + } + } +}); + +test('shared-memory token creation rejects a pre-created symlink', function () { + expect(DIRECTORY_SEPARATOR)->not->toBe('\\') + ->and(function_exists('symlink'))->toBeTrue() + ->and(function_exists('shm_attach'))->toBeTrue(); + + $namespace = 'token-' . bin2hex(random_bytes(4)); + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + . DIRECTORY_SEPARATOR . 'cachelayer' . DIRECTORY_SEPARATOR . 'shared-memory'; + if (!is_dir($directory)) { + mkdir($directory, 0700, true); + } + $target = tempnam(sys_get_temp_dir(), 'cachelayer-token-target-'); + expect($target)->not->toBeFalse(); + $token = $directory . DIRECTORY_SEPARATOR . hash('xxh128', $namespace) . '.tok'; + expect(symlink($target, $token))->toBeTrue(); + + try { + expect(fn() => new SharedMemoryCacheAdapter($namespace)) + ->toThrow(RuntimeException::class); + } finally { + if (is_link($token)) { + unlink($token); + } + if (is_string($target) && is_file($target)) { + unlink($target); + } + } +}); diff --git a/tests/Cache/SharedMemoryCachePoolTest.php b/tests/Cache/SharedMemoryCachePoolTest.php index 419b4007..58917626 100644 --- a/tests/Cache/SharedMemoryCachePoolTest.php +++ b/tests/Cache/SharedMemoryCachePoolTest.php @@ -7,9 +7,7 @@ use Infocyph\CacheLayer\Cache\Cache; if (! function_exists('shm_attach')) { - test('shared memory extension not loaded')->skip(); - - return; + throw new RuntimeException('System V shared memory is required for the configured cache test matrix.'); } test('shared memory adapter shares values across instances', function () { @@ -141,9 +139,7 @@ }); test('shared memory atomic claim has one winner under process contention', function () { - if (!function_exists('pcntl_fork')) { - $this->markTestSkipped('pcntl is required for the shared-memory contention test.'); - } + expect(function_exists('pcntl_fork'))->toBeTrue(); $namespace = 'shm-contention-' . getmypid(); $cache = Cache::sharedMemory($namespace); @@ -183,3 +179,18 @@ $cache->clear(); }); + + +test('shared memory atomic consume discards deferred overlays without resurrection', function () { + $cache = Cache::sharedMemory('shm-deferred-consume'); + $atomic = $cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($cache->set('state', 'stored'))->toBeTrue() + ->and($cache->saveDeferred($cache->getItem('state')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('state', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('state', 'missing'))->toBe('missing') + ->and($cache->commit())->toBeTrue() + ->and($cache->get('state'))->toBeNull(); + + $cache->clear(); +}); diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index 61df44bf..44b4c325 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -9,20 +9,19 @@ */ use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; /* ── Skip entire suite if SQLite missing ─────────────────────────── */ if (! in_array('sqlite', PDO::getAvailableDrivers(), true)) { - test('SQLite PDO driver not present – skipping')->skip(); - - return; + throw new RuntimeException('PDO SQLite is required for the configured cache test matrix.'); } /* ── bootstrap / teardown ────────────────────────────────────────── */ beforeEach(function () { $this->dbFile = sys_get_temp_dir().'/pest_sqlite_'.uniqid().'.sqlite'; - $this->cache = Cache::sqlite('tests', $this->dbFile); + $this->cache = Cache::sqlite('tests', $this->dbFile, new CacheOptions(allowClosures: true)); }); afterEach(function () { @@ -68,7 +67,7 @@ /* ── 3. deferred queue ──────────────────────────────────────────── */ test('saveDeferred() & commit() (sqlite)', function () { $this->cache->getItem('a')->set('A')->saveDeferred(); - expect($this->cache->get('a'))->toBeNull(); + expect($this->cache->get('a'))->toBe('A'); $this->cache->commit(); expect($this->cache->get('a'))->toBe('A'); diff --git a/tests/Cache/Support/FaultingFileCacheAdapter.php b/tests/Cache/Support/FaultingFileCacheAdapter.php new file mode 100644 index 00000000..72fe9981 --- /dev/null +++ b/tests/Cache/Support/FaultingFileCacheAdapter.php @@ -0,0 +1,53 @@ +fails('clear') && parent::clear(); + } + + public function deleteItem(string $key): bool + { + return !$this->fails('deleteItem') && parent::deleteItem($key); + } + + public function deleteItems(array $keys): bool + { + return !$this->fails('deleteItems') && parent::deleteItems($keys); + } + + public function save(CacheItemInterface $item): bool + { + return !$this->fails('save') && parent::save($item); + } + + public function saveItems(array $items): bool + { + return !$this->fails('saveItems') && parent::saveItems($items); + } + + private function fails(string $operation): bool + { + if ($this->failure !== $operation) { + return false; + } + if ($this->throws) { + throw new RuntimeException('Injected tier mutation failure.'); + } + + return true; + } +} diff --git a/tests/Cache/Support/PdoInvalidationPublisherProcess.php b/tests/Cache/Support/PdoInvalidationPublisherProcess.php new file mode 100644 index 00000000..2cbf130b --- /dev/null +++ b/tests/Cache/Support/PdoInvalidationPublisherProcess.php @@ -0,0 +1,45 @@ + PDO::ERRMODE_EXCEPTION]); +$transport = new PdoInvalidationTransport($connection, initializeSchema: false); +$connection->beginTransaction(); +file_put_contents($stateFile, 'started'); +$id = $transport->publishWithinTransaction( + $connection, + InvalidationEvent::key($cluster, 'application', $identifier, $origin), +); +file_put_contents($stateFile, 'acquired'); + +$holdMicros = (int) $holdMicros; +if ($holdMicros > 0) { + usleep($holdMicros); +} +if ($commit === '1') { + $connection->commit(); + file_put_contents($resultFile, $id); +} diff --git a/tests/Cache/TieredCachePoolTest.php b/tests/Cache/TieredCachePoolTest.php index f1de0c31..21eef11b 100644 --- a/tests/Cache/TieredCachePoolTest.php +++ b/tests/Cache/TieredCachePoolTest.php @@ -68,3 +68,150 @@ expect(fn() => Cache::tiered([['driver' => 'unknown-tier']])) ->toThrow(CacheInvalidArgumentException::class); }); + + +test('skipped L1 write-through invalidates promoted values before later reads', function () { + $l1 = new ArrayCacheAdapter('skip-stale'); + $l2 = new ArrayCacheAdapter('skip-stale'); + $cache = Cache::tiered([$l1, $l2], writeToL1: false); + + expect($cache->set('single', 'old'))->toBeTrue() + ->and($cache->get('single'))->toBe('old') + ->and($l1->getItem('single')->get())->toBe('old') + ->and($cache->set('single', 'new'))->toBeTrue() + ->and($l1->getItem('single')->isHit())->toBeFalse() + ->and($cache->get('single'))->toBe('new'); + + expect($cache->setMultiple(['one' => 'old-1', 'two' => 'old-2']))->toBeTrue() + ->and($cache->getMultiple(['one', 'two']))->toBe(['one' => 'old-1', 'two' => 'old-2']) + ->and($cache->setMultiple(['one' => 'new-1', 'two' => 'new-2']))->toBeTrue() + ->and($l1->getItem('one')->isHit())->toBeFalse() + ->and($l1->getItem('two')->isHit())->toBeFalse() + ->and($cache->getMultiple(['one', 'two']))->toBe(['one' => 'new-1', 'two' => 'new-2']); +}); + +test('tiered bulk reads preserve numeric-string logical keys', function () { + $l1 = new ArrayCacheAdapter('numeric-tier'); + $l2 = new ArrayCacheAdapter('numeric-tier'); + $cache = Cache::tiered([$l1, $l2], writeToL1: false); + + foreach (['0', '123', '-1', '01'] as $key) { + expect($cache->set($key, 'value-' . $key))->toBeTrue(); + } + + $actual = []; + foreach ($cache->getMultiple(['0', '123', '-1', '01']) as $key => $value) { + $actual[] = [$key, $value]; + } + + expect($actual)->toBe([ + ['0', 'value-0'], + ['123', 'value-123'], + ['-1', 'value--1'], + ['01', 'value-01'], + ]); +}); + + +test('tiered L1 remains fenced after unrelated successful writes until full clear', function () { + $l1 = new ArrayCacheAdapter('fenced-l1'); + $l2 = new ArrayCacheAdapter('fenced-l2'); + $cache = Cache::tiered([$l1, $l2]); + $adapter = (new ReflectionClass($cache))->getProperty('adapter')->getValue($cache); + + expect($cache->set('x', 'old'))->toBeTrue(); + $l2->save($l2->getItem('x')->set('new')); + + $readable = new ReflectionProperty($adapter, 'l1Readable'); + $readable->setValue($adapter, false); + + expect($cache->get('x'))->toBe('new') + ->and($cache->set('unrelated', 'value'))->toBeTrue() + ->and($readable->getValue($adapter))->toBeFalse() + ->and($cache->get('x'))->toBe('new') + ->and($cache->delete('unrelated'))->toBeTrue() + ->and($readable->getValue($adapter))->toBeFalse() + ->and($cache->get('x'))->toBe('new') + ->and($cache->clear())->toBeTrue() + ->and($readable->getValue($adapter))->toBeTrue(); +}); + +test('tier mutation failures fence reads until a complete clear', function (string $operation, bool $throws, bool $failOpen): void { + $directory = sys_get_temp_dir() . '/cachelayer-tier-failure-' . bin2hex(random_bytes(6)); + $l1 = new \Infocyph\CacheLayer\Tests\Cache\Support\FaultingFileCacheAdapter('fault', $directory); + $l2 = new ArrayCacheAdapter('fault'); + $cache = Cache::tiered([$l1, $l2], options: new \Infocyph\CacheLayer\Cache\CacheOptions(failOpen: $failOpen)); + $cache->set('x', 'old'); + $l2->save($l2->getItem('x')->set('new')); + $l1->failure = $operation; + $l1->throws = $throws; + + $mutate = match ($operation) { + 'clear' => fn() => $cache->clear(), + 'save' => fn() => $cache->set('unrelated', 'value'), + 'saveItems' => fn() => $cache->setMultiple(['unrelated' => 'value']), + 'deleteItem' => fn() => $cache->delete('unrelated'), + 'deleteItems' => fn() => $cache->deleteMultiple(['unrelated']), + }; + + try { + if ($throws && !$failOpen) { + expect($mutate)->toThrow(\Infocyph\CacheLayer\Exceptions\CacheBackendException::class); + } else { + expect($mutate())->toBeFalse(); + } + $expected = $l2->getItem('x')->get(); + expect($cache->get('x'))->toBe($expected); + $l1->failure = null; + $cache->set('unrelated', 'recovered'); + expect($cache->get('x'))->toBe($expected) + ->and($cache->clear())->toBeTrue() + ->and($l1->getItem('x')->isHit())->toBeFalse() + ->and($cache->get('x'))->toBeNull(); + } finally { + $l1->failure = null; + $l1->clear(); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($directory); + } +})->with(['clear', 'save', 'saveItems', 'deleteItem', 'deleteItems'])->with([false, true])->with([false, true]); + + +test('skipped writes and failed promotions keep every upper tier fenced', function (string $operation, bool $throws, bool $bulk): void { + $directory = sys_get_temp_dir() . '/cachelayer-tier-promotion-' . bin2hex(random_bytes(6)); + $l1 = new \Infocyph\CacheLayer\Tests\Cache\Support\FaultingFileCacheAdapter('promotion', $directory); + $middle = new ArrayCacheAdapter('middle'); + $last = new ArrayCacheAdapter('last'); + $cache = Cache::tiered([$l1, $middle, $last], writeToL1: $operation !== 'skip'); + $cache->set('x', 'old'); + $cache->get('x'); + $last->save($last->getItem('x')->set('new')); + $l1->throws = $throws; + $l1->failure = $operation === 'skip' ? 'deleteItems' : ($bulk ? 'saveItems' : 'save'); + + try { + if ($operation === 'skip') { + expect($bulk ? $cache->setMultiple(['x' => 'new']) : $cache->set('x', 'new'))->toBeFalse(); + } else { + $last->save($last->getItem('promote')->set('authoritative')); + $bulk ? $cache->getMultiple(['promote']) : $cache->get('promote'); + } + expect($cache->get('x'))->toBe('new') + ->and($cache->getMultiple(['x']))->toBe(['x' => 'new']); + $l1->failure = null; + $cache->set('unrelated', 'value'); + expect($cache->get('x'))->toBe('new'); + $cache->clear(); + } finally { + $l1->failure = null; + $l1->clear(); + $files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS), RecursiveIteratorIterator::CHILD_FIRST); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($directory); + } +})->with(['skip', 'promote'])->with([false, true])->with([false, true]); diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php index 8f5f61c3..ca8daa0c 100644 --- a/tests/Cache/ValkeyCachePoolTest.php +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -4,11 +4,12 @@ use Infocyph\CacheLayer\Cache\AtomicCacheInterface; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Counter\AtomicCounters; +use Infocyph\CacheLayer\Counter\Exception\AtomicCounterException; +use Infocyph\CacheLayer\Tests\Support\AtomicCounterProcessProbe; if (! class_exists(Redis::class)) { - test('phpredis ext not loaded - skipping valkey tests')->skip(); - - return; + throw new RuntimeException('phpredis is required for the configured Valkey test matrix.'); } $valkeyHost = getenv('IC_VALKEY_HOST') ?: getenv('CACHELAYER_VALKEY_HOST') ?: getenv('IC_REDIS_HOST') ?: getenv('CACHELAYER_REDIS_HOST') ?: '127.0.0.1'; @@ -22,10 +23,8 @@ $probe->auth($valkeyPassword); } $probe->ping(); -} catch (Throwable) { - test('Valkey server unreachable - skipping')->skip(); - - return; +} catch (Throwable $failure) { + throw new RuntimeException('Valkey service is required for the configured cache test matrix.', 0, $failure); } beforeEach(function () use ($valkeyHost, $valkeyPort, $valkeyPassword) { @@ -36,6 +35,7 @@ } $client->flushDB(); + $this->valkeyClient = $client; $this->cache = Cache::valkey( 'valkey-tests', sprintf('valkey://%s:%d', $valkeyHost, $valkeyPort), @@ -113,3 +113,85 @@ expect($atomic->setIfAbsent('claim', 'second', 30))->toBeTrue() ->and($this->cache->get('claim'))->toBe('second'); }); + + +test('Valkey atomic counters stay isolated from cache clear and preserve exact integers', function () { + $counters = AtomicCounters::valkey('valkey-tests', client: $this->valkeyClient); + $large = 9_007_199_254_740_993; + $first = $counters->increment('window', $large, 30); + $physical = 'cachelayer:counter:valkey-tests:window'; + $ttlBefore = $this->valkeyClient->ttl($physical); + $later = $counters->decrement('window', 2, 30); + $ttlAfter = $this->valkeyClient->ttl($physical); + + expect($first->value)->toBe($large) + ->and($first->initialized)->toBeTrue() + ->and($later->value)->toBe($large - 2) + ->and($later->initialized)->toBeFalse() + ->and($ttlAfter)->toBeGreaterThan(0) + ->and($ttlAfter)->toBeLessThanOrEqual($ttlBefore) + ->and($this->cache->set('ordinary', 'value'))->toBeTrue() + ->and($this->cache->clear())->toBeTrue() + ->and($counters->get('window'))->toBe($large - 2); + + $this->valkeyClient->set('cachelayer:counter:valkey-tests:invalid', '9223372036854775808'); + expect(fn () => $counters->get('invalid'))->toThrow(AtomicCounterException::class); +}); + +test('Valkey atomic counter initialization has exactly one winner under contention', function () use ($valkeyHost, $valkeyPort, $valkeyPassword) { + $counters = AtomicCounters::valkey('valkey-tests', client: $this->valkeyClient); + $wins = AtomicCounterProcessProbe::initializedWinners( + 'valkey', + $valkeyHost, + $valkeyPort, + $valkeyPassword, + 'valkey-tests', + 'contended-counter', + ); + + expect($wins)->toBe(1) + ->and($counters->get('contended-counter'))->toBe(8); +}); + + +test('Valkey atomic counters expire fixed windows', function () { + $counters = AtomicCounters::valkey('valkey-tests', client: $this->valkeyClient); + + expect($counters->increment('short-window', 1, 1)->initialized)->toBeTrue() + ->and($counters->get('short-window'))->toBe(1); + usleep(2_000_000); + + expect($counters->get('short-window'))->toBeNull(); +}); + + +test('Valkey atomic consume discards deferred overlays without resurrection', function () { + $atomic = $this->cache->atomic(); + expect($atomic)->not->toBeNull() + ->and($this->cache->set('deferred-consume', 'stored'))->toBeTrue() + ->and($this->cache->saveDeferred($this->cache->getItem('deferred-consume')->set('pending')))->toBeTrue() + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('stored') + ->and($atomic->getAndDelete('deferred-consume', 'missing'))->toBe('missing') + ->and($this->cache->commit())->toBeTrue() + ->and($this->cache->get('deferred-consume'))->toBeNull(); +}); + + +test('Valkey clear in boundary namespace preserves counter and lock domains', function () { + $cache = Cache::valkey('cachelayer', client: $this->valkeyClient); + $counters = AtomicCounters::valkey('audit-counter', client: $this->valkeyClient); + $locks = new \Infocyph\CacheLayer\Cache\Lock\RedisLockProvider($this->valkeyClient); + + expect($cache->set('ordinary', 'value'))->toBeTrue() + ->and($counters->increment('window', 5, 30)->value)->toBe(5); + + $held = $locks->acquire('boundary-lock', 0.0, 30.0); + expect($held)->not->toBeNull(); + + expect($cache->clear())->toBeTrue() + ->and($cache->get('ordinary'))->toBeNull() + ->and($counters->get('window'))->toBe(5) + ->and($locks->acquire('boundary-lock', 0.0, 30.0))->toBeNull(); + + $locks->release($held); +}); diff --git a/tests/Cache/WeakMapCachePoolTest.php b/tests/Cache/WeakMapCachePoolTest.php index d133f047..73ee6f38 100644 --- a/tests/Cache/WeakMapCachePoolTest.php +++ b/tests/Cache/WeakMapCachePoolTest.php @@ -3,9 +3,10 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; beforeEach(function () { - $this->cache = Cache::weakMap('weak-tests'); + $this->cache = Cache::weakMap('weak-tests', new CacheOptions(allowObjects: true)); }); test('weak map adapter stores scalar values', function () { diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index 4f6c8c0b..a8976cdb 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -4,9 +4,7 @@ use Infocyph\CacheLayer\Cluster\ClusterCache; use Infocyph\CacheLayer\Cluster\ClusterCacheConfig; -use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cluster\Consumer\InvalidationConsumer; use Infocyph\CacheLayer\Cluster\Consumer\InvalidationHandler; use Infocyph\CacheLayer\Cluster\Cursor\SqliteCursorStore; @@ -21,71 +19,13 @@ use Infocyph\CacheLayer\Cluster\Transport\Pdo\PdoInvalidationSchema; use Infocyph\CacheLayer\Node\NodeCacheConfig; use Infocyph\CacheLayer\Tests\Cluster\Support\InMemoryInvalidationTransport; -use Psr\Cache\CacheItemInterface; - -final class RejectingClusterCacheAdapter extends AbstractCacheAdapter -{ - /** @var array */ - public array $rejectedOperations = []; - - public function clear(): bool - { - return $this->reject('clear'); - } - - public function deleteItem(string $key): bool - { - return $this->reject('deleteItem', $key); - } - - public function deleteItems(array $keys): bool - { - return $this->reject('deleteItems', $keys); - } - - public function getItem(string $key): CacheItem - { - return $this->genericMiss($key); - } - - public function hasItem(string $key): bool - { - return $this->reject('hasItem', $key); - } - - public function multiFetch(array $keys): array - { - $items = []; - foreach ($keys as $key) { - $items[$key] = $this->genericMiss($key); - } - - return $items; - } - - public function save(CacheItemInterface $item): bool - { - return $this->reject('save', $item); - } - - public function saveItems(array $items): bool - { - return $this->reject('saveItems', $items); - } - - private function reject(string $operation, mixed $argument = null): bool - { - $this->rejectedOperations[$operation] = $argument; - - return false; - } -} +use Infocyph\CacheLayer\Tests\Cluster\Support\RejectingClusterCacheAdapter; beforeEach(function () { $this->clusterDirectory = sys_get_temp_dir() . '/cachelayer-cluster-' . uniqid(); $this->transport = new InMemoryInvalidationTransport(); - $this->clusterConfigA = new ClusterCacheConfig('test-cluster', 'node-a'); - $this->clusterConfigB = new ClusterCacheConfig('test-cluster', 'node-b'); + $this->clusterConfigA = new ClusterCacheConfig('test-cluster', 'node-a', 'memory-primary'); + $this->clusterConfigB = new ClusterCacheConfig('test-cluster', 'node-b', 'memory-primary'); $this->nodeConfigA = new NodeCacheConfig( $this->clusterDirectory . '/node-a.sqlite', 'application', @@ -174,6 +114,159 @@ private function reject(string $operation, mixed $argument = null): bool ->and($this->nodeB->cache()->get('search.list'))->toBeNull(); }); +test('cursor progress is isolated by namespace on the same node and SQLite store', function () { + $transport = new InMemoryInvalidationTransport(); + $sqliteFile = $this->clusterDirectory . '/shared-node.sqlite'; + $cluster = new ClusterCacheConfig('scope-cluster', 'shared-node', 'memory-scope'); + $alpha = ClusterCache::create( + new NodeCacheConfig($sqliteFile, 'alpha', apcuEnabled: false), + $cluster, + $transport, + ); + $beta = ClusterCache::create( + new NodeCacheConfig($sqliteFile, 'beta', apcuEnabled: false), + $cluster, + $transport, + ); + + $beta->cache()->set('shared', 'stale', 300); + $transport->publish(InvalidationEvent::key('scope-cluster', 'beta', 'shared', 'writer')); + + expect($alpha->consume())->toBe(1) + ->and($alpha->status()->cursor)->toBe('1') + ->and($beta->status()->cursor)->toBeNull() + ->and($beta->consume())->toBe(1) + ->and($beta->cache()->get('shared'))->toBeNull() + ->and($beta->status()->cursor)->toBe('1'); +}); + +test('cursor progress is isolated by transport identity on the same node scope', function () { + $transportA = new InMemoryInvalidationTransport(); + $transportB = new InMemoryInvalidationTransport(); + $sqliteFile = $this->clusterDirectory . '/shared-transport-node.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $runtimeA = ClusterCache::create( + $node, + new ClusterCacheConfig('transport-cluster', 'shared-node', 'transport-a'), + $transportA, + ); + $runtimeB = ClusterCache::create( + $node, + new ClusterCacheConfig('transport-cluster', 'shared-node', 'transport-b'), + $transportB, + ); + + $runtimeB->cache()->set('beta', 'stale', 300); + $transportA->publish(InvalidationEvent::key('transport-cluster', 'application', 'alpha', 'writer')); + $transportB->publish(InvalidationEvent::key('transport-cluster', 'application', 'beta', 'writer')); + + expect($runtimeA->consume())->toBe(1) + ->and($runtimeA->status()->cursor)->toBe('1') + ->and($runtimeB->status()->cursor)->toBeNull() + ->and($runtimeB->consume())->toBe(1) + ->and($runtimeB->cache()->get('beta'))->toBeNull() + ->and($runtimeB->status()->cursor)->toBe('1'); +}); + +test('legacy cursor migration clears the affected scope before establishing new progress', function () { + $sqliteFile = $this->clusterDirectory . '/legacy-cursor.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $cache = \Infocyph\CacheLayer\Node\NodeCache::create($node); + $cache->set('stale', 'value', 300); + + $pdo = new PDO('sqlite:' . $sqliteFile); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec( + 'CREATE TABLE cachelayer_cluster_cursors (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id)) WITHOUT ROWID', + ); + $statement = $pdo->prepare( + 'INSERT INTO cachelayer_cluster_cursors (cluster_name, node_id, last_event_id, updated_at) VALUES (?, ?, ?, ?)', + ); + $statement->execute(['migration-cluster', 'shared-node', '99', time()]); + + $transport = new InMemoryInvalidationTransport(); + $runtime = ClusterCache::create( + $node, + new ClusterCacheConfig('migration-cluster', 'shared-node', 'memory-migrated'), + $transport, + ); + + expect($runtime->status()->cursor)->toBeNull() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->lastRecoveryAt)->not->toBeNull() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->cursor)->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); +}); + +test('namespace-scoped v2 cursor migration is isolated per transport identity', function () { + $sqliteFile = $this->clusterDirectory . '/v2-cursor.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $cache = \Infocyph\CacheLayer\Node\NodeCache::create($node); + $cache->set('stale', 'value', 300); + + $pdo = new PDO('sqlite:' . $sqliteFile); + $pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $pdo->exec( + 'CREATE TABLE cachelayer_cluster_cursors_v2 (' + . 'cluster_name TEXT NOT NULL, node_id TEXT NOT NULL, namespace_name TEXT NOT NULL, ' + . 'last_event_id TEXT, updated_at INTEGER NOT NULL, ' + . 'PRIMARY KEY (cluster_name, node_id, namespace_name)) WITHOUT ROWID', + ); + $statement = $pdo->prepare( + 'INSERT INTO cachelayer_cluster_cursors_v2 ' + . '(cluster_name, node_id, namespace_name, last_event_id, updated_at) VALUES (?, ?, ?, ?, ?)', + ); + $statement->execute(['migration-cluster', 'shared-node', 'application', '42', time()]); + + $runtime = ClusterCache::create( + $node, + new ClusterCacheConfig('migration-cluster', 'shared-node', 'transport-v3'), + new InMemoryInvalidationTransport(), + ); + + expect($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->lastRecoveryAt)->not->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); +}); + + +test('scoped cursor persists across runtime restart and reset', function () { + $transport = new InMemoryInvalidationTransport(); + $sqliteFile = $this->clusterDirectory . '/restart-node.sqlite'; + $node = new NodeCacheConfig($sqliteFile, 'application', apcuEnabled: false); + $cluster = new ClusterCacheConfig('restart-cluster', 'restart-node', 'memory-restart'); + + $transport->publish(InvalidationEvent::key('restart-cluster', 'application', 'first', 'writer')); + $firstRuntime = ClusterCache::create($node, $cluster, $transport); + expect($firstRuntime->consume())->toBe(1) + ->and($firstRuntime->status()->cursor)->toBe('1'); + + unset($firstRuntime); + $secondRuntime = ClusterCache::create($node, $cluster, $transport); + $transport->publish(InvalidationEvent::key('restart-cluster', 'application', 'second', 'writer')); + + expect($secondRuntime->status()->cursor)->toBe('1') + ->and($secondRuntime->consume())->toBe(1) + ->and($secondRuntime->status()->cursor)->toBe('2'); + + $cursor = new SqliteCursorStore( + $sqliteFile, + 'restart-cluster', + 'restart-node', + 'application', + 'memory-restart', + ); + $cursor->reset('1'); + expect($cursor->current())->toBe('1'); + + $cursor->reset(null); + expect($cursor->current())->toBeNull() + ->and($cursor->requiresRecovery())->toBeFalse(); +}); + test('cluster status reports cursor position, pending events, and consume results', function () { $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'first', 'writer')); $this->transport->publish(InvalidationEvent::key('test-cluster', 'application', 'second', 'writer')); @@ -287,9 +380,11 @@ private function reject(string $operation, mixed $argument = null): bool }); test('cluster configuration and runtime inputs enforce transport bounds before publication', function () { - expect(fn() => new ClusterCacheConfig(str_repeat('c', 129), 'node')) + expect(fn() => new ClusterCacheConfig(str_repeat('c', 129), 'node', 'memory')) + ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) + ->and(fn() => new ClusterCacheConfig('cluster', str_repeat('n', 256), 'memory')) ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) - ->and(fn() => new ClusterCacheConfig('cluster', str_repeat('n', 256))) + ->and(fn() => new ClusterCacheConfig('cluster', 'node', str_repeat('t', 129))) ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) ->and(fn() => $this->nodeA->invalidateKey(str_repeat('k', 65))) ->toThrow(ClusterCacheException::class) @@ -350,7 +445,10 @@ private function reject(string $operation, mixed $argument = null): bool $this->clusterDirectory . '/failed-cursor.sqlite', 'failed-cluster', 'consumer', + 'application', + 'memory-primary', ); + $cursor->reset(null); // This test exercises replay after successful scope initialization. $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'failed-cluster'); $consumer = new InvalidationConsumer( $transport, @@ -379,6 +477,8 @@ private function reject(string $operation, mixed $argument = null): bool $this->clusterDirectory . '/recovery-failed-cursor.sqlite', 'recovery-failure', 'consumer', + 'application', + 'memory-primary', ); $cursor->advance('1'); $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'recovery-failure'); @@ -387,3 +487,137 @@ private function reject(string $operation, mixed $argument = null): bool ->and($cursor->current())->toBe('1') ->and(array_keys($adapter->rejectedOperations))->toBe(['clear']); }); + + +test('recovery clears stale local state when retained invalidation history disappears completely', function () { + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'first', 'writer'), + ); + expect($this->nodeB->consume())->toBe(1); + + $this->nodeB->cache()->set('stale-after-loss', 'value', 300); + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'stale-after-loss', 'writer'), + ); + $this->transport->discardBefore('test-cluster', PHP_INT_MAX); + + expect($this->nodeB->recoverIfRequired())->toBeTrue() + ->and($this->nodeB->cache()->get('stale-after-loss'))->toBeNull() + ->and($this->nodeB->status()->cursor)->toBeNull() + ->and($this->nodeB->recoverIfRequired())->toBeFalse(); +}); + +test('recovery clears and replays when a recreated transport restarts behind the stored cursor', function () { + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'one', 'writer'), + ); + $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'two', 'writer'), + ); + expect($this->nodeB->consume(2))->toBe(2) + ->and($this->nodeB->status()->cursor)->toBe('2'); + + $this->nodeB->cache()->set('reset-key', 'stale', 300); + + $replacementTransport = new InMemoryInvalidationTransport(); + $replacementTransport->publish( + InvalidationEvent::key('test-cluster', 'application', 'reset-key', 'writer'), + ); + $replacementRuntime = ClusterCache::create( + $this->nodeConfigB, + $this->clusterConfigB, + $replacementTransport, + ); + + expect($replacementRuntime->recoverIfRequired())->toBeTrue() + ->and($replacementRuntime->cache()->get('reset-key'))->toBeNull() + ->and($replacementRuntime->status()->cursor)->toBeNull() + ->and($replacementRuntime->consume())->toBe(1) + ->and($replacementRuntime->status()->cursor)->toBe('1'); +}); + + +test('PDO recovery clears stale local state after complete retained-history loss', function () { + $connection = new PDO('sqlite:' . $this->clusterDirectory . '/history-loss.sqlite'); + $transport = new PdoInvalidationTransport($connection, allowSqliteForTesting: true); + $node = new NodeCacheConfig( + $this->clusterDirectory . '/history-loss-node.sqlite', + 'application', + apcuEnabled: false, + ); + $cluster = new ClusterCacheConfig('pdo-history-loss', 'consumer', 'pdo-history-loss'); + $runtime = ClusterCache::create($node, $cluster, $transport); + + $transport->publish( + InvalidationEvent::key('pdo-history-loss', 'application', 'first', 'writer'), + ); + expect($runtime->consume())->toBe(1); + + $runtime->cache()->set('stale', 'value', 300); + $transport->publish( + InvalidationEvent::key('pdo-history-loss', 'application', 'stale', 'writer'), + ); + $connection->exec( + "DELETE FROM " . PdoInvalidationSchema::EVENT_TABLE . " WHERE cluster_name = 'pdo-history-loss'", + ); + + expect($runtime->recoverIfRequired())->toBeTrue() + ->and($runtime->cache()->get('stale'))->toBeNull() + ->and($runtime->status()->cursor)->toBeNull() + ->and($runtime->recoverIfRequired())->toBeFalse(); +}); + +test('a new history identity clears local state before exposing overlapping recreated events', function () { + $connection = new PDO('sqlite:' . $this->clusterDirectory . '/epoch-events.sqlite'); + $transport = new PdoInvalidationTransport($connection, allowSqliteForTesting: true); + $node = new NodeCacheConfig($this->clusterDirectory . '/epoch-node.sqlite', 'application', apcuEnabled: false); + $old = ClusterCache::create($node, new ClusterCacheConfig('epoch', 'consumer', 'history-1'), $transport); + foreach (['one', 'two'] as $key) { + $transport->publish(InvalidationEvent::key('epoch', 'application', $key, 'writer')); + } + expect($old->consume())->toBe(2); + $old->cache()->set('stale', 'old value'); + unset($old); + $connection->exec('DELETE FROM ' . PdoInvalidationSchema::EVENT_TABLE); + $connection->exec("DELETE FROM sqlite_sequence WHERE name = 'cachelayer_invalidation_events'"); + foreach (['stale', 'one', 'two'] as $key) { + $transport->publish(InvalidationEvent::key('epoch', 'application', $key, 'writer')); + } + $config = new ClusterCacheConfig('epoch', 'consumer', 'history-2'); + $replacement = ClusterCache::create($node, $config, $transport); + expect($replacement->cache()->get('stale'))->toBeNull() + ->and($replacement->status()->cursor)->toBeNull() + ->and($replacement->consume())->toBe(3); + $replacement->cache()->set('warm', 'kept'); + unset($replacement); + $restarted = ClusterCache::create($node, $config, $transport); + expect($restarted->cache()->get('warm'))->toBe('kept') + ->and($restarted->status()->cursor)->toBe('3') + ->and($restarted->consume())->toBe(0); +}); + + +test('a failed initial clear leaves a new history scope uninitialized for retry', function () { + $cursor = new SqliteCursorStore( + $this->clusterDirectory . '/uninitialized.sqlite', + 'new-history', + 'consumer', + 'application', + 'generation-2', + ); + $transport = new InMemoryInvalidationTransport(); + $failed = new ClusterRecoveryManager(new Cache(new RejectingClusterCacheAdapter()), $cursor, $transport, 'new-history'); + expect($cursor->requiresRecovery())->toBeTrue() + ->and(fn() => $failed->recoverIfRequired())->toThrow(ClusterCacheException::class) + ->and($cursor->requiresRecovery())->toBeTrue() + ->and($cursor->updatedAt())->toBeNull(); + + $cache = Cache::memory('application'); + $cache->set('stale', 'value'); + $retry = new ClusterRecoveryManager($cache, $cursor, $transport, 'new-history'); + expect($retry->recoverIfRequired())->toBeTrue() + ->and($cache->get('stale'))->toBeNull() + ->and($cursor->requiresRecovery())->toBeFalse() + ->and($cursor->current())->toBeNull() + ->and($retry->recoverIfRequired())->toBeFalse(); +}); diff --git a/tests/Cluster/Support/RejectingClusterCacheAdapter.php b/tests/Cluster/Support/RejectingClusterCacheAdapter.php new file mode 100644 index 00000000..9fe122b1 --- /dev/null +++ b/tests/Cluster/Support/RejectingClusterCacheAdapter.php @@ -0,0 +1,67 @@ + */ + public array $rejectedOperations = []; + + public function clear(): bool + { + return $this->reject('clear'); + } + + public function deleteItem(string $key): bool + { + return $this->reject('deleteItem', $key); + } + + public function deleteItems(array $keys): bool + { + return $this->reject('deleteItems', $keys); + } + + public function getItem(string $key): CacheItem + { + return $this->genericMiss($key); + } + + public function hasItem(string $key): bool + { + return $this->reject('hasItem', $key); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->genericMiss($key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + return $this->reject('save', $item); + } + + public function saveItems(array $items): bool + { + return $this->reject('saveItems', $items); + } + + private function reject(string $operation, mixed $argument = null): bool + { + $this->rejectedOperations[$operation] = $argument; + + return false; + } +} diff --git a/tests/Integration/RunwireIntegrationTest.php b/tests/Integration/RunwireIntegrationTest.php new file mode 100644 index 00000000..28078c5c --- /dev/null +++ b/tests/Integration/RunwireIntegrationTest.php @@ -0,0 +1,269 @@ +toBe(1) + ->and(memoize($loader))->toBe(1) + ->and($runs)->toBe(1); +}); + +it('isolates memoizers by the shared Runwire request context', function (): void { + $runtime = cacheLayerRunwireContext(); + RunwireIntegration::bind($runtime); + $runs = 0; + $loader = static function () use (&$runs): int { + return ++$runs; + }; + + $first = RequestContext::create($runtime); + $firstValues = RunwireIntegration::share( + $first, + null, + static fn(): array => [memoize($loader), memoize($loader)], + ); + $first->complete(); + + $second = RequestContext::create($runtime); + $secondValues = RunwireIntegration::share( + $second, + null, + static fn(): array => [memoize($loader), memoize($loader)], + ); + $second->complete(); + + expect($firstValues)->toBe([1, 1]) + ->and($secondValues)->toBe([2, 2]) + ->and($runs)->toBe(2); +}); + +it('bypasses process-global memoization in concurrent persistent runtimes without a shared request', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $runs = 0; + $loader = static function () use (&$runs): int { + return ++$runs; + }; + + expect(memoize($loader))->toBe(1) + ->and(memoize($loader))->toBe(2) + ->and($runs)->toBe(2); +}); + +it('keeps concurrent request memoizers isolated by fiber', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $requestA = RequestContext::create($runtime); + $requestB = RequestContext::create($runtime); + $runsA = 0; + $runsB = 0; + $loaderA = static function () use (&$runsA): int { + return ++$runsA; + }; + $loaderB = static function () use (&$runsB): int { + return ++$runsB; + }; + + $fiberA = new Fiber(static fn(): int => RunwireIntegration::share( + $requestA, + null, + static function () use ($loaderA): int { + $first = memoize($loaderA); + Fiber::suspend($first); + + return memoize($loaderA); + }, + )); + $fiberB = new Fiber(static fn(): int => RunwireIntegration::share( + $requestB, + null, + static function () use ($loaderB): int { + $first = memoize($loaderB); + Fiber::suspend($first); + + return memoize($loaderB); + }, + )); + + expect($fiberA->start())->toBe(1) + ->and($fiberB->start())->toBe(1); + $fiberA->resume(); + $fiberB->resume(); + + expect($fiberA->getReturn())->toBe(1) + ->and($fiberB->getReturn())->toBe(1) + ->and($runsA)->toBe(1) + ->and($runsB)->toBe(1); +}); + +it('shares the active Runwire task scope and capability set', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + $request = RequestContext::create($runtime); + RunwireIntegration::bind($runtime); + $coroutines = new CoroutineRuntime(); + + $result = $coroutines->run( + static function (CoroutineScope $scope) use ($request): array { + return RunwireIntegration::share( + $request, + $scope, + static fn(): array => [ + RunwireIntegration::current()?->scope === $scope, + RunwireIntegration::supports(RuntimeCapability::RUNWIRE_COROUTINES), + ], + ); + }, + ); + + expect($result)->toBe([true, true]); +}); + +it('restores the normal path after the shared Runwire runtime is released', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + RunwireIntegration::release($runtime); + $runs = 0; + $loader = static function () use (&$runs): int { + return ++$runs; + }; + + expect(RunwireIntegration::runtime())->toBeNull() + ->and(memoize($loader))->toBe(1) + ->and(memoize($loader))->toBe(1) + ->and($runs)->toBe(1); +}); + + +it('replaces runtime bindings without retaining a previous worker request scope', function (): void { + $firstRuntime = cacheLayerRunwireContext(concurrent: true); + $secondRuntime = RuntimeContext::fromCapabilities( + new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + persistentProcess: true, + persistentApplication: true, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, + ), + 'cachelayer-replacement-test', + workerSlot: 1, + generation: 2, + concurrent: true, + ); + $oldRequest = RequestContext::create($firstRuntime); + + RunwireIntegration::bind($firstRuntime); + RunwireIntegration::share( + $oldRequest, + null, + static fn(): int => memoize(static fn(): int => 1), + ); + RunwireIntegration::bind($secondRuntime); + + expect(RunwireIntegration::runtime())->toBe($secondRuntime) + ->and(RunwireIntegration::current())->toBeNull(); + + expect(fn(): mixed => RunwireIntegration::share( + $oldRequest, + null, + static fn(): string => 'stale', + ))->toThrow(LogicException::class, 'different runtime'); +}); + + +it('releases request-owned memoizers across repeated persistent request lifecycles', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $references = []; + + for ($index = 0; $index < 128; ++$index) { + $request = RequestContext::create($runtime); + $memoizer = RunwireIntegration::share( + $request, + null, + static fn(): mixed => memoize(), + ); + $references[] = WeakReference::create($memoizer); + $request->complete(); + unset($memoizer, $request); + } + + gc_collect_cycles(); + + foreach ($references as $reference) { + expect($reference->get())->toBeNull(); + } +}); + + +it('flushing one Runwire request does not change another live request memoizer identity', function (): void { + $runtime = cacheLayerRunwireContext(concurrent: true); + RunwireIntegration::bind($runtime); + $requestA = RequestContext::create($runtime); + $requestB = RequestContext::create($runtime); + $runs = 0; + $callback = static function () use (&$runs): int { + return ++$runs; + }; + + $result = RunwireIntegration::share( + $requestA, + null, + static function () use ($requestB, $callback, &$runs): array { + $first = memoize($callback); + + RunwireIntegration::share( + $requestB, + null, + static function (): void { + memoize(); + once(static fn(): string => 'request-b'); + flush_memoizers(); + }, + ); + + return [$first, memoize($callback), $runs]; + }, + ); + + expect($result)->toBe([1, 1, 1]); +}); diff --git a/tests/Integration/RunwireWorkerIntegrationTest.php b/tests/Integration/RunwireWorkerIntegrationTest.php new file mode 100644 index 00000000..2d9b819c --- /dev/null +++ b/tests/Integration/RunwireWorkerIntegrationTest.php @@ -0,0 +1,364 @@ +runwireWorkerDirectory = sys_get_temp_dir() . '/cachelayer-runwire-worker-' . uniqid(); + $this->runwireWorkerTransport = new InMemoryInvalidationTransport(); + $this->runwireWorkerNodeConfig = new NodeCacheConfig( + $this->runwireWorkerDirectory . '/node.sqlite', + 'application', + apcuEnabled: false, + ); + $this->runwireWorkerCluster = ClusterCache::create( + $this->runwireWorkerNodeConfig, + new ClusterCacheConfig('runwire-cluster', 'node-a', 'runwire-memory'), + $this->runwireWorkerTransport, + ); + RunwireIntegration::release(); +}); + +afterEach(function (): void { + RunwireIntegration::release(); + if (!is_dir($this->runwireWorkerDirectory)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->runwireWorkerDirectory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->runwireWorkerDirectory); +}); + +it('polls invalidations in bounded cycles on the normal path', function (): void { + foreach (['one', 'two', 'three'] as $key) { + $this->runwireWorkerTransport->publish( + InvalidationEvent::key('runwire-cluster', 'application', $key, 'writer'), + ); + } + + expect($this->runwireWorkerCluster->poll(limit: 1, cycles: 2, idleSeconds: 0.0))->toBe(2) + ->and($this->runwireWorkerCluster->consume())->toBe(1); +}); + +it('uses the shared Runwire task scope for cooperative polling waits', function (): void { + $runtime = cacheLayerWorkerRuntimeContext(); + $request = RequestContext::create($runtime); + $coroutines = new CoroutineRuntime(); + $order = []; + RunwireIntegration::bind($runtime); + + $processed = $coroutines->runRequest( + $request, + function (CoroutineScope $scope) use ($request, &$order): int { + $scope->spawn(static function () use (&$order): void { + $order[] = 'child'; + }); + + $result = RunwireIntegration::share( + $request, + $scope, + fn(): int => $this->runwireWorkerCluster->poll( + limit: 1, + cycles: 2, + idleSeconds: 0.001, + ), + ); + $order[] = 'after'; + + return $result; + }, + ); + + expect($processed)->toBe(0) + ->and($order)->toBe(['child', 'after']); +}); + +it('propagates Runwire cancellation instead of replaying worker operations through fallback', function (): void { + $runtime = cacheLayerWorkerRuntimeContext(); + $request = RequestContext::create($runtime); + $coroutines = new CoroutineRuntime(); + RunwireIntegration::bind($runtime); + + expect(fn() => $coroutines->runRequest( + $request, + function (CoroutineScope $scope) use ($request): int { + $scope->spawn(static function () use ($scope, $request): void { + $scope->sleep(0.001); + $request->cancel(CancellationReason::HOST_CANCELLED); + }); + + return RunwireIntegration::share( + $request, + $scope, + fn(): int => $this->runwireWorkerCluster->poll( + limit: 1, + cycles: 100, + idleSeconds: 0.01, + ), + ); + }, + ))->toThrow(CancelledException::class); +}); + + +it('runs bounded invalidation consumption inside the host-owned Runwire worker scope', function (): void { + foreach (['one', 'two', 'three'] as $key) { + $this->runwireWorkerTransport->publish( + InvalidationEvent::key('runwire-cluster', 'application', $key, 'writer'), + ); + } + + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + $loop = new SelectLoop(); + $worker->attachLoop($loop, 0.25); + $task = RunwireWorkerIntegration::startClusterConsumer( + $worker, + $this->runwireWorkerCluster, + batchSize: 1, + idleSeconds: 0.001, + ); + expect($task)->not->toBeNull(); + + $loop->delay(0.01, static function () use ($worker): void { + $worker->requestStop(); + }); + $loop->run(); + + expect($this->runwireWorkerCluster->consume())->toBe(0) + ->and($task?->state())->toBe(TaskState::CANCELLED) + ->and($worker->acceptingBackgroundWork())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); + +it('runs bounded node maintenance inside the host-owned Runwire worker scope', function (): void { + $connection = NodeSqliteConnection::create($this->runwireWorkerNodeConfig); + $statement = $connection->prepare( + 'INSERT INTO cachelayer_node_entries (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, ?)', + ); + $statement->execute([ + $this->runwireWorkerNodeConfig->namespace, + 'expired-runwire', + 'unused', + time() - 1, + ]); + + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + $loop = new SelectLoop(); + $worker->attachLoop($loop, 0.25); + $task = RunwireWorkerIntegration::startNodeMaintenance( + $worker, + NodeCache::maintenance($this->runwireWorkerNodeConfig), + intervalSeconds: 0.001, + pruneLimit: 1, + optimizeEvery: 2, + ); + expect($task)->not->toBeNull(); + + $loop->delay(0.01, static function () use ($worker): void { + $worker->requestStop(); + }); + $loop->run(); + + expect((int) $connection->query( + "SELECT COUNT(*) FROM cachelayer_node_entries WHERE cache_key = 'expired-runwire'", + )->fetchColumn())->toBe(0) + ->and($task?->state())->toBe(TaskState::CANCELLED) + ->and($worker->backgroundDrainExpired())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); + +it('keeps worker automation inactive when the shared runtime lacks Runwire coroutine capabilities', function (): void { + $runtime = RuntimeContext::fromCapabilities( + new RuntimeCapabilities(driver: RuntimeDriver::NATIVE), + 'cachelayer-worker-fallback', + concurrent: false, + ); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + + expect(RunwireWorkerIntegration::startClusterConsumer( + $worker, + $this->runwireWorkerCluster, + ))->toBeNull() + ->and(RunwireWorkerIntegration::startNodeMaintenance( + $worker, + NodeCache::maintenance($this->runwireWorkerNodeConfig), + ))->toBeNull(); + + $worker->close(); + fclose($readyParent); +}); + + +it('turns an unhandled CacheLayer consumer failure into a Runwire worker stop', function (): void { + $transport = new class implements InvalidationTransportInterface + { + public function consumeAfter(string $cluster, ?string $cursor, int $limit): InvalidationBatch + { + unset($cluster, $cursor, $limit); + + throw new RuntimeException('intentional invalidation backend failure'); + } + + public function isCursorBefore(string $cursor, string $oldestAvailableId): bool + { + unset($cursor, $oldestAvailableId); + + return false; + } + + public function newestAvailableId(string $cluster): ?string + { + unset($cluster); + + return null; + } + + public function oldestAvailableId(string $cluster): ?string + { + unset($cluster); + + return null; + } + + public function publish(InvalidationEvent $event): string + { + unset($event); + + return '1'; + } + }; + $cluster = ClusterCache::create( + $this->runwireWorkerNodeConfig, + new ClusterCacheConfig('runwire-failure', 'node-a', 'runwire-failure'), + $transport, + ); + + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + [$worker, $readyParent] = cacheLayerBackgroundWorkerContext(); + $loop = new SelectLoop(); + $worker->attachLoop($loop, 0.25); + + $task = RunwireWorkerIntegration::startClusterConsumer( + $worker, + $cluster, + batchSize: 1, + idleSeconds: 0.001, + ); + expect($task)->not->toBeNull(); + + $loop->run(); + + expect($task?->state())->toBe(TaskState::FAILED) + ->and($worker->stopping())->toBeTrue() + ->and($worker->shutdownReason())->toBe(ShutdownReason::FATAL_RUNTIME_ERROR) + ->and($worker->backgroundDrainExpired())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); + +it('does not take worker or loop ownership for unsupported worker topology', function (): void { + $runtime = cacheLayerWorkerRuntimeContext(); + RunwireIntegration::bind($runtime); + + [$readyParent, $readyChild] = stream_socket_pair( + STREAM_PF_UNIX, + STREAM_SOCK_STREAM, + STREAM_IPPROTO_IP, + ); + $pid = getmypid(); + $worker = new WorkerContext( + group: 'cachelayer-http', + slot: 0, + generation: 1, + pid: is_int($pid) ? $pid : 0, + parentPid: 0, + readyStream: $readyChild, + role: WorkerRole::HTTP, + ); + + expect(RunwireWorkerIntegration::startClusterConsumer( + $worker, + $this->runwireWorkerCluster, + ))->toBeNull() + ->and($worker->stopping())->toBeFalse(); + + $worker->close(); + fclose($readyParent); +}); diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php index 0b2ee764..8b775882 100644 --- a/tests/Memoize/MemoizeTest.php +++ b/tests/Memoize/MemoizeTest.php @@ -161,3 +161,84 @@ public function next(): int ->and(array_key_exists('seed-0', $staticCache->getValue($memoizer)))->toBeFalse() ->and(array_key_exists('seed-0', $weakMap[$object]))->toBeFalse(); }); + + +it('same-line closures remain distinct', function () { + [$first, $second] = [static fn(): string => 'first', static fn(): string => 'second']; + + expect(memoize($first))->toBe('first') + ->and(memoize($second))->toBe('second') + ->and(memoize($first))->toBe('first') + ->and(memoize()->stats()['hits'])->toBe(1); +}); + +it('memoizer type-tags object string and resource parameters', function () { + $object = new stdClass(); + $objectToken = 'stdClass#1'; + $resource = fopen('php://memory', 'rb'); + expect($resource)->toBeResource(); + + $identity = static fn(mixed $value): string => get_debug_type($value); + + expect(memoize($identity, [$object]))->toBe('stdClass') + ->and(memoize($identity, [$objectToken]))->toBe('string') + ->and(memoize($identity, [$resource]))->toBe('resource (stream)') + ->and(memoize($identity, ['res:stream#' . (int) $resource]))->toBe('string'); + + fclose($resource); +}); + +it('per-object memoization does not retain collected owners', function () { + $owner = new stdClass(); + $reference = WeakReference::create($owner); + + expect(remember($owner, static fn(): string => 'value'))->toBe('value'); + unset($owner); + gc_collect_cycles(); + + expect($reference->get())->toBeNull(); +}); + + +it('memoizer fingerprints recursive closure captures without traversing their graphs', function () { + $recursive = []; + $recursive['self'] = &$recursive; + $arrayClosure = static fn(): int => count($recursive); + + $selfClosure = null; + $selfClosure = static function () use (&$selfClosure): int { + return 7; + }; + + $left = null; + $right = null; + $left = static function () use (&$right): int { + return 11; + }; + $right = static function () use (&$left): int { + return 13; + }; + + expect(memoize($arrayClosure))->toBe(1) + ->and(memoize($arrayClosure))->toBe(1) + ->and(memoize($selfClosure))->toBe(7) + ->and(memoize($selfClosure))->toBe(7) + ->and(memoize($left))->toBe(11) + ->and(memoize($right))->toBe(13); +}); + +it('flushing an isolated memoizer does not invalidate another memoizer identity map', function () { + $first = Memoizer::isolated(); + $second = Memoizer::isolated(); + $runs = 0; + $callback = static function () use (&$runs): int { + return ++$runs; + }; + + expect($first->get($callback))->toBe(1); + $second->get(static fn(): string => 'other'); + $second->flush(); + + expect($first->get($callback))->toBe(1) + ->and($runs)->toBe(1); +}); diff --git a/tests/Node/NodeCacheMaintenanceTest.php b/tests/Node/NodeCacheMaintenanceTest.php new file mode 100644 index 00000000..d9be62b2 --- /dev/null +++ b/tests/Node/NodeCacheMaintenanceTest.php @@ -0,0 +1,56 @@ +maintenanceDirectory = sys_get_temp_dir() . '/cachelayer-maintenance-' . uniqid(); + $this->maintenanceConfig = new NodeCacheConfig( + $this->maintenanceDirectory . '/cache.sqlite', + 'maintenance', + apcuEnabled: false, + ); +}); + +afterEach(function (): void { + if (!is_dir($this->maintenanceDirectory)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($this->maintenanceDirectory, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($this->maintenanceDirectory); +}); + +it('runs one bounded prune checkpoint and optimize maintenance unit', function (): void { + $connection = NodeSqliteConnection::create($this->maintenanceConfig); + new NodeSqliteCacheAdapter($connection, $this->maintenanceConfig->namespace); + $statement = $connection->prepare( + 'INSERT INTO cachelayer_node_entries (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, ?)', + ); + $statement->execute([ + $this->maintenanceConfig->namespace, + 'expired', + 'unused', + time() - 1, + ]); + $maintenance = new NodeCacheMaintenance( + $connection, + new NodeCachePruner($connection, $this->maintenanceConfig->namespace), + ); + + expect($maintenance->cycle(pruneLimit: 1, checkpoint: true, optimize: true))->toBe(1) + ->and((int) $connection->query( + "SELECT COUNT(*) FROM cachelayer_node_entries WHERE cache_key = 'expired'", + )->fetchColumn())->toBe(0); +}); diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index 061029cd..7a2c1958 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -2,8 +2,11 @@ declare(strict_types=1); +use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Lock\LockHandle; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; @@ -14,6 +17,7 @@ use Infocyph\CacheLayer\Node\Maintenance\NodeCachePruner; use Infocyph\CacheLayer\Node\NodeCache; use Infocyph\CacheLayer\Node\NodeCacheConfig; +use Psr\Cache\CacheItemInterface; beforeEach(function () { $this->nodeCacheDirectory = sys_get_temp_dir() . '/cachelayer-node-' . uniqid(); @@ -174,6 +178,113 @@ public function release(?LockHandle $handle): void ->and($l2->getItem('coherent')->get())->toBe('old'); }); +test('node cache applies the complete cache policy from its configuration', function () { + $cache = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/policy.sqlite', + namespace: 'policy', + apcuEnabled: false, + options: new CacheOptions( + integrityKey: 'node-policy-key', + maxPayloadBytes: 1_048_576, + failOpen: false, + ), + )); + + expect($cache->hasPayloadIntegrity())->toBeTrue() + ->and($cache->isFailOpen())->toBeFalse() + ->and($cache->set('scalar', 'value'))->toBeTrue() + ->and($cache->get('scalar'))->toBe('value'); +}); + +test('node disables a failed L1 before it can serve stale data', function () { + $connection = NodeSqliteConnection::create($this->nodeConfig); + $l1 = new class extends AbstractCacheAdapter { + public bool $rejectWrites = false; + + /** @var array */ + private array $values = []; + + public function clear(): bool + { + $this->values = []; + + return true; + } + + public function deleteItem(string $key): bool + { + unset($this->values[$key]); + + return true; + } + + public function deleteItems(array $keys): bool + { + foreach ($keys as $key) { + unset($this->values[$key]); + } + + return true; + } + + public function getItem(string $key): CacheItem + { + return array_key_exists($key, $this->values) + ? new CacheItem($this, $key, $this->values[$key], true) + : new CacheItem($this, $key); + } + + public function hasItem(string $key): bool + { + return array_key_exists($key, $this->values); + } + + public function multiFetch(array $keys): array + { + $items = []; + foreach ($keys as $key) { + $items[$key] = $this->getItem($key); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if ($this->rejectWrites || !$this->supportsItem($item)) { + return false; + } + + $this->values[$item->getKey()] = $item->get(); + + return true; + } + + public function saveItems(array $items): bool + { + if ($this->rejectWrites || !$this->supportsItems($items)) { + return false; + } + + foreach ($items as $item) { + $this->values[$item->getKey()] = $item->get(); + } + + return true; + } + }; + $l2 = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); + $cache = new Cache(new NodeCacheAdapter($l1, $l2, true)); + + expect($cache->set('coherent', 'old', 300))->toBeTrue(); + $l1->rejectWrites = true; + + expect($cache->set('coherent', 'new', 300))->toBeFalse() + ->and($cache->get('coherent'))->toBe('new') + ->and($cache->setMultiple(['one' => 1, 'two' => 2], 300))->toBeTrue() + ->and($cache->getMultiple(['one', 'two']))->toBe(['one' => 1, 'two' => 2]); +}); + test('expired rows remain outside the read path until bounded pruning', function () { $connection = NodeSqliteConnection::create($this->nodeConfig); $adapter = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); @@ -196,3 +307,85 @@ public function release(?LockHandle $handle): void ->and(fn() => new NodeCacheConfig('/tmp/cache.sqlite', 'app', busyTimeoutMs: -1)) ->toThrow(NodeCacheConfigurationException::class); }); + +test('node APCu identity includes the SQLite store', function () { + expect(extension_loaded('apcu'))->toBeTrue() + ->and(apcu_enabled())->toBeTrue(); + apcu_clear_cache(); + + $first = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/first.sqlite', + namespace: 'shared.namespace', + apcuEnabled: true, + )); + $second = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/second.sqlite', + namespace: 'shared.namespace', + apcuEnabled: true, + )); + + expect($first->set('shared', 'first'))->toBeTrue() + ->and($second->get('shared'))->toBeNull() + ->and($second->set('shared', 'second'))->toBeTrue() + ->and($first->get('shared'))->toBe('first') + ->and($second->get('shared'))->toBe('second'); + + apcu_clear_cache(); +}); + +test('node lock identity includes the SQLite store', function () { + $keys = []; + $provider = new class ($keys) implements LockProviderInterface { + public function __construct(private array &$keys) {} + + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle + { + unset($waitSeconds); + $this->keys[] = $key; + + return new LockHandle($key, bin2hex(random_bytes(16)), leaseSeconds: $leaseSeconds); + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + return $handle instanceof LockHandle && $leaseSeconds > 0; + } + + public function release(?LockHandle $handle): void {} + }; + + foreach (['first.sqlite', 'second.sqlite'] as $file) { + $cache = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/' . $file, + namespace: 'shared.namespace', + apcuEnabled: false, + lockProvider: $provider, + )); + $cache->remember('same-key', static fn(): string => 'value', 30); + } + + expect($keys)->toHaveCount(2) + ->and($keys[0])->not->toBe($keys[1]); +}); + +test('node authority reflects whether an L1 cache can serve stale state', function () { + expect(extension_loaded('apcu'))->toBeTrue() + ->and(apcu_enabled())->toBeTrue(); + apcu_clear_cache(); + + $withL1 = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/with-l1.sqlite', + namespace: 'authority', + apcuEnabled: true, + )); + $l2Only = NodeCache::create(new NodeCacheConfig( + sqliteFile: $this->nodeCacheDirectory . '/l2-only.sqlite', + namespace: 'authority', + apcuEnabled: false, + )); + + expect($withL1->isAuthoritative())->toBeFalse() + ->and($l2Only->isAuthoritative())->toBeTrue(); + + apcu_clear_cache(); +}); diff --git a/tests/Support/AtomicCounterProcessProbe.php b/tests/Support/AtomicCounterProcessProbe.php new file mode 100644 index 00000000..11d9b87e --- /dev/null +++ b/tests/Support/AtomicCounterProcessProbe.php @@ -0,0 +1,55 @@ +connect($host, $port); + if ($password !== '') { + $client->auth($password); + } + $counter = $backend === 'valkey' + ? AtomicCounters::valkey($namespace, client: $client) + : AtomicCounters::redis($namespace, client: $client); + $initialized = $counter->increment($key)->initialized; + pcntl_exec('/bin/sh', ['-c', $initialized ? 'true' : 'false']); + + throw new RuntimeException('Unable to terminate atomic counter child process.'); + } + if ($pid > 0) { + $children[] = $pid; + } + } + + $wins = 0; + foreach ($children as $pid) { + pcntl_waitpid($pid, $status); + $wins += pcntl_wexitstatus($status) === 0 ? 1 : 0; + } + + return $wins; + } +} diff --git a/tests/Support/RedisConnectionTest.php b/tests/Support/RedisConnectionTest.php index 699b1731..0dd68b91 100644 --- a/tests/Support/RedisConnectionTest.php +++ b/tests/Support/RedisConnectionTest.php @@ -14,3 +14,14 @@ 'invalid database' => 'redis://127.0.0.1/database', 'invalid port' => 'redis://127.0.0.1:0', ]); + + +test('redis authentication helper marks every credential-bearing parameter sensitive', function () { + $authenticate = new ReflectionMethod(RedisConnection::class, 'authenticate'); + $credentials = $authenticate->getParameters()[1]; + $parseCredentials = new ReflectionMethod(RedisConnection::class, 'parseCredentials'); + $parts = $parseCredentials->getParameters()[0]; + + expect($credentials->getAttributes(SensitiveParameter::class))->toHaveCount(1) + ->and($parts->getAttributes(SensitiveParameter::class))->toHaveCount(1); +}); diff --git a/tools/release/consumer/composer.json b/tools/release/consumer/composer.json new file mode 100644 index 00000000..ac31dc44 --- /dev/null +++ b/tools/release/consumer/composer.json @@ -0,0 +1,25 @@ +{ + "name": "infocyph/cachelayer-release-consumer", + "type": "project", + "require": { + "php": ">=8.4", + "infocyph/cachelayer": "4.0.x-dev" + }, + "repositories": [ + { + "type": "path", + "url": "../../..", + "options": { + "symlink": false, + "versions": { + "infocyph/cachelayer": "4.0.x-dev" + } + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} diff --git a/tools/release/consumer/smoke.php b/tools/release/consumer/smoke.php new file mode 100644 index 00000000..b7530840 --- /dev/null +++ b/tools/release/consumer/smoke.php @@ -0,0 +1,17 @@ +set('ready', ['version' => 4], 30)) { + throw new RuntimeException('Consumer cache write failed.'); +} +if ($cache->get('ready') !== ['version' => 4]) { + throw new RuntimeException('Consumer cache read failed.'); +} + +fwrite(STDOUT, "CacheLayer production consumer smoke passed.\n"); diff --git a/tools/release/core-smoke.php b/tools/release/core-smoke.php new file mode 100644 index 00000000..089b2296 --- /dev/null +++ b/tools/release/core-smoke.php @@ -0,0 +1,52 @@ +set('plain', ['ok' => true], 30), 'Memory cache write failed.'); +$assert($memory->get('plain') === ['ok' => true], 'Memory cache read failed.'); + +$pending = $memory->getItem('deferred')->set('queued'); +$assert($memory->saveDeferred($pending), 'Deferred queue failed.'); +$assert($memory->get('deferred') === 'queued', 'Deferred value was not visible before commit.'); +$assert($memory->delete('deferred'), 'Deferred delete failed.'); +$assert($memory->commit(), 'Deferred commit failed.'); +$assert($memory->get('deferred') === null, 'Deleted deferred value was resurrected.'); + +$assert($memory->setMultiple(['0' => 'zero', '01' => 'leading', '-1' => 'negative']), 'Numeric-string batch write failed.'); +$assert($memory->get('0') === 'zero', 'Numeric key 0 did not round-trip.'); +$assert($memory->get('01') === 'leading', 'Numeric key 01 did not round-trip.'); +$assert($memory->get('-1') === 'negative', 'Numeric key -1 did not round-trip.'); + +$options = new CacheOptions( + integrityKey: str_repeat('k', 32), + allowClosures: false, + allowObjects: false, +); +$signed = Cache::memory('release-signed', $options); +$assert($signed->set('signed', 'value'), 'Signed cache write failed.'); +$assert($signed->get('signed') === 'value', 'Signed cache read failed.'); + +$base = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'cachelayer-release-' . bin2hex(random_bytes(6)); +$file = Cache::file('release-file', $base . DIRECTORY_SEPARATOR . 'file'); +$phpFiles = Cache::phpFiles('release-php-files', $base . DIRECTORY_SEPARATOR . 'php-files'); + +$assert($file->set('disk', 'file-value', 30), 'File cache write failed.'); +$assert($file->get('disk') === 'file-value', 'File cache read failed.'); +$assert($phpFiles->set('disk', 'php-file-value', 30), 'PHP-files cache write failed.'); +$assert($phpFiles->get('disk') === 'php-file-value', 'PHP-files cache read failed.'); +$assert($file->clear(), 'File cache clear failed.'); +$assert($phpFiles->clear(), 'PHP-files cache clear failed.'); + +fwrite(STDOUT, sprintf("CacheLayer core release smoke passed on PHP %s.\n", PHP_VERSION)); diff --git a/tools/release/psr-consumer/composer.json b/tools/release/psr-consumer/composer.json new file mode 100644 index 00000000..97810803 --- /dev/null +++ b/tools/release/psr-consumer/composer.json @@ -0,0 +1,35 @@ +{ + "name": "infocyph/cachelayer-release-psr-consumer", + "type": "project", + "require": { + "php": "^8.4 || ^8.5", + "cache/integration-tests": "^1.0", + "infocyph/cachelayer": "4.0.x-dev", + "phpunit/phpunit": "^11.5 || ^12.0" + }, + "repositories": [ + { + "type": "path", + "url": "../../..", + "options": { + "symlink": false, + "versions": { + "infocyph/cachelayer": "4.0.x-dev" + } + } + } + ], + "autoload-dev": { + "psr-4": { + "CacheLayerRelease\\": "tests/" + } + }, + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "allow-plugins": { + "pestphp/pest-plugin": false + }, + "sort-packages": true + } +} diff --git a/tools/release/redis-cluster-smoke.php b/tools/release/redis-cluster-smoke.php new file mode 100644 index 00000000..cfe3207b --- /dev/null +++ b/tools/release/redis-cluster-smoke.php @@ -0,0 +1,53 @@ +setMultiple($values, 60), 'Redis Cluster bulk write failed.'); +$read = $cache->getMultiple(array_keys($values)); +$assert($read === $values, 'Redis Cluster bulk read did not preserve values across slots.'); + +$atomic = $cache->atomic(); +if (!$atomic instanceof AtomicCacheInterface) { + throw new RuntimeException('Redis Cluster atomic capability is unavailable.'); +} +$assert($atomic->setIfAbsent('claim', 'first', 60), 'Redis Cluster first atomic claim failed.'); +$assert(!$atomic->setIfAbsent('claim', 'second', 60), 'Redis Cluster duplicate atomic claim succeeded.'); +$assert($atomic->compareAndSet('claim', 'first', 'replaced', 60), 'Redis Cluster compare-and-set failed.'); +$assert($atomic->getAndDelete('claim', 'missing') === 'replaced', 'Redis Cluster atomic consume failed.'); +$assert($atomic->getAndDelete('claim', 'missing') === 'missing', 'Redis Cluster atomic consume was not one-time.'); + +$assert($cache->setTagged('tagged', 'v1', ['group'], 60), 'Redis Cluster tagged write failed.'); +$assert($cache->invalidateTag('group'), 'Redis Cluster tag invalidation failed.'); +$assert($cache->get('tagged') === null, 'Redis Cluster stale tagged value survived invalidation.'); + +$assert($cache->clear(), 'Redis Cluster namespace clear failed.'); +$assert($cache->get('key-1') === null, 'Redis Cluster clear did not rotate namespace generation.'); + +fwrite(STDOUT, "CacheLayer real Redis Cluster smoke passed.\n"); diff --git a/tools/release/runwire-consumer/certify.php b/tools/release/runwire-consumer/certify.php new file mode 100644 index 00000000..a79d89fc --- /dev/null +++ b/tools/release/runwire-consumer/certify.php @@ -0,0 +1,305 @@ + $samples */ +function percentile(array $samples, float $percentile): float +{ + sort($samples, SORT_NUMERIC); + $index = (int) floor((count($samples) - 1) * $percentile); + + return (float) ($samples[$index] ?? 0.0); +} + +/** @param array $usage */ +function usageValue(array $usage, string $key): int +{ + $value = $usage[$key] ?? 0; + + return is_int($value) ? $value : 0; +} + +/** + * @param array $start + * @param array $end + */ +function cpuMicros(array $start, array $end): int +{ + return (usageValue($end, 'ru_utime.tv_sec') - usageValue($start, 'ru_utime.tv_sec')) * 1_000_000 + + usageValue($end, 'ru_utime.tv_usec') - usageValue($start, 'ru_utime.tv_usec') + + (usageValue($end, 'ru_stime.tv_sec') - usageValue($start, 'ru_stime.tv_sec')) * 1_000_000 + + usageValue($end, 'ru_stime.tv_usec') - usageValue($start, 'ru_stime.tv_usec'); +} + +function runCacheOperation(Cache $cache, int $index): void +{ + $tenant = $index % 32; + $key = 'tenant-' . $tenant . '-item-' . ($index % 256); + + if (($index % 8) === 0) { + if (!$cache->set($key, $index, 60)) { + throw new RuntimeException('Cache write failed.'); + } + + return; + } + + $cache->get($key); +} + +function cleanupDirectory(string $base): void +{ + if (!is_dir($base)) { + return; + } + + $files = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($base, FilesystemIterator::SKIP_DOTS), + RecursiveIteratorIterator::CHILD_FIRST, + ); + foreach ($files as $file) { + if (!$file instanceof SplFileInfo) { + continue; + } + + $file->isDir() ? rmdir($file->getPathname()) : unlink($file->getPathname()); + } + rmdir($base); +} + +/** + * @param array> $metrics + */ +function metricTotal(array $metrics, string $metric): int +{ + $total = 0; + foreach ($metrics as $counters) { + $total += $counters[$metric] ?? 0; + } + + return $total; +} + +function executeCacheRequest( + RuntimeContext $runtime, + Cache $cache, + int $index, + bool $integrated, +): bool { + $request = RequestContext::create($runtime); + + try { + $operation = static function () use ($cache, $index): void { + runCacheOperation($cache, $index); + }; + if ($integrated) { + RunwireIntegration::share($request, null, $operation); + } else { + $operation(); + } + + return true; + } catch (Throwable) { + return false; + } finally { + $request->complete(); + } +} + +/** @return array */ +function workload(RuntimeContext $runtime, bool $integrated): array +{ + $cache = Cache::memory($integrated ? 'runwire-on' : 'runwire-off'); + if ($integrated) { + RunwireIntegration::bind($runtime); + } else { + RunwireIntegration::release(); + } + + for ($index = 0; $index < WARMUP; ++$index) { + if (!executeCacheRequest($runtime, $cache, $index, $integrated)) { + throw new RuntimeException('Runwire certification warmup failed.'); + } + } + + $startingMetrics = $cache->exportMetrics(); + $latencies = []; + $errors = 0; + $startMemory = memory_get_usage(true); + $startCpu = getrusage(); + if ($startCpu === false) { + $startCpu = []; + } + $start = hrtime(true); + + for ($iteration = 0; $iteration < ITERATIONS; ++$iteration) { + $started = hrtime(true); + if (!executeCacheRequest($runtime, $cache, WARMUP + $iteration, $integrated)) { + ++$errors; + } + $latencies[] = (hrtime(true) - $started) / 1_000_000; + } + + $elapsed = (hrtime(true) - $start) / 1_000_000_000; + $endCpu = getrusage(); + if ($endCpu === false) { + $endCpu = []; + } + $metrics = $cache->exportMetrics(); + $cpuMicros = cpuMicros($startCpu, $endCpu); + + if ($integrated) { + RunwireIntegration::release($runtime); + } + gc_collect_cycles(); + + return [ + 'rpm' => ITERATIONS / max($elapsed, 0.000001) * 60, + 'p50_ms' => percentile($latencies, 0.50), + 'p95_ms' => percentile($latencies, 0.95), + 'p99_ms' => percentile($latencies, 0.99), + 'errors' => $errors, + 'memory_delta_bytes' => max(0, memory_get_usage(true) - $startMemory), + 'peak_memory_bytes' => memory_get_peak_usage(true), + 'cpu_ms' => $cpuMicros / 1_000, + 'backend_gets' => max(0, metricTotal($metrics, 'get') - metricTotal($startingMetrics, 'get')), + 'backend_sets' => max(0, metricTotal($metrics, 'set') - metricTotal($startingMetrics, 'set')), + ]; +} + +/** @return array */ +function invalidationAndMaintenance(): array +{ + $base = sys_get_temp_dir() . '/cachelayer-runwire-cert-' . bin2hex(random_bytes(6)); + mkdir($base, 0700, true); + + try { + $node = new NodeCacheConfig($base . '/node.sqlite', 'certification', apcuEnabled: false); + $transport = new PdoInvalidationTransport( + new PDO('sqlite:' . $base . '/events.sqlite'), + allowSqliteForTesting: true, + ); + $cluster = ClusterCache::create( + $node, + new ClusterCacheConfig('certification', 'consumer', 'sqlite-cert', consumerBatchSize: 100), + $transport, + ); + + for ($index = 0; $index < 100; ++$index) { + $transport->publish( + InvalidationEvent::key( + 'certification', + 'certification', + 'key-' . $index, + 'publisher', + ), + ); + } + + $started = hrtime(true); + $processed = $cluster->drain(limit: 25, maxBatches: 8); + $lagMs = (hrtime(true) - $started) / 1_000_000; + + $connection = new PDO('sqlite:' . $base . '/node.sqlite'); + $statement = $connection->prepare( + 'INSERT INTO cachelayer_node_entries (namespace, cache_key, payload, expires_at) VALUES (?, ?, ?, ?)', + ); + for ($index = 0; $index < 100; ++$index) { + $statement->execute(['certification', 'expired-' . $index, 'unused', time() - 1]); + } + + $maintenance = NodeCache::maintenance($node); + $samples = []; + for ($cycle = 0; $cycle < 10; ++$cycle) { + $cycleStarted = hrtime(true); + $maintenance->cycle(pruneLimit: 10, checkpoint: true, optimize: $cycle === 9); + $samples[] = (hrtime(true) - $cycleStarted) / 1_000_000; + } + + return [ + 'invalidation_events' => $processed, + 'invalidation_lag_ms' => $lagMs, + 'maintenance_p95_ms' => percentile($samples, 0.95), + 'pending_events' => $cluster->status()->pendingEventCount ?? -1, + ]; + } finally { + cleanupDirectory($base); + } +} + +$capabilities = new RuntimeCapabilities( + driver: RuntimeDriver::NATIVE, + persistentProcess: true, + persistentApplication: true, + runwireLoopAvailable: true, + supportsRunwireCoroutines: true, +); +$runtime = RuntimeContext::fromCapabilities( + $capabilities, + 'cachelayer-certification', + workerSlot: 0, + generation: 1, + concurrent: true, +); + +$baseline = workload($runtime, false); +$integrated = workload($runtime, true); +$operational = invalidationAndMaintenance(); + +$ratio = $integrated['rpm'] / max((float) $baseline['rpm'], 0.000001); +$p99Budget = ((float) $baseline['p99_ms'] * MAX_P99_MULTIPLIER) + MAX_EXTRA_P99_MS; + +$report = [ + 'php' => PHP_VERSION, + 'runwire' => Composer\InstalledVersions::getPrettyVersion('infocyph/runwire'), + 'iterations' => ITERATIONS, + 'warmup_iterations' => WARMUP, + 'baseline' => $baseline, + 'integrated' => $integrated, + 'rpm_ratio' => $ratio, + 'budgets' => [ + 'minimum_rpm_ratio' => MIN_RPM_RATIO, + 'maximum_integrated_p99_ms' => $p99Budget, + 'maximum_memory_delta_bytes' => MAX_MEMORY_DELTA_BYTES, + 'errors' => 0, + ], + 'operational' => $operational, +]; + +fwrite(STDOUT, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL); + +if ( + $baseline['errors'] !== 0 + || $integrated['errors'] !== 0 + || $ratio < MIN_RPM_RATIO + || $integrated['p99_ms'] > $p99Budget + || $integrated['memory_delta_bytes'] > MAX_MEMORY_DELTA_BYTES + || (int) $baseline['backend_gets'] + (int) $baseline['backend_sets'] !== ITERATIONS + || (int) $integrated['backend_gets'] + (int) $integrated['backend_sets'] !== ITERATIONS + || $operational['invalidation_events'] !== 100 + || $operational['pending_events'] !== 0 +) { + throw new RuntimeException('Runwire matched certification workload exceeded a release budget.'); +} diff --git a/tools/release/runwire-consumer/composer.json b/tools/release/runwire-consumer/composer.json new file mode 100644 index 00000000..2b24556d --- /dev/null +++ b/tools/release/runwire-consumer/composer.json @@ -0,0 +1,28 @@ +{ + "name": "infocyph/cachelayer-runwire-release-consumer", + "type": "project", + "require": { + "php": ">=8.4", + "ext-pdo": "*", + "ext-pdo_sqlite": "*", + "infocyph/cachelayer": "4.0.x-dev", + "infocyph/runwire": "^2.1" + }, + "repositories": [ + { + "type": "path", + "url": "../../..", + "options": { + "symlink": false, + "versions": { + "infocyph/cachelayer": "4.0.x-dev" + } + } + } + ], + "minimum-stability": "dev", + "prefer-stable": true, + "config": { + "sort-packages": true + } +} diff --git a/tools/release/runwire-consumer/smoke.php b/tools/release/runwire-consumer/smoke.php new file mode 100644 index 00000000..c7f6b8c7 --- /dev/null +++ b/tools/release/runwire-consumer/smoke.php @@ -0,0 +1,34 @@ +runRequest( + $request, + static function (CoroutineScope $scope) use ($request): void { + RunwireIntegration::share( + $request, + $scope, + static function (): void { + RunwireIntegration::checkpoint(); + }, + ); + }, + ); + } catch (CancelledException) { + ++$deadlines; + } + + continue; + } + + if ($cancellationCase) { + try { + new CoroutineRuntime()->runRequest( + $request, + static function (CoroutineScope $scope) use ($request): void { + RunwireIntegration::share( + $request, + $scope, + static function () use ($request): void { + $request->cancel(CancellationReason::HOST_CANCELLED); + RunwireIntegration::checkpoint(); + }, + ); + }, + ); + } catch (CancelledException) { + ++$cancellations; + } + + continue; + } + + if (($index % 191) === 0) { + ++$errors; + + throw new RuntimeException('intentional soak failure'); + } + + RunwireIntegration::share( + $request, + null, + static function () use ($cache, $index, &$validated): void { + $tenant = $index % 64; + $key = 'tenant-' . $tenant . '-request-' . $index; + + $memoized = memoize(static fn(int $value): int => $value + 1, [$index]); + if ($memoized !== $index + 1 || memoize(static fn(int $value): int => $value + 1, [$index]) !== $index + 1) { + throw new RuntimeException('request memoizer leaked or failed'); + } + + $item = $cache->getItem($key)->set($index); + if (!$cache->saveDeferred($item) || !$cache->commit() || $cache->get($key) !== $index) { + throw new RuntimeException('deferred cache write failed during soak'); + } + + ++$validated; + }, + ); + } catch (Throwable $exception) { + if ($exception->getMessage() !== 'intentional soak failure') { + throw $exception; + } + } finally { + if (!$request->completed()) { + $request->complete(); + } + } +} + +$coroutines = new CoroutineRuntime(); +$coroutines->run( + static function (CoroutineScope $scope) use ( + $runtime, + $cache, + &$validated, + ): void { + $tasks = []; + for ($index = 0; $index < CONCURRENT_REQUESTS; ++$index) { + $tasks[] = $scope->spawn( + static function () use ( + $runtime, + $scope, + $cache, + $index, + &$validated, + ): void { + $request = RequestContext::create($runtime); + + try { + RunwireIntegration::share( + $request, + $scope, + static function () use ( + $scope, + $cache, + $index, + &$validated, + ): void { + $tenant = $index % 16; + $key = 'concurrent-' . $tenant . '-' . $index; + $memo = memoize(static fn(int $value): int => $value * 2, [$index]); + $scope->yieldNow(); + if (memoize(static fn(int $value): int => $value * 2, [$index]) !== $memo) { + throw new RuntimeException('concurrent request memoizer leaked'); + } + + $item = $cache->getItem($key)->set($memo); + if (!$cache->saveDeferred($item) || !$cache->commit() || $cache->get($key) !== $memo) { + throw new RuntimeException('concurrent deferred cache write failed'); + } + + ++$validated; + }, + ); + } finally { + if (!$request->completed()) { + $request->complete(); + } + } + }, + ); + } + + foreach ($tasks as $task) { + $task->await(); + } + }, +); + +RunwireIntegration::release($runtime); +gc_collect_cycles(); +$memoryGrowth = max(0, memory_get_usage(true) - $startMemory); + +$report = [ + 'php' => PHP_VERSION, + 'runwire' => Composer\InstalledVersions::getPrettyVersion('infocyph/runwire'), + 'sequential_requests' => SEQUENTIAL_REQUESTS, + 'concurrent_requests' => CONCURRENT_REQUESTS, + 'intentional_failures' => $errors, + 'cancellations' => $cancellations, + 'deadlines' => $deadlines, + 'validated_requests' => $validated, + 'memory_growth_bytes' => $memoryGrowth, + 'peak_memory_bytes' => memory_get_peak_usage(true), +]; + +fwrite(STDOUT, json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR) . PHP_EOL); + +if ( + $validated < 2_000 + || $cancellations < 1 + || $deadlines < 1 + || $memoryGrowth > MAX_MEMORY_GROWTH_BYTES + || RunwireIntegration::runtime() !== null +) { + throw new RuntimeException('Runwire persistent-worker soak did not satisfy release invariants.'); +} diff --git a/tools/release/scylla-cql-smoke.php b/tools/release/scylla-cql-smoke.php new file mode 100644 index 00000000..df5dc522 --- /dev/null +++ b/tools/release/scylla-cql-smoke.php @@ -0,0 +1,48 @@ +set('plain', 'value', 60), 'Scylla CQL write failed.'); +$assert($cache->get('plain') === 'value', 'Scylla CQL read failed.'); + +$batch = []; +for ($index = 0; $index < 48; ++$index) { + $batch['batch-' . $index] = $index; +} +$assert($cache->setMultiple($batch, 60), 'Scylla CQL batch write failed.'); +$read = $cache->getMultiple(array_keys($batch)); +$assert($read === $batch, 'Scylla CQL batch read failed.'); + +$assert($cache->setTagged('tagged', 'v1', ['group'], 60), 'Scylla CQL tagged write failed.'); +$assert($cache->invalidateTag('group'), 'Scylla CQL tag rotation failed.'); +$assert($cache->get('tagged') === null, 'Scylla CQL stale tagged record survived invalidation.'); + +$assert($cache->delete('plain'), 'Scylla CQL delete failed.'); +$assert($cache->get('plain') === null, 'Scylla CQL delete did not remove the value.'); +$assert($cache->clear(), 'Scylla CQL namespace clear failed.'); +$assert($cache->get('batch-1') === null, 'Scylla CQL clear did not remove namespace values.'); + +fwrite(STDOUT, "CacheLayer real Scylla CQL smoke passed.\n");