From 9a51aa69a2e9f49627fb2ff92329bda174152027 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 11 Aug 2026 10:46:51 +0600 Subject: [PATCH 1/2] updated & fixed ops/tech issues --- README.md | 18 +- benchmarks/CacheBulkBench.php | 7 +- composer.json | 2 +- docs/adapters/file.rst | 7 +- docs/adapters/memcached.rst | 11 +- docs/adapters/mongodb.rst | 8 +- docs/adapters/pdo.rst | 21 +- docs/adapters/php-files.rst | 6 +- docs/adapters/redis-cluster.rst | 2 +- docs/adapters/redis.rst | 10 +- docs/adapters/scylladb.rst | 4 +- docs/adapters/shared-memory.rst | 2 + docs/adapters/valkey.rst | 10 +- docs/cache.rst | 21 +- docs/cluster/_content.inc | 50 ++- docs/cookbook.rst | 20 +- docs/functions.rst | 28 +- docs/index.rst | 12 +- docs/memoize/functions.rst | 10 +- docs/metrics-and-locking.rst | 3 +- docs/node/_content.inc | 25 +- docs/security.rst | 7 +- docs/serializer.rst | 5 + src/Cache/Adapter/AbstractCacheAdapter.php | 94 ++--- src/Cache/Adapter/AdapterValueNormalizer.php | 12 + src/Cache/Adapter/ApcuCacheAdapter.php | 89 +++-- src/Cache/Adapter/ArrayCacheAdapter.php | 83 +++-- src/Cache/Adapter/CachePayloadCodec.php | 30 +- src/Cache/Adapter/FileCacheAdapter.php | 80 ++--- .../Adapter/InternalCachePoolInterface.php | 10 +- src/Cache/Adapter/MemcachedCacheAdapter.php | 111 ++++-- src/Cache/Adapter/MongoDbCacheAdapter.php | 177 +++++---- src/Cache/Adapter/NullCacheAdapter.php | 32 +- src/Cache/Adapter/PdoCacheAdapter.php | 337 +++++++++--------- src/Cache/Adapter/PdoCacheSchema.php | 39 +- src/Cache/Adapter/PhpFilesCacheAdapter.php | 96 ++--- src/Cache/Adapter/RedisCacheAdapter.php | 110 +++--- .../Adapter/RedisClusterCacheAdapter.php | 146 ++++++-- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 232 +++++++----- .../Adapter/SecuresFilesystemDirectories.php | 28 ++ .../Adapter/SharedMemoryCacheAdapter.php | 160 +++++---- .../Adapter/TagGenerationCacheInterface.php | 18 + ...acheAdapter.php => TieredCacheAdapter.php} | 47 +-- src/Cache/Adapter/WeakMapCacheAdapter.php | 77 +--- src/Cache/Cache.php | 239 +++++++++---- src/Cache/CacheInput.php | 13 + src/Cache/CacheInterface.php | 5 +- src/Cache/CacheRecord.php | 4 +- src/Cache/CacheTagSnapshots.php | 19 +- src/Cache/Item/CacheItem.php | 13 +- src/Cache/Lock/FileLockProvider.php | 27 +- src/Cache/Lock/MemcachedLockProvider.php | 18 +- src/Cache/Metrics/CacheMetricsSnapshot.php | 27 ++ src/Cluster/ClusterCacheConfig.php | 9 +- src/Cluster/ClusterInput.php | 87 +++++ src/Cluster/ClusterRuntime.php | 9 + src/Cluster/Consumer/InvalidationHandler.php | 18 +- src/Cluster/Event/InvalidationEvent.php | 20 +- src/Cluster/Outbox/ClusterOutbox.php | 2 +- .../Recovery/ClusterRecoveryManager.php | 5 +- .../Transport/Pdo/PdoInvalidationSchema.php | 76 ++++ .../Pdo/PdoInvalidationTransport.php | 66 +--- src/Counter/RedisAtomicCounterStore.php | 14 +- src/Exceptions/CacheBackendException.php | 9 + .../CacheInvalidArgumentException.php | 3 +- src/Memoize/CallableFingerprint.php | 113 ++++++ src/Memoize/Memoizer.php | 68 +--- src/Memoize/OnceMemoizer.php | 73 ++-- src/Node/Adapter/NodeCacheAdapter.php | 118 ++++-- src/Node/Adapter/NodeSqliteCacheAdapter.php | 153 ++++---- src/Node/NodeCacheConfig.php | 12 +- src/functions.php | 22 +- tests/Cache/ArchitectureHardeningTest.php | 67 +++- tests/Cache/ArrayCachePoolTest.php | 5 - tests/Cache/CacheFeaturesTest.php | 128 ++++++- tests/Cache/CachePayloadCodecSecurityTest.php | 32 +- tests/Cache/LockProviderTest.php | 27 ++ tests/Cache/MemcachedCachePoolTest.php | 17 +- tests/Cache/RedisClusterCachePoolTest.php | 79 +++- tests/Cache/SharedMemoryCachePoolTest.php | 14 + ...hePoolTest.php => TieredCachePoolTest.php} | 15 +- tests/Cluster/ClusterCacheTest.php | 151 ++++++++ tests/Memoize/MemoizeTest.php | 54 ++- tests/Node/NodeCacheTest.php | 32 ++ 84 files changed, 2693 insertions(+), 1467 deletions(-) create mode 100644 src/Cache/Adapter/TagGenerationCacheInterface.php rename src/Cache/Adapter/{ChainCacheAdapter.php => TieredCacheAdapter.php} (89%) create mode 100644 src/Cache/Metrics/CacheMetricsSnapshot.php create mode 100644 src/Cluster/ClusterInput.php create mode 100644 src/Cluster/Transport/Pdo/PdoInvalidationSchema.php create mode 100644 src/Exceptions/CacheBackendException.php create mode 100644 src/Memoize/CallableFingerprint.php rename tests/Cache/{ChainCachePoolTest.php => TieredCachePoolTest.php} (75%) diff --git a/README.md b/README.md index e7bbc3d..5446efa 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ CacheLayer is a PHP 8.3+ caching toolkit built around four deliberately separate CacheLayer ├── Cache │ ├── PSR-6 and PSR-16 -│ ├── versioned tags +│ ├── generation-tagged records │ ├── bounded stampede protection │ └── tiering ├── Node Cache @@ -71,9 +71,9 @@ $cache->setTagged('article.7', $article, ['articles', 'author.12'], 600); $cache->invalidateTags(['articles', 'author.12']); ``` -Each record embeds its complete tag-version snapshot. Tag versions begin at zero, invalidation increments them atomically, and reads fetch all required versions in a batch. A mismatch makes the complete record stale; there are no per-entry reverse tag indexes or partially tagged writes. +Each tagged record embeds its complete snapshot of opaque 128-bit tag generations. Invalidation replaces each generation, and reads fetch all required generations in a batch. A missing or mismatched generation makes the complete record stale, so lost metadata cannot resurrect an older record. There are no per-entry reverse tag indexes or partially tagged writes. -Zero and negative PSR-16 TTLs delete the key. Missing tag metadata means version zero. +Zero and negative PSR-16 TTLs delete the key. Namespaces are validated—not normalized—and must be 1–64 characters matching `[A-Za-z0-9_.-]+`. ## Native bulk paths @@ -94,7 +94,7 @@ Bulk methods validate once and call the adapter’s native bulk contract. Deferr | File / PHP files | optimized sequential filesystem access | | Redis Cluster | fixed hash buckets and same-slot grouped operations | -Redis Cluster uses 128 stable bucket hash tags. Memcached and Redis Cluster clear a namespace by advancing epochs, so they do not scan, flush other namespaces, or maintain a permanent key membership index. +Redis Cluster uses 128 stable bucket hash tags. Memcached and Redis Cluster clear a namespace by replacing opaque namespace/bucket generations, so they do not scan, flush other namespaces, or maintain a permanent key membership index. ## Adapters @@ -109,7 +109,7 @@ Cache::sqlite(); Cache::mongodb(); Cache::scylla(); Cache::tiered([...]); ``` -Data and internal metadata use physically separate key spaces. SQL-like stores can install schema explicitly with `PdoCacheSchema::install()` and pass `initializeSchema: false` to `PdoCacheAdapter` in deployment-controlled environments. +Data and internal metadata use physically separate key spaces. Adapters are public for PSR-6 use, but tagging, stampede protection, policy-aware error handling, and metrics are facade responsibilities; use `Cache` for consistent CacheLayer semantics. SQL-like stores can install schema explicitly with `PdoCacheSchema::install()` and pass `initializeSchema: false` to `PdoCacheAdapter` in deployment-controlled environments. `phpFiles` creates executable PHP files and is only appropriate for a trusted directory and trusted payloads. Never point SQLite at NFS, SMB, or another shared network filesystem. @@ -169,21 +169,21 @@ Cluster Cache adds durable invalidation around independent Node Caches. It keeps ```php $runtime->invalidateKey('product.42'); $runtime->invalidateTags(['products', 'catalog']); -$runtime->invalidateNamespace(); +$runtime->clearNamespace(); $runtime->consume(); ``` -It does not replicate values and is not a distributed lock, session store, or counter system. +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. ## Atomic counters and memoization `AtomicCounters` uses an `AtomicCounterStoreInterface`; Redis/Valkey is the distributed implementation. Counters are never emulated with cache `get()` plus `set()`. -The `memoize()`, `remember(object: ...)`, and `once()` helpers plus `MemoizeTrait` provide bounded process-local memoization. They are independent of persistent backend caching. +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. ## Metrics and benchmarks -Metrics distinguish calls from key volume: `get_batch`, `get_batch_keys`, hits/misses, set/delete batch counts, tag-version fetches, promotions, lock outcomes, and backend failures. `exportMetrics()` returns a snapshot and can invoke an export hook. +Metrics distinguish calls from key volume: `get_batch`, `get_batch_keys`, hits/misses, set/delete batch counts, tag-generation fetches, promotions, lock outcomes, and backend failures. `exportMetrics()` returns a snapshot and can invoke an export hook. PHPBench scenarios in `benchmarks/` cover single operations, 10/100/1000-key bulk operations, tagged/plain records, tier and Node promotion, codec security/compression, and remember paths. Backend-focused tests separately verify operation counts for native bulk calls. These are microbenchmarks, not production throughput claims. diff --git a/benchmarks/CacheBulkBench.php b/benchmarks/CacheBulkBench.php index 6dc400c..99f1f37 100644 --- a/benchmarks/CacheBulkBench.php +++ b/benchmarks/CacheBulkBench.php @@ -116,7 +116,7 @@ public function benchTieredL1FullHit(array $params): int for ($index = 0; $index < $params['size']; $index++) { $key = 'tier-full.' . $index; $keys[] = $key; - $l1->set($key, $index, 60); + $l1->save($l1->createItem($key)->set($index)->expiresAfter(60)); } return count($cache->getMultiple($keys)); @@ -134,7 +134,7 @@ public function benchTieredL3Promotion(array $params): int for ($index = 0; $index < $params['size']; $index++) { $key = 'tier3.' . $index; $keys[] = $key; - $l3->set($key, $index, 60); + $l3->save($l3->createItem($key)->set($index)->expiresAfter(60)); } return count($cache->getMultiple($keys)); @@ -152,7 +152,7 @@ public function benchTieredPartialHit(array $params): int $key = 'tier.' . $index; $keys[] = $key; $target = $index % 2 === 0 ? $l1 : $l2; - $target->set($key, $index, 60); + $target->save($target->createItem($key)->set($index)->expiresAfter(60)); } return count($cache->getMultiple($keys)); @@ -160,6 +160,7 @@ public function benchTieredPartialHit(array $params): int public function provideSizes(): iterable { + yield '1 key' => ['size' => 1]; yield '10 keys' => ['size' => 10]; yield '100 keys' => ['size' => 100]; yield '1000 keys' => ['size' => 1000]; diff --git a/composer.json b/composer.json index 190e247..d604bbf 100644 --- a/composer.json +++ b/composer.json @@ -21,7 +21,7 @@ "scylladb", "valkey", "weakmap", - "chain-cache" + "tiered-cache" ], "authors": [ { diff --git a/docs/adapters/file.rst b/docs/adapters/file.rst index 12a644b..1a17899 100644 --- a/docs/adapters/file.rst +++ b/docs/adapters/file.rst @@ -11,7 +11,7 @@ Stores one cache payload per file under a namespace directory. Path layout: * base dir: provided ``$dir`` or ``sys_get_temp_dir() . '/cachelayer/files'`` -* namespace dir: ``cache_`` +* namespace dir: ``cache_`` * separate ``data`` and ``meta`` subdirectories * file name: ``hash('xxh128', $key) . '.cache'`` @@ -20,8 +20,13 @@ Highlights: * zero service dependencies * persists across process restarts * atomic write flow (``tempnam`` + ``rename``) +* restrictive directory validation and atomic metadata replacement * immutable namespace and directory configuration +Expired files are removed lazily when encountered; applications with very +large, low-read keysets should periodically clear or rotate their cache +directory as an operational maintenance policy. + Best for local/single-host environments. Example diff --git a/docs/adapters/memcached.rst b/docs/adapters/memcached.rst index 0cb200c..531d8c2 100644 --- a/docs/adapters/memcached.rst +++ b/docs/adapters/memcached.rst @@ -18,7 +18,7 @@ Highlights: * distributed in-memory cache * ``getMulti`` based batch reads * TTL-grouped ``setMulti`` batch writes -* namespace clear advances an epoch and never calls server-wide ``flush`` +* namespace clear replaces an opaque namespace generation and never calls server-wide ``flush`` * factory auto-configures ``MemcachedLockProvider`` for ``remember()`` when using this adapter * lock leases use ``add`` acquisition and CAS-guarded renewal/release so an expired owner's cleanup cannot delete a replacement owner's lock @@ -36,7 +36,8 @@ Example ['127.0.0.1', 11211, 100], ]); - $state = $cache->remember('user.42.state', function ($item) { - $item->expiresAfter(120); - return loadSessionState(42); - }); + $state = $cache->remember( + 'user.42.state', + fn () => loadSessionState(42), + ttl: 120, + ); diff --git a/docs/adapters/mongodb.rst b/docs/adapters/mongodb.rst index aac4797..1720d57 100644 --- a/docs/adapters/mongodb.rst +++ b/docs/adapters/mongodb.rst @@ -17,15 +17,19 @@ Requirements: Highlights: * namespace-scoped document storage -* base64-encoded payload persistence -* TTL-aware read-time pruning +* BSON binary payload persistence without Base64 expansion +* TTL-aware read-time pruning; production deployments should also install a + TTL index on the expiration field for background cleanup +* native ``$in`` reads and ``bulkWrite()`` mutations Supported injected collection methods: * ``findOne`` +* ``find`` * ``updateOne`` * ``deleteOne`` * ``deleteMany`` +* ``bulkWrite`` * ``countDocuments`` Example diff --git a/docs/adapters/pdo.rst b/docs/adapters/pdo.rst index 39185e8..71f6672 100644 --- a/docs/adapters/pdo.rst +++ b/docs/adapters/pdo.rst @@ -17,7 +17,8 @@ Highlights: * unified SQL adapter for MySQL, MariaDB, PostgreSQL, and other PDO drivers * defaults to SQLite when no DSN/PDO is provided -* physically separated data and metadata rows (``:d:`` and ``:m:``) +* exact namespace isolation through composite ``(namespace, kind, cache_key)`` rows +* binary payload columns with physically separated data and metadata kinds * automatic table/index initialization * driver-aware upsert strategy: - PostgreSQL/SQLite: native ``ON CONFLICT`` @@ -28,6 +29,8 @@ Highlights: * PostgreSQL locking uses the two-key advisory-lock form * SQLite and other PDO drivers without advisory locks use an injected ``FileLockProvider`` fallback +* expired data rows are misses and can be removed in bounded batches with + ``PdoCacheAdapter::pruneExpired($limit)`` Schema creation can be separated from runtime access. Run ``PdoCacheSchema::install($pdo, 'cachelayer_entries')`` during deployment, @@ -37,8 +40,10 @@ keeps automatic initialization enabled. PDO advisory locks remain owned by their creating connection until explicit release or connection loss. ``refresh()`` verifies local token ownership and -connection health. The provider rejects re-entrant acquisition of the same -lock through one provider instance. +connection health; it cannot extend a real server-side timed lease because PDO +advisory locks are connection-owned. Treat ``leaseSeconds`` as API +compatibility, not automatic expiry. The provider rejects re-entrant +acquisition of the same lock through one provider instance. Examples: @@ -72,10 +77,12 @@ Typical Usage $cache = Cache::pdo('orders'); - $summary = $cache->remember('orders.summary.today', function ($item) { - $item->expiresAfter(60); - return loadOrderSummary(); - }, tags: ['orders']); + $summary = $cache->remember( + 'orders.summary.today', + fn () => loadOrderSummary(), + ttl: 60, + tags: ['orders'], + ); // Invalidate all related records after an order mutation. $cache->invalidateTag('orders'); diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst index 39b3543..241c383 100644 --- a/docs/adapters/php-files.rst +++ b/docs/adapters/php-files.rst @@ -11,15 +11,19 @@ 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: ``phpcache_`` with separate ``data`` and ``meta`` subdirectories * file name: ``hash('xxh128', $key) . '.php'`` Highlights: * persistent local cache * opcode-cache aware (``opcache_invalidate`` on writes/deletes when available) +* OPcache invalidation occurs before replacement or unlink * immutable namespace and directory configuration +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. diff --git a/docs/adapters/redis-cluster.rst b/docs/adapters/redis-cluster.rst index bc3449a..c672339 100644 --- a/docs/adapters/redis-cluster.rst +++ b/docs/adapters/redis-cluster.rst @@ -17,7 +17,7 @@ Requirements: Highlights: * 128 fixed hash-tag buckets for cross-slot-safe grouped operations -* namespace clear advances each bucket epoch +* namespace clear replaces each opaque bucket generation * no permanent key index, stale membership, or cluster-wide scan Useful when using Redis Cluster topology. diff --git a/docs/adapters/redis.rst b/docs/adapters/redis.rst index ab63093..9a251a7 100644 --- a/docs/adapters/redis.rst +++ b/docs/adapters/redis.rst @@ -36,7 +36,9 @@ Example $cache = Cache::redis('api', 'redis://127.0.0.1:6379/0'); - $response = $cache->remember('endpoint:/v1/users?page=1', function ($item) { - $item->expiresAfter(30); - return fetchApiPayload(); - }, tags: ['users']); + $response = $cache->remember( + 'endpoint.v1.users.page.1', + fn () => fetchApiPayload(), + ttl: 30, + tags: ['users'], + ); diff --git a/docs/adapters/scylladb.rst b/docs/adapters/scylladb.rst index 9013df3..932a085 100644 --- a/docs/adapters/scylladb.rst +++ b/docs/adapters/scylladb.rst @@ -18,7 +18,9 @@ Highlights: * keyspace/table-backed cache entries with bounded partition buckets * bucket-grouped ``IN`` reads and bounded unlogged write batches * schema bootstrap with ``CREATE TABLE IF NOT EXISTS`` -* TTL stored as absolute timestamp in ``expires`` +* native Scylla TTL on data rows, plus the absolute expiration timestamp used + for read-time validation +* binary ``blob`` payload storage without Base64 expansion Supported injected session methods: diff --git a/docs/adapters/shared-memory.rst b/docs/adapters/shared-memory.rst index 38f7588..0295835 100644 --- a/docs/adapters/shared-memory.rst +++ b/docs/adapters/shared-memory.rst @@ -14,6 +14,8 @@ Highlights: * values shared across PHP processes on the same host * namespace-specific segment key strategy +* shared locks for reads and exclusive locks for mutation +* an owner marker that rejects accidental ``ftok`` segment collisions * good for host-local IPC cache use cases Notes: diff --git a/docs/adapters/valkey.rst b/docs/adapters/valkey.rst index a108596..ee095e0 100644 --- a/docs/adapters/valkey.rst +++ b/docs/adapters/valkey.rst @@ -36,7 +36,9 @@ Example $cache = Cache::valkey('api', 'valkey://127.0.0.1:6379/0'); - $payload = $cache->remember('endpoint:/v1/users?page=1', function ($item) { - $item->expiresAfter(30); - return fetchApiPayload(); - }, tags: ['users']); + $payload = $cache->remember( + 'endpoint.v1.users.page.1', + fn () => fetchApiPayload(), + ttl: 30, + tags: ['users'], + ); diff --git a/docs/cache.rst b/docs/cache.rst index c6a980a..db0af23 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -4,7 +4,7 @@ Cache facade ============ ``Infocyph\CacheLayer\Cache\Cache`` implements PSR-6, PSR-16, and -``ArrayAccess``. It adds versioned tags, native bulk operations, bounded +``ArrayAccess``. It adds generation-tagged records, native bulk operations, bounded stampede protection, tiering, metrics, and per-instance payload policy. Factories @@ -21,14 +21,15 @@ Configuration is fixed when the cache is constructed. Keys, tags, and TTL ------------------- -Keys and tags are 1--64 characters from ``A-Z``, ``a-z``, ``0-9``, ``_``, -``.``, and ``-``. Bulk input is completely validated before mutation. Zero or -negative TTL deletes the entry. +Keys, tags, and namespaces are 1--64 characters from ``A-Z``, ``a-z``, ``0-9``, +``_``, ``.``, and ``-``. Namespaces are validated without normalization, so +distinct inputs can never collapse into one cache. Bulk input is completely +validated before mutation. Zero or negative TTL deletes the entry. -Tags are stored as a version snapshot inside each record. Missing tag metadata -means version zero. Invalidation atomically increments tag versions; a read -fetches all required versions in one batch and rejects the whole record on any -mismatch. +Tags are stored as opaque 128-bit generation snapshots inside each record. +Invalidation replaces generations; a read fetches all required generations in +one batch and rejects the whole record on a missing or mismatched generation. +Consequently, evicted metadata cannot make an old tagged record valid again. Bulk behavior ------------- @@ -86,6 +87,10 @@ Construction and configuration failures throw. With the default return ``false`` while incrementing ``backend_failure``. Set ``failOpen=false`` to propagate the backend exception. +Adapters can be used directly as PSR-6 pools, but CacheLayer's tagging, +stampede protection, metrics, and fail-open policy live in the ``Cache`` +facade. Prefer the facade unless the narrower adapter behavior is intentional. + Tiering ------- diff --git a/docs/cluster/_content.inc b/docs/cluster/_content.inc index ff6ec54..ebd9c89 100644 --- a/docs/cluster/_content.inc +++ b/docs/cluster/_content.inc @@ -74,11 +74,13 @@ Cluster identity, nodes, and namespaces ``cluster`` A non-empty logical stream name. ``production``, ``staging``, and ``tenant-a`` can use the same transport without consuming each other's - events. There is no fixed CacheLayer limit on cluster names; transport - capacity and retention determine the practical limit. + events. It is validated as 1--128 characters from + ``[A-Za-z0-9_.-]``; transport capacity and retention determine the + practical number of clusters. ``nodeId`` - A non-empty identity for this running node. It must be unique among nodes in + A 1--255 character identity from ``[A-Za-z0-9_.-]`` for this running node. + It must be unique among nodes in the same cluster. The producer's own events are not applied a second time, but their cursor still advances. A stable hostname is suitable for long-lived hosts; use a unique instance/pod identity for ephemeral infrastructure. @@ -126,12 +128,12 @@ PDO transport ~~~~~~~~~~~~~ ``PdoInvalidationTransport`` is included for a supplied PostgreSQL or MySQL -PDO connection. It creates a shared ``cachelayer_invalidation_events`` table -and index when constructed. The connection account therefore needs the required -DDL and read/write rights when the transport is initialized. +PDO connection. Automatic schema initialization is convenient in development. +In production, install it separately and construct the transport without DDL: .. code-block:: php + use Infocyph\CacheLayer\Cluster\Transport\Pdo\PdoInvalidationSchema; use Infocyph\CacheLayer\Cluster\Transport\Pdo\PdoInvalidationTransport; $pdo = new PDO( @@ -141,7 +143,8 @@ DDL and read/write rights when the transport is initialized. [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION], ); - $transport = new PdoInvalidationTransport($pdo); + PdoInvalidationSchema::install($pdo); // deployment/bootstrap connection + $transport = new PdoInvalidationTransport($pdo, initializeSchema: false); SQLite support in this class is useful for a local test only. Do **not** use a SQLite database as a shared multi-node event transport in production. @@ -298,11 +301,8 @@ of the event-store availability. // Node-local read through APCu then SQLite; no transport call. $product = $runtime->cache()->remember( 'product.42', - function ($item) use ($products) { - $item->expiresAfter(300); - - return $products->find(42); - }, + fn () => $products->find(42), + ttl: 300, tags: ['products', 'product.42'], ); @@ -335,6 +335,10 @@ Use a key when one cached representation changed: $runtime->invalidateKey('product.42'); This deletes ``product.42`` locally and publishes a key event for other nodes. +Deletion cannot fence a resolver that was already reading older source data. +For mutable read-through data where ordering matters, also tag the record and +invalidate that tag; the generation check prevents an in-flight old result +from being stored as current. Tag invalidation ~~~~~~~~~~~~~~~~ @@ -397,14 +401,13 @@ Delete a product 2. Invalidate the direct representation and any affected list/search tags. 3. Allow consumers to propagate the same invalidation to other nodes. -There is no ``ClusterRuntime::invalidateTags()`` method. Publish one event per -tag instead: +Invalidate several tags with one runtime call; it publishes one event per +unique tag: .. code-block:: php $runtime->invalidateKey('product.42'); - $runtime->invalidateTag('products'); - $runtime->invalidateTag('search-results'); + $runtime->invalidateTags(['products', 'search-results']); Do not call ``$runtime->cache()->invalidateTags()`` for distributed work; that would only invalidate the initiating node. @@ -419,7 +422,14 @@ Each application node needs a consumer. ``consume()`` performs these steps: 3. Fetch up to the requested number of events after the current cursor. 4. Ignore events emitted by this same node (they were already applied locally). 5. Apply matching-namespace events from other nodes. -6. Advance the local SQLite cursor after every event, including ignored events. +6. Advance the local SQLite cursor only after successful application (or after + intentionally ignoring an origin/other-namespace event). + +If a local invalidation returns ``false`` or throws, consumption stops and the +cursor remains before that event so a later run retries it. A permanent poison +event is never skipped silently. After inspecting and quarantining the cause, +an operator may call ``skipEventAfterClear($eventId)``; this explicitly clears +the local namespace before advancing the cursor. ``consume()`` returns the number of events *read*, not the number applied. It returns zero when no events are available or when passed a limit below one. @@ -556,13 +566,17 @@ cursor predates the oldest available event, it cannot know every invalidation it missed. Before normal consumption, Cluster Cache therefore: 1. clears that node's entire local namespace; -2. resets its cursor to the oldest retained event ID; +2. advances its cursor only after that clear succeeds; 3. resumes consumption on subsequent reads of the event stream. ``recoverIfRequired()`` exposes this check directly and returns ``true`` when a clear/recovery occurred. Calling ``consume()`` already calls it, so direct calls are mainly useful for health checks or explicit startup handling. +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. + 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 diff --git a/docs/cookbook.rst b/docs/cookbook.rst index 0b2b08d..66594d7 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -27,10 +27,12 @@ Process flow: $cache = Cache::file('shop', __DIR__ . '/storage/cache'); // Read-through cache on miss with stampede protection. - $product = $cache->remember('product.42', function ($item) { - $item->expiresAfter(300); - return loadProductFromDatabase(42); - }, tags: ['products', 'product.42']); + $product = $cache->remember( + 'product.42', + fn () => loadProductFromDatabase(42), + ttl: 300, + tags: ['products', 'product.42'], + ); // On product update, invalidate only related cache. $cache->invalidateTags(['products', 'product.42']); @@ -67,10 +69,12 @@ Process flow: // 'secret', // ); - $invoice = $cache->remember('invoice.2026-1001', function ($item) { - $item->expiresAfter(180); - return buildInvoicePayload(1001); - }, tags: ['invoices', 'customer.77']); + $invoice = $cache->remember( + 'invoice.2026-1001', + fn () => buildInvoicePayload(1001), + ttl: 180, + tags: ['invoices', 'customer.77'], + ); // Invalidate by business scope when source data changes. $cache->invalidateTag('customer.77'); diff --git a/docs/functions.rst b/docs/functions.rst index 537af0d..f698cb7 100644 --- a/docs/functions.rst +++ b/docs/functions.rst @@ -6,25 +6,6 @@ Global Helper Functions CacheLayer autoloads helper functions from ``src/functions.php``. -sanitize_cache_ns() -------------------- - -.. php:function:: sanitize_cache_ns(string $ns): string - -Normalizes namespaces into safe key prefixes. - -Behavior: - -* Replaces any character outside ``[A-Za-z0-9_-]`` with ``_`` -* Uses an internal static memoization map for repeated inputs - -Example: - -.. code-block:: php - - sanitize_cache_ns('tenant/acme.v1'); - // "tenant_acme_v1" - memoize() --------- @@ -75,3 +56,12 @@ Useful for one-time initialization inside request/process scope. $config = once(function () { return loadLargeConfigArray(); }); + +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. diff --git a/docs/index.rst b/docs/index.rst index 430b293..cce95b1 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ CacheLayer is a standalone caching toolkit for PHP 8.3+ with: * PSR-6 and PSR-16 support behind one facade (``Cache``) * local, distributed, and cloud cache adapters -* tag-version invalidation (``setTagged``, ``invalidateTag``, ``invalidateTags``) +* generation-tag invalidation (``setTagged``, ``invalidateTag``, ``invalidateTags``) * stampede-resistant ``remember()`` with bounded pluggable locks * adapter-level metrics export hooks * payload compression controls @@ -36,10 +36,12 @@ Quick Start $cache = Cache::memory('app'); - $profile = $cache->remember('user.42', function ($item) { - $item->expiresAfter(300); - return ['id' => 42, 'name' => 'Ada']; - }, tags: ['users']); + $profile = $cache->remember( + 'user.42', + fn () => ['id' => 42, 'name' => 'Ada'], + ttl: 300, + tags: ['users'], + ); $cache->invalidateTag('users'); diff --git a/docs/memoize/functions.rst b/docs/memoize/functions.rst index b7feb28..30f9fa1 100644 --- a/docs/memoize/functions.rst +++ b/docs/memoize/functions.rst @@ -9,7 +9,7 @@ memoize(callable, params) ``memoize($callable, $params)`` caches return values by: -* callable signature +* callable identity, including Closure source/captures and object instance * normalized parameters hash Internally this uses ``Memoizer::get()``. @@ -39,7 +39,7 @@ once(callback) Key details: -* cache key includes caller context + callback fingerprint +* cache key includes the exact caller file/line/context + callback fingerprint * closure source fingerprinting is memoized * bounded cache size (2048 entries), oldest entry evicted @@ -56,3 +56,9 @@ Inspecting/Resetting Memoizer State $stats = $memo->stats(); // ['hits' => ..., 'misses' => ..., 'total' => ...] $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. diff --git a/docs/metrics-and-locking.rst b/docs/metrics-and-locking.rst index b18be76..10e78af 100644 --- a/docs/metrics-and-locking.rst +++ b/docs/metrics-and-locking.rst @@ -111,7 +111,8 @@ Provider semantics: release. * MySQL/MariaDB and PostgreSQL use connection-scoped advisory locks. Renewal verifies ownership and connection health; the lock remains held until - release or connection loss. + release or connection loss. These are not timed server-side leases, so the + handle's lease duration does not force expiry. * File locks retain an open ``flock`` until release. Renewal verifies that the owned file resource is still open. * SQLite and PDO drivers without native advisory locks use the file provider diff --git a/docs/node/_content.inc b/docs/node/_content.inc index 4593242..e3382a1 100644 --- a/docs/node/_content.inc +++ b/docs/node/_content.inc @@ -75,12 +75,12 @@ Write lifecycle 1. A normal write is persisted to SQLite first. 2. A successful SQLite write is copied to APCu when APCu is available. 3. A delete or namespace clear is attempted across both layers. -4. Tagged entries and tag-version metadata are local to this node. +4. Tagged entries and tag-generation metadata are local to this node. -With the default ``failOpen: true``, an L2 failure can fall back to an L1 write -so the request can continue. This is a resilience choice: the L1-only value is -lost when that PHP/APCu process is restarted. Set ``failOpen: false`` when a -storage failure must be surfaced to the application instead. +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``. Requirements and filesystem placement -------------------------------------- @@ -142,8 +142,8 @@ Configuration reference ``namespace`` Logical cache partition inside the SQLite database. It defaults to - ``default`` and is sanitized before use. Use a stable, application-specific - name such as ``catalog`` or ``billing``. + ``default`` and is strictly validated as 1--64 characters from + ``[A-Za-z0-9_.-]``. Use a stable name such as ``catalog`` or ``billing``. ``lockDirectory`` Optional directory for the file lock provider used by ``remember()``. Leave @@ -217,11 +217,8 @@ node from doing the same expensive work. $product = $cache->remember( 'product.42', - function ($item) use ($repository) { - $item->expiresAfter(300); - - return $repository->find(42); - }, + fn () => $repository->find(42), + ttl: 300, tags: ['products', 'product.42'], ); @@ -232,8 +229,8 @@ resolver safe to run more than once. Tags and invalidation ~~~~~~~~~~~~~~~~~~~~~ -Tags group related cached values. CacheLayer invalidates them by changing local -tag versions rather than scanning every cached key. +Tags group related cached values. CacheLayer invalidates them by replacing +local opaque generations rather than scanning every cached key. .. code-block:: php diff --git a/docs/security.rst b/docs/security.rst index fccad27..64df73b 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -36,6 +36,9 @@ Implemented Hardening disabled. * ``ClosureSerializer`` accepts only ``Closure`` and ``SignedClosureSerializer`` verifies HMAC-SHA256 before decoding. +* Closure payloads remain executable and may retain captured objects or request + state. Signing proves integrity, not that captures are safe for another + process or request. Per-instance construction API: @@ -77,7 +80,9 @@ subdirectories: * php-files adapter default base: ``sys_get_temp_dir()/cachelayer/phpfiles`` * PDO SQLite default: ``sys_get_temp_dir()/cachelayer/pdo/cache_.sqlite`` -These paths are created with restrictive permissions and world-writable checks. +Filesystem adapters create private directories and reject symlinked or +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 diff --git a/docs/serializer.rst b/docs/serializer.rst index 21906fa..1ec3a2b 100644 --- a/docs/serializer.rst +++ b/docs/serializer.rst @@ -31,3 +31,8 @@ Signed Closures Unsigned and signed Closure payloads are separate formats. Signature failures, malformed payloads, and payloads that do not contain a Closure throw ``InvalidArgumentException``. + +Closures are executable code and may capture objects, credentials, or request +state. Only serialize trusted closures into a trusted backend, prefer signed +payloads, and do not treat a valid signature as proof that captured data is +safe to retain or reuse in another request. diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index d2b58f7..885e06a 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -17,7 +17,7 @@ abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalC private ?CachePayloadCodec $codec = null; - /** @var array */ + /** @var array */ private array $localMetadata = []; private ?CacheOptions $options = null; @@ -65,13 +65,6 @@ public function createItem(string $key): CacheItemInterface return $this->genericMiss($key); } - public function get(string $key): mixed - { - $item = $this->getItem($key); - - return $item->isHit() ? $item->get() : null; - } - /** * @param list $keys * @return array @@ -82,24 +75,14 @@ public function getItems(array $keys = []): array } /** @param list $tags */ - public function getTagVersions(array $tags): array - { - $versions = []; - foreach ($tags as $tag) { - $versions[$tag] = $this->localMetadata[$tag] ?? 0; - } - - return $versions; - } - - /** @param list $tags */ - public function incrementTagVersions(array $tags): bool + public function getTagGenerations(array $tags): array { + $generations = []; foreach ($tags as $tag) { - $this->localMetadata[$tag] = ($this->localMetadata[$tag] ?? 0) + 1; + $generations[$tag] = $this->localMetadata[$tag] ??= self::newGeneration(); } - return true; + return $generations; } public function internalPersist(CacheItemInterface $item): bool @@ -112,6 +95,16 @@ public function internalQueue(CacheItemInterface $item): bool return $this->saveDeferred($item); } + /** @param list $tags */ + public function rotateTagGenerations(array $tags): bool + { + foreach ($tags as $tag) { + $this->localMetadata[$tag] = self::newGeneration(); + } + + return true; + } + public function saveDeferred(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -123,12 +116,23 @@ public function saveDeferred(CacheItemInterface $item): bool return true; } - public function set(string $key, mixed $value, ?int $ttl = null): bool + protected static function isGeneration(mixed $value): bool { - $item = $this->getItem($key); - $item->set($value)->expiresAfter($ttl); + return is_string($value) && strlen($value) === 32 && ctype_xdigit($value); + } - return $this->save($item); + protected static function newGeneration(): string + { + return bin2hex(random_bytes(16)); + } + + protected static function normalizeGeneration(mixed $value): ?string + { + if (!is_string($value) || strlen($value) !== 32 || !ctype_xdigit($value)) { + return null; + } + + return strtolower($value); } protected function decodeRecordFromBase64(string $payload): ?CacheRecord @@ -150,11 +154,11 @@ protected function decodeRecordFromBlob(string $blob): ?CacheRecord protected function encodeItem( CacheItemInterface $item, ?int $expiresAt, - ?int $namespaceEpoch = null, + ?string $namespaceGeneration = null, ): string { - $tags = $item instanceof CacheItem ? $item->getTagVersions() : []; + $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; - return $this->payloadCodec()->encode($item->get(), $expiresAt, $tags, $namespaceEpoch); + return $this->payloadCodec()->encode($item->get(), $expiresAt, $tags, $namespaceGeneration); } protected function genericDeleteAndMiss(string $key): CacheItem @@ -164,15 +168,6 @@ protected function genericDeleteAndMiss(string $key): CacheItem return $this->genericMiss($key); } - protected function genericFromBase64(string $key, ?string $payload): CacheItem - { - return $this->genericFromBase64WithInvalidator( - $key, - $payload, - fn(): bool => $this->deleteItem($key), - ); - } - /** @param callable(): bool $onInvalid */ protected function genericFromBase64WithInvalidator( string $key, @@ -187,15 +182,6 @@ protected function genericFromBase64WithInvalidator( ); } - protected function genericFromBlob(string $key, ?string $blob): CacheItem - { - return $this->genericFromBlobWithInvalidator( - $key, - $blob, - fn(): bool => $this->deleteItem($key), - ); - } - /** @param callable(): bool $onInvalid */ protected function genericFromBlobWithInvalidator( string $key, @@ -227,19 +213,9 @@ protected function genericMiss(string $key): CacheItem return new CacheItem($this, $key); } - /** - * @param list $keys - * @param callable(string): CacheItem $fetcher - * @return array - */ - protected function multiFetchItems(array $keys, callable $fetcher): array + protected function options(): CacheOptions { - $items = []; - foreach ($keys as $key) { - $items[$key] = $fetcher($key); - } - - return $items; + return $this->options ??= new CacheOptions(); } protected function resetLocalMetadata(): void diff --git a/src/Cache/Adapter/AdapterValueNormalizer.php b/src/Cache/Adapter/AdapterValueNormalizer.php index c9ee375..b8273ba 100644 --- a/src/Cache/Adapter/AdapterValueNormalizer.php +++ b/src/Cache/Adapter/AdapterValueNormalizer.php @@ -6,6 +6,18 @@ final class AdapterValueNormalizer { + /** @param array $values */ + public static function allTrue(array $values): bool + { + foreach ($values as $value) { + if ($value !== true) { + return false; + } + } + + return true; + } + /** * @phpstan-return array|null * @param mixed $value The value argument. diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index d933966..3d9c122 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\CacheItemInterface; @@ -19,7 +20,7 @@ * This This adapter requires the APCu extension to be installed and enabled. * @param string $namespace A namespace prefix to avoid key collisions. */ -class ApcuCacheAdapter extends AbstractCacheAdapter +class ApcuCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { private readonly string $ns; @@ -35,7 +36,7 @@ public function __construct(string $namespace = 'default') if (!extension_loaded('apcu') || !apcu_enabled()) { throw new RuntimeException('APCu extension is not enabled'); } - $this->ns = sanitize_cache_ns($namespace); + $this->ns = CacheInput::namespace($namespace); } public function clear(): bool @@ -48,11 +49,6 @@ public function clear(): bool return true; } - public function count(): int - { - return count($this->listKeys('d:')); - } - public function deleteItem(string $key): bool { $mapped = $this->map($key); @@ -96,19 +92,28 @@ public function getItem(string $key): CacheItem /** @param list $tags */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { if ($tags === []) { return []; } $raw = apcu_fetch(array_map($this->mapTag(...), $tags)); - $versions = []; + $generations = []; foreach ($tags as $tag) { - $value = is_array($raw) ? ($raw[$this->mapTag($tag)] ?? null) : null; - $versions[$tag] = is_int($value) && $value >= 0 ? $value : 0; + $key = $this->mapTag($tag); + $generation = self::normalizeGeneration(is_array($raw) ? ($raw[$key] ?? null) : null); + if ($generation === null) { + $candidate = self::newGeneration(); + $generation = self::normalizeGeneration(apcu_add($key, $candidate) ? $candidate : apcu_fetch($key)); + if ($generation === null) { + $generation = self::newGeneration(); + apcu_store($key, $generation); + } + } + $generations[$tag] = $generation; } - return $versions; + return $generations; } public function hasItem(string $key): bool @@ -116,21 +121,6 @@ public function hasItem(string $key): bool return apcu_exists($this->map($key)); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - foreach ($tags as $tag) { - $key = $this->mapTag($tag); - apcu_add($key, 0); - if (apcu_inc($key) === false) { - return false; - } - } - - return true; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -165,6 +155,36 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ + #[\Override] + public function readTagGenerations(array $tags): array + { + $raw = apcu_fetch(array_map($this->mapTag(...), $tags)); + $generations = []; + foreach ($tags as $tag) { + $generation = self::normalizeGeneration( + is_array($raw) ? ($raw[$this->mapTag($tag)] ?? null) : null, + ); + if ($generation !== null) { + $generations[$tag] = $generation; + } + } + + return $generations; + } + + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $generations = []; + foreach ($tags as $tag) { + $generations[$this->mapTag($tag)] = self::newGeneration(); + } + + return $generations === [] || apcu_store($generations) === []; + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -216,6 +236,21 @@ public function saveItems(array $items): bool return true; } + /** @param array $generations */ + #[\Override] + public function storeTagGenerations(array $generations): bool + { + $mapped = []; + foreach ($generations as $tag => $generation) { + if (!self::isGeneration($generation)) { + return false; + } + $mapped[$this->mapTag($tag)] = strtolower($generation); + } + + return $mapped === [] || apcu_store($mapped) === []; + } + /** * @param array $items The items argument. * @param array $stale The stale argument. diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 2199400..e9bfcf9 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -4,14 +4,15 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; -final class ArrayCacheAdapter extends AbstractCacheAdapter +final class ArrayCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { private readonly string $ns; - /** @var array */ + /** @var array */ private array $metadata = []; /** @var array */ @@ -19,7 +20,7 @@ final class ArrayCacheAdapter extends AbstractCacheAdapter public function __construct(string $namespace = 'default') { - $this->ns = sanitize_cache_ns($namespace); + $this->ns = CacheInput::namespace($namespace); } public function clear(): bool @@ -31,13 +32,6 @@ public function clear(): bool return true; } - public function count(): int - { - $this->pruneExpired(); - - return count($this->store); - } - public function deleteItem(string $key): bool { unset($this->store[$this->map($key)]); @@ -63,19 +57,27 @@ public function getItem(string $key): CacheItem $mapped = $this->map($key); $blob = $this->store[$mapped] ?? null; - return $this->genericFromBlob($key, is_string($blob) ? $blob : null); + return $this->genericFromBlobWithInvalidator( + $key, + is_string($blob) ? $blob : null, + function () use ($mapped): bool { + unset($this->store[$mapped]); + + return true; + }, + ); } /** @param list $tags */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $versions = []; + $generations = []; foreach ($tags as $tag) { - $versions[$tag] = $this->metadata[$tag] ?? 0; + $generations[$tag] = $this->metadata[$tag] ??= self::newGeneration(); } - return $versions; + return $generations; } public function hasItem(string $key): bool @@ -96,17 +98,6 @@ public function hasItem(string $key): bool return true; } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - foreach ($tags as $tag) { - $this->metadata[$tag] = ($this->metadata[$tag] ?? 0) + 1; - } - - return true; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -132,6 +123,24 @@ function () use ($mapped): bool { return $items; } + /** @param list $tags */ + #[\Override] + public function readTagGenerations(array $tags): array + { + return array_intersect_key($this->metadata, array_fill_keys($tags, true)); + } + + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + foreach ($tags as $tag) { + $this->metadata[$tag] = self::newGeneration(); + } + + return true; + } + public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { @@ -163,18 +172,22 @@ public function saveItems(array $items): bool return true; } - private function map(string $key): string + /** @param array $generations */ + #[\Override] + public function storeTagGenerations(array $generations): bool { - return $this->ns . ':d:' . $key; + foreach ($generations as $tag => $generation) { + if (!self::isGeneration($generation)) { + return false; + } + $this->metadata[$tag] = strtolower($generation); + } + + return true; } - private function pruneExpired(): void + private function map(string $key): string { - foreach ($this->store as $mapped => $blob) { - $record = $this->decodeRecordFromBlob($blob); - if ($record === null) { - unset($this->store[$mapped]); - } - } + return $this->ns . ':d:' . $key; } } diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index 22d9372..6976595 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -73,13 +73,13 @@ public function decode(string $blob): ?CacheRecord } /** - * @param array $tags + * @param array $tags */ public function encode( mixed $value, ?int $expiresAt, array $tags = [], - ?int $namespaceEpoch = null, + ?string $namespaceGeneration = null, ): string { [$encoding, $encodedValue] = $this->encodeValue($value); $serialized = serialize([ @@ -88,7 +88,7 @@ public function encode( 'value' => $encodedValue, 'expires' => $expiresAt, 'tags' => $tags, - 'epoch' => $namespaceEpoch, + 'namespace' => $namespaceGeneration, ]); if ($this->isPayloadTooLarge($serialized)) { throw new RuntimeException('The encoded cache record exceeds the configured payload limit.'); @@ -252,14 +252,11 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord if (!is_array($tags)) { return null; } - foreach ($tags as $tag => $version) { - if (!is_string($tag) || !is_int($version) || $version < 0) { - return null; - } - } - - $epoch = $decoded['epoch'] ?? null; - if ($epoch !== null && (!is_int($epoch) || $epoch < 0)) { + $namespaceGeneration = $decoded['namespace'] ?? null; + if ($namespaceGeneration !== null + && (!is_string($namespaceGeneration) + || strlen($namespaceGeneration) !== 32 + || !ctype_xdigit($namespaceGeneration))) { return null; } @@ -268,7 +265,16 @@ private function normalizeRecord(mixed $decoded): ?CacheRecord return null; } - return new CacheRecord($value['value'], $expiresAt, $tags, $epoch); + foreach ($tags as $tag => $generation) { + if (!is_string($tag) + || !is_string($generation) + || strlen($generation) !== 32 + || !ctype_xdigit($generation)) { + return null; + } + } + + return new CacheRecord($value['value'], $expiresAt, $tags, $namespaceGeneration); } private function unserializeNative(string $payload): mixed diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index f8f7a5e..bf595d0 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\CacheItemInterface; @@ -55,11 +56,6 @@ public function clear(): bool return $ok; } - public function count(): int - { - return iterator_count(new \FilesystemIterator($this->dataDirectory, \FilesystemIterator::SKIP_DOTS)); - } - public function deleteItem(string $key): bool { $file = $this->fileFor($key); @@ -101,16 +97,22 @@ public function getItem(string $key): CacheItem /** @param list $tags */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $versions = []; + $generations = []; foreach ($tags as $tag) { $path = $this->metadataFileFor($tag); $value = is_file($path) ? file_get_contents($path) : false; - $versions[$tag] = is_string($value) && ctype_digit($value) ? (int) $value : 0; + if (!self::isGeneration($value)) { + $value = self::newGeneration(); + if (!$this->atomicReplace($path, $value)) { + throw new RuntimeException('Unable to initialize file tag generation.'); + } + } + $generations[$tag] = strtolower((string) $value); } - return $versions; + return $generations; } public function hasItem(string $key): bool @@ -118,36 +120,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - foreach ($tags as $tag) { - $path = $this->metadataFileFor($tag); - $handle = fopen($path, 'c+'); - if (!is_resource($handle) || !flock($handle, LOCK_EX)) { - if (is_resource($handle)) { - fclose($handle); - } - - return false; - } - $raw = stream_get_contents($handle); - $version = is_string($raw) && ctype_digit($raw) ? (int) $raw : 0; - rewind($handle); - ftruncate($handle, 0); - $written = fwrite($handle, (string) ($version + 1)); - fflush($handle); - flock($handle, LOCK_UN); - fclose($handle); - if ($written === false) { - return false; - } - } - - return true; - } - /** * @param list $keys * @return array @@ -182,6 +154,20 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + foreach ($tags as $tag) { + $path = $this->metadataFileFor($tag); + if (!$this->atomicReplace($path, self::newGeneration())) { + return false; + } + } + + return true; + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -206,24 +192,18 @@ public function saveItems(array $items): bool return $ok; } - private function assertWritableDirectory(string $path, string $message): void - { - if (!is_writable($path)) { - throw new RuntimeException($message); - } - } - private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); - $ns = sanitize_cache_ns($ns); + $ns = CacheInput::namespace($ns); $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; if (is_dir($this->dataDirectory) && is_dir($this->metadataDirectory)) { - $this->assertWritableDirectory($this->dataDirectory, 'Cache data directory is not writable'); - $this->assertWritableDirectory($this->metadataDirectory, 'Cache metadata directory is not writable'); + $this->assertSecureDirectory($baseDir, 'Cache base directory'); + $this->assertSecureDirectory($this->dataDirectory, 'Cache data directory'); + $this->assertSecureDirectory($this->metadataDirectory, 'Cache metadata directory'); return; } @@ -281,7 +261,7 @@ private function fileFor(string $key): string private function metadataFileFor(string $tag): string { - return $this->metadataDirectory . hash('xxh128', $tag) . '.version'; + return $this->metadataDirectory . hash('xxh128', $tag) . '.generation'; } private function persistItem(CacheItemInterface $item): bool diff --git a/src/Cache/Adapter/InternalCachePoolInterface.php b/src/Cache/Adapter/InternalCachePoolInterface.php index 51565b3..fb90b2c 100644 --- a/src/Cache/Adapter/InternalCachePoolInterface.php +++ b/src/Cache/Adapter/InternalCachePoolInterface.php @@ -18,12 +18,9 @@ public function createItem(string $key): CacheItemInterface; /** * @param list $tags - * @return array + * @return array */ - public function getTagVersions(array $tags): array; - - /** @param list $tags */ - public function incrementTagVersions(array $tags): bool; + public function getTagGenerations(array $tags): array; public function internalPersist(CacheItemInterface $item): bool; @@ -35,6 +32,9 @@ public function internalQueue(CacheItemInterface $item): bool; */ public function multiFetch(array $keys): array; + /** @param list $tags */ + public function rotateTagGenerations(array $tags): bool; + /** * @param array $items */ diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index cef8280..03c6338 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -25,7 +26,7 @@ public function __construct( if (!class_exists(\Memcached::class)) { throw new RuntimeException('Memcached extension not loaded'); } - $this->namespace = sanitize_cache_ns($namespace); + $this->namespace = CacheInput::namespace($namespace); $this->client = $client ?? new \Memcached(); if ($client === null) { $this->client->addServers($servers); @@ -34,8 +35,7 @@ public function __construct( public function clear(): bool { - $this->client->add($this->epochKey(), 0); - $cleared = $this->client->increment($this->epochKey()) !== false; + $cleared = $this->client->set($this->generationKey(), self::newGeneration()); $this->deferred = []; return $cleared; @@ -76,12 +76,12 @@ public function getClient(): \Memcached public function getItem(string $key): CacheItem { $mapped = $this->mapData($key); - $stored = $this->client->getMulti([$this->epochKey(), $mapped]); + $stored = $this->client->getMulti([$this->generationKey(), $mapped]); $stored = is_array($stored) ? $stored : []; - $epoch = $this->normalizeVersion($stored[$this->epochKey()] ?? null); + $generation = $this->namespaceGeneration($stored[$this->generationKey()] ?? null); $blob = $stored[$mapped] ?? null; $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; - if ($record !== null && ($record->namespaceEpoch ?? 0) === $epoch) { + if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } if (is_string($blob)) { @@ -93,22 +93,23 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { if ($tags === []) { return []; } $stored = $this->client->getMulti(array_map($this->mapTag(...), $tags)); $stored = is_array($stored) ? $stored : []; - $versions = []; + $generations = []; foreach ($tags as $tag) { - $versions[$tag] = $this->normalizeVersion($stored[$this->mapTag($tag)] ?? null); + $key = $this->mapTag($tag); + $generations[$tag] = $this->tagGeneration($key, $stored[$key] ?? null); } - return $versions; + return $generations; } public function hasItem(string $key): bool @@ -116,21 +117,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - foreach ($tags as $tag) { - $key = $this->mapTag($tag); - $this->client->add($key, 0); - if ($this->client->increment($key) === false) { - return false; - } - } - - return true; - } - /** * @param list $keys * @return array @@ -141,20 +127,20 @@ public function multiFetch(array $keys): array return []; } - $physical = [$this->epochKey()]; + $physical = [$this->generationKey()]; foreach ($keys as $key) { $physical[] = $this->mapData($key); } $stored = $this->client->getMulti($physical, \Memcached::GET_PRESERVE_ORDER); $stored = is_array($stored) ? $stored : []; - $epoch = $this->normalizeVersion($stored[$this->epochKey()] ?? null); + $generation = $this->namespaceGeneration($stored[$this->generationKey()] ?? null); $items = []; $stale = []; foreach ($keys as $key) { $mapped = $this->mapData($key); $blob = $stored[$mapped] ?? null; $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; - if ($record === null || ($record->namespaceEpoch ?? 0) !== $epoch) { + if ($record === null || $record->namespaceGeneration !== $generation) { $items[$key] = $this->genericMiss($key); if (is_string($blob)) { $stale[] = $mapped; @@ -171,6 +157,18 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $generations = []; + foreach ($tags as $tag) { + $generations[$this->mapTag($tag)] = self::newGeneration(); + } + + return $generations === [] || $this->client->setMulti($generations); + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -183,7 +181,7 @@ public function save(CacheItemInterface $item): bool return $this->client->set( $this->mapData($item->getKey()), - $this->encodeItem($item, $expiration['expiresAt'], $this->namespaceEpoch()), + $this->encodeItem($item, $expiration['expiresAt'], $this->namespaceGeneration()), $expiration['ttl'] ?? 0, ); } @@ -191,7 +189,7 @@ public function save(CacheItemInterface $item): bool /** @param array $items */ public function saveItems(array $items): bool { - $epoch = $this->namespaceEpoch(); + $generation = $this->namespaceGeneration(); $groups = []; $expired = []; foreach ($items as $item) { @@ -208,7 +206,7 @@ public function saveItems(array $items): bool $groups[$ttl][$this->mapData($item->getKey())] = $this->encodeItem( $item, $expiration['expiresAt'], - $epoch, + $generation, ); } @@ -224,9 +222,9 @@ public function saveItems(array $items): bool return true; } - private function epochKey(): string + private function generationKey(): string { - return $this->namespace . ':m:epoch'; + return $this->namespace . ':m:generation'; } private function mapData(string $key): string @@ -239,15 +237,50 @@ private function mapTag(string $tag): string return $this->namespace . ':m:tag:' . $tag; } - private function namespaceEpoch(): int + private function namespaceGeneration(mixed $value = null): string { - $value = $this->client->get($this->epochKey()); + if ($value === null) { + $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 $this->normalizeVersion($value); + return $generation; } - private function normalizeVersion(mixed $value): int + private function tagGeneration(string $key, mixed $value): string { - return is_int($value) && $value >= 0 ? $value : 0; + $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), + ); + 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/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index ff0a19a..5ebefe0 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -4,11 +4,12 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; -final class MongoDbCacheAdapter extends AbstractCacheAdapter +final class MongoDbCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { private readonly string $ns; @@ -16,7 +17,7 @@ public function __construct( private readonly object $collection, string $namespace = 'default', ) { - $this->ns = sanitize_cache_ns($namespace); + $this->ns = CacheInput::namespace($namespace); foreach (['findOne', 'find', 'updateOne', 'bulkWrite', 'deleteOne', 'deleteMany', 'countDocuments'] as $method) { if (!method_exists($this->collection, $method)) { @@ -51,20 +52,6 @@ public function clear(): bool return true; } - public function count(): int - { - $count = $this->collection->countDocuments([ - 'ns' => $this->ns, - 'kind' => 'data', - '$or' => [ - ['expires' => null], - ['expires' => ['$gt' => time()]], - ], - ]); - - return is_numeric($count) ? max(0, (int) $count) : 0; - } - public function deleteItem(string $key): bool { $this->collection->deleteOne(['_id' => $this->mapData($key)]); @@ -96,38 +83,34 @@ public function getItem(string $key): CacheItem return $this->genericMiss($key); } - $payload = $row['payload'] ?? null; + $payload = $this->binaryString($row['payload'] ?? null); - return $this->genericFromBase64($key, is_string($payload) ? $payload : null); + return $this->genericFromBlobWithInvalidator( + $key, + $payload, + fn(): bool => $this->deleteItem($key), + ); } /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $versions = array_fill_keys($tags, 0); - if ($tags === []) { - return $versions; - } - $documents = $this->collection->find([ - '_id' => ['$in' => array_map($this->mapTag(...), $tags)], - ]); - if (!is_iterable($documents)) { - throw new RuntimeException('MongoDB find() must return an iterable result.'); - } - foreach ($documents as $document) { - $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); - $tag = is_array($row) ? ($row['tag'] ?? null) : null; - $version = is_array($row) ? ($row['version'] ?? null) : null; - if (is_string($tag) && is_numeric($version)) { - $versions[$tag] = max(0, (int) $version); + $generations = $this->readTagGenerations($tags); + $missing = []; + foreach ($tags as $tag) { + if (!isset($generations[$tag])) { + $missing[$tag] = self::newGeneration(); } } + if ($missing !== [] && !$this->storeTagGenerations($missing)) { + throw new RuntimeException('Unable to initialize MongoDB tag generations.'); + } - return $versions; + return $generations + $missing; } public function hasItem(string $key): bool @@ -143,28 +126,6 @@ public function hasItem(string $key): bool return is_numeric($count) && (int) $count > 0; } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - $operations = []; - foreach ($tags as $tag) { - $operations[] = ['updateOne' => [ - ['_id' => $this->mapTag($tag)], - [ - '$setOnInsert' => ['ns' => $this->ns, 'kind' => 'metadata', 'tag' => $tag], - '$inc' => ['version' => 1], - ], - ['upsert' => true], - ]]; - } - if ($operations !== []) { - $this->collection->bulkWrite($operations, ['ordered' => false]); - } - - return true; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -189,8 +150,8 @@ public function multiFetch(array $keys): array $stale = []; foreach ($keys as $key) { $row = $byId[$this->mapData($key)] ?? null; - $payload = is_array($row) && is_string($row['payload'] ?? null) ? $row['payload'] : null; - $item = $this->genericFromBase64WithInvalidator($key, $payload, static fn(): bool => true); + $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; @@ -201,6 +162,44 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ + #[\Override] + public function readTagGenerations(array $tags): array + { + if ($tags === []) { + return []; + } + $documents = $this->collection->find([ + '_id' => ['$in' => array_map($this->mapTag(...), $tags)], + ]); + if (!is_iterable($documents)) { + throw new RuntimeException('MongoDB find() must return an iterable result.'); + } + $generations = []; + foreach ($documents as $document) { + $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); + $tag = is_array($row) ? ($row['tag'] ?? null) : null; + $generation = is_array($row) ? self::normalizeGeneration($row['generation'] ?? null) : null; + if (is_string($tag) && $generation !== null) { + $generations[$tag] = $generation; + } + } + + return $generations; + } + + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $generations = []; + foreach ($tags as $tag) { + $generations[$tag] = self::newGeneration(); + } + + return $this->storeTagGenerations($generations); + } + public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { @@ -210,7 +209,7 @@ public function save(CacheItemInterface $item): bool '$set' => [ 'ns' => $this->ns, 'kind' => 'data', - 'payload' => base64_encode($this->encodeItem($saveItem, $expires['expiresAt'])), + 'payload' => $this->binaryValue($this->encodeItem($saveItem, $expires['expiresAt'])), 'expires' => $expires['expiresAt'], ], ], @@ -241,7 +240,7 @@ public function saveItems(array $items): bool ['$set' => [ 'ns' => $this->ns, 'kind' => 'data', - 'payload' => base64_encode($this->encodeItem($item, $expiration['expiresAt'])), + 'payload' => $this->binaryValue($this->encodeItem($item, $expiration['expiresAt'])), 'expires' => $expiration['expiresAt'], ]], ['upsert' => true], @@ -255,6 +254,58 @@ public function saveItems(array $items): bool return true; } + /** @param array $generations */ + #[\Override] + public function storeTagGenerations(array $generations): bool + { + $operations = []; + foreach ($generations as $tag => $generation) { + if (!self::isGeneration($generation)) { + return false; + } + $operations[] = ['updateOne' => [ + ['_id' => $this->mapTag($tag)], + [ + '$set' => [ + 'ns' => $this->ns, + 'kind' => 'metadata', + 'tag' => $tag, + 'generation' => strtolower($generation), + ], + ], + ['upsert' => true], + ]]; + } + if ($operations !== []) { + $this->collection->bulkWrite($operations, ['ordered' => false]); + } + + return true; + } + + private function binaryString(mixed $value): ?string + { + if (is_string($value)) { + return $value; + } + if (is_object($value) && is_callable([$value, 'getData'])) { + $data = $value->getData(); + + return is_string($data) ? $data : null; + } + + return null; + } + + private function binaryValue(string $value): mixed + { + if (class_exists(\MongoDB\BSON\Binary::class)) { + return new \MongoDB\BSON\Binary($value, \MongoDB\BSON\Binary::TYPE_GENERIC); + } + + return $value; + } + private function mapData(string $key): string { return $this->ns . ':d:' . $key; diff --git a/src/Cache/Adapter/NullCacheAdapter.php b/src/Cache/Adapter/NullCacheAdapter.php index e677d34..6b95072 100644 --- a/src/Cache/Adapter/NullCacheAdapter.php +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -16,11 +16,6 @@ public function clear(): bool return true; } - public function count(): int - { - return 0; - } - public function deleteItem(string $key): bool { unset($key); @@ -46,9 +41,14 @@ public function getItem(string $key): CacheItem /** @param list $tags */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - return array_fill_keys($tags, 0); + $generations = []; + foreach ($tags as $tag) { + $generations[$tag] = self::newGeneration(); + } + + return $generations; } public function hasItem(string $key): bool @@ -58,15 +58,6 @@ public function hasItem(string $key): bool return false; } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - unset($tags); - - return true; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -82,6 +73,15 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + unset($tags); + + return true; + } + public function save(CacheItemInterface $item): bool { return $this->supportsItem($item); diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 6bac026..0fb4ba9 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -4,7 +4,10 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use PDO; +use PDOException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -14,11 +17,15 @@ final class PdoCacheAdapter extends AbstractCacheAdapter private const string DEFAULT_SQLITE_DIR = 'cachelayer/pdo'; + private const string KIND_DATA = 'data'; + + private const string KIND_TAG = 'tag'; + private readonly string $driver; private readonly string $namespace; - private readonly \PDO $pdo; + private readonly PDO $pdo; private readonly string $table; @@ -27,7 +34,7 @@ public function __construct( ?string $dsn = null, ?string $username = null, ?string $password = null, - ?\PDO $pdo = null, + ?PDO $pdo = null, string $table = 'cachelayer_entries', bool $initializeSchema = true, ) { @@ -35,12 +42,12 @@ public function __construct( throw new RuntimeException('Invalid PDO cache table name.'); } - $this->namespace = sanitize_cache_ns($namespace); + $this->namespace = CacheInput::namespace($namespace); $this->table = $table; $resolvedDsn = $dsn ?? 'sqlite:' . self::defaultSqliteFileForNamespace($this->namespace); - $this->pdo = $pdo ?? new \PDO($resolvedDsn, $username, $password); - $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); - $driver = $this->pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $this->pdo = $pdo ?? new PDO($resolvedDsn, $username, $password); + $this->pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); + $driver = $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $this->driver = is_string($driver) ? $driver : ''; if ($this->driver === 'sqlite') { $this->pdo->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;'); @@ -61,65 +68,53 @@ public static function defaultSqliteFileForNamespace(string $namespace): string if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { throw new RuntimeException("Unable to create SQLite cache directory: {$directory}"); } - if (!is_writable($directory)) { - throw new RuntimeException("SQLite cache directory is not writable: {$directory}"); + $permissions = fileperms($directory); + if (!is_writable($directory) || ($permissions !== false && (($permissions & 0x0002) === 0x0002))) { + throw new RuntimeException("SQLite cache directory must be writable and not world-writable: {$directory}"); } - return $directory . DIRECTORY_SEPARATOR . 'cache_' . sanitize_cache_ns($namespace) . '.sqlite'; + return $directory . DIRECTORY_SEPARATOR . 'cache_' . CacheInput::namespace($namespace) . '.sqlite'; } public function clear(): bool { - $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey LIKE ?"); - $cleared = $statement->execute([$this->namespace . ':%']); + $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE namespace = ?"); + $cleared = $statement->execute([$this->namespace]); $this->deferred = []; return $cleared; } - public function count(): int + public function deleteItem(string $key): bool { $statement = $this->pdo->prepare( - "SELECT COUNT(*) FROM {$this->table} - WHERE ckey LIKE ? AND (expires IS NULL OR expires > ?)", + "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", ); - $statement->execute([$this->namespace . ':d:%', time()]); - $count = $statement->fetchColumn(); - return is_numeric($count) ? max(0, (int) $count) : 0; - } - - public function deleteItem(string $key): bool - { - $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey = ?"); - - return $statement->execute([$this->mapData($key)]); + return $statement->execute([$this->namespace, self::KIND_DATA, $key]); } /** @param list $keys */ public function deleteItems(array $keys): bool { - return $this->deleteMapped(array_map($this->mapData(...), $keys)); + return $this->deleteByKind(self::KIND_DATA, $keys); } - public function getClient(): \PDO + public function getClient(): PDO { return $this->pdo; } public function getItem(string $key): CacheItem { - $statement = $this->pdo->prepare( - "SELECT payload, expires FROM {$this->table} WHERE ckey = ? LIMIT 1", - ); - $statement->execute([$this->mapData($key)]); - $row = $statement->fetch(\PDO::FETCH_ASSOC); + $rows = $this->fetchRows(self::KIND_DATA, [$key]); + $row = $rows[$key] ?? null; if (!is_array($row)) { return $this->genericMiss($key); } $item = $this->hydrate($key, $row); - if ($item !== null) { + if ($item instanceof CacheItem) { return $item; } $this->deleteItem($key); @@ -129,24 +124,32 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $mapped = []; + if ($tags === []) { + return []; + } + + $rows = $this->fetchRows(self::KIND_TAG, $tags); + $generations = []; + $initialize = []; foreach ($tags as $tag) { - $mapped[$tag] = $this->mapTag($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); } - $rows = $this->fetchRows(array_values($mapped)); - $versions = []; - foreach ($mapped as $tag => $physical) { - $row = $rows[$physical] ?? null; - $payload = is_array($row) ? $row['payload'] : null; - $versions[$tag] = is_string($payload) && ctype_digit($payload) ? (int) $payload : 0; + if (!$this->upsertRows($initialize)) { + throw new RuntimeException('Unable to initialize PDO tag generations.'); } - return $versions; + return $generations; } public function hasItem(string $key): bool @@ -154,61 +157,60 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - if ($tags === []) { - return true; - } - - $sql = match ($this->driver) { - 'pgsql', 'sqlite' => "INSERT INTO {$this->table} (ckey, payload, expires) VALUES (?, '1', NULL) - ON CONFLICT (ckey) DO UPDATE SET payload = CAST({$this->table}.payload AS INTEGER) + 1", - 'mysql', 'mariadb' => "INSERT INTO {$this->table} (ckey, payload, expires) VALUES (?, '1', NULL) - ON DUPLICATE KEY UPDATE payload = CAST(payload AS UNSIGNED) + 1", - default => null, - }; - if ($sql === null) { - return $this->incrementTagsWithTransaction($tags); - } - - $statement = $this->pdo->prepare($sql); - foreach ($tags as $tag) { - if (!$statement->execute([$this->mapTag($tag)])) { - return false; - } - } - - return true; - } - /** * @param list $keys * @return array */ public function multiFetch(array $keys): array { - $mapped = []; - foreach ($keys as $key) { - $mapped[$key] = $this->mapData($key); - } - $rows = $this->fetchRows(array_values($mapped)); + $rows = $this->fetchRows(self::KIND_DATA, $keys); $items = []; $stale = []; - foreach ($mapped as $logical => $physical) { - $row = $rows[$physical] ?? null; - $item = is_array($row) ? $this->hydrate($logical, $row) : null; - $items[$logical] = $item ?? $this->genericMiss($logical); + 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[] = $physical; + $stale[] = $key; } } - $this->deleteMapped($stale); + $this->deleteByKind(self::KIND_DATA, $stale); return $items; } + public function pruneExpired(int $limit = 1000): int + { + if ($limit < 1) { + throw new RuntimeException('PDO prune limit must be positive.'); + } + $statement = $this->pdo->prepare( + "SELECT cache_key FROM {$this->table} WHERE namespace = ? AND kind = ? " + . 'AND expires IS NOT NULL AND expires <= ? LIMIT ?', + ); + $statement->bindValue(1, $this->namespace, PDO::PARAM_STR); + $statement->bindValue(2, self::KIND_DATA, PDO::PARAM_STR); + $statement->bindValue(3, time(), 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; + } + + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $rows = []; + foreach ($tags as $tag) { + $rows[] = [self::KIND_TAG, $tag, self::newGeneration(), null]; + } + + return $this->upsertRows($rows); + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -220,8 +222,9 @@ public function save(CacheItemInterface $item): bool } return $this->upsertRows([[ - $this->mapData($item->getKey()), - base64_encode($this->encodeItem($item, $expiration['expiresAt'])), + self::KIND_DATA, + $item->getKey(), + $this->encodeItem($item, $expiration['expiresAt']), $expiration['expiresAt'], ]]); } @@ -237,26 +240,30 @@ public function saveItems(array $items): bool } $expiration = CachePayloadCodec::expirationFromItem($item); if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { - $expired[] = $this->mapData($item->getKey()); + $expired[] = $item->getKey(); continue; } $rows[] = [ - $this->mapData($item->getKey()), - base64_encode($this->encodeItem($item, $expiration['expiresAt'])), + self::KIND_DATA, + $item->getKey(), + $this->encodeItem($item, $expiration['expiresAt']), $expiration['expiresAt'], ]; } - return $this->deleteMapped($expired) && $this->upsertRows($rows); + return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } - /** @param list $mappedKeys */ - private function deleteMapped(array $mappedKeys): bool + /** @param list $keys */ + private function deleteByKind(string $kind, array $keys): bool { - foreach (array_chunk($mappedKeys, self::BATCH_SIZE) as $chunk) { + foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { $marks = implode(',', array_fill(0, count($chunk), '?')); - if (!$this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey IN ({$marks})")->execute($chunk)) { + $parameters = [$this->namespace, $kind, ...$chunk]; + if (!$this->pdo->prepare( + "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", + )->execute($parameters)) { return false; } } @@ -265,24 +272,32 @@ private function deleteMapped(array $mappedKeys): bool } /** - * @param list $mappedKeys + * @param list $keys * @return array */ - private function fetchRows(array $mappedKeys): array + private function fetchRows(string $kind, array $keys): array { $rows = []; - foreach (array_chunk($mappedKeys, self::BATCH_SIZE) as $chunk) { + foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { $marks = implode(',', array_fill(0, count($chunk), '?')); $statement = $this->pdo->prepare( - "SELECT ckey, payload, expires FROM {$this->table} WHERE ckey IN ({$marks})", + "SELECT cache_key, payload, expires FROM {$this->table} " + . "WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", ); - $statement->execute($chunk); - foreach ($statement->fetchAll(\PDO::FETCH_ASSOC) as $row) { - if (!is_array($row) || !is_string($row['ckey'] ?? null) || !is_string($row['payload'] ?? null)) { + $statement->execute([$this->namespace, $kind, ...$chunk]); + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (!is_array($row) || !is_string($row['cache_key'] ?? null)) { continue; } - $rows[$row['ckey']] = [ - 'payload' => $row['payload'], + $payload = $row['payload'] ?? null; + if (is_resource($payload)) { + $payload = stream_get_contents($payload); + } + if (!is_string($payload)) { + continue; + } + $rows[$row['cache_key']] = [ + 'payload' => $payload, 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null, ]; } @@ -291,40 +306,70 @@ private function fetchRows(array $mappedKeys): array return $rows; } - /** @param array $row */ + /** @param array{payload:string, expires:int|null} $row */ private function hydrate(string $key, array $row): ?CacheItem { - $expiresAt = is_numeric($row['expires']) ? (int) $row['expires'] : null; - if (CachePayloadCodec::isExpired($expiresAt) || !is_string($row['payload'])) { + if (CachePayloadCodec::isExpired($row['expires'])) { return null; } - $record = $this->decodeRecordFromBase64($row['payload']); + $record = $this->decodeRecordFromBlob($row['payload']); return $record === null ? null : $this->genericItemFromRecord($key, $record); } - /** @param list $tags */ - private function incrementTagsWithTransaction(array $tags): bool + /** @param list $rows */ + private function upsertChunk(array $rows): bool + { + if (!in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { + return $this->upsertGenericRows($rows); + } + + $values = implode(',', array_fill(0, count($rows), '(?, ?, ?, ?, ?)')); + $suffix = in_array($this->driver, ['pgsql', 'sqlite'], true) + ? 'ON CONFLICT (namespace, kind, cache_key) DO UPDATE SET ' + . 'payload = EXCLUDED.payload, expires = EXCLUDED.expires' + : 'ON DUPLICATE KEY UPDATE payload = VALUES(payload), expires = VALUES(expires)'; + $parameters = []; + foreach ($rows as [$kind, $key, $payload, $expires]) { + array_push($parameters, $this->namespace, $kind, $key, $payload, $expires); + } + + return $this->pdo->prepare( + "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) " + . "VALUES {$values} {$suffix}", + )->execute($parameters); + } + + /** @param list $rows */ + private function upsertGenericRows(array $rows): bool { $this->pdo->beginTransaction(); try { - foreach ($tags as $tag) { - $key = $this->mapTag($tag); + foreach ($rows as [$kind, $key, $payload, $expires]) { $update = $this->pdo->prepare( - "UPDATE {$this->table} SET payload = CAST(payload AS INTEGER) + 1 WHERE ckey = ?", + "UPDATE {$this->table} SET payload = ?, expires = ? " + . 'WHERE namespace = ? AND kind = ? AND cache_key = ?', ); - $update->execute([$key]); + $update->execute([$payload, $expires, $this->namespace, $kind, $key]); if ($update->rowCount() === 0) { + $exists = $this->pdo->prepare( + "SELECT 1 FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", + ); + $exists->execute([$this->namespace, $kind, $key]); + if ($exists->fetchColumn() !== false) { + continue; + } $this->pdo->prepare( - "INSERT INTO {$this->table} (ckey, payload, expires) VALUES (?, '1', NULL)", - )->execute([$key]); + "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) " + . 'VALUES (?, ?, ?, ?, ?)', + )->execute([$this->namespace, $kind, $key, $payload, $expires]); } } return $this->pdo->commit(); - } catch (\PDOException $failure) { + } catch (PDOException $failure) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } @@ -333,69 +378,9 @@ private function incrementTagsWithTransaction(array $tags): bool } } - private function mapData(string $key): string - { - return $this->namespace . ':d:' . $key; - } - - private function mapTag(string $tag): string - { - return $this->namespace . ':m:tag:' . $tag; - } - - /** @param list $rows */ - private function upsertChunk(array $rows): bool - { - if (!in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { - $this->pdo->beginTransaction(); - - try { - foreach ($rows as $row) { - $this->upsertGeneric($row); - } - - return $this->pdo->commit(); - } catch (\PDOException $failure) { - if ($this->pdo->inTransaction()) { - $this->pdo->rollBack(); - } - - throw $failure; - } - } - - $values = implode(',', array_fill(0, count($rows), '(?, ?, ?)')); - $suffix = in_array($this->driver, ['pgsql', 'sqlite'], true) - ? 'ON CONFLICT (ckey) DO UPDATE SET payload = EXCLUDED.payload, expires = EXCLUDED.expires' - : 'ON DUPLICATE KEY UPDATE payload = VALUES(payload), expires = VALUES(expires)'; - $parameters = []; - foreach ($rows as $row) { - array_push($parameters, ...$row); - } - - return $this->pdo->prepare( - "INSERT INTO {$this->table} (ckey, payload, expires) VALUES {$values} {$suffix}", - )->execute($parameters); - } - - /** @param array{0:string, 1:string, 2:int|null} $row */ - private function upsertGeneric(array $row): void - { - $update = $this->pdo->prepare("UPDATE {$this->table} SET payload = ?, expires = ? WHERE ckey = ?"); - $update->execute([$row[1], $row[2], $row[0]]); - if ($update->rowCount() === 0) { - $this->pdo->prepare( - "INSERT INTO {$this->table} (ckey, payload, expires) VALUES (?, ?, ?)", - )->execute($row); - } - } - - /** @param list $rows */ + /** @param list $rows */ private function upsertRows(array $rows): bool { - if ($rows === []) { - return true; - } foreach (array_chunk($rows, self::BATCH_SIZE) as $chunk) { if (!$this->upsertChunk($chunk)) { return false; diff --git a/src/Cache/Adapter/PdoCacheSchema.php b/src/Cache/Adapter/PdoCacheSchema.php index 8e47b49..5af9707 100644 --- a/src/Cache/Adapter/PdoCacheSchema.php +++ b/src/Cache/Adapter/PdoCacheSchema.php @@ -4,37 +4,52 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use PDO; +use PDOException; use RuntimeException; final class PdoCacheSchema { - public static function install(\PDO $pdo, string $table = 'cachelayer_entries'): void + public static function install(PDO $pdo, string $table = 'cachelayer_entries'): void { if (preg_match('/^[A-Za-z0-9_]+$/D', $table) !== 1) { throw new RuntimeException('Invalid PDO cache table name.'); } - $driverValue = $pdo->getAttribute(\PDO::ATTR_DRIVER_NAME); + $driverValue = $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); $driver = is_string($driverValue) ? $driverValue : ''; - $keyType = in_array($driver, ['mysql', 'mariadb'], true) ? 'VARCHAR(191)' : 'TEXT'; + $identifier = in_array($driver, ['mysql', 'mariadb'], true) ? 'VARCHAR(191)' : 'TEXT'; + $payload = match ($driver) { + 'mysql', 'mariadb' => 'MEDIUMBLOB', + 'pgsql' => 'BYTEA', + default => 'BLOB', + }; $pdo->exec( "CREATE TABLE IF NOT EXISTS {$table} ( - ckey {$keyType} PRIMARY KEY, - payload TEXT NOT NULL, - expires BIGINT NULL + namespace {$identifier} NOT NULL, + kind {$identifier} NOT NULL, + cache_key {$identifier} NOT NULL, + payload {$payload} NOT NULL, + expires BIGINT NULL, + PRIMARY KEY (namespace, kind, cache_key) )", ); $index = $table . '_expires_idx'; - try { - $pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$table}(expires)"); - } catch (\PDOException) { + if (in_array($driver, ['mysql', 'mariadb'], true)) { try { - $pdo->exec("CREATE INDEX {$index} ON {$table}(expires)"); - } catch (\PDOException) { - // The index already exists or the driver does not support this syntax. + $pdo->exec("CREATE INDEX {$index} ON {$table}(namespace, kind, expires)"); + } catch (PDOException $exception) { + $duplicate = is_array($exception->errorInfo) && ($exception->errorInfo[1] ?? null) === 1061; + if (!$duplicate) { + throw $exception; + } } + + return; } + + $pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$table}(namespace, kind, expires)"); } } diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 0e7bb6d..3fac0cb 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -27,8 +28,8 @@ public function clear(): bool { $ok = true; foreach (glob($this->dataDirectory . '*.php') ?: [] as $file) { - $ok = (!is_file($file) || unlink($file)) && $ok; $this->invalidateOpcache($file); + $ok = (!is_file($file) || unlink($file)) && $ok; } foreach (glob($this->metadataDirectory . '*') ?: [] as $file) { $ok = (!is_file($file) || unlink($file)) && $ok; @@ -39,36 +40,12 @@ public function clear(): bool return $ok; } - public function count(): int - { - $count = 0; - foreach (glob($this->dataDirectory . '*.php') ?: [] as $file) { - $row = require $file; - if (!is_array($row) || !isset($row['p']) || !is_string($row['p'])) { - continue; - } - - $blob = base64_decode($row['p'], true); - if (!is_string($blob)) { - continue; - } - - $record = $this->decodeRecordFromBlob($blob); - if ($record !== null) { - $count++; - } - } - - return $count; - } - public function deleteItem(string $key): bool { $file = $this->fileFor($key); - $ok = !is_file($file) || unlink($file); $this->invalidateOpcache($file); - return $ok; + return !is_file($file) || unlink($file); } /** @@ -109,16 +86,22 @@ public function getItem(string $key): CacheItem /** @param list $tags */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $versions = []; + $generations = []; foreach ($tags as $tag) { $path = $this->metadataFileFor($tag); $value = is_file($path) ? file_get_contents($path) : false; - $versions[$tag] = is_string($value) && ctype_digit($value) ? (int) $value : 0; + if (!self::isGeneration($value)) { + $value = self::newGeneration(); + if (!$this->atomicReplace($path, $value)) { + throw new RuntimeException('Unable to initialize PHP-file tag generation.'); + } + } + $generations[$tag] = strtolower((string) $value); } - return $versions; + return $generations; } public function hasItem(string $key): bool @@ -126,35 +109,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - foreach ($tags as $tag) { - $handle = fopen($this->metadataFileFor($tag), 'c+'); - if (!is_resource($handle) || !flock($handle, LOCK_EX)) { - if (is_resource($handle)) { - fclose($handle); - } - - return false; - } - $raw = stream_get_contents($handle); - $version = is_string($raw) && ctype_digit($raw) ? (int) $raw : 0; - rewind($handle); - ftruncate($handle, 0); - $written = fwrite($handle, (string) ($version + 1)); - fflush($handle); - flock($handle, LOCK_UN); - fclose($handle); - if ($written === false) { - return false; - } - } - - return true; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -186,6 +140,19 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + foreach ($tags as $tag) { + if (!$this->atomicReplace($this->metadataFileFor($tag), self::newGeneration())) { + return false; + } + } + + return true; + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -213,7 +180,7 @@ public function saveItems(array $items): bool private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); - $ns = sanitize_cache_ns($ns); + $ns = CacheInput::namespace($ns); $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; @@ -261,15 +228,13 @@ private function fileFor(string $key): string private function invalidateOpcache(string $file): void { if (function_exists('opcache_invalidate')) { - if (is_file($file)) { - opcache_invalidate($file, true); - } + opcache_invalidate($file, true); } } private function metadataFileFor(string $tag): string { - return $this->metadataDirectory . hash('xxh128', $tag) . '.version'; + return $this->metadataDirectory . hash('xxh128', $tag) . '.generation'; } private function persistItem(CacheItemInterface $item): bool @@ -298,6 +263,7 @@ private function persistItem(CacheItemInterface $item): bool return false; } + $this->invalidateOpcache($file); if (!rename($tmp, $file)) { if (is_file($tmp)) { unlink($tmp); @@ -306,8 +272,6 @@ private function persistItem(CacheItemInterface $item): bool return false; } - $this->invalidateOpcache($file); - return true; } } diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 73faa67..cdc571e 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Support\RedisConnection; @@ -47,7 +48,7 @@ public function __construct( throw new RuntimeException('phpredis extension not loaded'); } - $this->ns = sanitize_cache_ns($namespace); + $this->ns = CacheInput::namespace($namespace); $this->redis = $client ?? $this->connect($dsn); } @@ -65,17 +66,6 @@ public function clear(): bool return true; } - public function count(): int - { - $iter = null; - $count = 0; - while ($keys = $this->redis->scan($iter, $this->ns . ':d:*', 1000)) { - $count += count($keys); - } - - return $count; - } - public function deleteItem(string $key): bool { return $this->redis->del($this->map($key)) !== false; @@ -117,7 +107,7 @@ public function getItem(string $key): CacheItem /** @param list $tags */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { if ($tags === []) { return []; @@ -125,13 +115,20 @@ public function getTagVersions(array $tags): array $values = $this->redis->mget(array_map($this->mapTag(...), $tags)); $values = is_array($values) ? array_values($values) : []; - $versions = []; + $generations = []; + $missing = []; foreach ($tags as $index => $tag) { $value = $values[$index] ?? null; - $versions[$tag] = is_numeric($value) ? max(0, (int) $value) : 0; + $generation = self::normalizeGeneration($value); + if ($generation === null) { + $missing[$tag] = $value; + + continue; + } + $generations[$tag] = $generation; } - return $versions; + return $generations + $this->initializeTagGenerations($missing); } public function hasItem(string $key): bool @@ -139,25 +136,6 @@ public function hasItem(string $key): bool return $this->redis->exists($this->map($key)) === 1; } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - if ($tags === []) { - return true; - } - if (count($tags) === 1) { - return $this->redis->incr($this->mapTag($tags[0])) !== false; - } - - $pipeline = $this->redis->multi(\Redis::PIPELINE); - foreach ($tags as $tag) { - $pipeline->incr($this->mapTag($tag)); - } - - return $pipeline->exec() !== false; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -205,6 +183,21 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + if ($tags === []) { + return true; + } + $generations = []; + foreach ($tags as $tag) { + $generations[$this->mapTag($tag)] = self::newGeneration(); + } + + return $this->redis->mset($generations); + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -259,12 +252,8 @@ public function saveItems(array $items): bool if ($expiring === []) { return $ok; } - $pipeline = $this->redis->multi(\Redis::PIPELINE); - foreach ($expiring as [$key, $ttl, $blob]) { - $pipeline->setex($key, $ttl, $blob); - } - return $pipeline->exec() !== false && $ok; + return $ok && $this->saveExpiring($expiring); } private function connect(string $dsn): \Redis @@ -276,6 +265,33 @@ private function connect(string $dsn): \Redis } } + /** + * @param array $missing + * @return array + */ + 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; + } + + return $generations; + } + private function map(string $key): string { return $this->ns . ':d:' . $key; @@ -285,4 +301,18 @@ private function mapTag(string $tag): string { return $this->ns . ':m:tag:' . $tag; } + + /** @param list $records */ + private function saveExpiring(array $records): bool + { + $pipeline = $this->redis->multi(\Redis::PIPELINE); + foreach ($records as [$key, $ttl, $blob]) { + $pipeline->setex($key, $ttl, $blob); + } + $results = $pipeline->exec(); + + return is_array($results) + && count($results) === count($records) + && AdapterValueNormalizer::allTrue($results); + } } diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index dcfa838..1bdd663 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -12,6 +13,8 @@ final class RedisClusterCacheAdapter extends AbstractCacheAdapter { private const int BUCKET_COUNT = 128; + private const int PIPELINE_MODE = 2; + private readonly object $cluster; private readonly string $namespace; @@ -31,19 +34,19 @@ public function __construct( } $client = new \RedisCluster(null, $seeds, $timeout, $readTimeout, $persistent); } - foreach (['del', 'exists', 'get', 'incr', 'mget', 'mset', 'set', 'setex'] as $method) { + foreach (['del', 'exists', 'get', 'mget', 'mset', 'multi', 'set', 'setex'] as $method) { if (!method_exists($client, $method)) { throw new RuntimeException("Redis Cluster client must expose {$method}()."); } } - $this->namespace = sanitize_cache_ns($namespace); + $this->namespace = CacheInput::namespace($namespace); $this->cluster = $client; } public function clear(): bool { for ($bucket = 0; $bucket < self::BUCKET_COUNT; $bucket++) { - if ($this->call('incr', $this->epochKey($bucket)) === false) { + if (!$this->call('set', $this->generationKey($bucket), self::newGeneration())) { return false; } } @@ -77,12 +80,12 @@ public function getClient(): object public function getItem(string $key): CacheItem { $bucket = $this->bucket($key); - $values = $this->call('mget', [$this->epochKey($bucket), $this->mapData($key)]); + $values = $this->call('mget', [$this->generationKey($bucket), $this->mapData($key)]); $values = is_array($values) ? array_values($values) : []; - $epoch = $this->normalizeVersion($values[0] ?? null); + $generation = $this->namespaceGeneration($bucket, $values[0] ?? null); $blob = $values[1] ?? null; $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; - if ($record !== null && ($record->namespaceEpoch ?? 0) === $epoch) { + if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); } if (is_string($blob)) { @@ -94,21 +97,30 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $versions = []; + $generations = []; 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) { - $versions[$tag] = $this->normalizeVersion($values[$index] ?? null); + $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.'); } } - return $versions; + return $generations; } public function hasItem(string $key): bool @@ -116,19 +128,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - foreach ($tags as $tag) { - if ($this->call('incr', $this->mapTag($tag)) === false) { - return false; - } - } - - return true; - } - /** * @param list $keys * @return array @@ -152,6 +151,23 @@ public function multiFetch(array $keys): array return $ordered; } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + foreach ($this->groupByBucket($tags) as $group) { + $generations = []; + foreach ($group as $tag) { + $generations[$this->mapTag($tag)] = self::newGeneration(); + } + if (!$this->call('mset', $generations)) { + return false; + } + } + + return true; + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -162,8 +178,8 @@ public function save(CacheItemInterface $item): bool return $this->deleteItem($item->getKey()); } $bucket = $this->bucket($item->getKey()); - $epoch = $this->normalizeVersion($this->call('get', $this->epochKey($bucket))); - $blob = $this->encodeItem($item, $expiration['expiresAt'], $epoch); + $generation = $this->namespaceGeneration($bucket); + $blob = $this->encodeItem($item, $expiration['expiresAt'], $generation); return (bool) ($expiration['ttl'] === null ? $this->call('set', $this->mapData($item->getKey()), $blob) @@ -197,9 +213,14 @@ private function call(string $method, mixed ...$arguments): mixed return $this->cluster->{$method}(...$arguments); } - private function epochKey(int $bucket): string + private function callObject(object $target, string $method, mixed ...$arguments): mixed { - return $this->prefix($bucket) . ':m:epoch'; + $callable = [$target, $method]; + if (!is_callable($callable)) { + return false; + } + + return $callable(...$arguments); } /** @@ -208,16 +229,16 @@ private function epochKey(int $bucket): string */ private function fetchBucket(int $bucket, array $keys): array { - $physical = [$this->epochKey($bucket), ...array_map($this->mapData(...), $keys)]; + $physical = [$this->generationKey($bucket), ...array_map($this->mapData(...), $keys)]; $values = $this->call('mget', $physical); $values = is_array($values) ? array_values($values) : []; - $epoch = $this->normalizeVersion($values[0] ?? null); + $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; - if ($record !== null && ($record->namespaceEpoch ?? 0) === $epoch) { + if ($record !== null && $record->namespaceGeneration === $generation) { $items[$key] = $this->genericItemFromRecord($key, $record); continue; @@ -231,6 +252,11 @@ private function fetchBucket(int $bucket, array $keys): array return ['items' => $items, 'stale' => $stale]; } + private function generationKey(int $bucket): string + { + return $this->prefix($bucket) . ':m:generation'; + } + /** * @param list $keys * @return array> @@ -269,9 +295,28 @@ private function mapTag(string $tag): string return $this->prefix($this->bucket($tag)) . ':m:tag:' . $tag; } - private function normalizeVersion(mixed $value): int + private function namespaceGeneration(int $bucket, mixed $value = null): string { - return is_numeric($value) ? max(0, (int) $value) : 0; + if ($value === null) { + $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; } private function prefix(int $bucket): string @@ -282,8 +327,9 @@ private function prefix(int $bucket): string /** @param list $items */ private function saveBucket(int $bucket, array $items): bool { - $epoch = $this->normalizeVersion($this->call('get', $this->epochKey($bucket))); + $generation = $this->namespaceGeneration($bucket); $plain = []; + $expiring = []; foreach ($items as $item) { $expiration = CachePayloadCodec::expirationFromItem($item); if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { @@ -291,17 +337,39 @@ private function saveBucket(int $bucket, array $items): bool continue; } - $blob = $this->encodeItem($item, $expiration['expiresAt'], $epoch); + $blob = $this->encodeItem($item, $expiration['expiresAt'], $generation); if ($expiration['ttl'] === null) { $plain[$this->mapData($item->getKey())] = $blob; continue; } - if (!$this->call('setex', $this->mapData($item->getKey()), $expiration['ttl'], $blob)) { - return false; - } + $expiring[] = [$this->mapData($item->getKey()), $expiration['ttl'], $blob]; + } + + if ($plain !== [] && !$this->call('mset', $plain)) { + return false; + } + if ($expiring === []) { + return true; + } + + return $this->saveExpiring($expiring); + } + + /** @param list $records */ + private function saveExpiring(array $records): bool + { + $pipeline = $this->call('multi', self::PIPELINE_MODE); + if (!is_object($pipeline)) { + return false; + } + foreach ($records as [$key, $ttl, $blob]) { + $this->callObject($pipeline, 'setex', $key, $ttl, $blob); } + $results = $this->callObject($pipeline, 'exec'); - return $plain === [] || (bool) $this->call('mset', $plain); + return is_array($results) + && count($results) === count($records) + && AdapterValueNormalizer::allTrue($results); } } diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index e85714c..afe28be 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -6,13 +6,13 @@ use Cassandra\ExecutionOptions; use Cassandra\SimpleStatement; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; -use Throwable; use Traversable; -final class ScyllaDbCacheAdapter extends AbstractCacheAdapter +final class ScyllaDbCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { private const int WRITE_BATCH_SIZE = 50; @@ -36,7 +36,7 @@ public function __construct( throw new RuntimeException('ScyllaDbCacheAdapter requires session method `execute()`.'); } - $this->ns = sanitize_cache_ns($namespace); + $this->ns = CacheInput::namespace($namespace); $resolvedTable = self::validateIdentifier($table, 'table'); $resolvedKeyspace = self::validateIdentifier($keyspace, 'keyspace'); $this->qualifiedTable = $resolvedKeyspace . '.' . $resolvedTable; @@ -65,26 +65,6 @@ public function clear(): bool return true; } - public function count(): int - { - $now = time(); - $count = 0; - for ($bucket = 0; $bucket < $this->bucketCount; $bucket++) { - $rows = $this->queryRows( - "SELECT expires FROM {$this->qualifiedTable} WHERE ns = ? AND bucket = ?", - [$this->ns, $bucket], - ); - foreach ($rows as $row) { - $expiresAt = $this->normalizeExpiry($row['expires'] ?? null); - if ($expiresAt === null || $expiresAt > $now) { - $count++; - } - } - } - - return $count; - } - public function deleteItem(string $key): bool { $this->executeCql( @@ -130,34 +110,32 @@ public function getItem(string $key): CacheItem $payload = $this->normalizeString($row['payload'] ?? null); - return $this->genericFromBase64($key, $payload); + return $this->genericFromBlobWithInvalidator( + $key, + $payload, + fn(): bool => $this->deleteItem($key), + ); } /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $versions = array_fill_keys($tags, 0); - foreach ($this->groupByBucket($tags) as $bucket => $group) { - $marks = implode(',', array_fill(0, count($group), '?')); - $rows = $this->queryRows( - "SELECT tag, version FROM {$this->metadataTable} " - . "WHERE ns = ? AND bucket = ? AND tag IN ({$marks})", - [$this->ns, $bucket, ...$group], - ); - foreach ($rows as $row) { - $tag = $this->normalizeString($row['tag'] ?? null); - $version = $row['version'] ?? null; - if ($tag !== null && is_numeric($version)) { - $versions[$tag] = max(0, (int) $version); - } + $generations = $this->readTagGenerations($tags); + $missing = []; + foreach ($tags as $tag) { + if (!isset($generations[$tag])) { + $missing[$tag] = self::newGeneration(); } } + if ($missing !== [] && !$this->storeTagGenerations($missing)) { + throw new RuntimeException('Unable to initialize ScyllaDB tag generations.'); + } - return $versions; + return $generations + $missing; } public function hasItem(string $key): bool @@ -165,20 +143,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - foreach ($tags as $tag) { - $this->executeCql( - "UPDATE {$this->metadataTable} SET version = version + 1 WHERE ns = ? AND bucket = ? AND tag = ?", - [$this->ns, $this->bucket($tag), $tag], - ); - } - - return true; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -187,43 +151,73 @@ public function incrementTagVersions(array $tags): 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); + } + + return $items; + } + + /** @param list $tags */ + #[\Override] + public function readTagGenerations(array $tags): array + { + $generations = []; + foreach ($this->groupByBucket($tags) as $bucket => $group) { $marks = implode(',', array_fill(0, count($group), '?')); $rows = $this->queryRows( - "SELECT ckey, payload, expires FROM {$this->qualifiedTable} " - . "WHERE ns = ? AND bucket = ? AND ckey IN ({$marks})", - [$this->ns, $bucket, ...array_map($this->mapData(...), $group)], + "SELECT tag, generation FROM {$this->metadataTable} " + . "WHERE ns = ? AND bucket = ? AND tag IN ({$marks})", + [$this->ns, $bucket, ...$group], ); - $byKey = []; foreach ($rows as $row) { - $physical = $this->normalizeString($row['ckey'] ?? null); - if ($physical !== null) { - $byKey[$physical] = $row; + $tag = $this->normalizeString($row['tag'] ?? null); + $generation = $this->normalizeString($row['generation'] ?? null); + $generation = self::normalizeGeneration($generation); + if ($tag !== null && $generation !== null) { + $generations[$tag] = $generation; } } - foreach ($group as $key) { - $row = $byKey[$this->mapData($key)] ?? null; - $payload = is_array($row) ? $this->normalizeString($row['payload'] ?? null) : null; - $items[$key] = $this->genericFromBase64($key, $payload); - } } - return $items; + return $generations; + } + + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $generations = []; + foreach ($tags as $tag) { + $generations[$tag] = self::newGeneration(); + } + + return $this->storeTagGenerations($generations); } public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { - $this->executeCql( - "INSERT INTO {$this->qualifiedTable} (ns, bucket, ckey, payload, expires) VALUES (?, ?, ?, ?, ?)", - [ - $this->ns, - $this->bucket($saveItem->getKey()), - $this->mapData($saveItem->getKey()), - base64_encode($this->encodeItem($saveItem, $expires['expiresAt'])), - $expires['expiresAt'], - ], - ); + $cql = "INSERT INTO {$this->qualifiedTable} (ns, bucket, ckey, payload, expires) " + . 'VALUES (?, ?, ?, ?, ?)'; + $arguments = [ + $this->ns, + $this->bucket($saveItem->getKey()), + $this->mapData($saveItem->getKey()), + $this->encodeItem($saveItem, $expires['expiresAt']), + $expires['expiresAt'], + ]; + if ($expires['ttl'] !== null) { + $cql .= ' USING TTL ?'; + $arguments[] = $expires['ttl']; + } + $this->executeCql($cql, $arguments); return true; }); @@ -244,7 +238,11 @@ public function saveItems(array $items): bool continue; } - $active[$this->bucket($item->getKey())][] = [$item, $expiration['expiresAt']]; + $active[$this->bucket($item->getKey())][] = [ + $item, + $expiration['expiresAt'], + $expiration['ttl'], + ]; } if (!$this->deleteItems($expired)) { return false; @@ -258,6 +256,23 @@ public function saveItems(array $items): bool return true; } + /** @param array $generations */ + #[\Override] + public function storeTagGenerations(array $generations): bool + { + foreach ($generations as $tag => $generation) { + if (!self::isGeneration($generation)) { + return false; + } + $this->executeCql( + "INSERT INTO {$this->metadataTable} (ns, bucket, tag, generation) VALUES (?, ?, ?, ?)", + [$this->ns, $this->bucket($tag), $tag, strtolower($generation)], + ); + } + + return true; + } + private static function validateIdentifier(string $value, string $label): string { if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $value)) { @@ -296,7 +311,7 @@ private function createSchemaIfMissing(): void ns text, bucket int, ckey text, - payload text, + payload blob, expires bigint, PRIMARY KEY ((ns, bucket), ckey) )", @@ -306,7 +321,7 @@ private function createSchemaIfMissing(): void ns text, bucket int, tag text, - version counter, + generation text, PRIMARY KEY ((ns, bucket), tag) )", ); @@ -322,11 +337,7 @@ private function executeCql(string $cql, array $arguments = []): mixed $statement = $this->statementFor($cql); $options = $this->executionOptions($arguments); - try { - return $this->callSession('execute', [$statement, $options]); - } catch (Throwable) { - return $this->callSession('execute', [$statement]); - } + return $this->callSession('execute', [$statement, $options]); } /** @@ -343,6 +354,43 @@ private function executionOptions(array $arguments): mixed return $options; } + /** + * @param list $keys + * @return array{items:array, invalid:list} + */ + private function fetchBucketItems(int $bucket, array $keys): array + { + $marks = implode(',', array_fill(0, count($keys), '?')); + $rows = $this->queryRows( + "SELECT ckey, payload, expires FROM {$this->qualifiedTable} " + . "WHERE ns = ? AND bucket = ? AND ckey IN ({$marks})", + [$this->ns, $bucket, ...array_map($this->mapData(...), $keys)], + ); + $byKey = []; + foreach ($rows as $row) { + $physical = $this->normalizeString($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); + $items[$key] = $record === null + ? $this->genericMiss($key) + : $this->genericItemFromRecord($key, $record); + if (is_array($row) && $record === null) { + $invalid[] = $key; + } + } + + return ['items' => $items, 'invalid' => $invalid]; + } + /** * @param string $cql The cql argument. * @param array $arguments The arguments argument. @@ -455,22 +503,26 @@ private function queryRows(string $cql, array $arguments = []): array return []; } - /** @param list $items */ + /** @param list $items */ private function saveBucket(int $bucket, array $items): void { $inserts = []; $arguments = []; - foreach ($items as [$item, $expiresAt]) { + foreach ($items as [$item, $expiresAt, $ttl]) { $inserts[] = "INSERT INTO {$this->qualifiedTable} " - . '(ns, bucket, ckey, payload, expires) VALUES (?, ?, ?, ?, ?);'; + . '(ns, bucket, ckey, payload, expires) VALUES (?, ?, ?, ?, ?)' + . ($ttl === null ? ';' : ' USING TTL ?;'); array_push( $arguments, $this->ns, $bucket, $this->mapData($item->getKey()), - base64_encode($this->encodeItem($item, $expiresAt)), + $this->encodeItem($item, $expiresAt), $expiresAt, ); + if ($ttl !== null) { + $arguments[] = $ttl; + } } $this->executeCql('BEGIN UNLOGGED BATCH ' . implode(' ', $inserts) . ' APPLY BATCH', $arguments); } diff --git a/src/Cache/Adapter/SecuresFilesystemDirectories.php b/src/Cache/Adapter/SecuresFilesystemDirectories.php index e407254..8aefe33 100644 --- a/src/Cache/Adapter/SecuresFilesystemDirectories.php +++ b/src/Cache/Adapter/SecuresFilesystemDirectories.php @@ -19,9 +19,37 @@ protected function assertSecureDirectory(string $path, string $label): void { $this->assertPathNotSymlink($path, $label); + if (!is_dir($path) || !is_writable($path)) { + 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}"); } } + + protected function atomicReplace(string $path, string $contents): bool + { + $lock = fopen($path . '.lock', 'c'); + if (!is_resource($lock) || !flock($lock, LOCK_EX)) { + if (is_resource($lock)) { + fclose($lock); + } + + return false; + } + + $temporary = tempnam(dirname($path), 'cl_'); + $stored = $temporary !== false + && file_put_contents($temporary, $contents, LOCK_EX) === strlen($contents) + && rename($temporary, $path); + if (!$stored && is_string($temporary) && is_file($temporary)) { + unlink($temporary); + } + flock($lock, LOCK_UN); + fclose($lock); + + return $stored; + } } diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 9f3c194..cdf0d3a 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -4,14 +4,17 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; -final class SharedMemoryCacheAdapter extends AbstractCacheAdapter +final class SharedMemoryCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { use SecuresFilesystemDirectories; + private const string OWNER_KEY = "\0cachelayer-owner"; + private const int VAR_ID = 1; /** @var resource */ @@ -31,15 +34,16 @@ public function __construct( throw new RuntimeException('ext-sysvshm is not available'); } - $this->ns = sanitize_cache_ns($namespace); + $this->ns = CacheInput::namespace($namespace); $this->tokenFile = $this->createTokenFile(); $this->segment = $this->attachSegment($segmentSize); $this->lockHandle = $this->openLockHandle(); $this->withExclusiveLock(function (): void { if (!shm_has_var($this->segment, self::VAR_ID)) { - shm_put_var($this->segment, self::VAR_ID, []); + shm_put_var($this->segment, self::VAR_ID, [self::OWNER_KEY => $this->ownerIdentity()]); } + $this->loadStore(); }); } @@ -54,40 +58,14 @@ public function clear(): bool $this->deferred = []; return $this->withExclusiveLock( - fn(): bool => shm_put_var($this->segment, self::VAR_ID, []), + fn(): bool => shm_put_var( + $this->segment, + self::VAR_ID, + [self::OWNER_KEY => $this->ownerIdentity()], + ), ); } - public function count(): int - { - return $this->withExclusiveLock(function (): int { - $store = $this->loadStore(); - $changed = false; - $count = 0; - - foreach ($store as $key => $blob) { - if (!str_starts_with($key, $this->ns . ':d:') || !is_string($blob)) { - continue; - } - $record = $this->decodeRecordFromBlob($blob); - if ($record === null) { - unset($store[$key]); - $changed = true; - - continue; - } - - $count++; - } - - if ($changed) { - $this->store($store); - } - - return $count; - }); - } - public function deleteItem(string $key): bool { $mapped = $this->map($key); @@ -141,21 +119,23 @@ function () use ($mapped): ?string { /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - return $this->withSharedLock(function () use ($tags): array { - $store = $this->loadStore(); - $versions = []; - foreach ($tags as $tag) { - $version = $store[$this->mapTag($tag)] ?? null; - $versions[$tag] = is_int($version) && $version >= 0 ? $version : 0; + $generations = $this->readTagGenerations($tags); + $missing = []; + foreach ($tags as $tag) { + if (!isset($generations[$tag])) { + $missing[$tag] = self::newGeneration(); } + } + if ($missing !== []) { + $this->storeTagGenerations($missing); + } - return $versions; - }); + return $generations + $missing; } public function hasItem(string $key): bool @@ -163,32 +143,16 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - return $this->withExclusiveLock(function () use ($tags): bool { - $store = $this->loadStore(); - foreach ($tags as $tag) { - $key = $this->mapTag($tag); - $version = $store[$key] ?? null; - $store[$key] = (is_int($version) && $version >= 0 ? $version : 0) + 1; - } - - return $this->store($store); - }); - } - /** * @param list $keys * @return array */ public function multiFetch(array $keys): array { - return $this->withExclusiveLock(function () use ($keys): array { + [$items, $invalid] = $this->withSharedLock(function () use ($keys): array { $store = $this->loadStore(); $items = []; - $changed = false; + $invalid = []; foreach ($keys as $key) { $mapped = $this->map($key); $blob = $store[$mapped] ?? null; @@ -197,18 +161,49 @@ public function multiFetch(array $keys): array ? $this->genericMiss($key) : $this->genericItemFromRecord($key, $record); if ($blob !== null && $record === null) { - unset($store[$mapped]); - $changed = true; + $invalid[] = $key; } } - if ($changed) { - $this->store($store); + + return [$items, $invalid]; + }); + if ($invalid !== []) { + $this->deleteItems($invalid); + } + + return $items; + } + + /** @param list $tags */ + #[\Override] + public function readTagGenerations(array $tags): array + { + return $this->withSharedLock(function () use ($tags): array { + $store = $this->loadStore(); + $generations = []; + foreach ($tags as $tag) { + $generation = $store[$this->mapTag($tag)] ?? null; + if (self::isGeneration($generation)) { + $generations[$tag] = strtolower((string) $generation); + } } - return $items; + return $generations; }); } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $generations = []; + foreach ($tags as $tag) { + $generations[$tag] = self::newGeneration(); + } + + return $this->storeTagGenerations($generations); + } + public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { @@ -255,6 +250,23 @@ public function saveItems(array $items): bool }); } + /** @param array $generations */ + #[\Override] + public function storeTagGenerations(array $generations): bool + { + return $this->withExclusiveLock(function () use ($generations): bool { + $store = $this->loadStore(); + foreach ($generations as $tag => $generation) { + if (!self::isGeneration($generation)) { + return false; + } + $store[$this->mapTag($tag)] = strtolower($generation); + } + + return $this->store($store); + }); + } + private function attachSegment(int $segmentSize): \SysvSharedMemory { if (!function_exists('ftok')) { @@ -300,7 +312,7 @@ private function createTokenFile(): string } /** - * @phpstan-return array + * @phpstan-return array */ private function loadStore(): array { @@ -316,10 +328,13 @@ private function loadStore(): array $out = []; foreach ($store as $key => $value) { - if (is_string($key) && (is_string($value) || is_int($value))) { + if (is_string($key) && is_string($value)) { $out[$key] = $value; } } + if (($out[self::OWNER_KEY] ?? null) !== $this->ownerIdentity()) { + throw new RuntimeException('Shared-memory key collision detected'); + } return $out; } @@ -347,6 +362,11 @@ private function openLockHandle(): mixed throw new RuntimeException('Unable to open the shared-memory lock file'); } + private function ownerIdentity(): string + { + return hash('sha256', self::class . "\0" . $this->ns . "\0" . $this->tokenFile); + } + private function prepareDirectory(string $directory): void { $this->assertPathNotSymlink($directory, 'Shared-memory cache directory'); @@ -361,7 +381,7 @@ private function prepareDirectory(string $directory): void /** * @param array $store The store argument. - * @phpstan-param array $store + * @phpstan-param array $store */ private function store(array $store): bool { diff --git a/src/Cache/Adapter/TagGenerationCacheInterface.php b/src/Cache/Adapter/TagGenerationCacheInterface.php new file mode 100644 index 0000000..b79b8a5 --- /dev/null +++ b/src/Cache/Adapter/TagGenerationCacheInterface.php @@ -0,0 +1,18 @@ + $tags + * @return array + */ + public function readTagGenerations(array $tags): array; + + /** @param array $generations */ + public function storeTagGenerations(array $generations): bool; +} diff --git a/src/Cache/Adapter/ChainCacheAdapter.php b/src/Cache/Adapter/TieredCacheAdapter.php similarity index 89% rename from src/Cache/Adapter/ChainCacheAdapter.php rename to src/Cache/Adapter/TieredCacheAdapter.php index 600e5ef..750c610 100644 --- a/src/Cache/Adapter/ChainCacheAdapter.php +++ b/src/Cache/Adapter/TieredCacheAdapter.php @@ -9,9 +9,10 @@ use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use InvalidArgumentException; +use LogicException; use Psr\Cache\CacheItemInterface; -final class ChainCacheAdapter extends AbstractCacheAdapter +final class TieredCacheAdapter extends AbstractCacheAdapter { /** @param list $pools */ public function __construct( @@ -87,14 +88,16 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - $first = $this->pools[0]; + foreach (array_reverse($this->pools) as $authoritative) { + return $authoritative->getTagGenerations($tags); + } - return $first->getTagVersions($tags); + throw new LogicException('A tiered cache must retain an authoritative pool.'); } public function hasItem(string $key): bool @@ -102,18 +105,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - $incremented = true; - foreach ($this->pools as $pool) { - $incremented = $pool->incrementTagVersions($tags) && $incremented; - } - - return $incremented; - } - /** * @param list $keys * @return array @@ -127,7 +118,7 @@ public function multiFetch(array $keys): array break; } $wanted = array_keys($remaining); - $fetched = iterator_to_array($pool->getItems($wanted), true); + $fetched = $pool->multiFetch($wanted); $hits = []; foreach ($wanted as $key) { $item = $fetched[$key] ?? null; @@ -151,6 +142,18 @@ public function multiFetch(array $keys): array return $ordered; } + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $incremented = true; + foreach ($this->pools as $pool) { + $incremented = $pool->rotateTagGenerations($tags) && $incremented; + } + + return $incremented; + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -181,11 +184,11 @@ public function saveItems(array $items): bool private function copyItem(CacheItemInterface $source): CacheItem { $ttl = $source instanceof CacheItem ? $source->ttlSeconds() : null; - $tags = $source instanceof CacheItem ? $source->getTagVersions() : []; + $tags = $source instanceof CacheItem ? $source->getTagGenerations() : []; return (new CacheItem($this, $source->getKey(), $source->get(), true)) ->expiresAfter($ttl) - ->setTagVersions($tags); + ->setTagGenerations($tags); } /** @param array $items */ @@ -215,7 +218,7 @@ private function saveIntoPool(InternalCachePoolInterface $pool, array $items): b $target->set($item->get()); $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); if ($target instanceof CacheItem && $item instanceof CacheItem) { - $target->setTagVersions($item->getTagVersions()); + $target->setTagGenerations($item->getTagGenerations()); } $targets[$key] = $target; } @@ -229,7 +232,7 @@ private function saveOneIntoPool(InternalCachePoolInterface $pool, CacheItemInte $target->set($item->get()); $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); if ($target instanceof CacheItem && $item instanceof CacheItem) { - $target->setTagVersions($item->getTagVersions()); + $target->setTagGenerations($item->getTagGenerations()); } return $pool->save($target); diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index bc551c9..a805f1a 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -4,9 +4,10 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Closure; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; -use WeakMap; use WeakReference; final class WeakMapCacheAdapter extends AbstractCacheAdapter @@ -19,19 +20,15 @@ final class WeakMapCacheAdapter extends AbstractCacheAdapter /** @var array */ private array $weakExpires = []; - /** @var WeakMap */ - private WeakMap $weakObjects; - /** @var array> */ private array $weakRefs = []; - /** @var array> */ + /** @var array> */ private array $weakTags = []; public function __construct(string $namespace = 'default') { - $this->ns = sanitize_cache_ns($namespace); - $this->weakObjects = new WeakMap(); + $this->ns = CacheInput::namespace($namespace); } public function clear(): bool @@ -40,48 +37,17 @@ public function clear(): bool $this->weakRefs = []; $this->weakExpires = []; $this->weakTags = []; - $this->weakObjects = new WeakMap(); $this->deferred = []; $this->resetLocalMetadata(); return true; } - public function count(): int - { - $this->pruneCollected(); - $this->pruneExpiredScalar(); - - $count = count($this->scalarStore); - foreach ($this->weakRefs as $mapped => $ref) { - $obj = $ref->get(); - if (!is_object($obj)) { - continue; - } - - if (CachePayloadCodec::isExpired($this->weakExpires[$mapped] ?? null)) { - continue; - } - - $count++; - } - - return $count; - } - public function deleteItem(string $key): bool { $mapped = $this->map($key); unset($this->scalarStore[$mapped], $this->weakExpires[$mapped], $this->weakTags[$mapped]); - $ref = $this->weakRefs[$mapped] ?? null; - if ($ref instanceof WeakReference) { - $obj = $ref->get(); - if (is_object($obj) && isset($this->weakObjects[$obj])) { - unset($this->weakObjects[$obj]); - } - } - unset($this->weakRefs[$mapped]); return true; @@ -111,14 +77,14 @@ public function getItem(string $key): CacheItem $exp = $this->weakExpires[$mapped] ?? null; if (is_object($obj) && !CachePayloadCodec::isExpired($exp)) { - $item = new CacheItem($this, $key); - $item->set($obj); - if ($exp !== null) { - $item->expiresAt(CachePayloadCodec::toDateTime($exp)); - } - $item->setTagVersions($this->weakTags[$mapped] ?? []); - - return $item; + return new CacheItem( + $this, + $key, + $obj, + true, + CachePayloadCodec::toDateTime($exp), + $this->weakTags[$mapped] ?? [], + ); } $this->deleteItem($key); @@ -226,13 +192,18 @@ private function persistItem(CacheItemInterface $item, array $expires): bool $value = $item->get(); if (is_object($value)) { + if ($value instanceof Closure && !$this->options()->allowClosures) { + return false; + } + if (!$value instanceof Closure && !$this->options()->allowObjects) { + return false; + } $ref = WeakReference::create($value); $this->weakRefs[$mapped] = $ref; $this->weakExpires[$mapped] = $expires['expiresAt']; $this->weakTags[$mapped] = $item instanceof CacheItem - ? $item->getTagVersions() + ? $item->getTagGenerations() : []; - $this->weakObjects[$value] = ['key' => $mapped, 'expires' => $expires['expiresAt']]; unset($this->scalarStore[$mapped]); return true; @@ -253,14 +224,4 @@ private function pruneCollected(): void } } } - - private function pruneExpiredScalar(): void - { - foreach ($this->scalarStore as $mapped => $blob) { - $record = $this->decodeRecordFromBlob($blob); - if ($record === null) { - unset($this->scalarStore[$mapped]); - } - } - } } diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 930586e..6dd3aa9 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -14,8 +14,10 @@ use Infocyph\CacheLayer\Cache\Lock\PdoLockProvider; use Infocyph\CacheLayer\Cache\Lock\RedisLockProvider; use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; +use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsSnapshot; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; +use Infocyph\CacheLayer\Exceptions\CacheBackendException; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use MongoDB\Client; use Psr\Cache\CacheItemInterface; @@ -38,7 +40,9 @@ public function __construct( private LockProviderInterface $lockProvider = new FileLockProvider(), private CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), ?CacheOptions $options = null, + private readonly string $namespace = 'default', ) { + CacheInput::namespace($namespace); $this->options = $options ?? new CacheOptions(); if ($adapter instanceof AbstractCacheAdapter) { $adapter->configureOptions($this->options); @@ -47,7 +51,7 @@ public function __construct( public static function apcu(string $namespace = 'default', ?CacheOptions $options = null): self { - return new self(new Adapter\ApcuCacheAdapter($namespace), options: $options); + return new self(new Adapter\ApcuCacheAdapter($namespace), options: $options, namespace: $namespace); } public static function file( @@ -55,7 +59,7 @@ public static function file( ?string $dir = null, ?CacheOptions $options = null, ): self { - return new self(new Adapter\FileCacheAdapter($namespace, $dir), options: $options); + return new self(new Adapter\FileCacheAdapter($namespace, $dir), options: $options, namespace: $namespace); } /** @param list $servers */ @@ -71,12 +75,13 @@ public static function memcached( $adapter, new MemcachedLockProvider($adapter->getClient()), options: $options, + namespace: $namespace, ); } public static function memory(string $namespace = 'default', ?CacheOptions $options = null): self { - return new self(new Adapter\ArrayCacheAdapter($namespace), options: $options); + return new self(new Adapter\ArrayCacheAdapter($namespace), options: $options, namespace: $namespace); } public static function mongodb( @@ -89,7 +94,11 @@ public static function mongodb( ?CacheOptions $options = null, ): self { if ($collection !== null) { - return new self(new Adapter\MongoDbCacheAdapter($collection, $namespace), options: $options); + return new self( + new Adapter\MongoDbCacheAdapter($collection, $namespace), + options: $options, + namespace: $namespace, + ); } if ($client === null) { if (!class_exists(Client::class)) { @@ -103,12 +112,13 @@ public static function mongodb( return new self( Adapter\MongoDbCacheAdapter::fromClient($client, $database, $collectionName, $namespace), options: $options, + namespace: $namespace, ); } public static function nullStore(?CacheOptions $options = null): self { - return new self(new Adapter\NullCacheAdapter(), options: $options); + return new self(new Adapter\NullCacheAdapter(), options: $options, namespace: 'null'); } public static function pdo( @@ -122,7 +132,12 @@ public static function pdo( ): self { $adapter = new Adapter\PdoCacheAdapter($namespace, $dsn, $username, $password, $pdo, $table); - return new self($adapter, new PdoLockProvider($adapter->getClient()), options: $options); + return new self( + $adapter, + new PdoLockProvider($adapter->getClient()), + options: $options, + namespace: $namespace, + ); } public static function phpFiles( @@ -130,7 +145,7 @@ public static function phpFiles( ?string $dir = null, ?CacheOptions $options = null, ): self { - return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir), options: $options); + return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir), options: $options, namespace: $namespace); } public static function redis( @@ -141,7 +156,12 @@ public static function redis( ): self { $adapter = new Adapter\RedisCacheAdapter($namespace, $dsn, $client); - return new self($adapter, new RedisLockProvider($adapter->getClient()), options: $options); + return new self( + $adapter, + new RedisLockProvider($adapter->getClient()), + options: $options, + namespace: $namespace, + ); } /** @param list $seeds */ @@ -164,6 +184,7 @@ public static function redisCluster( $client, ), options: $options, + namespace: $namespace, ); } @@ -187,6 +208,7 @@ public static function scylla( return new self( new Adapter\ScyllaDbCacheAdapter($session, $keyspace, $table, $namespace, $bucketCount), options: $options, + namespace: $namespace, ); } @@ -195,7 +217,11 @@ public static function sharedMemory( int $segmentSize = 16_777_216, ?CacheOptions $options = null, ): self { - return new self(new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize), options: $options); + return new self( + new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize), + options: $options, + namespace: $namespace, + ); } public static function sqlite( @@ -213,13 +239,15 @@ public static function tiered( array $tiers, bool $writeToL1 = true, ?CacheOptions $options = null, + string $namespace = 'tiered', ): self { $metrics = new InMemoryCacheMetricsCollector(); return new self( - new Adapter\ChainCacheAdapter(TieredPoolFactory::fromArray($tiers), $writeToL1, $metrics), + new Adapter\TieredCacheAdapter(TieredPoolFactory::fromArray($tiers), $writeToL1, $metrics), metrics: $metrics, options: $options, + namespace: $namespace, ); } @@ -231,12 +259,17 @@ public static function valkey( ): self { $adapter = new Adapter\ValkeyCacheAdapter($namespace, $dsn, $client); - return new self($adapter, new RedisLockProvider($adapter->getClient()), options: $options); + return new self( + $adapter, + new RedisLockProvider($adapter->getClient()), + options: $options, + namespace: $namespace, + ); } public static function weakMap(string $namespace = 'default', ?CacheOptions $options = null): self { - return new self(new Adapter\WeakMapCacheAdapter($namespace), options: $options); + return new self(new Adapter\WeakMapCacheAdapter($namespace), options: $options, namespace: $namespace); } public function clear(): bool @@ -372,7 +405,7 @@ public function invalidateTag(string $tag): bool public function invalidateTags(array $tags): bool { $tags = CacheInput::tags($tags); - $invalidated = $this->backendBool(fn(): bool => $this->adapter->incrementTagVersions($tags)); + $invalidated = $this->backendBool(fn(): bool => $this->adapter->rotateTagGenerations($tags)); $this->metric(count($tags) === 1 ? 'tag_invalidate' : 'tag_invalidate_batch'); return $invalidated; @@ -398,6 +431,7 @@ public function offsetUnset(mixed $offset): void $this->delete($this->requireStringOffset($offset)); } + /** @param callable(): mixed $resolver */ public function remember( string $key, callable $resolver, @@ -426,9 +460,21 @@ public function remember( ); if ($lock === null) { $this->metric('lock_timeout'); + $item = $this->getItem($key); + if ($item->isHit()) { + $this->metric('remember_hit'); + + return $item->get(); + } + + $generations = $this->captureTagGenerations($tags); $this->metric('remember_unlocked_compute'); - $value = $resolver($item); - $this->storeResolved($key, $value, $ttl, $tags); + $value = $resolver(); + if ($generations !== null && $this->tagGenerationsUnchanged($generations)) { + $this->storeResolved($key, $value, $ttl, $generations); + } elseif ($tags !== []) { + $this->metric('remember_discarded_after_tag_change'); + } return $value; } @@ -442,14 +488,25 @@ public function remember( return $item->get(); } - $value = $resolver($item); + $generations = $this->captureTagGenerations($tags); + $value = $resolver(); if (!$this->backend( fn(): bool => $this->lockProvider->refresh($lock, self::LOCK_LEASE_SECONDS), false, )) { $this->metric('lock_refresh_failure'); + $this->metric('remember_discarded_after_lock_loss'); + + return $value; + } + if ($generations === null || !$this->tagGenerationsUnchanged($generations)) { + if ($tags !== []) { + $this->metric('remember_discarded_after_tag_change'); + } + + return $value; } - $this->storeResolved($key, $value, $ttl, $tags); + $this->storeResolved($key, $value, $ttl, $generations); return $value; } finally { @@ -543,27 +600,12 @@ public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = n return $this->delete($key); } - $versions = $this->backend( - fn(): array => $this->adapter->getTagVersions($tags), - null, - ); - if (!is_array($versions)) { + $generations = $this->captureTagGenerations($tags); + if ($generations === null) { return false; } - $snapshot = []; - foreach ($tags as $tag) { - $version = $versions[$tag] ?? null; - $snapshot[$tag] = is_int($version) && $version >= 0 ? $version : 0; - } - $item = $this->adapter->createItem($key); - if (!$item instanceof CacheItem) { - throw new CacheInvalidArgumentException('Tagged caching requires CacheLayer cache items.'); - } - $item->set($value)->setTagVersions($snapshot)->expiresAfter($ttlSeconds); - $saved = $this->save($item); - $this->metric('set_tagged'); - return $saved; + return $this->setTaggedWithGenerations($key, $value, $generations, $ttlSeconds); } public function useMemcachedLock(?\Memcached $client = null, string $prefix = 'cachelayer:lock:'): self @@ -602,7 +644,9 @@ private function backend(callable $operation, mixed $fallback): mixed } catch (Throwable $failure) { $this->metric('backend_failure'); if (!$this->options->failOpen) { - throw $failure; + throw $failure instanceof CacheBackendException + ? $failure + : new CacheBackendException('Cache backend operation failed.', 0, $failure); } return $fallback; @@ -622,7 +666,9 @@ private function backendBool(callable $operation): bool } catch (Throwable $failure) { $this->metric('backend_failure'); if (!$this->options->failOpen) { - throw $failure; + throw $failure instanceof CacheBackendException + ? $failure + : new CacheBackendException('Cache backend operation failed.', 0, $failure); } return false; @@ -630,19 +676,42 @@ private function backendBool(callable $operation): bool } /** - * @param list $keys - * @return array + * @param list $tags + * @return array|null */ - private function fetchItems(array $keys): array + private function captureTagGenerations(array $tags): ?array { - $items = []; - foreach ($this->adapter->getItems($keys) as $key => $item) { - if (is_string($key) && $item instanceof CacheItemInterface) { - $items[$key] = $item; + if ($tags === []) { + return []; + } + + $stored = $this->backend( + fn(): array => $this->adapter->getTagGenerations($tags), + null, + ); + if (!is_array($stored)) { + return null; + } + + $generations = []; + foreach ($tags as $tag) { + $generation = $stored[$tag] ?? null; + if (!is_string($generation) || strlen($generation) !== 32 || !ctype_xdigit($generation)) { + return null; } + $generations[$tag] = strtolower($generation); } - return $items; + return $generations; + } + + /** + * @param list $keys + * @return array + */ + private function fetchItems(array $keys): array + { + return $this->adapter->multiFetch($keys); } private function jitteredTtl(?int $ttl): ?int @@ -672,16 +741,7 @@ private function miss(string $key): CacheItemInterface */ private function readableMetricsSnapshot(array $snapshot): array { - $readable = []; - foreach ($snapshot as $adapterClass => $counters) { - $separator = strrpos($adapterClass, '\\'); - $short = $separator === false ? $adapterClass : substr($adapterClass, $separator + 1); - $short = preg_replace('/CacheAdapter$/', '', $short) ?? $short; - $name = strtolower(preg_replace('/(? $generations */ + private function setTaggedWithGenerations( + string $key, + mixed $value, + array $generations, + ?int $ttlSeconds, + ): bool { + $item = $this->adapter->createItem($key); + if (!$item instanceof CacheItem) { + throw new CacheInvalidArgumentException('Tagged caching requires CacheLayer cache items.'); + } + $item->set($value)->setTagGenerations($generations)->expiresAfter($ttlSeconds); + $saved = $this->save($item); + $this->metric('set_tagged'); + + return $saved; + } + private function stampedeLockKey(string $key): string { - return 'cachelayer:lock:' . hash('xxh128', $key); + return 'cachelayer:remember:' . hash('xxh128', $this->namespace . "\0" . $key); } - /** @param list $tags */ - private function storeResolved(string $key, mixed $value, mixed $ttl, array $tags): void + /** @param array $generations */ + private function storeResolved(string $key, mixed $value, ?int $ttl, array $generations): void { - $ttlSeconds = CacheInput::ttl($ttl); - if ($ttlSeconds !== null && $ttlSeconds <= 0) { + if ($ttl !== null && $ttl <= 0) { $this->delete($key); return; } - $ttlSeconds = $this->jitteredTtl($ttlSeconds); - if ($tags === []) { + $ttlSeconds = $this->jitteredTtl($ttl); + if ($generations === []) { $this->set($key, $value, $ttlSeconds); return; } - $this->setTagged($key, $value, $tags, $ttlSeconds); + $this->setTaggedWithGenerations($key, $value, $generations, $ttlSeconds); + } + + /** @param array $expected */ + private function tagGenerationsUnchanged(array $expected): bool + { + if ($expected === []) { + return true; + } + + $current = $this->captureTagGenerations(array_keys($expected)); + + return $current !== null && $current === $expected; } private function validateTagSnapshot(CacheItemInterface $item): CacheItemInterface { - if (!$item instanceof CacheItem || !$item->isHit() || $item->getTagVersions() === []) { + if (!$item instanceof CacheItem || !$item->isHit() || $item->getTagGenerations() === []) { return $item; } - $tags = array_keys($item->getTagVersions()); - $versions = $this->backend( - fn(): array => $this->adapter->getTagVersions($tags), + $tags = array_keys($item->getTagGenerations()); + $generations = $this->backend( + fn(): array => $this->adapter->getTagGenerations($tags), null, ); - $this->metric('tag_version_fetch_batch'); - if (!is_array($versions)) { + $this->metric('tag_generation_fetch_batch'); + if (!is_array($generations)) { return $this->miss($item->getKey()); } - if (CacheTagSnapshots::isCurrent($item, $versions)) { + if (CacheTagSnapshots::isCurrent($item, $generations)) { return $item; } $this->backendBool(fn(): bool => $this->adapter->deleteItem($item->getKey())); @@ -749,15 +838,15 @@ private function validateTagSnapshots(array $items): array return $items; } - $versions = $this->backend( - fn(): array => $this->adapter->getTagVersions($tags), + $generations = $this->backend( + fn(): array => $this->adapter->getTagGenerations($tags), null, ); - $this->metric('tag_version_fetch_batch'); - if (!is_array($versions)) { + $this->metric('tag_generation_fetch_batch'); + if (!is_array($generations)) { return CacheTagSnapshots::missTagged($items, $this->miss(...)); } - $validated = CacheTagSnapshots::rejectStale($items, $versions, $this->miss(...)); + $validated = CacheTagSnapshots::rejectStale($items, $generations, $this->miss(...)); $stale = $validated['stale']; if ($stale !== []) { $this->backendBool(fn(): bool => $this->adapter->deleteItems($stale)); diff --git a/src/Cache/CacheInput.php b/src/Cache/CacheInput.php index abcbeac..b0e141a 100644 --- a/src/Cache/CacheInput.php +++ b/src/Cache/CacheInput.php @@ -55,6 +55,19 @@ public static function materializeKeys(iterable $keys): array return self::keys($materialized); } + public static function namespace(string $namespace): string + { + if (strlen($namespace) < 1 + || strlen($namespace) > 64 + || preg_match('/^[A-Za-z0-9_.-]+$/D', $namespace) !== 1) { + throw new CacheInvalidArgumentException( + 'Cache namespaces must contain 1-64 characters from A-Z, a-z, 0-9, _, ., and -.', + ); + } + + return $namespace; + } + /** * @param array $tags * @return list diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php index 44805de..cc921e2 100644 --- a/src/Cache/CacheInterface.php +++ b/src/Cache/CacheInterface.php @@ -21,7 +21,10 @@ public function invalidateTag(string $tag): bool; /** @param list $tags */ public function invalidateTags(array $tags): bool; - /** @param list $tags */ + /** + * @param callable(): mixed $resolver + * @param list $tags + */ public function remember(string $key, callable $resolver, mixed $ttl = null, array $tags = []): mixed; public function setLockProvider(LockProviderInterface $lockProvider): self; diff --git a/src/Cache/CacheRecord.php b/src/Cache/CacheRecord.php index b9aaa6b..5793132 100644 --- a/src/Cache/CacheRecord.php +++ b/src/Cache/CacheRecord.php @@ -8,12 +8,12 @@ final readonly class CacheRecord { /** - * @param array $tags + * @param array $tags */ public function __construct( public mixed $value, public ?int $expiresAt = null, public array $tags = [], - public ?int $namespaceEpoch = null, + public ?string $namespaceGeneration = null, ) {} } diff --git a/src/Cache/CacheTagSnapshots.php b/src/Cache/CacheTagSnapshots.php index e922e94..65313b9 100644 --- a/src/Cache/CacheTagSnapshots.php +++ b/src/Cache/CacheTagSnapshots.php @@ -21,7 +21,7 @@ public static function collectTags(array $items): array if (!$item instanceof CacheItem || !$item->isHit()) { continue; } - foreach ($item->getTagVersions() as $tag => $_version) { + foreach ($item->getTagGenerations() as $tag => $_generation) { $tagSet[$tag] = true; } } @@ -29,11 +29,12 @@ public static function collectTags(array $items): array return array_keys($tagSet); } - /** @param array $versions */ - public static function isCurrent(CacheItem $item, array $versions): bool + /** @param array $generations */ + public static function isCurrent(CacheItem $item, array $generations): bool { - foreach ($item->getTagVersions() as $tag => $expected) { - if (($versions[$tag] ?? 0) !== $expected) { + foreach ($item->getTagGenerations() as $tag => $expected) { + $current = $generations[$tag] ?? null; + if (!is_string($current) || !hash_equals($expected, $current)) { return false; } } @@ -59,15 +60,15 @@ public static function missTagged(array $items, callable $miss): array /** * @param array $items - * @param array $versions + * @param array $generations * @param callable(string): CacheItemInterface $miss * @return array{items:array, stale:list} */ - public static function rejectStale(array $items, array $versions, callable $miss): array + public static function rejectStale(array $items, array $generations, callable $miss): array { $stale = []; foreach ($items as $key => $item) { - if (!$item instanceof CacheItem || !$item->isHit() || self::isCurrent($item, $versions)) { + if (!$item instanceof CacheItem || !$item->isHit() || self::isCurrent($item, $generations)) { continue; } $stale[] = $key; @@ -79,6 +80,6 @@ public static function rejectStale(array $items, array $versions, callable $miss private static function isTaggedHit(CacheItemInterface $item): bool { - return $item instanceof CacheItem && $item->isHit() && $item->getTagVersions() !== []; + return $item instanceof CacheItem && $item->isHit() && $item->getTagGenerations() !== []; } } diff --git a/src/Cache/Item/CacheItem.php b/src/Cache/Item/CacheItem.php index fc917f2..dce9d16 100644 --- a/src/Cache/Item/CacheItem.php +++ b/src/Cache/Item/CacheItem.php @@ -13,13 +13,13 @@ final class CacheItem implements CacheItemInterface { /** - * @param array $tags + * @param array $tags */ public function __construct( private readonly InternalCachePoolInterface $pool, private readonly string $key, private mixed $value = null, - private bool $hit = false, + private readonly bool $hit = false, private ?DateTimeInterface $expiration = null, private array $tags = [], ) {} @@ -58,8 +58,8 @@ public function getKey(): string return $this->key; } - /** @return array */ - public function getTagVersions(): array + /** @return array */ + public function getTagGenerations(): array { return $this->tags; } @@ -87,15 +87,14 @@ public function saveDeferred(): static public function set(mixed $value): static { $this->value = $value; - $this->hit = true; return $this; } /** - * @param array $tags + * @param array $tags */ - public function setTagVersions(array $tags): static + public function setTagGenerations(array $tags): static { $this->tags = $tags; diff --git a/src/Cache/Lock/FileLockProvider.php b/src/Cache/Lock/FileLockProvider.php index e5a5090..1136598 100644 --- a/src/Cache/Lock/FileLockProvider.php +++ b/src/Cache/Lock/FileLockProvider.php @@ -30,15 +30,14 @@ public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 3 } $activeLocks = &self::activeRegistry(); - if (isset($activeLocks[$key])) { + if (!$this->prepareDirectory()) { return null; } - if (!is_dir($this->directory) && !mkdir($this->directory, 0770, true) && !is_dir($this->directory)) { + $path = $this->directory . DIRECTORY_SEPARATOR . self::digestLockKey($key) . '.lock'; + if (isset($activeLocks[$path])) { return null; } - - $path = $this->directory . DIRECTORY_SEPARATOR . self::digestLockKey($key) . '.lock'; $handle = $this->openLockFile($path); if (!is_resource($handle)) { return null; @@ -62,9 +61,9 @@ public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 3 return null; } - $activeLocks[$key] = true; + $activeLocks[$path] = true; - return new LockHandle($key, $token, $handle, $leaseSeconds); + return new LockHandle($path, $token, $handle, $leaseSeconds); } public function refresh(?LockHandle $handle, float $leaseSeconds): bool @@ -117,4 +116,20 @@ private function openLockFile(string $path): mixed restore_error_handler(); } } + + private function prepareDirectory(): bool + { + if (is_link($this->directory)) { + return false; + } + if (!is_dir($this->directory) + && !mkdir($this->directory, 0700, true) + && !is_dir($this->directory)) { + return false; + } + $permissions = fileperms($this->directory); + + return is_writable($this->directory) + && ($permissions === false || ($permissions & 0x0002) !== 0x0002); + } } diff --git a/src/Cache/Lock/MemcachedLockProvider.php b/src/Cache/Lock/MemcachedLockProvider.php index cbca9a7..016653e 100644 --- a/src/Cache/Lock/MemcachedLockProvider.php +++ b/src/Cache/Lock/MemcachedLockProvider.php @@ -68,14 +68,18 @@ public function release(?LockHandle $handle): void $values = $this->memcached->getMulti([$lock->key], \Memcached::GET_EXTENDED); $entry = is_array($values) ? ($values[$lock->key] ?? null) : null; $casToken = is_array($entry) ? ($entry['cas'] ?? null) : null; - if ( - is_array($entry) - && ($entry['value'] ?? null) === $lock->token - && (is_float($casToken) || is_int($casToken)) - && $this->memcached->cas((float) $casToken, $lock->key, $lock->token, 1) - ) { - $this->memcached->delete($lock->key); + if (!is_array($entry) + || ($entry['value'] ?? null) !== $lock->token + || (!is_float($casToken) && !is_int($casToken))) { + return; } + + $this->memcached->cas( + (float) $casToken, + $lock->key, + 'released:' . bin2hex(random_bytes(16)), + 1, + ); }); } } diff --git a/src/Cache/Metrics/CacheMetricsSnapshot.php b/src/Cache/Metrics/CacheMetricsSnapshot.php new file mode 100644 index 0000000..7198ea5 --- /dev/null +++ b/src/Cache/Metrics/CacheMetricsSnapshot.php @@ -0,0 +1,27 @@ +> $snapshot + * @return array> + */ + public static function readable(array $snapshot): array + { + $readable = []; + foreach ($snapshot as $adapterClass => $counters) { + $separator = strrpos($adapterClass, '\\'); + $short = $separator === false ? $adapterClass : substr($adapterClass, $separator + 1); + $short = preg_replace('/CacheAdapter$/', '', $short) ?? $short; + $name = strtolower(preg_replace('/(? 128 || preg_match('/^[0-9-]+$/D', $eventId) !== 1) { + throw new ClusterCacheException('Cluster event IDs must be 1-128 numeric or hyphen characters.'); + } + + return $eventId; + } + + public static function identifier(InvalidationEventType $type, ?string $identifier): ?string + { + if ($type === InvalidationEventType::Namespace) { + if ($identifier !== null) { + throw new ClusterCacheException('Namespace invalidation events must not contain an identifier.'); + } + + return null; + } + if ($identifier === null) { + throw new ClusterCacheException('Key and tag invalidation events require an identifier.'); + } + + try { + if ($type === InvalidationEventType::Key) { + CacheInput::key($identifier); + } else { + CacheInput::tags([$identifier]); + } + } catch (CacheInvalidArgumentException $exception) { + throw new ClusterCacheException($exception->getMessage(), 0, $exception); + } + + return $identifier; + } + + public static function namespace(string $namespace): string + { + try { + return CacheInput::namespace($namespace); + } catch (CacheInvalidArgumentException $exception) { + throw new ClusterConfigurationException($exception->getMessage(), 0, $exception); + } + } + + public static function nodeId(string $nodeId): string + { + self::boundedName($nodeId, 255, 'node ID', ClusterConfigurationException::class); + + return $nodeId; + } + + /** @param class-string $exceptionClass */ + private static function boundedName(string $value, int $maximum, string $label, string $exceptionClass): void + { + if (strlen($value) < 1 + || strlen($value) > $maximum + || preg_match('/^[A-Za-z0-9_.-]+$/D', $value) !== 1) { + throw new $exceptionClass(sprintf( + 'The %s must contain 1-%d characters from A-Z, a-z, 0-9, _, ., and -.', + $label, + $maximum, + )); + } + } +} diff --git a/src/Cluster/ClusterRuntime.php b/src/Cluster/ClusterRuntime.php index 847546e..61a0731 100644 --- a/src/Cluster/ClusterRuntime.php +++ b/src/Cluster/ClusterRuntime.php @@ -114,6 +114,15 @@ public function recoverIfRequired(): bool return $recovered; } + public function skipEventAfterClear(string $eventId): void + { + ClusterInput::eventId($eventId); + if (!$this->cache->clear()) { + throw new ClusterCacheException('Unable to clear the local cache before skipping a cluster event.'); + } + $this->cursorStore->advance($eventId); + } + public function status(): ClusterStatus { $cursor = $this->cursorStore->current(); diff --git a/src/Cluster/Consumer/InvalidationHandler.php b/src/Cluster/Consumer/InvalidationHandler.php index 3d95266..bb3654f 100644 --- a/src/Cluster/Consumer/InvalidationHandler.php +++ b/src/Cluster/Consumer/InvalidationHandler.php @@ -7,6 +7,7 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cluster\Event\InvalidationEvent; use Infocyph\CacheLayer\Cluster\Event\InvalidationEventType; +use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; final readonly class InvalidationHandler { @@ -21,24 +22,23 @@ public function handle(InvalidationEvent $event): void return; } - match ($event->type) { + $handled = match ($event->type) { InvalidationEventType::Key => $this->invalidateKey($event), InvalidationEventType::Namespace => $this->cache->clear(), InvalidationEventType::Tag => $this->invalidateTag($event), }; + if (!$handled) { + throw new ClusterCacheException('Unable to apply cluster invalidation to the local cache.'); + } } - private function invalidateKey(InvalidationEvent $event): void + private function invalidateKey(InvalidationEvent $event): bool { - if ($event->identifier !== null) { - $this->cache->delete($event->identifier); - } + return $event->identifier !== null && $this->cache->delete($event->identifier); } - private function invalidateTag(InvalidationEvent $event): void + private function invalidateTag(InvalidationEvent $event): bool { - if ($event->identifier !== null) { - $this->cache->invalidateTag($event->identifier); - } + return $event->identifier !== null && $this->cache->invalidateTag($event->identifier); } } diff --git a/src/Cluster/Event/InvalidationEvent.php b/src/Cluster/Event/InvalidationEvent.php index 1092494..e891c0a 100644 --- a/src/Cluster/Event/InvalidationEvent.php +++ b/src/Cluster/Event/InvalidationEvent.php @@ -4,6 +4,7 @@ namespace Infocyph\CacheLayer\Cluster\Event; +use Infocyph\CacheLayer\Cluster\ClusterInput; use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; final readonly class InvalidationEvent @@ -17,17 +18,10 @@ public function __construct( public string $originNodeId, public int $createdAt, ) { - if ($cluster === '' || $namespace === '' || $originNodeId === '') { - throw new ClusterCacheException('Invalidation events require a cluster, namespace, and origin node ID.'); - } - - if ($type === InvalidationEventType::Namespace && $identifier !== null) { - throw new ClusterCacheException('Namespace invalidation events must not contain an identifier.'); - } - - if ($type !== InvalidationEventType::Namespace && ($identifier === null || $identifier === '')) { - throw new ClusterCacheException('Key and tag invalidation events require a non-empty identifier.'); - } + ClusterInput::cluster($cluster); + ClusterInput::namespace($namespace); + ClusterInput::nodeId($originNodeId); + ClusterInput::identifier($type, $identifier); if ($createdAt < 0) { throw new ClusterCacheException('Invalidation event timestamps cannot be negative.'); @@ -51,9 +45,7 @@ public static function tag(string $cluster, string $namespace, string $tag, stri public function withId(string $id): self { - if ($id === '') { - throw new ClusterCacheException('Invalidation event IDs cannot be empty.'); - } + ClusterInput::eventId($id); return new self($id, $this->cluster, $this->namespace, $this->type, $this->identifier, $this->originNodeId, $this->createdAt); } diff --git a/src/Cluster/Outbox/ClusterOutbox.php b/src/Cluster/Outbox/ClusterOutbox.php index 7494722..db2058f 100644 --- a/src/Cluster/Outbox/ClusterOutbox.php +++ b/src/Cluster/Outbox/ClusterOutbox.php @@ -80,7 +80,7 @@ public function invalidateTags(array $tags): void private function outboxOrigin(): string { - return $this->nodeId . ':outbox'; + return $this->nodeId . '.outbox'; } private function publish(InvalidationEvent $event): void diff --git a/src/Cluster/Recovery/ClusterRecoveryManager.php b/src/Cluster/Recovery/ClusterRecoveryManager.php index 7790703..c5e82ae 100644 --- a/src/Cluster/Recovery/ClusterRecoveryManager.php +++ b/src/Cluster/Recovery/ClusterRecoveryManager.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cluster\Cursor\CursorStoreInterface; +use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportInterface; final readonly class ClusterRecoveryManager @@ -25,7 +26,9 @@ public function recoverIfRequired(): bool return false; } - $this->cache->clear(); + 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/Pdo/PdoInvalidationSchema.php b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php new file mode 100644 index 0000000..87319dc --- /dev/null +++ b/src/Cluster/Transport/Pdo/PdoInvalidationSchema.php @@ -0,0 +1,76 @@ +exec(self::createTableSql($driver)); + self::createIndex($connection, $driver); + } catch (PDOException $exception) { + throw new ClusterTransportException('Unable to initialize the PDO invalidation transport schema.', 0, $exception); + } + } + + public static function validateConnection(PDO $connection, bool $allowSqliteForTesting = false): string + { + return self::driver($connection, $allowSqliteForTesting); + } + + private static function createIndex(PDO $connection, string $driver): void + { + $ifNotExists = $driver === 'mysql' ? '' : ' IF NOT EXISTS'; + + try { + $connection->exec( + 'CREATE INDEX' . $ifNotExists . ' cachelayer_invalidation_events_cluster_idx ' + . 'ON ' . self::TABLE . ' (cluster_name, event_id)', + ); + } catch (PDOException $exception) { + $duplicate = is_array($exception->errorInfo) && ($exception->errorInfo[1] ?? null) === 1061; + if ($driver !== 'mysql' || !$duplicate) { + throw $exception; + } + } + } + + private static function createTableSql(string $driver): string + { + $id = match ($driver) { + 'mysql' => 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY', + 'pgsql' => 'BIGSERIAL PRIMARY KEY', + default => 'INTEGER PRIMARY KEY AUTOINCREMENT', + }; + + 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)'; + } + + private static function driver(PDO $connection, bool $allowSqliteForTesting): string + { + $driver = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); + $driver = is_string($driver) ? $driver : ''; + if (!in_array($driver, ['mysql', 'pgsql', 'sqlite'], true)) { + throw new ClusterTransportException('PDO invalidation transport supports MySQL and PostgreSQL only.'); + } + if ($driver === 'sqlite' && !$allowSqliteForTesting) { + throw new ClusterTransportException('SQLite is not a supported shared Cluster Cache transport.'); + } + + return $driver; + } +} diff --git a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php index 8461127..277689a 100644 --- a/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php +++ b/src/Cluster/Transport/Pdo/PdoInvalidationTransport.php @@ -20,18 +20,16 @@ private string $driver; - public function __construct(private PDO $connection, bool $allowSqliteForTesting = false) - { + public function __construct( + private PDO $connection, + bool $allowSqliteForTesting = false, + bool $initializeSchema = true, + ) { $this->connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - $driver = $connection->getAttribute(PDO::ATTR_DRIVER_NAME); - $this->driver = is_string($driver) ? $driver : ''; - if (!in_array($this->driver, ['mysql', 'pgsql', 'sqlite'], true)) { - throw new ClusterTransportException('PDO invalidation transport supports MySQL and PostgreSQL only.'); - } - if ($this->driver === 'sqlite' && !$allowSqliteForTesting) { - throw new ClusterTransportException('SQLite is not a supported shared Cluster Cache transport.'); + $this->driver = PdoInvalidationSchema::validateConnection($connection, $allowSqliteForTesting); + if ($initializeSchema) { + PdoInvalidationSchema::install($connection, $allowSqliteForTesting); } - $this->createSchemaIfMissing(); } public function consumeAfter(string $cluster, ?string $cursor, int $limit): InvalidationBatch @@ -159,47 +157,6 @@ private function consumeSql(?string $cursor): string . 'FROM ' . self::TABLE . ' WHERE ' . $where . ' ORDER BY event_id ASC LIMIT :limit'; } - private function createClusterIndexIfMissing(): void - { - $ifNotExists = $this->driver === 'mysql' ? '' : ' IF NOT EXISTS'; - - try { - $this->connection->exec( - 'CREATE INDEX' . $ifNotExists . ' cachelayer_invalidation_events_cluster_idx ' - . 'ON ' . self::TABLE . ' (cluster_name, event_id)', - ); - } catch (PDOException $exception) { - if ($this->driver !== 'mysql' || !$this->isDuplicateIndex($exception)) { - throw new ClusterTransportException('Unable to initialize the PDO invalidation transport index.', 0, $exception); - } - } - } - - private function createSchemaIfMissing(): void - { - try { - $this->connection->exec($this->createTableSql()); - } catch (PDOException $exception) { - throw new ClusterTransportException('Unable to initialize the PDO invalidation transport schema.', 0, $exception); - } - - $this->createClusterIndexIfMissing(); - } - - private function createTableSql(): string - { - $id = match ($this->driver) { - 'mysql' => 'BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY', - 'pgsql' => 'BIGSERIAL PRIMARY KEY', - default => 'INTEGER PRIMARY KEY AUTOINCREMENT', - }; - - return 'CREATE TABLE IF NOT EXISTS ' . self::TABLE . ' (' - . 'event_id ' . $id . ', cluster_name VARCHAR(128) NOT NULL, namespace_name VARCHAR(128) NOT NULL, ' - . 'event_type VARCHAR(32) NOT NULL, identifier VARCHAR(512) NULL, origin_node_id VARCHAR(255) NOT NULL, ' - . 'created_at BIGINT NOT NULL)'; - } - private function eventBoundary(string $cluster, string $aggregate): ?string { try { @@ -300,13 +257,6 @@ private function insert(PDO $connection, InvalidationEvent $event): string return $id; } - private function isDuplicateIndex(PDOException $exception): bool - { - $errorInfo = $exception->errorInfo; - - return is_array($errorInfo) && ($errorInfo[1] ?? null) === 1061; - } - private function pruneSql(): string { $selection = 'SELECT event_id FROM ' . self::TABLE . ' WHERE created_at < :boundary ORDER BY event_id LIMIT :limit'; diff --git a/src/Counter/RedisAtomicCounterStore.php b/src/Counter/RedisAtomicCounterStore.php index e1b3319..0aace25 100644 --- a/src/Counter/RedisAtomicCounterStore.php +++ b/src/Counter/RedisAtomicCounterStore.php @@ -4,7 +4,9 @@ namespace Infocyph\CacheLayer\Counter; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Counter\Exception\AtomicCounterException; +use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; final readonly class RedisAtomicCounterStore implements AtomicCounterStoreInterface { @@ -27,7 +29,11 @@ public function __construct( throw new AtomicCounterException('phpredis extension not loaded'); } - $this->namespace = sanitize_cache_ns($namespace); + try { + $this->namespace = CacheInput::namespace($namespace); + } catch (CacheInvalidArgumentException $failure) { + throw new AtomicCounterException($failure->getMessage(), 0, $failure); + } } public function decrement(string $key, int $by = 1, ?int $ttlSeconds = null): AtomicCounterValue @@ -80,8 +86,10 @@ private function change(string $key, int $by, ?int $ttlSeconds): AtomicCounterVa private function map(string $key): string { - if (!preg_match('/^[A-Za-z0-9_.-]+$/D', $key)) { - throw new AtomicCounterException('Atomic counter key is invalid.'); + try { + CacheInput::key($key); + } catch (CacheInvalidArgumentException $failure) { + throw new AtomicCounterException($failure->getMessage(), 0, $failure); } return $this->namespace . ':counter:' . $key; diff --git a/src/Exceptions/CacheBackendException.php b/src/Exceptions/CacheBackendException.php new file mode 100644 index 0000000..fc9daad --- /dev/null +++ b/src/Exceptions/CacheBackendException.php @@ -0,0 +1,9 @@ +|null */ + private static ?WeakMap $closures = null; + + private static int $nextObjectId = 0; + + /** @var WeakMap|null */ + private static ?WeakMap $objects = null; + + public static function callable(callable $callable): string + { + if ($callable instanceof Closure) { + return self::closure($callable); + } + if (is_string($callable)) { + return 'string:' . $callable; + } + if (is_array($callable)) { + $target = is_object($callable[0]) + ? self::object($callable[0]) + : $callable[0]; + + return 'array:' . $target . '::' . $callable[1]; + } + + if (is_object($callable)) { + return 'invokable:' . self::object($callable); + } + + throw new \LogicException('Unsupported callable form.'); + } + + public static function flush(): void + { + self::$closures = new WeakMap(); + self::$objects = new WeakMap(); + } + + 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, + }; + } + + private static function closure(Closure $closure): string + { + self::$closures ??= new WeakMap(); + if (isset(self::$closures[$closure])) { + return self::$closures[$closure]; + } + + $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(), + ]; + + return self::$closures[$closure] = 'closure:' . hash('xxh128', serialize($identity)); + } + + private static function object(object $object): string + { + self::$objects ??= new WeakMap(); + + return self::$objects[$object] ??= 'obj:' . $object::class . '#' . ++self::$nextObjectId; + } + + /** + * @param array $values + * @return array + */ + private static function values(array $values): array + { + $normalized = []; + foreach ($values as $key => $value) { + $normalized[$key] = self::value($value); + } + + return $normalized; + } +} diff --git a/src/Memoize/Memoizer.php b/src/Memoize/Memoizer.php index 6c960dd..8ad8fc1 100644 --- a/src/Memoize/Memoizer.php +++ b/src/Memoize/Memoizer.php @@ -4,9 +4,7 @@ namespace Infocyph\CacheLayer\Memoize; -use Closure; use ReflectionException; -use ReflectionFunction; use WeakMap; final class Memoizer @@ -42,6 +40,7 @@ public function flush(): void $this->staticCache = []; $this->objectCache = new WeakMap(); $this->hits = $this->misses = 0; + CallableFingerprint::flush(); } /** @@ -53,7 +52,7 @@ public function flush(): void public function get(callable $callable, array $params = []): mixed { $cacheKey = self::buildCacheKey( - self::callableSignature($callable), + CallableFingerprint::callable($callable), $params, ); @@ -81,7 +80,7 @@ public function get(callable $callable, array $params = []): mixed public function getFor(object $object, callable $callable, array $params = []): mixed { $cacheKey = self::buildCacheKey( - self::callableSignature($callable), + CallableFingerprint::callable($callable), $params, ); @@ -126,45 +125,12 @@ private static function buildCacheKey(string $signature, array $params): string $normalized = []; foreach ($params as $param) { - $normalized[] = self::normalizeParam($param); + $normalized[] = CallableFingerprint::value($param); } return $signature . '|' . hash('xxh128', serialize($normalized)); } - /** - * @throws ReflectionException - * @param callable $callable The callable argument. - */ - private static function callableSignature(callable $callable): string - { - if ($callable instanceof Closure) { - $rf = new ReflectionFunction($callable); - $file = $rf->getFileName() ?: 'internal'; - - return 'closure:' . $file . ':' . $rf->getStartLine() . '-' . $rf->getEndLine(); - } - - if (is_string($callable)) { - return 'string:' . $callable; - } - - if (is_array($callable)) { - $target = is_object($callable[0]) ? $callable[0]::class : $callable[0]; - - return 'array:' . $target . '::' . $callable[1]; - } - - if (is_object($callable)) { - return 'invokable:' . $callable::class; - } - - $rf = new ReflectionFunction(Closure::fromCallable($callable)); - $file = $rf->getFileName() ?: 'internal'; - - return 'callable:' . $file . ':' . $rf->getStartLine() . '-' . $rf->getEndLine(); - } - /** * @param array $cache The cache bucket. * @phpstan-param array $cache @@ -178,30 +144,4 @@ private static function evictOldestIfFull(array &$cache): void $oldest = array_key_first($cache); unset($cache[$oldest]); } - - /** - * @param array $values The values to normalize. - * @phpstan-param array $values - * @phpstan-return array - */ - private static function normalizeArray(array $values): array - { - $normalized = []; - foreach ($values as $key => $value) { - $normalized[$key] = self::normalizeParam($value); - } - - return $normalized; - } - - private static function normalizeParam(mixed $value): mixed - { - return match (true) { - $value instanceof Closure => 'closure#' . spl_object_id($value), - is_object($value) => 'obj#' . spl_object_id($value), - is_resource($value) => 'res#' . get_resource_type($value) . '#' . (int) $value, - is_array($value) => self::normalizeArray($value), - default => $value, - }; - } } diff --git a/src/Memoize/OnceMemoizer.php b/src/Memoize/OnceMemoizer.php index a0df46d..a77eb76 100644 --- a/src/Memoize/OnceMemoizer.php +++ b/src/Memoize/OnceMemoizer.php @@ -16,9 +16,6 @@ final class OnceMemoizer /** @var array */ private array $cache = []; - /** @var array */ - private array $closureSourceMemo = []; - /** @var list */ private array $order = []; @@ -33,12 +30,12 @@ public function flush(): void { $this->cache = []; $this->order = []; - $this->closureSourceMemo = []; + CallableFingerprint::flush(); } - public function once(callable $callback): mixed + public function once(callable $callback, int $callerOffset = 0): mixed { - $key = $this->cacheKey($callback); + $key = $this->cacheKey($callback, $callerOffset); if (array_key_exists($key, $this->cache)) { return $this->cache[$key]; } @@ -50,60 +47,38 @@ public function once(callable $callback): mixed return $value; } - private function cacheKey(callable $callback): string + private function cacheKey(callable $callback, int $callerOffset): string { - $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 3); - $caller = $bt[2] ?? $bt[1] ?? []; + $bt = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 4 + $callerOffset); + $location = $bt[1 + $callerOffset] ?? []; + $caller = $bt[2 + $callerOffset] ?? $location; + $callerObject = $caller['object'] ?? null; - return ($caller['file'] ?? '(unknown)') + return ($location['file'] ?? '(unknown)') + . ':' . ($location['line'] ?? 0) . ':' . ($caller['class'] ?? '') . ':' . $this->normalizeCallerFunction($caller['function'] ?? '(unknown)') + . ':' . (is_object($callerObject) ? spl_object_id($callerObject) : '') . ':' . $this->callbackFingerprint($callback); } private function callbackFingerprint(callable $callback): string { - return match (true) { - $callback instanceof Closure => $this->closureFingerprint($callback), - is_string($callback) => 'string:' . $callback, - is_array($callback) => 'array:' . (is_object($callback[0]) ? $callback[0]::class : $callback[0]) . '::' . $callback[1], - is_object($callback) => 'invokable:' . $callback::class, - default => 'callable:' . get_debug_type($callback), - }; - } - - private function closureFingerprint(Closure $closure): string - { - $rf = new ReflectionFunction($closure); - $file = $rf->getFileName(); - $start = $rf->getStartLine(); - $end = $rf->getEndLine(); - $lineFingerprint = 'closure-lines:' . ($file ?: 'internal') . ':' . $start . '-' . $end; - - if (!is_string($file) || $file === '') { - return $lineFingerprint; - } - if (!is_readable($file)) { - return $lineFingerprint; + if ($callback instanceof Closure) { + $reflection = new ReflectionFunction($callback); + $bound = $reflection->getClosureThis(); + + return implode(':', [ + 'closure', + $reflection->getFileName() ?: 'internal', + $reflection->getStartLine(), + $reflection->getEndLine(), + $reflection->getClosureScopeClass()?->getName() ?? '', + $bound === null ? '' : $bound::class . '#' . spl_object_id($bound), + ]); } - $sourceKey = $file . ':' . $start . '-' . $end; - $cached = $this->closureSourceMemo[$sourceKey] ?? null; - if (is_string($cached)) { - return $cached; - } - - $lines = file($file, FILE_IGNORE_NEW_LINES); - if (!is_array($lines)) { - return $lineFingerprint; - } - - $snippet = implode("\n", array_slice($lines, $start - 1, $end - $start + 1)); - $normalized = preg_replace('/\s+/', '', $snippet) ?? $snippet; - $fingerprint = 'closure-src:' . hash('xxh128', $normalized); - $this->closureSourceMemo[$sourceKey] = $fingerprint; - - return $fingerprint; + return CallableFingerprint::callable($callback); } private function normalizeCallerFunction(string $callerFunction): string diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 42b1638..b143366 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; +use Infocyph\CacheLayer\Cache\Adapter\TagGenerationCacheInterface; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; @@ -13,7 +14,7 @@ use Psr\Cache\CacheItemInterface; use Throwable; -final class NodeCacheAdapter extends AbstractCacheAdapter +final class NodeCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { public function __construct( private readonly ?InternalCachePoolInterface $l1, @@ -82,16 +83,29 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - return $this->attempt( - fn(): array => $this->l2->getTagVersions($tags), - array_fill_keys($tags, 0), + $cached = !$this->l1 instanceof TagGenerationCacheInterface + ? [] + : $this->attempt(fn(): array => $this->l1->readTagGenerations($tags), [], 'l1_failure'); + $missing = array_values(array_diff($tags, array_keys($cached))); + if ($missing === []) { + return $cached; + } + + $loaded = $this->attempt( + fn(): array => $this->l2->getTagGenerations($missing), + $this->freshGenerations($missing), 'l2_failure', ); + if ($this->l1 instanceof TagGenerationCacheInterface) { + $this->attempt(fn(): bool => $this->l1->storeTagGenerations($loaded), false, 'l1_failure'); + } + + return $cached + $loaded; } public function hasItem(string $key): bool @@ -99,17 +113,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - $l2 = $this->attempt(fn(): bool => $this->l2->incrementTagVersions($tags), false, 'l2_failure'); - $l1 = $this->l1 === null - || $this->attempt(fn(): bool => $this->l1->incrementTagVersions($tags), false, 'l1_failure'); - - return $l2 && $l1; - } - /** * @param list $keys * @return array @@ -132,20 +135,49 @@ public function multiFetch(array $keys): array return $ordered; } + /** @param list $tags */ + #[\Override] + public function readTagGenerations(array $tags): array + { + return $this->getTagGenerations($tags); + } + + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $l2 = $this->attempt(fn(): bool => $this->l2->rotateTagGenerations($tags), false, 'l2_failure'); + if (!$l2 || !$this->l1 instanceof TagGenerationCacheInterface) { + return $l2; + } + + $fenced = $this->attempt(fn(): bool => $this->l1->rotateTagGenerations($tags), false, 'l1_failure'); + $generations = $this->attempt(fn(): array => $this->l2->getTagGenerations($tags), [], 'l2_failure'); + $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'); + } + + return $fenced && $stored; + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { return false; } $stored = $this->saveOneInto($this->l2, $item, 'l2_failure'); - if (!$stored && !$this->failOpen) { + if (!$stored) { return false; } if ($this->l1 === null) { return $stored; } - return $this->saveOneInto($this->l1, $item, 'l1_failure') || $stored; + $this->saveOneInto($this->l1, $item, 'l1_failure'); + + return true; } /** @param array $items */ @@ -158,16 +190,23 @@ public function saveItems(array $items): bool } $stored = $this->saveInto($this->l2, $items, 'l2_failure'); - if (!$stored && !$this->failOpen) { + if (!$stored) { return false; } if ($this->l1 !== null) { - $l1Stored = $this->saveInto($this->l1, $items, 'l1_failure'); - - return $stored || $l1Stored; + $this->saveInto($this->l1, $items, 'l1_failure'); } - return $stored; + return true; + } + + /** @param array $generations */ + #[\Override] + public function storeTagGenerations(array $generations): bool + { + return $this->l2->storeTagGenerations($generations) + && (!$this->l1 instanceof TagGenerationCacheInterface + || $this->l1->storeTagGenerations($generations)); } /** @@ -190,6 +229,20 @@ private function attempt(callable $operation, mixed $fallback, string $failureMe } } + /** + * @param list $tags + * @return array + */ + private function freshGenerations(array $tags): array + { + $generations = []; + foreach ($tags as $tag) { + $generations[$tag] = self::newGeneration(); + } + + return $generations; + } + private function metric(string $name, int $amount = 1): void { if ($amount > 0) { @@ -200,11 +253,11 @@ private function metric(string $name, int $amount = 1): void private function nodeItem(CacheItemInterface $item): CacheItem { $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; - $tags = $item instanceof CacheItem ? $item->getTagVersions() : []; + $tags = $item instanceof CacheItem ? $item->getTagGenerations() : []; return (new CacheItem($this, $item->getKey(), $item->get(), true)) ->expiresAfter($ttl) - ->setTagVersions($tags); + ->setTagGenerations($tags); } /** @@ -265,14 +318,7 @@ private function readL2(array $keys, array &$results): array */ private function readPool(InternalCachePoolInterface $pool, array $keys): array { - $items = []; - foreach ($pool->getItems($keys) as $key => $item) { - if (is_string($key) && $item instanceof CacheItemInterface) { - $items[$key] = $item; - } - } - - return $items; + return $pool->multiFetch($keys); } /** @param array $items */ @@ -287,7 +333,7 @@ private function saveInto( if ($item instanceof CacheItem) { $target->expiresAfter($item->ttlSeconds()); if ($target instanceof CacheItem) { - $target->setTagVersions($item->getTagVersions()); + $target->setTagGenerations($item->getTagGenerations()); } } $targets[$key] = $target; @@ -305,7 +351,7 @@ private function saveOneInto( if ($item instanceof CacheItem) { $target->expiresAfter($item->ttlSeconds()); if ($target instanceof CacheItem) { - $target->setTagVersions($item->getTagVersions()); + $target->setTagGenerations($item->getTagGenerations()); } } diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index 15cf009..e1aebbd 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -6,13 +6,15 @@ use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\CachePayloadCodec; +use Infocyph\CacheLayer\Cache\Adapter\TagGenerationCacheInterface; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Node\Exception\NodeCacheStorageException; use PDO; use PDOException; use Psr\Cache\CacheItemInterface; -final class NodeSqliteCacheAdapter extends AbstractCacheAdapter +final class NodeSqliteCacheAdapter extends AbstractCacheAdapter implements TagGenerationCacheInterface { private const string TABLE = 'cachelayer_node_entries'; @@ -20,12 +22,15 @@ final class NodeSqliteCacheAdapter extends AbstractCacheAdapter private readonly \PDOStatement $lookupStatement; + private readonly string $namespace; + private readonly \PDOStatement $upsertStatement; public function __construct( private readonly PDO $connection, - private readonly string $namespace, + string $namespace, ) { + $this->namespace = CacheInput::namespace($namespace); $this->createSchemaIfMissing(); $this->deleteStatement = $connection->prepare( 'DELETE FROM ' . self::TABLE . ' WHERE namespace = :namespace AND cache_key = :cache_key', @@ -73,22 +78,6 @@ public function connection(): PDO return $this->connection; } - public function count(): int - { - try { - $statement = $this->connection->prepare( - 'SELECT COUNT(*) FROM ' . self::TABLE . ' WHERE namespace = :namespace ' - . 'AND (expires_at IS NULL OR expires_at > :current_time)', - ); - $statement->execute([':namespace' => $this->namespace, ':current_time' => time()]); - $count = $statement->fetchColumn(); - - return is_numeric($count) ? max(0, (int) $count) : 0; - } catch (PDOException $exception) { - throw $this->storageException('Unable to count node SQLite cache entries.', $exception); - } - } - public function deleteItem(string $key): bool { try { @@ -153,34 +142,23 @@ public function getItem(string $key): CacheItem /** * @param list $tags - * @return array + * @return array */ #[\Override] - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { - if ($tags === []) { - return []; - } - $keys = array_map($this->mapTag(...), $tags); - $marks = implode(',', array_fill(0, count($keys), '?')); - $statement = $this->connection->prepare( - 'SELECT cache_key, payload FROM ' . self::TABLE - . " WHERE namespace = ? AND cache_key IN ({$marks})", - ); - $statement->execute([$this->namespace, ...$keys]); - $stored = []; - foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { - if (is_array($row) && is_string($row['cache_key'] ?? null)) { - $stored[$row['cache_key']] = $row['payload'] ?? null; + $generations = $this->readTagGenerations($tags); + $missing = []; + foreach ($tags as $tag) { + if (!isset($generations[$tag])) { + $missing[$tag] = self::newGeneration(); } } - $versions = []; - foreach ($tags as $tag) { - $value = $stored[$this->mapTag($tag)] ?? null; - $versions[$tag] = is_string($value) && ctype_digit($value) ? (int) $value : 0; + if ($missing !== [] && !$this->storeTagGenerations($missing)) { + throw new NodeCacheStorageException('Unable to initialize node SQLite tag generations.'); } - return $versions; + return $generations + $missing; } public function hasItem(string $key): bool @@ -188,24 +166,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** @param list $tags */ - #[\Override] - public function incrementTagVersions(array $tags): bool - { - $statement = $this->connection->prepare( - 'INSERT INTO ' . self::TABLE . ' (namespace, cache_key, payload, expires_at) ' - . "VALUES (?, ?, '1', NULL) ON CONFLICT(namespace, cache_key) " - . 'DO UPDATE SET payload = CAST(payload AS INTEGER) + 1', - ); - foreach ($tags as $tag) { - if (!$statement->execute([$this->namespace, $this->mapTag($tag)])) { - return false; - } - } - - return true; - } - /** * @param array $keys The keys argument. * @phpstan-param list $keys @@ -231,16 +191,74 @@ public function multiFetch(array $keys): array } } $items = []; + $invalid = []; foreach ($keys as $key) { $payload = $rows[$this->mapData($key)] ?? null; - $items[$key] = is_string($payload) - ? $this->genericFromBlob($key, $payload) - : $this->genericMiss($key); + if (!is_string($payload)) { + $items[$key] = $this->genericMiss($key); + + continue; + } + $record = $this->decodeRecordFromBlob($payload); + if ($record === null) { + $invalid[] = $key; + $items[$key] = $this->genericMiss($key); + + continue; + } + $items[$key] = $this->genericItemFromRecord($key, $record); + } + if ($invalid !== []) { + $this->deleteItems($invalid); } return $items; } + /** @param list $tags */ + #[\Override] + public function readTagGenerations(array $tags): array + { + if ($tags === []) { + return []; + } + $keys = array_map($this->mapTag(...), $tags); + $marks = implode(',', array_fill(0, count($keys), '?')); + $statement = $this->connection->prepare( + 'SELECT cache_key, payload FROM ' . self::TABLE + . " WHERE namespace = ? AND cache_key IN ({$marks})", + ); + $statement->execute([$this->namespace, ...$keys]); + $stored = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + $generation = is_array($row) ? self::normalizeGeneration($row['payload'] ?? null) : null; + if (is_array($row) && is_string($row['cache_key'] ?? null) && $generation !== null) { + $stored[$row['cache_key']] = $generation; + } + } + $generations = []; + foreach ($tags as $tag) { + $generation = $stored[$this->mapTag($tag)] ?? null; + if (is_string($generation)) { + $generations[$tag] = $generation; + } + } + + return $generations; + } + + /** @param list $tags */ + #[\Override] + public function rotateTagGenerations(array $tags): bool + { + $generations = []; + foreach ($tags as $tag) { + $generations[$tag] = self::newGeneration(); + } + + return $this->storeTagGenerations($generations); + } + public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { @@ -328,6 +346,21 @@ public function saveMany(array $items): bool } } + /** @param array $generations */ + #[\Override] + public function storeTagGenerations(array $generations): bool + { + $rows = []; + foreach ($generations as $tag => $generation) { + if (!self::isGeneration($generation)) { + return false; + } + $rows[] = [$this->namespace, $this->mapTag($tag), strtolower($generation), null]; + } + + return $this->upsertRows($rows); + } + private function createSchemaIfMissing(): void { try { diff --git a/src/Node/NodeCacheConfig.php b/src/Node/NodeCacheConfig.php index bceb82a..182e462 100644 --- a/src/Node/NodeCacheConfig.php +++ b/src/Node/NodeCacheConfig.php @@ -4,7 +4,9 @@ namespace Infocyph\CacheLayer\Node; +use Infocyph\CacheLayer\Cache\CacheInput; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; +use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Node\Exception\NodeCacheConfigurationException; final readonly class NodeCacheConfig @@ -24,10 +26,6 @@ public function __construct( throw new NodeCacheConfigurationException('The SQLite cache file path is invalid.'); } - if ($namespace === '') { - throw new NodeCacheConfigurationException('The cache namespace cannot be empty.'); - } - if ($lockDirectory !== null && ($lockDirectory === '' || str_contains($lockDirectory, "\0"))) { throw new NodeCacheConfigurationException('The lock directory path is invalid.'); } @@ -36,6 +34,10 @@ public function __construct( throw new NodeCacheConfigurationException('The SQLite busy timeout cannot be negative.'); } - $this->namespace = sanitize_cache_ns($namespace); + try { + $this->namespace = CacheInput::namespace($namespace); + } catch (CacheInvalidArgumentException $failure) { + throw new NodeCacheConfigurationException($failure->getMessage(), 0, $failure); + } } } diff --git a/src/functions.php b/src/functions.php index 67ff866..e954b86 100644 --- a/src/functions.php +++ b/src/functions.php @@ -5,18 +5,6 @@ use Infocyph\CacheLayer\Memoize\Memoizer; use Infocyph\CacheLayer\Memoize\OnceMemoizer; -if (!function_exists('sanitize_cache_ns')) { - /** - * Normalize cache namespaces into safe key prefixes. - */ - function sanitize_cache_ns(string $ns): string - { - $sanitized = preg_replace('/[^A-Za-z0-9_\-]/', '_', $ns); - - return is_string($sanitized) ? $sanitized : ''; - } -} - if (!function_exists('memoize')) { /** * @param array $params @@ -59,6 +47,14 @@ function remember(?object $object = null, ?callable $callable = null, array $par if (!function_exists('once')) { function once(callable $callback): mixed { - return OnceMemoizer::instance()->once($callback); + return OnceMemoizer::instance()->once($callback, 1); + } +} + +if (!function_exists('flush_memoizers')) { + function flush_memoizers(): void + { + Memoizer::instance()->flush(); + OnceMemoizer::instance()->flush(); } } diff --git a/tests/Cache/ArchitectureHardeningTest.php b/tests/Cache/ArchitectureHardeningTest.php index c30a95a..eb3619d 100644 --- a/tests/Cache/ArchitectureHardeningTest.php +++ b/tests/Cache/ArchitectureHardeningTest.php @@ -9,17 +9,18 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Exceptions\CacheBackendException; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; final class ArchitectureHardeningTest extends AbstractCacheAdapter { - /** @var array}> */ + /** @var array}> */ private array $records = []; - /** @var array */ - private array $versions = []; + /** @var array */ + private array $generations = []; public int $deleteBatches = 0; @@ -36,7 +37,7 @@ final class ArchitectureHardeningTest extends AbstractCacheAdapter public function clear(): bool { $this->records = []; - $this->versions = []; + $this->generations = []; return true; } @@ -83,7 +84,7 @@ public function multiFetch(array $keys): array } $items[$key] = (new CacheItem($this, $key, $record['value'], true)) ->expiresAt(CachePayloadCodec::toDateTime($record['expires'])) - ->setTagVersions($record['tags']); + ->setTagGenerations($record['tags']); } return $items; @@ -112,31 +113,31 @@ public function saveItems(array $items): bool $this->records[$item->getKey()] = [ 'value' => $item->get(), 'expires' => $ttl === null ? null : time() + $ttl, - 'tags' => $item instanceof CacheItem ? $item->getTagVersions() : [], + 'tags' => $item instanceof CacheItem ? $item->getTagGenerations() : [], ]; } return true; } - public function getTagVersions(array $tags): array + public function getTagGenerations(array $tags): array { $this->tagFetchBatches++; if ($this->throwOnTagRead) { throw new RuntimeException('backend tag read failed'); } - $versions = []; + $generations = []; foreach ($tags as $tag) { - $versions[$tag] = $this->versions[$tag] ?? 0; + $generations[$tag] = $this->generations[$tag] ??= bin2hex(random_bytes(16)); } - return $versions; + return $generations; } - public function incrementTagVersions(array $tags): bool + public function rotateTagGenerations(array $tags): bool { foreach ($tags as $tag) { - $this->versions[$tag] = ($this->versions[$tag] ?? 0) + 1; + $this->generations[$tag] = bin2hex(random_bytes(16)); } return true; @@ -182,7 +183,7 @@ public function resetOperationCounts(): void ->and($adapter->deleteBatches)->toBe(0); }); -test('bulk tagged reads fetch tag versions once and reject whole stale records', function () { +test('bulk tagged reads fetch tag generations once and reject whole stale records', function () { $adapter = new ArchitectureHardeningTest(); $cache = new Cache($adapter); $cache->setTagged('one', 1, ['group', 'shared']); @@ -211,6 +212,31 @@ public function resetOperationCounts(): void ->and($second->getItem('local')->isHit())->toBeFalse(); }); +test('invalid argument failures satisfy both PSR cache contracts', function () { + $exception = new CacheInvalidArgumentException('invalid'); + + expect($exception)->toBeInstanceOf(\Psr\Cache\InvalidArgumentException::class) + ->and($exception)->toBeInstanceOf(\Psr\SimpleCache\InvalidArgumentException::class); +}); + +test('namespaces are strictly validated instead of normalized', function () { + expect(fn() => Cache::memory('tenant/a'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => Cache::memory('tenant:a'))->toThrow(CacheInvalidArgumentException::class) + ->and(fn() => Cache::memory(str_repeat('n', 65)))->toThrow(CacheInvalidArgumentException::class) + ->and(Cache::memory('tenant-a.v1'))->toBeInstanceOf(Cache::class); +}); + +test('missing tag metadata cannot resurrect a tagged record', function () { + $adapter = new \Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter('generation-loss'); + $cache = new Cache($adapter); + $cache->setTagged('record', 'stale', ['products']); + + $metadata = new \ReflectionProperty($adapter, 'metadata'); + $metadata->setValue($adapter, []); + + expect($cache->get('record'))->toBeNull(); +}); + test('zero and negative ttl delete through single and bulk APIs', function () { $cache = new Cache(new ArchitectureHardeningTest()); $cache->setMultiple(['zero' => 1, 'negative' => 2]); @@ -232,7 +258,14 @@ public function resetOperationCounts(): void $closedAdapter = new ArchitectureHardeningTest(); $closed = new Cache($closedAdapter, options: new CacheOptions(failOpen: false)); $closedAdapter->throwOnRead = true; - expect(fn() => $closed->get('key'))->toThrow(RuntimeException::class); + expect(fn() => $closed->get('key'))->toThrow(CacheBackendException::class); +}); + +test('fail-closed backend failures satisfy both PSR cache exception contracts', function () { + $exception = new CacheBackendException('failed'); + + expect($exception)->toBeInstanceOf(\Psr\Cache\CacheException::class) + ->and($exception)->toBeInstanceOf(\Psr\SimpleCache\CacheException::class); }); test('tag metadata failures cannot expose or create tagged values', function () { @@ -254,9 +287,9 @@ public function resetOperationCounts(): void $l1 = new ArchitectureHardeningTest(); $l2 = new ArchitectureHardeningTest(); $cache = Cache::tiered([$l1, $l2]); - $l1->set('l1', 1); - $l2->set('l2a', 2); - $l2->set('l2b', 3); + $l1->save($l1->createItem('l1')->set(1)); + $l2->save($l2->createItem('l2a')->set(2)); + $l2->save($l2->createItem('l2b')->set(3)); $l1->resetOperationCounts(); $l2->resetOperationCounts(); diff --git a/tests/Cache/ArrayCachePoolTest.php b/tests/Cache/ArrayCachePoolTest.php index 928f49e..8b8b70a 100644 --- a/tests/Cache/ArrayCachePoolTest.php +++ b/tests/Cache/ArrayCachePoolTest.php @@ -54,11 +54,6 @@ public function clear(): bool return true; } - public function count(): int - { - return 0; - } - public function deleteItem(string $key): bool { return $key !== "\0"; diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index 22197cc..f58ac86 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -50,13 +50,12 @@ $v1 = $this->cache->remember( 'hot', - function ($item) use (&$count) { + function () use (&$count) { $count++; - $item->expiresAfter(30); return 'payload'; }, - null, + 30, ['hot-path'], ); @@ -104,11 +103,7 @@ function () use (&$count) { }); test('remember respects ttl argument expiry', function () { - $this->cache->remember('short', function ($item) { - $item->expiresAfter(1); - - return 'value'; - }, 1); + $this->cache->remember('short', fn() => 'value', 1); usleep(2_000_000); @@ -128,7 +123,7 @@ function () use (&$count) { ->and($this->cache->deleteItems(['never-there', 'also-missing']))->toBeTrue(); }); -test('tag version invalidation marks prior entries stale', function () { +test('tag generation invalidation marks prior entries stale', function () { $this->cache->setTagged('article', 'v1', ['content']); expect($this->cache->get('article'))->toBe('v1'); @@ -148,18 +143,20 @@ function () use (&$count) { ->and($this->cache->get('tagged'))->toBeNull(); }); -test('file tag increments do not lose concurrent updates', function () { +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.'); } + $adapter = new FileCacheAdapter('features', $this->cacheDir); + $before = $adapter->getTagGenerations(['concurrent'])['concurrent']; $children = []; for ($worker = 0; $worker < 4; $worker++) { $pid = pcntl_fork(); if ($pid === 0) { $adapter = new FileCacheAdapter('features', $this->cacheDir); for ($increment = 0; $increment < 25; $increment++) { - $adapter->incrementTagVersions(['concurrent']); + $adapter->rotateTagGenerations(['concurrent']); } pcntl_exec(PHP_BINARY, ['-r', '']); throw new RuntimeException('Unable to terminate concurrency-test worker.'); @@ -173,8 +170,9 @@ function () use (&$count) { expect(pcntl_wexitstatus($status))->toBe(0); } - $adapter = new FileCacheAdapter('features', $this->cacheDir); - expect($adapter->getTagVersions(['concurrent']))->toBe(['concurrent' => 100]); + $generation = $adapter->getTagGenerations(['concurrent'])['concurrent']; + expect($generation)->toMatch('/^[a-f0-9]{32}$/') + ->and($generation)->not->toBe($before); }); test('remember uses configured lock provider', function () { @@ -214,6 +212,110 @@ public function release(?LockHandle $handle): void ->and($calls['release'])->toBe(1); }); +test('remember discards a value invalidated while its resolver runs', function () { + $value = $this->cache->remember('raced', function (): string { + $this->cache->invalidateTag('products'); + + return 'stale'; + }, 300, ['products']); + + expect($value)->toBe('stale') + ->and($this->cache->get('raced'))->toBeNull(); +}); + +test('remember rechecks after lock timeout before resolving', function () { + $cache = Cache::memory('timeout-recheck'); + $provider = new class($cache) implements LockProviderInterface { + public function __construct(private Cache $cache) {} + + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle + { + expect($key)->not->toBeEmpty() + ->and($waitSeconds)->toBeGreaterThanOrEqual(0) + ->and($leaseSeconds)->toBeGreaterThan(0); + $this->cache->set('filled', 'winner', 300); + + return null; + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + expect($handle)->toBeInstanceOf(LockHandle::class) + ->and($leaseSeconds)->toBeGreaterThan(0); + + return false; + } + + public function release(?LockHandle $handle): void {} + }; + $runs = 0; + $cache->setLockProvider($provider); + + expect($cache->remember('filled', function () use (&$runs): string { + ++$runs; + + return 'loser'; + }))->toBe('winner') + ->and($runs)->toBe(0); +}); + +test('remember never stores after lock ownership is lost', function () { + $cache = Cache::memory('lost-lock'); + $provider = new class implements LockProviderInterface { + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle + { + expect($waitSeconds)->toBeGreaterThanOrEqual(0); + + return new LockHandle($key, 'owner', leaseSeconds: $leaseSeconds); + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + expect($handle)->toBeInstanceOf(LockHandle::class) + ->and($leaseSeconds)->toBeGreaterThan(0); + + return false; + } + + public function release(?LockHandle $handle): void {} + }; + $cache->setLockProvider($provider); + + expect($cache->remember('lost', fn(): string => 'computed', 300))->toBe('computed') + ->and($cache->get('lost'))->toBeNull() + ->and($cache->exportMetrics()['array']['remember_discarded_after_lock_loss'] ?? 0)->toBe(1); +}); + +test('remember lock identities include the cache namespace', 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 + { + expect($waitSeconds)->toBeGreaterThanOrEqual(0); + $this->keys[] = $key; + + return new LockHandle($key, bin2hex(random_bytes(16)), leaseSeconds: $leaseSeconds); + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + expect($handle)->toBeInstanceOf(LockHandle::class) + ->and($leaseSeconds)->toBeGreaterThan(0); + + return true; + } + + public function release(?LockHandle $handle): void {} + }; + Cache::memory('tenant-a')->setLockProvider($provider)->remember('same', fn(): int => 1); + Cache::memory('tenant-b')->setLockProvider($provider)->remember('same', fn(): int => 2); + + expect($keys)->toHaveCount(2) + ->and($keys[0])->not->toBe($keys[1]); +}); + test('metrics collector exports hit and miss counters', function () { $collector = new InMemoryCacheMetricsCollector(); $this->cache->setMetricsCollector($collector); diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index 383a5b9..5d312d9 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -3,18 +3,20 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Adapter\CachePayloadCodec; +use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\CacheOptions; test('payload codec signs and verifies CacheLayer v2 records', function () { $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); - $blob = $codec->encode(['k' => 'v'], null, ['group' => 2]); + $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); expect($record?->value)->toBe(['k' => 'v']) - ->and($record?->tags)->toBe(['group' => 2]); + ->and($record?->tags)->toBe(['group' => $generation]); }); test('payload codec rejects tampered signed payload', function () { @@ -24,6 +26,20 @@ expect($codec->decode($blob . 'x'))->toBeNull(); }); +test('cache treats a corrupted signed record as a miss and deletes it', function () { + $adapter = new ArrayCacheAdapter('signed-cleanup'); + $cache = new Cache($adapter, options: new CacheOptions(integrityKey: 'secret-key-123')); + $cache->set('record', 'value'); + + $store = new ReflectionProperty($adapter, 'store'); + $records = $store->getValue($adapter); + $records['signed-cleanup:d:record'] .= 'tampered'; + $store->setValue($adapter, $records); + + expect($cache->get('record', 'fallback'))->toBe('fallback') + ->and($store->getValue($adapter))->not->toHaveKey('signed-cleanup:d:record'); +}); + test('payload codec policies are isolated between instances', function () { $unsigned = new CachePayloadCodec(); $signed = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); @@ -45,6 +61,18 @@ ->and($strict->get('scalar'))->toBe('still-valid'); }); +test('weak map enforces object and closure policies without serializing references', function () { + $strict = Cache::weakMap('strict-weak', new CacheOptions(allowObjects: false, allowClosures: false)); + $closuresOnly = Cache::weakMap('closure-weak', new CacheOptions(allowObjects: false, allowClosures: true)); + $closure = static fn(): string => 'retained'; + + expect($strict->set('object', new stdClass()))->toBeFalse() + ->and($strict->set('closure', $closure))->toBeFalse() + ->and($closuresOnly->set('object', new stdClass()))->toBeFalse() + ->and($closuresOnly->set('closure', $closure))->toBeTrue() + ->and($closuresOnly->get('closure'))->toBe($closure); +}); + test('payload codec delegates only top-level closures to special serialization', function () { $codec = new CachePayloadCodec(); $blob = $codec->encode(static fn(int $value): int => $value + 1, null); diff --git a/tests/Cache/LockProviderTest.php b/tests/Cache/LockProviderTest.php index 1ec0f2d..85546b4 100644 --- a/tests/Cache/LockProviderTest.php +++ b/tests/Cache/LockProviderTest.php @@ -45,6 +45,33 @@ ->toThrow(InvalidArgumentException::class); }); +test('file lock registry keeps identical keys in different directories independent', function (): void { + $firstDirectory = sys_get_temp_dir() . '/cachelayer-lock-a-' . bin2hex(random_bytes(5)); + $secondDirectory = sys_get_temp_dir() . '/cachelayer-lock-b-' . bin2hex(random_bytes(5)); + $first = new FileLockProvider($firstDirectory); + $second = new FileLockProvider($secondDirectory); + + try { + $firstHandle = $first->acquire('shared-key', 0.0, 1.0); + $secondHandle = $second->acquire('shared-key', 0.0, 1.0); + + expect($firstHandle)->not->toBeNull() + ->and($secondHandle)->not->toBeNull(); + + $first->release($firstHandle); + $second->release($secondHandle); + } finally { + foreach ([$firstDirectory, $secondDirectory] as $directory) { + foreach (glob($directory . '/*.lock') ?: [] as $file) { + unlink($file); + } + if (is_dir($directory)) { + rmdir($directory); + } + } + } +}); + test('sqlite PDO locks use the shared file-lock fallback', function (): void { if (!extension_loaded('pdo_sqlite')) { test()->markTestSkipped('pdo_sqlite is not available.'); diff --git a/tests/Cache/MemcachedCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php index a16f3d6..00cafd5 100644 --- a/tests/Cache/MemcachedCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -11,6 +11,7 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Cache\Lock\MemcachedLockProvider; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; /* ── Skip suite if Memcached unavailable ─────────────────────────── */ @@ -119,7 +120,7 @@ ->toThrow(InvalidArgumentException::class); }); -test('clear only advances this namespace epoch', function () use ($memcachedHost, $memcachedPort) { +test('clear only rotates this namespace generation', function () use ($memcachedHost, $memcachedPort) { $other = Cache::memcached('other', [[$memcachedHost, $memcachedPort, 0]], $this->client); $this->cache->set('z', 9); $other->set('z', 10); @@ -128,6 +129,20 @@ ->and($other->get('z'))->toBe(10); }); +test('an expired Memcached lock owner cannot delete its replacement', function () { + $provider = new MemcachedLockProvider($this->client); + $oldOwner = $provider->acquire('reports', 0.0, 30.0); + expect($oldOwner)->not->toBeNull(); + if ($oldOwner === null) { + return; + } + + $this->client->set($oldOwner->key, 'replacement-token', 30); + $provider->release($oldOwner); + + expect($this->client->get($oldOwner->key))->toBe('replacement-token'); +}); + test('Memcached adapter multiFetch()', function () { $this->cache->set('m1', 'foo'); $this->cache->set('m2', 'bar'); diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index d9b3f62..51f1627 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -12,6 +12,17 @@ public int $mgetCalls = 0; + public int $pipelineCalls = 0; + + public int $setexCalls = 0; + + public bool $failPipelineCommand = false; + + private bool $pipelined = false; + + /** @var list */ + private array $pipelineResults = []; + public function del(string|array $keys): int { $deleted = 0; @@ -63,8 +74,21 @@ public function mset(array $values): bool return true; } - public function set(string $key, string $value): bool + public function multi(int $mode): self { + $this->pipelineCalls++; + $this->pipelined = $mode > 0; + $this->pipelineResults = []; + + return $this; + } + + /** @param list $options */ + public function set(string $key, string $value, array $options = []): bool + { + if (in_array('nx', $options, true) && isset($this->values[$key])) { + return false; + } $this->values[$key] = ['value' => $value, 'expires' => null]; return true; @@ -72,17 +96,40 @@ public function set(string $key, string $value): bool public function setex(string $key, int $ttl, string $value): bool { + $this->setexCalls++; $this->values[$key] = ['value' => $value, 'expires' => time() + max(1, $ttl)]; + if ($this->pipelined) { + $this->pipelineResults[] = !$this->failPipelineCommand; + } return true; } + /** @return list */ + public function exec(): array + { + $this->pipelined = false; + + return $this->pipelineResults; + } + /** @return list */ public function keys(): array { return array_keys($this->values); } + public function dropGenerationFor(string $logicalKey): void + { + foreach (array_keys($this->values) as $physicalKey) { + if (!str_ends_with($physicalKey, ':d:' . $logicalKey)) { + continue; + } + $prefix = substr($physicalKey, 0, -strlen(':d:' . $logicalKey)); + unset($this->values[$prefix . ':m:generation']); + } + } + private function prune(string $key): void { $expires = $this->values[$key]['expires'] ?? null; @@ -118,10 +165,38 @@ private function prune(string $key): void expect($this->cache->get('ttl'))->toBeNull(); }); -test('redis cluster clear uses bucket epochs without a permanent key index', function () { +test('redis cluster adapter rejects a partial expiring pipeline write', function () { + $this->cluster->failPipelineCommand = true; + + expect($this->cache->setMultiple(['first' => 1, 'second' => 2], 60))->toBeFalse(); +}); + +test('redis cluster groups expiring bulk writes into bounded bucket pipelines', function () { + $values = []; + $buckets = []; + for ($index = 0; $index < 100; $index++) { + $key = 'bucketed.' . $index; + $values[$key] = $index; + $buckets[hexdec(substr(hash('xxh3', $key), 0, 8)) % 128] = true; + } + + expect($this->cache->setMultiple($values, 60))->toBeTrue() + ->and($this->cluster->setexCalls)->toBe(100) + ->and($this->cluster->pipelineCalls)->toBe(count($buckets)); +}); + +test('redis cluster clear rotates bucket generations without a permanent key index', function () { $this->cache->setMultiple(['a' => 1, 'b' => 2]); $this->cache->clear(); expect($this->cache->getMultiple(['a', 'b']))->toBe(['a' => null, 'b' => null]) ->and(implode('|', $this->cluster->keys()))->not->toContain('__keys'); }); + +test('missing bucket generation cannot resurrect data written before clear', function () { + $this->cache->set('old', 'stale'); + $this->cache->clear(); + $this->cluster->dropGenerationFor('old'); + + expect($this->cache->get('old'))->toBeNull(); +}); diff --git a/tests/Cache/SharedMemoryCachePoolTest.php b/tests/Cache/SharedMemoryCachePoolTest.php index 3ff5876..9263a20 100644 --- a/tests/Cache/SharedMemoryCachePoolTest.php +++ b/tests/Cache/SharedMemoryCachePoolTest.php @@ -33,3 +33,17 @@ $adapter->clear(); }); + +test('shared memory namespace clear cannot affect another namespace', function () { + $first = Cache::sharedMemory('shm-isolation-a'); + $second = Cache::sharedMemory('shm-isolation-b'); + + $first->set('key', 'first'); + $second->set('key', 'second'); + $first->clear(); + + expect($first->get('key'))->toBeNull() + ->and($second->get('key'))->toBe('second'); + + $second->clear(); +}); diff --git a/tests/Cache/ChainCachePoolTest.php b/tests/Cache/TieredCachePoolTest.php similarity index 75% rename from tests/Cache/ChainCachePoolTest.php rename to tests/Cache/TieredCachePoolTest.php index 1c36614..f1de0c3 100644 --- a/tests/Cache/ChainCachePoolTest.php +++ b/tests/Cache/TieredCachePoolTest.php @@ -12,14 +12,14 @@ $this->cache = Cache::tiered([$this->l1, $this->l2]); }); -test('chain adapter writes through all pools', function () { +test('tiered adapter writes through all pools', function () { $this->cache->set('k', 'value'); expect($this->l1->getItem('k')->isHit())->toBeTrue() ->and($this->l2->getItem('k')->isHit())->toBeTrue(); }); -test('chain adapter promotes value from lower tier to upper tier', function () { +test('tiered adapter promotes value from lower tier to upper tier', function () { $item = $this->l2->getItem('promote'); $item->set('from-l2')->save(); @@ -53,6 +53,17 @@ ->and($l1->getItem('x')->isHit())->toBeTrue(); }); +test('tiered tag validation follows the authoritative last tier', function () { + $l1 = new ArrayCacheAdapter('tier-disagreement'); + $l2 = new ArrayCacheAdapter('tier-disagreement'); + $cache = Cache::tiered([$l1, $l2]); + $cache->setTagged('tagged', 'value', ['products']); + + expect($cache->get('tagged'))->toBe('value'); + $l2->rotateTagGenerations(['products']); + expect($cache->get('tagged'))->toBeNull(); +}); + test('tiered cache rejects unsupported driver descriptors', function () { expect(fn() => Cache::tiered([['driver' => 'unknown-tier']])) ->toThrow(CacheInvalidArgumentException::class); diff --git a/tests/Cluster/ClusterCacheTest.php b/tests/Cluster/ClusterCacheTest.php index d56a579..4f6c8c0 100644 --- a/tests/Cluster/ClusterCacheTest.php +++ b/tests/Cluster/ClusterCacheTest.php @@ -4,14 +4,82 @@ 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; use Infocyph\CacheLayer\Cluster\Event\InvalidationEvent; use Infocyph\CacheLayer\Cluster\Event\InvalidationEventType; use Infocyph\CacheLayer\Cluster\Exception\ClusterCacheException; use Infocyph\CacheLayer\Cluster\Exception\ClusterTransportException; +use Infocyph\CacheLayer\Cluster\Health\ClusterStatusTracker; +use Infocyph\CacheLayer\Cluster\Recovery\ClusterRecoveryManager; use Infocyph\CacheLayer\Cluster\Transport\InvalidationTransportData; use Infocyph\CacheLayer\Cluster\Transport\Pdo\PdoInvalidationTransport; +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; + } +} beforeEach(function () { $this->clusterDirectory = sys_get_temp_dir() . '/cachelayer-cluster-' . uniqid(); @@ -165,6 +233,19 @@ ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterTransportException::class); }); +test('PDO transport can use a separately bootstrapped schema without DDL on construction', function () { + $connection = new \PDO('sqlite:' . $this->clusterDirectory . '/preinstalled-transport.sqlite'); + PdoInvalidationSchema::install($connection, allowSqliteForTesting: true); + $transport = new PdoInvalidationTransport( + $connection, + allowSqliteForTesting: true, + initializeSchema: false, + ); + + expect($transport->publish(InvalidationEvent::key('preinstalled', 'application', 'key', 'writer'))) + ->toBe('1'); +}); + test('invalidation transport data rejects malformed and overflowing timestamps', function () { expect(fn () => InvalidationTransportData::unsignedInteger('-1', 'created_at', 'test transport')) ->toThrow(ClusterTransportException::class) @@ -205,6 +286,29 @@ ))->toThrow(ClusterCacheException::class); }); +test('cluster configuration and runtime inputs enforce transport bounds before publication', function () { + expect(fn() => new ClusterCacheConfig(str_repeat('c', 129), 'node')) + ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) + ->and(fn() => new ClusterCacheConfig('cluster', str_repeat('n', 256))) + ->toThrow(\Infocyph\CacheLayer\Cluster\Exception\ClusterConfigurationException::class) + ->and(fn() => $this->nodeA->invalidateKey(str_repeat('k', 65))) + ->toThrow(ClusterCacheException::class) + ->and(fn() => $this->nodeA->invalidateTag(str_repeat('t', 65))) + ->toThrow(ClusterCacheException::class); +}); + +test('poison event escape hatch clears local data before advancing the cursor', function () { + $this->nodeB->cache()->set('stale', 'value'); + $eventId = $this->transport->publish( + InvalidationEvent::key('test-cluster', 'application', 'poison', 'writer'), + ); + + $this->nodeB->skipEventAfterClear($eventId); + + expect($this->nodeB->cache()->get('stale'))->toBeNull() + ->and($this->nodeB->status()->cursor)->toBe($eventId); +}); + test('transactional outbox publishes with the source transaction and applies locally after commit', function () { $connection = new \PDO('sqlite:' . $this->clusterDirectory . '/outbox.sqlite'); $transport = new PdoInvalidationTransport($connection, allowSqliteForTesting: true); @@ -236,3 +340,50 @@ expect($runtime->consume())->toBe(1) ->and($runtime->cache()->get('product.42'))->toBeNull(); }); + +test('consumer keeps its cursor when local invalidation returns false', function () { + $adapter = new RejectingClusterCacheAdapter(); + $cache = new Cache($adapter); + $transport = new InMemoryInvalidationTransport(); + $transport->publish(InvalidationEvent::key('failed-cluster', 'application', 'key', 'writer')); + $cursor = new SqliteCursorStore( + $this->clusterDirectory . '/failed-cursor.sqlite', + 'failed-cluster', + 'consumer', + ); + $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'failed-cluster'); + $consumer = new InvalidationConsumer( + $transport, + $cursor, + new InvalidationHandler($cache, 'application'), + $recovery, + 'failed-cluster', + 'consumer', + new ClusterStatusTracker(), + ); + + expect(fn() => $consumer->consume())->toThrow(ClusterCacheException::class) + ->and($cursor->current())->toBeNull() + ->and(array_keys($adapter->rejectedOperations))->toBe(['deleteItem']); +}); + +test('recovery keeps its cursor when the required clear returns false', function () { + $adapter = new RejectingClusterCacheAdapter(); + $cache = new Cache($adapter); + $transport = new InMemoryInvalidationTransport(); + foreach (['one', 'two', 'three'] as $key) { + $transport->publish(InvalidationEvent::key('recovery-failure', 'application', $key, 'writer')); + } + $transport->discardBefore('recovery-failure', 3); + $cursor = new SqliteCursorStore( + $this->clusterDirectory . '/recovery-failed-cursor.sqlite', + 'recovery-failure', + 'consumer', + ); + $cursor->advance('1'); + $recovery = new ClusterRecoveryManager($cache, $cursor, $transport, 'recovery-failure'); + + expect(fn() => $recovery->recoverIfRequired())->toThrow(ClusterCacheException::class) + ->and($cursor->current())->toBe('1') + ->and(array_keys($adapter->rejectedOperations))->toBe(['clear']); +}); diff --git a/tests/Memoize/MemoizeTest.php b/tests/Memoize/MemoizeTest.php index 4414325..0b2ee76 100644 --- a/tests/Memoize/MemoizeTest.php +++ b/tests/Memoize/MemoizeTest.php @@ -52,7 +52,7 @@ ->and(memoize()->stats()['hits'])->toBe(1); }); -it('once() caches by call site', function () { +it('once() keeps distinct source lines independent', function () { $counter = 0; $value = (function () use (&$counter) { @@ -68,8 +68,56 @@ })(); expect($value)->toBe(1) - ->and($valueAgain)->toBe(1) - ->and($counter)->toBe(1); + ->and($valueAgain)->toBe(2) + ->and($counter)->toBe(2); +}); + +it('memoizer distinguishes closure captures and object instances', function () { + $make = static fn(int $offset): Closure => fn(int $value): int => $value + $offset; + $first = $make(10); + $second = $make(20); + $service = static fn(int $offset): object => new class($offset) { + public function __construct(private int $offset) {} + + public function calculate(int $value): int + { + return $value + $this->offset; + } + }; + $serviceA = $service(10); + $serviceB = $service(20); + + expect(memoize($first, [1]))->toBe(11) + ->and(memoize($second, [1]))->toBe(21) + ->and(memoize([$serviceA, 'calculate'], [1]))->toBe(11) + ->and(memoize([$serviceB, 'calculate'], [1]))->toBe(21); +}); + +it('memoizer distinguishes separate reference captures from the same source', function () { + $first = 10; + $second = 10; + $factory = static function (int &$capture): Closure { + return static function () use (&$capture): int { + return $capture; + }; + }; + + expect(memoize($factory($first)))->toBe(10); + $second = 20; + + expect(memoize($factory($second)))->toBe(20); +}); + +it('flush_memoizers resets process lifetime memoization', function () { + $runs = 0; + $loader = function () use (&$runs): int { + return ++$runs; + }; + + expect(memoize($loader))->toBe(1) + ->and(memoize($loader))->toBe(1); + flush_memoizers(); + expect(memoize($loader))->toBe(2); }); it('memoize trait caches values within object', function () { diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index 90212ba..061029c 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -142,6 +142,38 @@ public function release(?LockHandle $handle): void ->and(($after['l2_batch_promote'] ?? 0) - ($before['l2_batch_promote'] ?? 0))->toBe(2); }); +test('node tagged L1 hits use cached generation metadata', function () { + $connection = NodeSqliteConnection::create($this->nodeConfig); + $l1 = new ArrayCacheAdapter($this->nodeConfig->namespace); + $l2 = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); + $cache = new Cache(new NodeCacheAdapter($l1, $l2, false)); + $cache->setTagged('tagged', 'value', ['products'], 300); + $connection->prepare( + "DELETE FROM cachelayer_node_entries WHERE namespace = ? AND cache_key = 'm:tag:products'", + )->execute([$this->nodeConfig->namespace]); + + expect($cache->get('tagged'))->toBe('value'); + $l1->clear(); + expect($cache->get('tagged'))->toBeNull(); +}); + +test('node never writes L1 when its authoritative L2 write fails', function () { + $connection = NodeSqliteConnection::create($this->nodeConfig); + $l1 = new ArrayCacheAdapter($this->nodeConfig->namespace); + $l2 = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); + $cache = new Cache(new NodeCacheAdapter($l1, $l2)); + $cache->set('coherent', 'old', 300); + $l1->clear(); + $connection->exec( + "CREATE TRIGGER reject_node_update BEFORE UPDATE ON cachelayer_node_entries " + . "WHEN OLD.cache_key = 'd:coherent' BEGIN SELECT RAISE(ABORT, 'write rejected'); END", + ); + + expect($cache->set('coherent', 'new', 300))->toBeFalse() + ->and($l1->getItem('coherent')->isHit())->toBeFalse() + ->and($l2->getItem('coherent')->get())->toBe('old'); +}); + test('expired rows remain outside the read path until bounded pruning', function () { $connection = NodeSqliteConnection::create($this->nodeConfig); $adapter = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); From 6e1fb5a13dd0305acd8561a0ad07bd28d65d9128 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Tue, 11 Aug 2026 10:56:35 +0600 Subject: [PATCH 2/2] updated & fixed ops/tech issues --- tests/Cache/ValkeyCachePoolTest.php | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/Cache/ValkeyCachePoolTest.php b/tests/Cache/ValkeyCachePoolTest.php index 0763b3a..22a4411 100644 --- a/tests/Cache/ValkeyCachePoolTest.php +++ b/tests/Cache/ValkeyCachePoolTest.php @@ -54,12 +54,11 @@ test('valkey adapter supports remember lock path', function () { $runs = 0; - $v1 = $this->cache->remember('once', function ($item) use (&$runs) { - $runs++; - $item->expiresAfter(30); + $v1 = $this->cache->remember('once', function () use (&$runs) { + ++$runs; return 'value'; - }); + }, 30); $v2 = $this->cache->remember('once', fn () => 'new-value'); expect($v1)->toBe('value')