From c927782bb146469267017a5d6410fc06f0004770 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 10 Aug 2026 09:59:00 +0600 Subject: [PATCH] updated doc+fixing code issues --- .gitignore | 1 + README.md | 281 ++-- benchmarks/CacheBulkBench.php | 198 +++ benchmarks/CachePolicyBench.php | 126 ++ benchmarks/ClosureSerializerBench.php | 31 + benchmarks/MemoizeBench.php | 11 + benchmarks/NodeCacheBench.php | 99 ++ benchmarks/SerializerBench.php | 53 - composer.json | 2 +- docs/adapters/apcu.rst | 8 +- docs/adapters/file.rst | 7 +- docs/adapters/index.rst | 6 +- docs/adapters/memcached.rst | 10 +- docs/adapters/mongodb.rst | 2 +- docs/adapters/null-store.rst | 2 +- docs/adapters/pdo.rst | 10 +- docs/adapters/php-files.rst | 8 +- docs/adapters/redis-cluster.rst | 10 +- docs/adapters/scylladb.rst | 11 +- docs/adapters/serialization.rst | 9 +- docs/adapters/shared-memory.rst | 2 +- docs/adapters/sqlite.rst | 2 +- docs/adapters/{chain.rst => tiered.rst} | 12 +- docs/adapters/weak-map.rst | 4 +- docs/cache.rst | 350 +---- docs/cookbook.rst | 14 +- docs/index.rst | 6 +- docs/security.rst | 36 +- docs/serializer.rst | 70 +- src/Cache/Adapter/AbstractCacheAdapter.php | 261 ++-- src/Cache/Adapter/ApcuCacheAdapter.php | 116 +- src/Cache/Adapter/ArrayCacheAdapter.php | 74 +- src/Cache/Adapter/CachePayloadCodec.php | 364 ++--- src/Cache/Adapter/ChainCacheAdapter.php | 234 ++- src/Cache/Adapter/FileCacheAdapter.php | 202 ++- .../Adapter/GenericCacheItemPoolBehavior.php | 32 - .../Adapter/InternalCachePoolInterface.php | 25 +- src/Cache/Adapter/MemCacheAdapter.php | 392 ----- src/Cache/Adapter/MemcachedCacheAdapter.php | 253 ++++ src/Cache/Adapter/MongoDbCacheAdapter.php | 147 +- src/Cache/Adapter/NullCacheAdapter.php | 37 +- src/Cache/Adapter/PdoCacheAdapter.php | 554 ++++--- src/Cache/Adapter/PdoCacheSchema.php | 40 + src/Cache/Adapter/PhpFilesCacheAdapter.php | 202 ++- src/Cache/Adapter/RedisCacheAdapter.php | 129 +- .../Adapter/RedisClusterCacheAdapter.php | 292 ++-- src/Cache/Adapter/ScyllaDbCacheAdapter.php | 220 ++- .../Adapter/SharedMemoryCacheAdapter.php | 134 +- src/Cache/Adapter/WeakMapCacheAdapter.php | 133 +- src/Cache/Cache.php | 1275 +++++------------ src/Cache/CacheInput.php | 97 ++ src/Cache/CacheInterface.php | 61 +- src/Cache/CacheOptions.php | 46 + src/Cache/CacheReadRememberTrait.php | 195 --- src/Cache/CacheRecord.php | 19 + src/Cache/CacheTagSnapshots.php | 84 ++ src/Cache/Item/AbstractCacheItem.php | 136 -- src/Cache/Item/ApcuCacheItem.php | 7 - src/Cache/Item/CacheItem.php | 111 ++ src/Cache/Item/FileCacheItem.php | 7 - src/Cache/Item/GenericCacheItem.php | 7 - src/Cache/Item/MemCacheItem.php | 7 - src/Cache/Item/RedisCacheItem.php | 7 - .../CacheMetricsCollectorInterface.php | 2 +- .../Metrics/InMemoryCacheMetricsCollector.php | 4 +- src/Cache/Tiering/TieredPoolFactory.php | 23 +- src/Node/Adapter/NodeCacheAdapter.php | 379 ++--- src/Node/Adapter/NodeSqliteCacheAdapter.php | 224 ++- src/Node/NodeCache.php | 2 + src/Serializer/ClosureSerializer.php | 62 + src/Serializer/SignedClosureSerializer.php | 49 + src/Serializer/ValueSerializer.php | 405 ------ tests/Cache/ApcuCachePoolTest.php | 70 +- tests/Cache/ArchitectureHardeningTest.php | 268 ++++ tests/Cache/ArrayCachePoolTest.php | 39 +- tests/Cache/CacheFeaturesTest.php | 77 +- tests/Cache/CachePayloadCodecSecurityTest.php | 85 +- tests/Cache/ChainCachePoolTest.php | 2 +- tests/Cache/FileCachePoolTest.php | 116 +- ...oolTest.php => MemcachedCachePoolTest.php} | 82 +- tests/Cache/MongoDbCachePoolTest.php | 54 +- tests/Cache/PdoCachePoolTest.php | 9 +- tests/Cache/PdoMysqlCachePoolTest.php | 9 +- tests/Cache/PdoPgsqlCachePoolTest.php | 9 +- tests/Cache/RedisCachePoolTest.php | 63 +- tests/Cache/RedisClusterCachePoolTest.php | 101 +- tests/Cache/ScyllaDbCachePoolTest.php | 105 +- tests/Cache/SqliteCachePoolTest.php | 66 +- tests/Node/NodeCacheTest.php | 21 + tests/Serializer/ClosureSerializerTest.php | 39 + tests/Serializer/ValueSerializerTest.php | 84 -- 91 files changed, 5178 insertions(+), 4552 deletions(-) create mode 100644 benchmarks/CacheBulkBench.php create mode 100644 benchmarks/CachePolicyBench.php create mode 100644 benchmarks/ClosureSerializerBench.php create mode 100644 benchmarks/NodeCacheBench.php delete mode 100644 benchmarks/SerializerBench.php rename docs/adapters/{chain.rst => tiered.rst} (66%) delete mode 100644 src/Cache/Adapter/GenericCacheItemPoolBehavior.php delete mode 100644 src/Cache/Adapter/MemCacheAdapter.php create mode 100644 src/Cache/Adapter/MemcachedCacheAdapter.php create mode 100644 src/Cache/Adapter/PdoCacheSchema.php create mode 100644 src/Cache/CacheInput.php create mode 100644 src/Cache/CacheOptions.php delete mode 100644 src/Cache/CacheReadRememberTrait.php create mode 100644 src/Cache/CacheRecord.php create mode 100644 src/Cache/CacheTagSnapshots.php delete mode 100644 src/Cache/Item/AbstractCacheItem.php delete mode 100644 src/Cache/Item/ApcuCacheItem.php create mode 100644 src/Cache/Item/CacheItem.php delete mode 100644 src/Cache/Item/FileCacheItem.php delete mode 100644 src/Cache/Item/GenericCacheItem.php delete mode 100644 src/Cache/Item/MemCacheItem.php delete mode 100644 src/Cache/Item/RedisCacheItem.php create mode 100644 src/Serializer/ClosureSerializer.php create mode 100644 src/Serializer/SignedClosureSerializer.php delete mode 100644 src/Serializer/ValueSerializer.php create mode 100644 tests/Cache/ArchitectureHardeningTest.php rename tests/Cache/{MemCachePoolTest.php => MemcachedCachePoolTest.php} (63%) create mode 100644 tests/Serializer/ClosureSerializerTest.php delete mode 100644 tests/Serializer/ValueSerializerTest.php diff --git a/.gitignore b/.gitignore index f02dfc8..de37c51 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ test.php var vendor .codex +plan.md diff --git a/README.md b/README.md index e080cc7..e7bbc3d 100644 --- a/README.md +++ b/README.md @@ -1,208 +1,231 @@ # CacheLayer [![Security & Standards](https://github.com/infocyph/CacheLayer/actions/workflows/security-standards.yml/badge.svg)](https://github.com/infocyph/CacheLayer/actions/workflows/security-standards.yml) -![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/CacheLayer?color=green\&link=https%3A%2F%2Fpackagist.org%2Fpackages%2Finfocyph%2FCacheLayer) +![Packagist Downloads](https://img.shields.io/packagist/dt/infocyph/CacheLayer?color=green) [![License: MIT](https://img.shields.io/badge/License-MIT-green.svg)](https://opensource.org/licenses/MIT) ![Packagist Version](https://img.shields.io/packagist/v/infocyph/CacheLayer) ![Packagist PHP Version](https://img.shields.io/packagist/dependency-v/infocyph/CacheLayer/php) -![GitHub Code Size](https://img.shields.io/github/languages/code-size/infocyph/CacheLayer) -[![Documentation](https://img.shields.io/badge/Documentation-CacheLayer-blue?logo=readthedocs&logoColor=white)](https://docs.infocyph.com/projects/CacheLayer/) -CacheLayer is a standalone cache toolkit for modern PHP applications. -It provides a unified API over PSR-6 and PSR-16 with local, distributed, -and cloud adapters. +CacheLayer is a PHP 8.3+ caching toolkit built around four deliberately separate concerns: + +```text +CacheLayer +├── Cache +│ ├── PSR-6 and PSR-16 +│ ├── versioned tags +│ ├── bounded stampede protection +│ └── tiering +├── Node Cache +│ └── APCu L1 → SQLite L2 +├── Cluster Cache +│ └── durable invalidation between Node Caches +├── Atomic Counters +└── Process-local Memoization +``` -## Project Background +The ordinary cache is disposable storage. Cluster Cache distributes invalidations, not values. Atomic counters remain outside the cache contract because they require stronger semantics. Memoization stays process-local. -CacheLayer was separated from the existing Intermix project to improve package -visibility, maintenance focus, and faster feature enrichment for caching. +## Installation -## Features +```bash +composer require infocyph/cachelayer +``` -- Unified `Cache` facade implementing PSR-6, PSR-16, `ArrayAccess`, and `Countable` -- Adapter support for APCu, File, PHP Files, Memcached, Redis, Valkey, Redis Cluster, PDO (SQLite default), Shared Memory, MongoDB, and ScyllaDB -- Tiered cache composition via `Cache::tiered()` (L1/L2/... descriptors or pool instances) -- Node-local APCu L1 + SQLite L2 cache via `NodeCache` -- Durable multi-node invalidation coordination via `ClusterCache` -- Tagged invalidation with versioned tags: `setTagged()`, `invalidateTag()`, `invalidateTags()` -- Stampede-safe `remember()` with pluggable lock providers -- Per-adapter metrics counters and export hooks -- Payload compression controls -- Value serializer helpers for closures/resources -- Memoization helpers: `memoize()`, `remember()`, `once()` +Choose extensions and client packages only for the backends you use: APCu, Redis/Valkey, Memcached, PDO, SysV shared memory, MongoDB, or Cassandra/ScyllaDB. -## Requirements +## Cache -- PHP 8.3+ -- Composer +```php +use Infocyph\CacheLayer\Cache\Cache; -Optional extensions/packages depend on adapter choice: +$cache = Cache::sqlite('app', '/var/cache/my-app/cache.sqlite'); -- `ext-apcu` -- `ext-redis` -- `ext-memcached` -- `ext-pdo` + driver (`pdo_sqlite`, `pdo_pgsql`, `pdo_mysql`, ...) -- `ext-sysvshm` -- `mongodb/mongodb` -- `ext-cassandra` +$cache->setMultiple([ + 'profile.1' => ['name' => 'Ada'], + 'profile.2' => ['name' => 'Grace'], +], 300); -## Installation +$profiles = $cache->getMultiple(['profile.1', 'profile.2', 'profile.3']); +$cache->deleteMultiple(['profile.1', 'profile.2']); +``` -```bash -composer require infocyph/cachelayer +`Cache` implements PSR-6, PSR-16, and `ArrayAccess`. It intentionally does not implement `Countable`, magic property access, runtime namespace mutation, or compatibility aliases. Keys and tags must be 1–64 characters and match `[A-Za-z0-9_.-]+`; invalid bulk input is rejected before storage is changed. + +A callable passed as the PSR-16 `get()` default is returned as a value. Use the explicit `remember()` API to compute and persist a miss: + +```php +$user = $cache->remember( + 'user.42', + fn () => $repository->find(42), + ttl: 300, + tags: ['users'], +); ``` -## Usage +`remember()` follows get → miss → lock → recheck → resolve → save → release. Lock waiting is bounded; a timeout computes fail-open and records the unlocked computation. No lock operation occurs on a hit. + +## Tags and expiration ```php -use Infocyph\CacheLayer\Cache\Cache; +$cache->setTagged('article.7', $article, ['articles', 'author.12'], 600); +$cache->invalidateTags(['articles', 'author.12']); +``` -$cache = Cache::pdo('app'); // defaults to sqlite file under sys temp cachelayer/pdo +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. -$cache->setTagged('user:1', ['name' => 'Ada'], ['users'], 300); +Zero and negative PSR-16 TTLs delete the key. Missing tag metadata means version zero. -$user = $cache->remember('user:1', function ($item) { - $item->expiresAfter(300); - return ['name' => 'Ada']; -}, tags: ['users']); +## Native bulk paths -$cache->invalidateTag('users'); +Bulk methods validate once and call the adapter’s native bulk contract. Deferred PSR-6 items are also persisted through the same bulk path on `commit()`. -$metrics = $cache->exportMetrics(); -``` +| Backend | Bulk read/write strategy | +|---|---| +| Array memory | direct array lookup/update | +| WeakMap | one prune pass plus direct lookup | +| Null store | immediate misses/no-op writes | +| APCu | array `apcu_fetch`, grouped stores | +| Redis / Valkey | `MGET`, `MSET`, pipelined TTL writes | +| Memcached | `getMulti`, TTL-grouped `setMulti` | +| PDO | chunked `IN (...)`, multi-row upsert | +| MongoDB | `$in`, `bulkWrite` | +| ScyllaDB | partition-bucketed `IN`, bounded unlogged batches | +| Shared memory | one lock per batch operation | +| File / PHP files | optimized sequential filesystem access | +| Redis Cluster | fixed hash buckets and same-slot grouped operations | -## Lock leases +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. -`remember()` uses the configured lock provider to prevent concurrent cache -fills. Lock providers expose an explicit lease and renewal contract: +## Adapters + +The public factories are: ```php -$handle = $locks->acquire('reports:daily', waitSeconds: 2, leaseSeconds: 30); - -if ($handle !== null) { - try { - // Renew before the lease expires when the protected work is long-lived. - $locks->refresh($handle, leaseSeconds: 30); - } finally { - $locks->release($handle); - } -} +Cache::memory(); Cache::weakMap(); Cache::nullStore(); +Cache::apcu(); Cache::file(); Cache::phpFiles(); +Cache::sharedMemory(); Cache::redis(); Cache::valkey(); +Cache::redisCluster(); Cache::memcached(); Cache::pdo(); +Cache::sqlite(); Cache::mongodb(); Cache::scylla(); +Cache::tiered([...]); ``` -Redis and Valkey use token-checked Lua operations. Memcached uses CAS ownership -checks. MySQL and PostgreSQL use connection-scoped advisory locks. File locks -retain an open `flock`; SQLite and other PDO drivers safely fall back to that -file-lock implementation. Release is always ownership guarded and best effort. +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. -## Tiered Flow (L1 -> L2 -> DB) +`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. -```php -use Infocyph\CacheLayer\Cache\Cache; +## Tiering +```php $cache = Cache::tiered([ - ['driver' => 'apcu', 'namespace' => 'app'], // L1 - ['driver' => 'valkey', 'namespace' => 'app', 'dsn' => 'valkey://127.0.0.1:6379'], // L2 -], writeToL1: false); // optional L1 write-through + ['driver' => 'apcu', 'namespace' => 'app'], + ['driver' => 'valkey', 'namespace' => 'app'], +]); +``` + +A bulk read asks L1 for the full batch, asks later tiers only for remaining keys, and promotes hits upward in batches. Writes and deletes are one batch per participating tier. + +## Immutable security and failure policy -$value = $cache->remember('user:42', function ($item) use ($pdo) { - $item->expiresAfter(300); +Payload and runtime policy is provided at construction and never stored globally: - $stmt = $pdo->prepare('SELECT payload FROM users_cache_source WHERE id = ?'); - $stmt->execute([42]); +```php +use Infocyph\CacheLayer\Cache\CacheOptions; + +$options = new CacheOptions( + integrityKey: $_ENV['CACHE_INTEGRITY_KEY'], + maxPayloadBytes: 8_388_608, + compressionThreshold: 4096, + compressionLevel: 6, + allowClosures: false, + allowObjects: false, + failOpen: true, +); - return $stmt->fetchColumn(); -}); +$cache = Cache::redis('app', options: $options); ``` -Request flow: -- check APCu (L1) -- check Redis/Valkey (L2) -- query DB on miss -- write L2 -- optionally write L1 (controlled by `writeToL1`) +Records use only the CacheLayer v2 markers `cl2:`, `cl2-gz:`, and `cl2-sig:`. Compression is threshold-based and retained only when smaller. HMAC verification, payload bounds, bounded decompression, and deserialization policy are isolated per cache instance. Corrupt payloads are safe misses. + +Construction and configuration errors throw. Runtime backend failures default to fail-open: reads become misses, writes/deletes return `false`, and `backend_failure` is recorded. Set `failOpen: false` to propagate runtime failures. `CacheOptions::fromEnvironment()` explicitly reads `CACHELAYER_PAYLOAD_INTEGRITY_KEY` and `CACHELAYER_MAX_PAYLOAD_BYTES`; environment state is never read implicitly. -## Node-local cache (APCu -> SQLite) +## Node Cache ```php use Infocyph\CacheLayer\Node\NodeCache; use Infocyph\CacheLayer\Node\NodeCacheConfig; -$cache = NodeCache::create(new NodeCacheConfig( +$node = NodeCache::create(new NodeCacheConfig( namespace: 'app', sqliteFile: '/var/cache/my-app/cache.sqlite', )); ``` -This topology keeps an independent, disposable cache on each application -server: APCu provides hot in-memory reads when available, while SQLite keeps a -larger local cache across PHP-FPM restarts. It does not synchronize entries or -invalidation across servers. +Node Cache combines an APCu L1 with a local SQLite L2, uses miss-only bulk L2 reads and bulk L1 promotion, and retains WAL, `synchronous=NORMAL`, bounded busy timeout, bounded pruning, checkpoint, and optimization maintenance. -## Cluster invalidation - -```php -use Infocyph\CacheLayer\Cluster\ClusterCache; -use Infocyph\CacheLayer\Cluster\ClusterCacheConfig; +## Cluster Cache -$cluster = ClusterCache::create( - node: new NodeCacheConfig(sqliteFile: '/var/cache/my-app/cache.sqlite'), - cluster: new ClusterCacheConfig('production', gethostname()), - transport: $durableInvalidationTransport, -); +Cluster Cache adds durable invalidation around independent Node Caches. It keeps per-node cursors, replay, retention-gap recovery, consumer status, key/tag/namespace invalidation, bounded draining, PDO or Redis/Valkey Streams transports, and a transactional outbox. -$cluster->invalidateKey('product.42'); -$cluster->consume(); +```php +$runtime->invalidateKey('product.42'); +$runtime->invalidateTags(['products', 'catalog']); +$runtime->invalidateNamespace(); +$runtime->consume(); ``` -Cluster Cache distributes only durable invalidation events. It never replicates -cached values, APCu state, or SQLite files; ordinary reads and writes remain -local to each application node. - -## Security Hardening +It does not replicate values and is not a distributed lock, session store, or counter system. -CacheLayer includes optional payload/serialization hardening controls: +## Atomic counters and memoization -```php -$cache - ->configurePayloadSecurity( - integrityKey: 'replace-with-strong-secret', - maxPayloadBytes: 8_388_608, - ) - ->configureSerializationSecurity( - allowClosurePayloads: false, - allowObjectPayloads: false, - ); -``` +`AtomicCounters` uses an `AtomicCounterStoreInterface`; Redis/Valkey is the distributed implementation. Counters are never emulated with cache `get()` plus `set()`. -You can also set: +The `memoize()`, `remember(object: ...)`, and `once()` helpers plus `MemoizeTrait` provide bounded process-local memoization. They are independent of persistent backend caching. -- `CACHELAYER_PAYLOAD_INTEGRITY_KEY` -- `CACHELAYER_MAX_PAYLOAD_BYTES` +## Metrics and benchmarks -## Testing +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. -```bash -composer test:code -``` +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. -Or run the full test pipeline: +## Development ```bash -composer test:all +composer ic:doctor +composer ic:process +composer ic:tests ``` +Integration suites self-skip when their optional service or extension is unavailable. + ## Security -Protected by [PHPForge](https://github.com/infocyph/PHPForge) — an automated quality and security gate for PHP projects. +Do not disclose suspected vulnerabilities in a public issue, discussion or pull request. Follow [SECURITY.md](SECURITY.md) and use [GitHub private vulnerability reporting](https://github.com/infocyph/CacheLayer/security/advisories/new). + +CacheLayer is protected by [PHPForge](https://github.com/infocyph/PHPForge), which provides automated tests, static and taint analysis, dependency auditing, architecture checks and release-readiness gates. Automated controls do not replace responsible disclosure or manual review. + ---
Made with ❤️ for the PHP community
MIT Licensed
- Documentation • + DocumentationSecurityCode of Conduct • - Contributing • - Report | Request | Suggest + Contributing
+ 🗂️ + Bug • + Feature • + Documentation • + Question • + CI failure
+ 🔀 + General • + Bug fix • + Feature • + Refactor • + Performance • + Security & reliability • + Documentation • + Maintenance
diff --git a/benchmarks/CacheBulkBench.php b/benchmarks/CacheBulkBench.php new file mode 100644 index 0000000..6dc400c --- /dev/null +++ b/benchmarks/CacheBulkBench.php @@ -0,0 +1,198 @@ + */ + private array $keys = []; + + private ?string $sqliteFile = null; + + /** @var array */ + private array $values = []; + + /** @param array{size:int} $params */ + public function setUp(array $params): void + { + $this->cache = Cache::memory('bulk-bench'); + $this->keys = []; + $this->values = []; + for ($index = 0; $index < $params['size']; $index++) { + $key = 'key.' . $index; + $this->keys[] = $key; + $this->values[$key] = $index; + } + $this->cache->setMultiple($this->values, 60); + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + #[Bench\BeforeMethods('setUp')] + public function benchMemoryDeleteMultiple(array $params): int + { + unset($params); + + return $this->cache->deleteMultiple($this->keys) ? count($this->keys) : 0; + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + #[Bench\BeforeMethods('setUp')] + public function benchMemoryGetMultiple(array $params): int + { + unset($params); + + return count($this->cache->getMultiple($this->keys)); + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + #[Bench\BeforeMethods('setUp')] + public function benchMemorySetMultiple(array $params): int + { + unset($params); + + return $this->cache->setMultiple($this->values, 60) ? count($this->values) : 0; + } + + public function benchSingleMemoryHit(): int + { + $cache = Cache::memory('single-memory'); + $cache->set('hot', 42); + + return (int) $cache->get('hot'); + } + + #[Bench\BeforeMethods('setUpSingleSqlite')] + #[Bench\AfterMethods('tearDownSqlite')] + public function benchSingleSqliteHit(): int + { + return (int) $this->cache->get('hot'); + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + #[Bench\BeforeMethods('setUpSqlite')] + #[Bench\AfterMethods('tearDownSqlite')] + public function benchSqliteGetMultiple(array $params): int + { + unset($params); + + return count($this->cache->getMultiple($this->keys)); + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + #[Bench\BeforeMethods('setUp')] + public function benchTaggedBatchRead(array $params): int + { + unset($params); + foreach ($this->values as $key => $value) { + $this->cache->setTagged($key, $value, ['bulk']); + } + + return count($this->cache->getMultiple($this->keys)); + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + public function benchTieredL1FullHit(array $params): int + { + $l1 = new ArrayCacheAdapter('tier-full-l1'); + $l2 = new ArrayCacheAdapter('tier-full-l2'); + $cache = Cache::tiered([$l1, $l2]); + $keys = []; + for ($index = 0; $index < $params['size']; $index++) { + $key = 'tier-full.' . $index; + $keys[] = $key; + $l1->set($key, $index, 60); + } + + return count($cache->getMultiple($keys)); + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + public function benchTieredL3Promotion(array $params): int + { + $l1 = new ArrayCacheAdapter('tier3-l1'); + $l2 = new ArrayCacheAdapter('tier3-l2'); + $l3 = new ArrayCacheAdapter('tier3-l3'); + $cache = Cache::tiered([$l1, $l2, $l3]); + $keys = []; + for ($index = 0; $index < $params['size']; $index++) { + $key = 'tier3.' . $index; + $keys[] = $key; + $l3->set($key, $index, 60); + } + + return count($cache->getMultiple($keys)); + } + + /** @param array{size:int} $params */ + #[Bench\ParamProviders('provideSizes')] + public function benchTieredPartialHit(array $params): int + { + $l1 = new ArrayCacheAdapter('tier-l1'); + $l2 = new ArrayCacheAdapter('tier-l2'); + $cache = Cache::tiered([$l1, $l2]); + $keys = []; + for ($index = 0; $index < $params['size']; $index++) { + $key = 'tier.' . $index; + $keys[] = $key; + $target = $index % 2 === 0 ? $l1 : $l2; + $target->set($key, $index, 60); + } + + return count($cache->getMultiple($keys)); + } + + public function provideSizes(): iterable + { + yield '10 keys' => ['size' => 10]; + yield '100 keys' => ['size' => 100]; + yield '1000 keys' => ['size' => 1000]; + } + + public function setUpSingleSqlite(): void + { + $this->sqliteFile = sys_get_temp_dir() . '/cachelayer-bench-' . uniqid() . '.sqlite'; + $this->cache = Cache::sqlite('sqlite-single-bench', $this->sqliteFile); + $this->cache->set('hot', 42, 60); + } + + /** @param array{size:int} $params */ + public function setUpSqlite(array $params): void + { + $this->sqliteFile = sys_get_temp_dir() . '/cachelayer-bench-' . uniqid() . '.sqlite'; + $this->cache = Cache::sqlite('sqlite-bench', $this->sqliteFile); + $this->keys = []; + $this->values = []; + for ($index = 0; $index < $params['size']; $index++) { + $key = 'sqlite.' . $index; + $this->keys[] = $key; + $this->values[$key] = $index; + } + $this->cache->setMultiple($this->values, 60); + } + + public function tearDownSqlite(): void + { + $this->cache = Cache::memory('released'); + if ($this->sqliteFile !== null && is_file($this->sqliteFile)) { + unlink($this->sqliteFile); + } + $this->sqliteFile = null; + } +} diff --git a/benchmarks/CachePolicyBench.php b/benchmarks/CachePolicyBench.php new file mode 100644 index 0000000..9259095 --- /dev/null +++ b/benchmarks/CachePolicyBench.php @@ -0,0 +1,126 @@ +payload = str_repeat('cachelayer-payload-', 256); + } + + /** @param array{threshold:int} $params */ + #[Bench\ParamProviders('provideCompressionThresholds')] + public function benchCompressedCodec(array $params): int + { + $codec = new CachePayloadCodec(new CacheOptions(compressionThreshold: $params['threshold'])); + $record = $codec->decode($codec->encode($this->payload, null)); + + return strlen((string) $record?->value); + } + + public function benchHmacCodec(): int + { + $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'benchmark-secret')); + $record = $codec->decode($codec->encode($this->payload, null)); + + return strlen((string) $record?->value); + } + + public function benchPlainGet(): int + { + $cache = Cache::memory('plain-get'); + $cache->set('hot', 42); + + return (int) $cache->get('hot'); + } + + public function benchPlainSet(): int + { + return Cache::memory('plain-set')->set('key', 42) ? 1 : 0; + } + + public function benchRememberContendedTimeout(): int + { + $lock = new class implements LockProviderInterface { + public function acquire(string $key, float $waitSeconds, float $leaseSeconds = 30.0): ?LockHandle + { + unset($key, $waitSeconds, $leaseSeconds); + + return null; + } + + public function refresh(?LockHandle $handle, float $leaseSeconds): bool + { + unset($handle, $leaseSeconds); + + return false; + } + + public function release(?LockHandle $handle): void + { + unset($handle); + } + }; + $cache = new Cache(new ArrayCacheAdapter('contended'), $lock); + + return (int) $cache->remember('cold', static fn(): int => 42, 60); + } + + public function benchRememberHit(): int + { + $cache = Cache::memory('remember-hit'); + $cache->set('hot', 42); + + return (int) $cache->remember('hot', static fn(): int => 0, 60); + } + + public function benchRememberMiss(): int + { + $cache = Cache::memory('remember-miss'); + + return (int) $cache->remember('cold', static fn(): int => 42, 60); + } + + public function benchTaggedGet(): int + { + $cache = Cache::memory('tagged-get'); + $cache->setTagged('hot', 42, ['group']); + + return (int) $cache->get('hot'); + } + + public function benchTaggedSet(): int + { + return Cache::memory('tagged-set')->setTagged('key', 42, ['group']) ? 1 : 0; + } + + public function benchUnsignedCodec(): int + { + $codec = new CachePayloadCodec(); + $record = $codec->decode($codec->encode($this->payload, null)); + + return strlen((string) $record?->value); + } + + public function provideCompressionThresholds(): iterable + { + yield '512 bytes' => ['threshold' => 512]; + yield '4096 bytes' => ['threshold' => 4096]; + yield '8192 bytes' => ['threshold' => 8192]; + } +} diff --git a/benchmarks/ClosureSerializerBench.php b/benchmarks/ClosureSerializerBench.php new file mode 100644 index 0000000..8c3a0a8 --- /dev/null +++ b/benchmarks/ClosureSerializerBench.php @@ -0,0 +1,31 @@ + $value + 5; + $payload = ClosureSerializer::serialize($closure); + $restored = ClosureSerializer::unserialize($payload); + + return $restored(10); + } + + public function benchSignedClosure(): int + { + $serializer = ClosureSerializer::signed('benchmark-signing-key'); + $payload = $serializer->serialize(static fn(int $value): int => $value + 5); + $restored = $serializer->unserialize($payload); + + return $restored(10); + } +} diff --git a/benchmarks/MemoizeBench.php b/benchmarks/MemoizeBench.php index 6ebddc5..6a223bb 100644 --- a/benchmarks/MemoizeBench.php +++ b/benchmarks/MemoizeBench.php @@ -40,4 +40,15 @@ public function benchObjectMemoizeHit(): int return $sum; } + + #[Bench\BeforeMethods(['setUp'])] + public function benchOnceCallSiteLookup(): int + { + $sum = 0; + for ($index = 0; $index < 100; $index++) { + $sum += once(static fn(): int => 21); + } + + return $sum; + } } diff --git a/benchmarks/NodeCacheBench.php b/benchmarks/NodeCacheBench.php new file mode 100644 index 0000000..e1dfee6 --- /dev/null +++ b/benchmarks/NodeCacheBench.php @@ -0,0 +1,99 @@ + */ + private array $keys = []; + + private string $sqliteFile; + + public function tearDown(): void + { + $this->cache = Cache::memory('node-bench-released'); + foreach ([$this->sqliteFile, $this->sqliteFile . '-shm', $this->sqliteFile . '-wal'] as $file) { + if (is_file($file)) { + unlink($file); + } + } + if (is_dir($this->directory)) { + rmdir($this->directory); + } + } + + #[Bench\BeforeMethods('setUpFullHit')] + #[Bench\AfterMethods('tearDown')] + public function benchNodeL1FullHit(): int + { + return count($this->cache->getMultiple($this->keys)); + } + + #[Bench\BeforeMethods('setUpMixedHit')] + #[Bench\AfterMethods('tearDown')] + public function benchNodeMixedL1SqliteHit(): int + { + return count($this->cache->getMultiple($this->keys)); + } + + public function setUpFullHit(): void + { + [$this->cache, $l1] = $this->createNodeCache(); + unset($l1); + $this->seed(); + } + + public function setUpMixedHit(): void + { + [$this->cache, $l1] = $this->createNodeCache(); + $this->seed(); + $l1->deleteItems(array_values(array_filter( + $this->keys, + static fn(string $key): bool => ((int) substr($key, strrpos($key, '.') + 1)) % 2 !== 0, + ))); + } + + /** @return array{Cache, ArrayCacheAdapter} */ + private function createNodeCache(): array + { + $this->directory = sys_get_temp_dir() . '/cachelayer-node-bench-' . uniqid(); + if (!mkdir($this->directory, 0700) && !is_dir($this->directory)) { + throw new \RuntimeException('Unable to create the Node benchmark directory.'); + } + $this->sqliteFile = $this->directory . '/cache.sqlite'; + $namespace = 'node-bench'; + $config = new NodeCacheConfig($this->sqliteFile, $namespace, apcuEnabled: false); + $l1 = new ArrayCacheAdapter($namespace); + $l2 = new NodeSqliteCacheAdapter(NodeSqliteConnection::create($config), $namespace); + + return [new Cache(new NodeCacheAdapter($l1, $l2, false)), $l1]; + } + + private function seed(): void + { + $values = []; + $this->keys = []; + for ($index = 0; $index < 100; $index++) { + $key = 'node.' . $index; + $this->keys[] = $key; + $values[$key] = $index; + } + $this->cache->setMultiple($values, 60); + } +} diff --git a/benchmarks/SerializerBench.php b/benchmarks/SerializerBench.php deleted file mode 100644 index 78d3bef..0000000 --- a/benchmarks/SerializerBench.php +++ /dev/null @@ -1,53 +0,0 @@ - - */ - private array $payload; - - public function __construct() - { - $this->payload = [ - 'id' => 123, - 'name' => 'cache-layer', - 'flags' => [true, false, true], - 'meta' => ['release' => 2, 'enabled' => true], - ]; - } - - public function benchEncodeDecodeArray(): int - { - $blob = ValueSerializer::encode($this->payload); - $decoded = ValueSerializer::decode($blob); - - return (int) ($decoded['id'] ?? 0); - } - - public function benchSerializeUnserializeArray(): int - { - $blob = ValueSerializer::serialize($this->payload); - $decoded = ValueSerializer::unserialize($blob); - - return (int) ($decoded['id'] ?? 0); - } - - public function benchSerializeUnserializeClosure(): int - { - $fn = static fn(int $v): int => $v + 5; - $blob = ValueSerializer::serialize($fn); - $restored = ValueSerializer::unserialize($blob); - - return $restored(10); - } -} diff --git a/composer.json b/composer.json index d4e8c61..190e247 100644 --- a/composer.json +++ b/composer.json @@ -40,7 +40,7 @@ "psr/simple-cache": "^3.0" }, "require-dev": { - "infocyph/phpforge": "dev-main", + "infocyph/phpforge": "dev-main@dev", "mongodb/mongodb": "^1.20 || ^2.0" }, "suggest": { diff --git a/docs/adapters/apcu.rst b/docs/adapters/apcu.rst index 3dd761a..1d7b7ff 100644 --- a/docs/adapters/apcu.rst +++ b/docs/adapters/apcu.rst @@ -15,11 +15,9 @@ Requirements: Highlights: * in-memory shared cache in the PHP runtime environment -* namespace-prefixed keys (``:``) +* physically separated data and metadata keys (``:d:`` and ``:m:``) * efficient bulk fetch through APCu array fetch path -``Cache::local()`` will choose APCu automatically when available. - Use When -------- @@ -34,8 +32,8 @@ Example use Infocyph\CacheLayer\Cache\Cache; $cache = Cache::apcu('app'); - $cache->set('feature_flag:new_checkout', true, 60); + $cache->set('feature_flag.new_checkout', true, 60); - if ($cache->has('feature_flag:new_checkout')) { + if ($cache->has('feature_flag.new_checkout')) { // fast local hit } diff --git a/docs/adapters/file.rst b/docs/adapters/file.rst index 44e09e7..12a644b 100644 --- a/docs/adapters/file.rst +++ b/docs/adapters/file.rst @@ -12,6 +12,7 @@ Path layout: * base dir: provided ``$dir`` or ``sys_get_temp_dir() . '/cachelayer/files'`` * namespace dir: ``cache_`` +* separate ``data`` and ``meta`` subdirectories * file name: ``hash('xxh128', $key) . '.cache'`` Highlights: @@ -19,7 +20,7 @@ Highlights: * zero service dependencies * persists across process restarts * atomic write flow (``tempnam`` + ``rename``) -* ``setNamespaceAndDirectory()`` supported +* immutable namespace and directory configuration Best for local/single-host environments. @@ -32,8 +33,8 @@ Example $cache = Cache::file('catalog', __DIR__ . '/storage/cache'); - $cache->setTagged('category:shoes', ['count' => 120], ['catalog'], 300); - $payload = $cache->get('category:shoes'); + $cache->setTagged('category.shoes', ['count' => 120], ['catalog'], 300); + $payload = $cache->get('category.shoes'); // Flush all catalog-tagged entries after product import. $cache->invalidateTag('catalog'); diff --git a/docs/adapters/index.rst b/docs/adapters/index.rst index f545b75..2b198e7 100644 --- a/docs/adapters/index.rst +++ b/docs/adapters/index.rst @@ -11,8 +11,8 @@ Choosing quickly: * Start with ``file`` or ``pdo`` for most applications. * Use ``memory``/``apcu`` for fastest local access. -* Use ``redis``/``valkey``/``memcache`` for distributed deployments. -* Use cloud adapters (``mongodb``, ``scyllaDb``) when cache must live outside app hosts. +* Use ``redis``/``valkey``/``memcached`` for distributed deployments. +* Use ``mongodb``/``scylla`` when cache must live outside application hosts. .. toctree:: :maxdepth: 1 @@ -20,7 +20,7 @@ Choosing quickly: array-memory weak-map null-store - chain + tiered file php-files apcu diff --git a/docs/adapters/memcached.rst b/docs/adapters/memcached.rst index d2f495a..0cb200c 100644 --- a/docs/adapters/memcached.rst +++ b/docs/adapters/memcached.rst @@ -1,12 +1,12 @@ .. _adapters.memcached: ================================= -Memcached Adapter (``memcache``) +Memcached Adapter (``memcached``) ================================= Factory: -``Cache::memcache(string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], ?Memcached $client = null)`` +``Cache::memcached(string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], ?Memcached $client = null)`` Requirements: @@ -17,6 +17,8 @@ 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`` * 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 @@ -30,11 +32,11 @@ Example use Infocyph\CacheLayer\Cache\Cache; - $cache = Cache::memcache('session', [ + $cache = Cache::memcached('session', [ ['127.0.0.1', 11211, 100], ]); - $state = $cache->remember('user:42:state', function ($item) { + $state = $cache->remember('user.42.state', function ($item) { $item->expiresAfter(120); return loadSessionState(42); }); diff --git a/docs/adapters/mongodb.rst b/docs/adapters/mongodb.rst index a27ca76..aac4797 100644 --- a/docs/adapters/mongodb.rst +++ b/docs/adapters/mongodb.rst @@ -42,4 +42,4 @@ Example uri: 'mongodb://127.0.0.1:27017', ); - $cache->set('dashboard:kpi', ['orders' => 120, 'refunds' => 4], 120); + $cache->set('dashboard.kpi', ['orders' => 120, 'refunds' => 4], 120); diff --git a/docs/adapters/null-store.rst b/docs/adapters/null-store.rst index 1172839..8ffb252 100644 --- a/docs/adapters/null-store.rst +++ b/docs/adapters/null-store.rst @@ -11,7 +11,7 @@ No-op adapter that never persists values. Behavior: * ``set()`` returns true -* ``get()`` always misses unless default/callable path is used +* ``get()`` always misses and returns the supplied default unchanged * ``remember()`` recomputes every call Useful for disabling caching without changing calling code. diff --git a/docs/adapters/pdo.rst b/docs/adapters/pdo.rst index 89c0ab2..39185e8 100644 --- a/docs/adapters/pdo.rst +++ b/docs/adapters/pdo.rst @@ -17,7 +17,7 @@ Highlights: * unified SQL adapter for MySQL, MariaDB, PostgreSQL, and other PDO drivers * defaults to SQLite when no DSN/PDO is provided -* namespace-prefixed row keys (``:``) +* physically separated data and metadata rows (``:d:`` and ``:m:``) * automatic table/index initialization * driver-aware upsert strategy: - PostgreSQL/SQLite: native ``ON CONFLICT`` @@ -29,6 +29,12 @@ Highlights: * SQLite and other PDO drivers without advisory locks use an injected ``FileLockProvider`` fallback +Schema creation can be separated from runtime access. Run +``PdoCacheSchema::install($pdo, 'cachelayer_entries')`` during deployment, +then construct ``PdoCacheAdapter`` with ``initializeSchema: false`` when the +application connection does not have DDL privileges. The convenience factory +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 @@ -66,7 +72,7 @@ Typical Usage $cache = Cache::pdo('orders'); - $summary = $cache->remember('orders:summary:today', function ($item) { + $summary = $cache->remember('orders.summary.today', function ($item) { $item->expiresAfter(60); return loadOrderSummary(); }, tags: ['orders']); diff --git a/docs/adapters/php-files.rst b/docs/adapters/php-files.rst index c7c7f2f..39b3543 100644 --- a/docs/adapters/php-files.rst +++ b/docs/adapters/php-files.rst @@ -11,14 +11,14 @@ 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_`` +* 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) -* ``setNamespaceAndDirectory()`` supported +* immutable namespace and directory configuration Good for environments where opcode cache integration is desired. Use only in trusted environments, since cache entries are stored as executable @@ -32,6 +32,6 @@ Example use Infocyph\CacheLayer\Cache\Cache; $cache = Cache::phpFiles('view-cache', __DIR__ . '/storage/php-cache'); - $cache->set('compiled:home', $compiledTemplate, 900); + $cache->set('compiled.home', $compiledTemplate, 900); - $compiled = $cache->get('compiled:home'); + $compiled = $cache->get('compiled.home'); diff --git a/docs/adapters/redis-cluster.rst b/docs/adapters/redis-cluster.rst index 7201d79..bc3449a 100644 --- a/docs/adapters/redis-cluster.rst +++ b/docs/adapters/redis-cluster.rst @@ -11,12 +11,14 @@ Factory: Requirements: * RedisCluster support via ``ext-redis``, or -* injected client exposing expected methods (``get``, ``set``, ``setex``, ``del``, ``exists``, ``sAdd``, ``sRem``, ``sCard``, ``sMembers``) +* injected client exposing ``get``, ``set``, ``setex``, ``del``, ``exists``, + ``incr``, ``mget``, and ``mset`` Highlights: -* cluster-aware storage -* tracks namespace key membership through an index set (``:__keys``) for clear/count operations +* 128 fixed hash-tag buckets for cross-slot-safe grouped operations +* namespace clear advances each bucket epoch +* no permanent key index, stale membership, or cluster-wide scan Useful when using Redis Cluster topology. @@ -32,4 +34,4 @@ Example ['10.0.0.11:6379', '10.0.0.12:6379', '10.0.0.13:6379'], ); - $cache->set('cart:token:abc', ['items' => 3], 1200); + $cache->set('cart.token.abc', ['items' => 3], 1200); diff --git a/docs/adapters/scylladb.rst b/docs/adapters/scylladb.rst index 83b462a..9013df3 100644 --- a/docs/adapters/scylladb.rst +++ b/docs/adapters/scylladb.rst @@ -1,12 +1,12 @@ .. _adapters.scylladb: ================================== -ScyllaDB Adapter (``scyllaDb``) +ScyllaDB Adapter (``scylla``) ================================== Factory: -``Cache::scyllaDb(string $namespace = 'default', ?object $session = null, string $keyspace = 'cachelayer', string $table = 'cachelayer_entries')`` +``Cache::scylla(string $namespace = 'default', ?object $session = null, string $keyspace = 'cachelayer', string $table = 'cachelayer_entries', int $bucketCount = 128)`` Requirements: @@ -15,7 +15,8 @@ Requirements: Highlights: -* keyspace/table-backed cache entries with namespace partitioning +* 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`` @@ -31,10 +32,10 @@ Example use Infocyph\CacheLayer\Cache\Cache; - $cache = Cache::scyllaDb( + $cache = Cache::scylla( namespace: 'edge', keyspace: 'cachelayer', table: 'cachelayer_entries', ); - $cache->set('homepage:blocks', $blocks, 45); + $cache->set('homepage.blocks', $blocks, 45); diff --git a/docs/adapters/serialization.rst b/docs/adapters/serialization.rst index d137ced..c210427 100644 --- a/docs/adapters/serialization.rst +++ b/docs/adapters/serialization.rst @@ -4,12 +4,14 @@ Serialization in Adapters =================================== -All adapters rely on ``CachePayloadCodec`` and ``ValueSerializer`` to persist -arbitrary values consistently. +All adapters rely on ``CachePayloadCodec``. It uses PHP's native serialization +for ordinary values and delegates only top-level ``Closure`` values to +``ClosureSerializer``. The payload format stores: * value +* value encoding (native or Closure) * absolute expiration timestamp (or null) * internal format marker @@ -25,4 +27,5 @@ Example $payload = $cache->get('payload'); -See :ref:`serializer` for resource handlers, closure support, and serializer API details. +Resources and nested Closures are not supported. See :ref:`serializer` for the +Closure-only serializer API. diff --git a/docs/adapters/shared-memory.rst b/docs/adapters/shared-memory.rst index dc0ff43..38f7588 100644 --- a/docs/adapters/shared-memory.rst +++ b/docs/adapters/shared-memory.rst @@ -29,4 +29,4 @@ Example use Infocyph\CacheLayer\Cache\Cache; $cache = Cache::sharedMemory('worker-bus', 8 * 1024 * 1024); - $cache->set('heartbeat:worker-1', time(), 15); + $cache->set('heartbeat.worker-1', time(), 15); diff --git a/docs/adapters/sqlite.rst b/docs/adapters/sqlite.rst index c467047..4716e78 100644 --- a/docs/adapters/sqlite.rst +++ b/docs/adapters/sqlite.rst @@ -29,4 +29,4 @@ Example use Infocyph\CacheLayer\Cache\Cache; $cache = Cache::sqlite('jobs', __DIR__ . '/storage/cache/jobs.sqlite'); - $cache->set('job:run:summary', ['ok' => 12, 'failed' => 1], 300); + $cache->set('job.run.summary', ['ok' => 12, 'failed' => 1], 300); diff --git a/docs/adapters/chain.rst b/docs/adapters/tiered.rst similarity index 66% rename from docs/adapters/chain.rst rename to docs/adapters/tiered.rst index e4f8afd..2961d5c 100644 --- a/docs/adapters/chain.rst +++ b/docs/adapters/tiered.rst @@ -1,18 +1,18 @@ -.. _adapters.chain: +.. _adapters.tiered: ========================= -Chain Adapter (``chain``) +Tiered Adapter (``tiered``) ========================= -Factory: ``Cache::chain(array $pools)`` +Factory: ``Cache::tiered(array $pools, bool $writeToL1 = true)`` Composes multiple PSR-6 pools into a tiered cache. Behavior: * writes are propagated to all tiers -* reads search from first tier to last tier -* hit in lower tier is promoted upward +* reads send only remaining misses to each later tier +* lower-tier hits are promoted upward in a batch Typical layout: @@ -26,7 +26,7 @@ Example: use Infocyph\CacheLayer\Cache\Adapter\ArrayCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\RedisCacheAdapter; - $cache = Cache::chain([ + $cache = Cache::tiered([ new ArrayCacheAdapter('l1'), new RedisCacheAdapter('l2'), ]); diff --git a/docs/adapters/weak-map.rst b/docs/adapters/weak-map.rst index ffe78a4..7bedab7 100644 --- a/docs/adapters/weak-map.rst +++ b/docs/adapters/weak-map.rst @@ -26,5 +26,5 @@ Example $cache = Cache::weakMap('objects'); $dto = (object) ['id' => 42, 'name' => 'Ada']; - $cache->set('dto:42', $dto, 30); - $sameObject = $cache->get('dto:42'); + $cache->set('dto.42', $dto, 30); + $sameObject = $cache->get('dto.42'); diff --git a/docs/cache.rst b/docs/cache.rst index 683be9a..c6a980a 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -1,314 +1,100 @@ .. _cache: -============================ -Cache Facade (``Cache``) -============================ +Cache facade +============ -``Infocyph\CacheLayer\Cache\Cache`` is the unified facade for CacheLayer. -It implements: +``Infocyph\CacheLayer\Cache\Cache`` implements PSR-6, PSR-16, and +``ArrayAccess``. It adds versioned tags, native bulk operations, bounded +stampede protection, tiering, metrics, and per-instance payload policy. -* PSR-6 (``CacheItemPoolInterface``) -* PSR-16 (``Psr\SimpleCache\CacheInterface``) -* ``ArrayAccess`` -* ``Countable`` +Factories +--------- -It also adds tagged invalidation, stampede-safe ``remember()``, lock provider -selection, metrics hooks, and payload compression controls. +The supported factories are ``memory()``, ``weakMap()``, ``nullStore()``, +``apcu()``, ``file()``, ``phpFiles()``, ``sharedMemory()``, ``redis()``, +``valkey()``, ``redisCluster()``, ``memcached()``, ``pdo()``, ``sqlite()``, +``mongodb()``, ``scylla()``, and ``tiered()``. -CacheLayer was separated from the existing Intermix project for better -standalone visibility and faster cache-specific feature enrichment. +There are no compatibility aliases or runtime namespace/directory setters. +Configuration is fixed when the cache is constructed. -Installation ------------- +Keys, tags, and TTL +------------------- -.. code-block:: bash +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. - composer require infocyph/cachelayer +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. -Quick Example +Bulk behavior ------------- -.. code-block:: php - - use Infocyph\CacheLayer\Cache\Cache; - - $cache = Cache::file('app', __DIR__ . '/storage/cache'); - - $user = $cache->remember('user:42', function ($item) { - $item->expiresAfter(300); - return fetchUserFromDatabase(42); - }, tags: ['users']); - - $cache->invalidateTag('users'); - -Factory Methods ---------------- - -The facade exposes factory methods for all bundled adapters: - -* ``Cache::local(string $namespace = 'default', ?string $dir = null)`` -* ``Cache::file(string $namespace = 'default', ?string $dir = null)`` -* ``Cache::phpFiles(string $namespace = 'default', ?string $dir = null)`` -* ``Cache::apcu(string $namespace = 'default')`` -* ``Cache::memcache(string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], ?Memcached $client = null)`` -* ``Cache::redis(string $namespace = 'default', string $dsn = 'redis://127.0.0.1:6379', ?Redis $client = null)`` -* ``Cache::valkey(string $namespace = 'default', string $dsn = 'valkey://127.0.0.1:6379', ?Redis $client = null)`` -* ``Cache::redisCluster(string $namespace = 'default', array $seeds = ['127.0.0.1:6379'], float $timeout = 1.0, float $readTimeout = 1.0, bool $persistent = false, ?object $client = null)`` -* ``Cache::sqlite(string $namespace = 'default', ?string $file = null)`` -* ``Cache::pdo(string $namespace = 'default', ?string $dsn = null, ?string $username = null, ?string $password = null, ?PDO $pdo = null, string $table = 'cachelayer_entries')`` -* ``Cache::memory(string $namespace = 'default')`` -* ``Cache::weakMap(string $namespace = 'default')`` -* ``Cache::sharedMemory(string $namespace = 'default', int $segmentSize = 16777216)`` -* ``Cache::nullStore()`` -* ``Cache::chain(array $pools)`` -* ``Cache::tiered(array $tiers, bool $writeToL1 = true)`` -* ``Cache::mongodb(string $namespace = 'default', ?object $collection = null, ?object $client = null, string $database = 'cachelayer', string $collectionName = 'entries', string $uri = 'mongodb://127.0.0.1:27017')`` -* ``Cache::scyllaDb(string $namespace = 'default', ?object $session = null, string $keyspace = 'cachelayer', string $table = 'cachelayer_entries')`` +``getMultiple()``, ``setMultiple()``, and ``deleteMultiple()`` call the native +adapter batch contract. ``saveDeferred()`` queues items and ``commit()`` uses +the same native bulk persistence path. Metrics distinguish batch operation +count from key count. -``local()`` chooses APCu when available (``extension_loaded('apcu')`` and ``apcu_enabled()``), otherwise File cache. +``get()`` defaults +------------------ -``pdo()`` defaults to SQLite (temp-file database per namespace) when DSN/PDO is not provided. -``sqlite()`` is a convenience wrapper over ``pdo()`` for explicit SQLite file selection. - -``tiered()`` accepts either concrete pool instances or descriptor arrays with a -``driver`` key (for example ``apcu``, ``valkey``, ``redis``, ``pdo``, ``sqlite``). -Use ``writeToL1 = false`` to skip write-through to the first tier while still -allowing promotion from lower tiers on read. - -Tiered L1/L2/DB flow example: +A callable PSR-16 default is returned unchanged. It is never executed or +cached. Use ``remember()`` for explicit computation: .. code-block:: php - use Infocyph\CacheLayer\Cache\Cache; - - $cache = Cache::tiered([ - ['driver' => 'apcu', 'namespace' => 'app'], // L1 - ['driver' => 'valkey', 'namespace' => 'app', 'dsn' => 'valkey://127.0.0.1:6379'], // L2 - ], writeToL1: false); // optional L1 write-through - - $value = $cache->remember('user:42', function ($item) use ($pdo) { - $item->expiresAfter(300); - - $stmt = $pdo->prepare('SELECT payload FROM users_cache_source WHERE id = ?'); - $stmt->execute([42]); - - return $stmt->fetchColumn(); - }); + $value = $cache->remember( + 'report.daily', + fn () => buildDailyReport(), + ttl: 60, + tags: ['reports'], + ); -Request path: +The remember path is get, miss, lock, recheck, resolve, save, and release. Lock +waiting is bounded and cache hits perform no lock operation. -* check APCu (L1) -* check Redis/Valkey (L2) -* query DB on miss -* write L2 -* optionally write L1 (``writeToL1``) - -Key and TTL Rules +Immutable options ----------------- -Key validation is strict and shared across PSR-6/PSR-16 calls: - -* Allowed characters: ``A-Z``, ``a-z``, ``0-9``, ``_``, ``.``, ``-`` -* Empty keys or keys with spaces are rejected -* Invalid keys throw ``Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException`` - -TTL handling: - -* Supported types: ``null``, ``int``, ``DateInterval`` -* Negative TTL is rejected -* TTL ``0`` behaves as immediate expiry (adapters treat it as delete/expired) - -PSR-16 Methods --------------- - -Common helpers: - -* ``get(string $key, mixed $default = null): mixed`` -* ``set(string $key, mixed $value, int|DateInterval|null $ttl = null): bool`` -* ``delete(string $key): bool`` -* ``clear(): bool`` -* ``getMultiple(iterable $keys, mixed $default = null): iterable`` -* ``setMultiple(iterable $values, int|DateInterval|null $ttl = null): bool`` -* ``deleteMultiple(iterable $keys): bool`` -* ``has(string $key): bool`` - -``get()`` callable default -~~~~~~~~~~~~~~~~~~~~~~~~ - -If ``$default`` is callable, ``get()`` internally uses ``remember()`` semantics. -On miss, the callable is executed and the result is persisted. - -.. code-block:: php - - $value = $cache->get('profile:42', function ($item) { - $item->expiresAfter(120); - return computeProfile(); - }); - -PSR-6 Methods -------------- - -Standard pool methods are available and delegated to the underlying adapter: - -* ``getItem()`` -* ``getItems()`` -* ``hasItem()`` -* ``save()`` -* ``saveDeferred()`` -* ``commit()`` -* ``deleteItem()`` -* ``deleteItems()`` -* ``clear()`` - -For adapters that implement ``multiFetch(array $keys)``, ``getItems()`` uses it -for efficient batch retrieval. - -Tagged Caching --------------- - -CacheLayer uses tag-version invalidation (no full key scans required): - -* ``setTagged(string $key, mixed $value, array $tags, mixed $ttl = null): bool`` -* ``invalidateTag(string $tag): bool`` -* ``invalidateTags(array $tags): bool`` - -When a tag is invalidated, its internal version is incremented. Entries tagged -with older versions become stale and are treated as misses on read. - -.. code-block:: php - - $cache->setTagged('home:feed', $payload, ['feed', 'home'], 300); - - $cache->invalidateTag('feed'); - $cache->get('home:feed'); // null (stale) - -Stampede-Safe ``remember()`` --------------------------- - -``remember()`` protects expensive recomputation with a lock provider: - .. code-block:: php - $value = $cache->remember('report:daily', function ($item) { - $item->expiresAfter(60); - return buildDailyReport(); - }, tags: ['reports']); - -Behavior: - -1. Read existing value. -2. On miss, acquire a bounded lock lease (``FileLockProvider`` by default). -3. Re-check value under lock. -4. Compute and save value. -5. Apply jitter to TTL to reduce herd effects. -6. Release lock. - -Lock provider selection: - -* ``setLockProvider(LockProviderInterface $provider): self`` -* ``useRedisLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self`` -* ``useValkeyLock(?Redis $client = null, string $prefix = 'cachelayer:lock:'): self`` -* ``useMemcachedLock(?Memcached $client = null, string $prefix = 'cachelayer:lock:'): self`` - -Factory defaults: - -* ``Cache::redis(...)`` auto-configures ``RedisLockProvider`` -* ``Cache::valkey(...)`` auto-configures ``RedisLockProvider`` -* ``Cache::memcache(...)`` auto-configures ``MemcachedLockProvider`` -* ``Cache::pdo(...)`` / ``Cache::sqlite(...)`` auto-configure ``PdoLockProvider`` -* other adapters default to ``FileLockProvider`` - -The built-in ``remember()`` miss path requests a 30-second lease and waits up -to five seconds for ownership. Cache hits do not acquire or inspect a lock. -Keep resolvers within the lease duration. For longer operations, coordinate -explicitly through ``LockProviderInterface`` and renew with ``refresh()`` as -described in :doc:`metrics-and-locking`. - -Metrics and Export Hooks ------------------------- - -Methods: - -* ``setMetricsCollector(CacheMetricsCollectorInterface $metrics): self`` -* ``exportMetrics(): array`` -* ``setMetricsExportHook(?callable $hook): self`` - -Default collector is ``InMemoryCacheMetricsCollector``. - -Metrics are grouped by readable adapter name and metric name, for example: - -.. code-block:: php - - [ - 'file' => [ - 'hit' => 10, - 'miss' => 4, - 'set' => 3, - ], - ] - -Payload Compression -------------------- - -Use ``configurePayloadCompression(?int $thresholdBytes = null, int $level = 6)`` -to enable compression for encoded payloads. - -Notes: + use Infocyph\CacheLayer\Cache\Cache; + use Infocyph\CacheLayer\Cache\CacheOptions; -* Compression is applied when payload size meets/exceeds threshold. -* Requires ``gzencode``/``gzdecode`` functions. -* Compression configuration is global (``CachePayloadCodec`` static state). + $cache = Cache::redis('app', options: new CacheOptions( + integrityKey: $_ENV['CACHE_INTEGRITY_KEY'], + maxPayloadBytes: 8_388_608, + compressionThreshold: 4096, + allowClosures: false, + allowObjects: false, + failOpen: true, + )); -Payload and Serialization Security ----------------------------------- +``CacheOptions::fromEnvironment()`` is the explicit opt-in for +``CACHELAYER_PAYLOAD_INTEGRITY_KEY`` and ``CACHELAYER_MAX_PAYLOAD_BYTES``. +Options are isolated per instance and cannot be changed after record processing +begins. -Methods: +Runtime failure policy +---------------------- -* ``configurePayloadSecurity(?string $integrityKey = null, ?int $maxPayloadBytes = 8388608): self`` -* ``configureSerializationSecurity(bool $allowClosurePayloads = true, bool $allowObjectPayloads = true): self`` +Construction and configuration failures throw. With the default +``failOpen=true``, runtime read failures become misses and write/delete failures +return ``false`` while incrementing ``backend_failure``. Set ``failOpen=false`` +to propagate the backend exception. -Example: +Tiering +------- .. code-block:: php - $cache - ->configurePayloadSecurity( - integrityKey: 'replace-with-strong-secret', - maxPayloadBytes: 8_388_608, - ) - ->configureSerializationSecurity( - allowClosurePayloads: false, - allowObjectPayloads: false, - ); - -Environment variables: - -* ``CACHELAYER_PAYLOAD_INTEGRITY_KEY`` -* ``CACHELAYER_MAX_PAYLOAD_BYTES`` - -Convenience Features --------------------- - -Array and magic access: - -* ``$cache['key'] = 'value';`` -* ``$cache['key'];`` -* ``$cache->key = 'value';`` -* ``$cache->key;`` - -Counting: - -* ``count($cache)`` delegates to adapter ``Countable`` support when available. - -Namespace/Directory Mutation ----------------------------- - -``setNamespaceAndDirectory(string $namespace, ?string $dir = null): void`` -forwards to adapters that support runtime namespace/directory changes. - -Supported by: - -* File cache adapter -* PHP files cache adapter + $cache = Cache::tiered([ + ['driver' => 'apcu', 'namespace' => 'app'], + ['driver' => 'valkey', 'namespace' => 'app'], + ]); -Unsupported adapters throw ``BadMethodCallException``. +A batch is read once per tier for only the keys still missing, and lower-tier +hits are promoted upward in a batch. Writes and deletes are one batch per tier. diff --git a/docs/cookbook.rst b/docs/cookbook.rst index 66ace85..0b2b08d 100644 --- a/docs/cookbook.rst +++ b/docs/cookbook.rst @@ -27,13 +27,13 @@ Process flow: $cache = Cache::file('shop', __DIR__ . '/storage/cache'); // Read-through cache on miss with stampede protection. - $product = $cache->remember('product:42', function ($item) { + $product = $cache->remember('product.42', function ($item) { $item->expiresAfter(300); return loadProductFromDatabase(42); - }, tags: ['products', 'product:42']); + }, tags: ['products', 'product.42']); // On product update, invalidate only related cache. - $cache->invalidateTags(['products', 'product:42']); + $cache->invalidateTags(['products', 'product.42']); // Optional: inspect adapter-level metrics. $metrics = $cache->exportMetrics(); @@ -67,18 +67,18 @@ Process flow: // 'secret', // ); - $invoice = $cache->remember('invoice:2026-1001', function ($item) { + $invoice = $cache->remember('invoice.2026-1001', function ($item) { $item->expiresAfter(180); return buildInvoicePayload(1001); - }, tags: ['invoices', 'customer:77']); + }, tags: ['invoices', 'customer.77']); // Invalidate by business scope when source data changes. - $cache->invalidateTag('customer:77'); + $cache->invalidateTag('customer.77'); Recommended Rollout Pattern --------------------------- -1. Start with ``Cache::local()`` or ``Cache::file()``. +1. Start with ``Cache::apcu()`` or ``Cache::file()``. 2. Add tags to all business-domain cache keys. 3. Replace direct ``get()+set()`` misses with ``remember()``. 4. Watch ``exportMetrics()`` and tune TTL values. diff --git a/docs/index.rst b/docs/index.rst index 89356f5..430b293 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -7,10 +7,10 @@ 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``) -* stampede-safe ``remember()`` with pluggable lock providers +* stampede-resistant ``remember()`` with bounded pluggable locks * adapter-level metrics export hooks * payload compression controls -* value serialization for closures and resources +* Closure-only serialization through Opis Closure * process-local memoization helpers (``memoize``, ``remember``, ``once``) Project Background @@ -36,7 +36,7 @@ Quick Start $cache = Cache::memory('app'); - $profile = $cache->remember('user:42', function ($item) { + $profile = $cache->remember('user.42', function ($item) { $item->expiresAfter(300); return ['id' => 42, 'name' => 'Ada']; }, tags: ['users']); diff --git a/docs/security.rst b/docs/security.rst index c2900b9..fccad27 100644 --- a/docs/security.rst +++ b/docs/security.rst @@ -27,29 +27,31 @@ Implemented Hardening * When an integrity key is configured, unsigned payloads are rejected. * Maximum payload size can be enforced at decode time. * Compressed payload expansion is capped before deserialization. -* ``ValueSerializer`` supports strict mode: +* Per-cache codec policy can: - * block closure payloads - * block object payloads + * block top-level Closure payloads + * block native object payloads -* Native scalar/array serialization paths now decode with - ``allowed_classes => false``. +* Native payload decoding uses ``allowed_classes => false`` when objects are + disabled. +* ``ClosureSerializer`` accepts only ``Closure`` and + ``SignedClosureSerializer`` verifies HMAC-SHA256 before decoding. -Runtime API: +Per-instance construction API: .. code-block:: php - $cache - ->configurePayloadSecurity( - integrityKey: 'replace-with-strong-secret', - maxPayloadBytes: 8_388_608, - ) - ->configureSerializationSecurity( - allowClosurePayloads: false, - allowObjectPayloads: false, - ); - -Environment Variables: + use Infocyph\CacheLayer\Cache\Cache; + use Infocyph\CacheLayer\Cache\CacheOptions; + + $cache = Cache::redis('app', options: new CacheOptions( + integrityKey: 'replace-with-strong-secret', + maxPayloadBytes: 8_388_608, + allowClosures: false, + allowObjects: false, + )); + +``CacheOptions::fromEnvironment()`` explicitly reads: * ``CACHELAYER_PAYLOAD_INTEGRITY_KEY`` * ``CACHELAYER_MAX_PAYLOAD_BYTES`` diff --git a/docs/serializer.rst b/docs/serializer.rst index 1f012c5..21906fa 100644 --- a/docs/serializer.rst +++ b/docs/serializer.rst @@ -1,61 +1,33 @@ .. _serializer: ===================== -Value Serialization +Closure Serialization ===================== -``Infocyph\CacheLayer\Serializer\ValueSerializer`` is used by adapters to encode -and decode cached payloads. +CacheLayer uses native PHP serialization for ordinary cache records. The +specialized ``ClosureSerializer`` exists only because PHP cannot serialize a +``Closure`` directly. It does not expose a mixed-value serializer API and does +not support resource handlers or recursively wrapped values. -What it handles ---------------- +Public API +---------- -* scalar values and arrays -* closures (via ``opis/closure``) -* registered resource types +* ``ClosureSerializer::serialize(Closure $closure): string`` +* ``ClosureSerializer::unserialize(string $payload): Closure`` +* ``ClosureSerializer::isSerialized(string $payload): bool`` +* ``ClosureSerializer::signed(string $key): SignedClosureSerializer`` -Core Methods ------------- +Signed Closures +--------------- -* ``serialize(mixed $value): string`` -* ``unserialize(string $blob): mixed`` -* ``encode(mixed $value, bool $base64 = true): string`` -* ``decode(string $payload, bool $base64 = true): mixed`` -* ``wrap(mixed $value): mixed`` -* ``unwrap(mixed $value): mixed`` -* ``registerResourceHandler(string $type, callable $wrapFn, callable $restoreFn): void`` -* ``clearResourceHandlers(): void`` +.. code-block:: php -Resource Handler Example ------------------------- + use Infocyph\CacheLayer\Serializer\ClosureSerializer; -.. code-block:: php + $serializer = ClosureSerializer::signed('application-secret'); + $payload = $serializer->serialize(static fn (int $value): int => $value * 2); + $closure = $serializer->unserialize($payload); - use Infocyph\CacheLayer\Serializer\ValueSerializer; - - ValueSerializer::registerResourceHandler( - 'stream', - function ($res): array { - $meta = stream_get_meta_data($res); - rewind($res); - - return [ - 'mode' => $meta['mode'], - 'content' => stream_get_contents($res), - ]; - }, - function (array $data) { - $s = fopen('php://memory', $data['mode']); - fwrite($s, $data['content']); - rewind($s); - - return $s; - }, - ); - -Notes ------ - -* Registering the same resource type twice throws ``InvalidArgumentException``. -* Wrapping/serializing unregistered resources throws ``InvalidArgumentException``. -* Closure detection has an internal bounded memo cache. +Unsigned and signed Closure payloads are separate formats. Signature failures, +malformed payloads, and payloads that do not contain a Closure throw +``InvalidArgumentException``. diff --git a/src/Cache/Adapter/AbstractCacheAdapter.php b/src/Cache/Adapter/AbstractCacheAdapter.php index cde8481..d2b58f7 100644 --- a/src/Cache/Adapter/AbstractCacheAdapter.php +++ b/src/Cache/Adapter/AbstractCacheAdapter.php @@ -4,32 +4,65 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Countable; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\CacheRecord; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use Psr\Cache\CacheItemPoolInterface; -abstract class AbstractCacheAdapter implements CacheItemPoolInterface, Countable, InternalCachePoolInterface +abstract class AbstractCacheAdapter implements CacheItemPoolInterface, InternalCachePoolInterface { /** @var array */ protected array $deferred = []; + private ?CachePayloadCodec $codec = null; + + /** @var array */ + private array $localMetadata = []; + + private ?CacheOptions $options = null; + /** - * Determines if this adapter supports the given cache item. - * - * @param CacheItemInterface $item The cache item to check. + * @param list $keys + * @return array */ - abstract protected function supportsItem(CacheItemInterface $item): bool; + abstract public function multiFetch(array $keys): array; + + /** @param array $items */ + abstract public function saveItems(array $items): bool; public function commit(): bool { - $ok = true; - foreach ($this->deferred as $key => $item) { - $ok = $this->save($item) && $ok; - unset($this->deferred[$key]); + if ($this->deferred === []) { + return true; + } + + $deferred = $this->deferred; + $saved = $this->saveItems($deferred); + if ($saved) { + $this->deferred = []; + } + + return $saved; + } + + /** @internal */ + public function configureOptions(CacheOptions $options): void + { + if ($this->codec !== null) { + if ($this->options == $options) { + return; + } + + throw new \LogicException('Cache options cannot change after the adapter starts processing records.'); } - return $ok; + $this->options = $options; + } + + public function createItem(string $key): CacheItemInterface + { + return $this->genericMiss($key); } public function get(string $key): mixed @@ -40,15 +73,33 @@ public function get(string $key): mixed } /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return iterable + * @param list $keys + * @return array */ - public function getItems(array $keys = []): iterable + public function getItems(array $keys = []): array { - foreach ($keys as $key) { - yield $key => $this->getItem($key); + return $this->multiFetch($keys); + } + + /** @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 + { + foreach ($tags as $tag) { + $this->localMetadata[$tag] = ($this->localMetadata[$tag] ?? 0) + 1; + } + + return true; } public function internalPersist(CacheItemInterface $item): bool @@ -66,6 +117,7 @@ public function saveDeferred(CacheItemInterface $item): bool if (!$this->supportsItem($item)) { return false; } + $this->deferred[$item->getKey()] = $item; return true; @@ -79,42 +131,40 @@ public function set(string $key, mixed $value, ?int $ttl = null): bool return $this->save($item); } - /** - * @phpstan-return array{value:mixed,expires:int|null}|null - * @param string $payload The payload argument. - */ - protected function decodeRecordFromBase64(string $payload): ?array + protected function decodeRecordFromBase64(string $payload): ?CacheRecord { $blob = base64_decode($payload, true); - if (!is_string($blob)) { - return null; - } - return $this->decodeRecordFromBlob($blob); + return is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; } - /** - * @phpstan-return array{value:mixed,expires:int|null}|null - * @param string $blob The blob argument. - */ - protected function decodeRecordFromBlob(string $blob): ?array + protected function decodeRecordFromBlob(string $blob): ?CacheRecord { - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { - return null; - } + $record = $this->payloadCodec()->decode($blob); - return $record; + return $record !== null && !CachePayloadCodec::isExpired($record->expiresAt) + ? $record + : null; } - protected function genericDeleteAndMiss(string $key): GenericCacheItem + protected function encodeItem( + CacheItemInterface $item, + ?int $expiresAt, + ?int $namespaceEpoch = null, + ): string { + $tags = $item instanceof CacheItem ? $item->getTagVersions() : []; + + return $this->payloadCodec()->encode($item->get(), $expiresAt, $tags, $namespaceEpoch); + } + + protected function genericDeleteAndMiss(string $key): CacheItem { $this->deleteItem($key); return $this->genericMiss($key); } - protected function genericFromBase64(string $key, ?string $payload): GenericCacheItem + protected function genericFromBase64(string $key, ?string $payload): CacheItem { return $this->genericFromBase64WithInvalidator( $key, @@ -123,18 +173,21 @@ protected function genericFromBase64(string $key, ?string $payload): GenericCach ); } - /** - * @param string $key The key argument. - * @param string|null $payload The payload argument. - * @param callable $onInvalid The on invalid argument. - * @phpstan-param callable():bool $onInvalid - */ - protected function genericFromBase64WithInvalidator(string $key, ?string $payload, callable $onInvalid): GenericCacheItem - { - return $this->genericFromEncodedWithInvalidator($key, $payload, $onInvalid, $this->decodeRecordFromBase64(...)); + /** @param callable(): bool $onInvalid */ + protected function genericFromBase64WithInvalidator( + string $key, + ?string $payload, + callable $onInvalid, + ): CacheItem { + return $this->genericFromEncodedWithInvalidator( + $key, + $payload, + $onInvalid, + $this->decodeRecordFromBase64(...), + ); } - protected function genericFromBlob(string $key, ?string $blob): GenericCacheItem + protected function genericFromBlob(string $key, ?string $blob): CacheItem { return $this->genericFromBlobWithInvalidator( $key, @@ -143,46 +196,41 @@ protected function genericFromBlob(string $key, ?string $blob): GenericCacheItem ); } - /** - * @param string $key The key argument. - * @param string|null $blob The blob argument. - * @param callable $onInvalid The on invalid argument. - * @phpstan-param callable():bool $onInvalid - */ - protected function genericFromBlobWithInvalidator(string $key, ?string $blob, callable $onInvalid): GenericCacheItem - { - return $this->genericFromEncodedWithInvalidator($key, $blob, $onInvalid, $this->decodeRecordFromBlob(...)); + /** @param callable(): bool $onInvalid */ + protected function genericFromBlobWithInvalidator( + string $key, + ?string $blob, + callable $onInvalid, + ): CacheItem { + return $this->genericFromEncodedWithInvalidator( + $key, + $blob, + $onInvalid, + $this->decodeRecordFromBlob(...), + ); } - /** - * @param string $key The key argument. - * @param array $record The record argument. - * @phpstan-param array{value:mixed,expires:int|null} $record - */ - protected function genericItemFromRecord(string $key, array $record): GenericCacheItem + protected function genericItemFromRecord(string $key, CacheRecord $record): CacheItem { - $item = new GenericCacheItem($this, $key); - $item->set($record['value']); - if ($record['expires'] !== null) { - $item->expiresAt(CachePayloadCodec::toDateTime($record['expires'])); - } - - return $item; + return new CacheItem( + $this, + $key, + $record->value, + true, + CachePayloadCodec::toDateTime($record->expiresAt), + $record->tags, + ); } - protected function genericMiss(string $key): GenericCacheItem + protected function genericMiss(string $key): CacheItem { - return new GenericCacheItem($this, $key); + return new CacheItem($this, $key); } /** - * @template T of CacheItemInterface - * - * @param array $keys The keys argument. - * @param callable $fetcher The fetcher argument. - * @phpstan-param list $keys - * @phpstan-param callable(string):T $fetcher - * @phpstan-return array + * @param list $keys + * @param callable(string): CacheItem $fetcher + * @return array */ protected function multiFetchItems(array $keys, callable $fetcher): array { @@ -194,10 +242,13 @@ protected function multiFetchItems(array $keys, callable $fetcher): array return $items; } + protected function resetLocalMetadata(): void + { + $this->localMetadata = []; + } + /** - * @param CacheItemInterface $item The item argument. - * @param callable $writer The writer argument. - * @phpstan-param callable(CacheItemInterface,array{ttl:int|null,expiresAt:int|null}):bool $writer + * @param callable(CacheItemInterface, array{ttl:int|null, expiresAt:int|null}): bool $writer */ protected function saveEncoded(CacheItemInterface $item, callable $writer): bool { @@ -205,34 +256,43 @@ protected function saveEncoded(CacheItemInterface $item, callable $writer): bool return false; } - $expires = CachePayloadCodec::expirationFromItem($item); - if ($expires['ttl'] === 0) { + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { return $this->deleteItem($item->getKey()); } - return $writer($item, $expires); + return $writer($item, $expiration); + } + + protected function supportsItem(CacheItemInterface $item): bool + { + return $item instanceof CacheItem && $item->belongsTo($this); + } + + /** @param array $items */ + protected function supportsItems(array $items): bool + { + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + } + + return true; } - /** - * @param string $key The key argument. - * @param string|null $encoded The encoded argument. - * @param callable $onInvalid The on invalid argument. - * @param callable $decoder The decoder argument. - * @phpstan-param callable():bool $onInvalid - * @phpstan-param callable(string):(array{value:mixed,expires:int|null}|null) $decoder - */ private function genericFromEncodedWithInvalidator( string $key, ?string $encoded, callable $onInvalid, callable $decoder, - ): GenericCacheItem { - if (!is_string($encoded)) { + ): CacheItem { + if ($encoded === null) { return $this->genericMiss($key); } $record = $decoder($encoded); - if ($record === null) { + if (!$record instanceof CacheRecord) { $onInvalid(); return $this->genericMiss($key); @@ -240,4 +300,11 @@ private function genericFromEncodedWithInvalidator( return $this->genericItemFromRecord($key, $record); } + + private function payloadCodec(): CachePayloadCodec + { + $this->options ??= new CacheOptions(); + + return $this->codec ??= new CachePayloadCodec($this->options); + } } diff --git a/src/Cache/Adapter/ApcuCacheAdapter.php b/src/Cache/Adapter/ApcuCacheAdapter.php index e6e630c..d933966 100644 --- a/src/Cache/Adapter/ApcuCacheAdapter.php +++ b/src/Cache/Adapter/ApcuCacheAdapter.php @@ -4,7 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\ApcuCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -50,7 +50,7 @@ public function clear(): bool public function count(): int { - return count($this->listKeys()); + return count($this->listKeys('d:')); } public function deleteItem(string $key): bool @@ -69,15 +69,14 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { - $ok = true; - foreach ($keys as $k) { - $ok = $this->deleteItem($k) && $ok; + if ($keys === []) { + return true; } - return $ok; + return apcu_delete(array_map($this->map(...), $keys)) === []; } - public function getItem(string $key): ApcuCacheItem + public function getItem(string $key): CacheItem { $apcuKey = $this->map($key); $success = false; @@ -85,14 +84,31 @@ public function getItem(string $key): ApcuCacheItem if ($success && is_string($raw)) { $item = $this->hitItemFromBlob($key, $raw); - if ($item instanceof ApcuCacheItem) { + if ($item instanceof CacheItem) { return $item; } apcu_delete($apcuKey); } - return new ApcuCacheItem($this, $key); + return new CacheItem($this, $key); + } + + /** @param list $tags */ + #[\Override] + public function getTagVersions(array $tags): array + { + if ($tags === []) { + return []; + } + $raw = apcu_fetch(array_map($this->mapTag(...), $tags)); + $versions = []; + foreach ($tags as $tag) { + $value = is_array($raw) ? ($raw[$this->mapTag($tag)] ?? null) : null; + $versions[$tag] = is_int($value) && $value >= 0 ? $value : 0; + } + + return $versions; } public function hasItem(string $key): bool @@ -100,10 +116,25 @@ 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { @@ -124,7 +155,7 @@ public function multiFetch(array $keys): array continue; } - $items[$k] = new ApcuCacheItem($this, $k); + $items[$k] = new CacheItem($this, $k); } if ($stale !== []) { @@ -141,20 +172,48 @@ public function save(CacheItemInterface $item): bool } $expires = CachePayloadCodec::expirationFromItem($item); $ttl = $expires['ttl']; - if ($ttl === 0) { + if ($ttl !== null && $ttl <= 0) { apcu_delete($this->map($item->getKey())); return true; } - $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + $blob = $this->encodeItem($item, $expires['expiresAt']); return apcu_store($this->map($item->getKey()), $blob, $ttl ?? 0); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool { - return $item instanceof ApcuCacheItem; + if (!$this->supportsItems($items)) { + return false; + } + + $groups = []; + $expired = []; + foreach ($items as $item) { + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $this->map($item->getKey()); + + continue; + } + $ttl = $expiration['ttl'] ?? 0; + $groups[$ttl][$this->map($item->getKey())] = $this->encodeItem($item, $expiration['expiresAt']); + } + + if ($expired !== []) { + apcu_delete($expired); + } + + foreach ($groups as $ttl => $records) { + if (apcu_store($records, null, (int) $ttl) !== []) { + return false; + } + } + + return true; } /** @@ -162,7 +221,7 @@ protected function supportsItem(CacheItemInterface $item): bool * @param array $stale The stale argument. * @param string $key The key argument. * @param array $raw The raw argument. - * @phpstan-param array $items + * @phpstan-param array $items * @phpstan-param list $stale * @phpstan-param array $raw */ @@ -174,7 +233,7 @@ private function appendFetchedHit(array &$items, array &$stale, string $key, arr } $item = $this->hitItemFromBlob($key, $raw[$mapped]); - if ($item instanceof ApcuCacheItem) { + if ($item instanceof CacheItem) { $items[$key] = $item; return true; @@ -185,31 +244,23 @@ private function appendFetchedHit(array &$items, array &$stale, string $key, arr return false; } - private function hitItemFromBlob(string $key, string $blob): ?ApcuCacheItem + private function hitItemFromBlob(string $key, string $blob): ?CacheItem { $record = $this->decodeRecordFromBlob($blob); if ($record === null) { return null; } - $expiresAt = CachePayloadCodec::toDateTime($record['expires']); - - return new ApcuCacheItem( - pool: $this, - key: $key, - value: $record['value'], - hit: true, - exp: $expiresAt, - ); + return $this->genericItemFromRecord($key, $record); } /** * @phpstan-return list */ - private function listKeys(): array + private function listKeys(string $keyspace = ''): array { $iter = new \APCUIterator( - '/^' . preg_quote($this->ns . ':', '/') . '/', + '/^' . preg_quote($this->ns . ':' . $keyspace, '/') . '/', APC_ITER_KEY, ); $out = []; @@ -222,6 +273,11 @@ private function listKeys(): array private function map(string $key): string { - return $this->ns . ':' . $key; + return $this->ns . ':d:' . $key; + } + + private function mapTag(string $tag): string + { + return $this->ns . ':m:tag:' . $tag; } } diff --git a/src/Cache/Adapter/ArrayCacheAdapter.php b/src/Cache/Adapter/ArrayCacheAdapter.php index 0fd0f9f..2199400 100644 --- a/src/Cache/Adapter/ArrayCacheAdapter.php +++ b/src/Cache/Adapter/ArrayCacheAdapter.php @@ -4,13 +4,16 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; final class ArrayCacheAdapter extends AbstractCacheAdapter { private readonly string $ns; + /** @var array */ + private array $metadata = []; + /** @var array */ private array $store = []; @@ -22,6 +25,7 @@ public function __construct(string $namespace = 'default') public function clear(): bool { $this->store = []; + $this->metadata = []; $this->deferred = []; return true; @@ -54,7 +58,7 @@ public function deleteItems(array $keys): bool return true; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { $mapped = $this->map($key); $blob = $this->store[$mapped] ?? null; @@ -62,6 +66,18 @@ public function getItem(string $key): GenericCacheItem return $this->genericFromBlob($key, is_string($blob) ? $blob : null); } + /** @param list $tags */ + #[\Override] + public function getTagVersions(array $tags): array + { + $versions = []; + foreach ($tags as $tag) { + $versions[$tag] = $this->metadata[$tag] ?? 0; + } + + return $versions; + } + public function hasItem(string $key): bool { $mapped = $this->map($key); @@ -80,16 +96,37 @@ 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { $items = []; foreach ($keys as $key) { - $items[$key] = $this->getItem($key); + $mapped = $this->map($key); + $blob = $this->store[$mapped] ?? null; + $items[$key] = $this->genericFromBlobWithInvalidator( + $key, + is_string($blob) ? $blob : null, + function () use ($mapped): bool { + unset($this->store[$mapped]); + + return true; + }, + ); } return $items; @@ -98,27 +135,44 @@ public function multiFetch(array $keys): array public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { - $this->store[$this->map($saveItem->getKey())] = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); + $this->store[$this->map($saveItem->getKey())] = $this->encodeItem($saveItem, $expires['expiresAt']); return true; }); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool { - return $item instanceof GenericCacheItem; + if (!$this->supportsItems($items)) { + return false; + } + + $now = time(); + foreach ($items as $item) { + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + unset($this->store[$this->map($item->getKey())]); + + continue; + } + $expiresAt = $expiration['ttl'] === null ? null : $now + $expiration['ttl']; + $this->store[$this->map($item->getKey())] = $this->encodeItem($item, $expiresAt); + } + + return true; } private function map(string $key): string { - return $this->ns . ':' . $key; + return $this->ns . ':d:' . $key; } private function pruneExpired(): void { foreach ($this->store as $mapped => $blob) { - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $record = $this->decodeRecordFromBlob($blob); + if ($record === null) { unset($this->store[$mapped]); } } diff --git a/src/Cache/Adapter/CachePayloadCodec.php b/src/Cache/Adapter/CachePayloadCodec.php index af7b651..22d9372 100644 --- a/src/Cache/Adapter/CachePayloadCodec.php +++ b/src/Cache/Adapter/CachePayloadCodec.php @@ -4,303 +4,309 @@ namespace Infocyph\CacheLayer\Cache\Adapter; +use Closure; use DateTimeImmutable; use DateTimeInterface; -use Infocyph\CacheLayer\Cache\Item\AbstractCacheItem; -use Infocyph\CacheLayer\Serializer\ValueSerializer; +use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\CacheRecord; +use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Serializer\ClosureSerializer; +use InvalidArgumentException; use Psr\Cache\CacheItemInterface; +use RuntimeException; use Throwable; -final class CachePayloadCodec +final readonly class CachePayloadCodec { - private const string COMPRESSED_PREFIX = 'imx-gz:'; + private const string COMPRESSED_PREFIX = 'cl2-gz:'; - private const string FORMAT = 'imx-record-v1'; + private const string PLAIN_PREFIX = 'cl2:'; - private const string SIGNED_PREFIX = 'imx-sig-v1:'; + private const string SIGNED_PREFIX = 'cl2-sig:'; - private static int $compressionLevel = 6; + public function __construct(private CacheOptions $options = new CacheOptions()) {} - private static ?int $compressionThresholdBytes = null; - - private static ?string $integrityKey = null; - - private static ?int $maxPayloadBytes = 8_388_608; + /** @return array{ttl:int|null,expiresAt:int|null} */ + public static function expirationFromItem(CacheItemInterface $item): array + { + $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; - private static bool $securityBootstrapped = false; + return [ + 'ttl' => $ttl, + 'expiresAt' => $ttl === null ? null : time() + $ttl, + ]; + } - public static function configureCompression(?int $thresholdBytes = null, int $level = 6): void + public static function isExpired(?int $expiresAt, ?int $now = null): bool { - self::$compressionThresholdBytes = $thresholdBytes === null ? null : max(1, $thresholdBytes); - self::$compressionLevel = max(1, min(9, $level)); + return $expiresAt !== null && $expiresAt <= ($now ?? time()); } - public static function configureSecurity( - ?string $integrityKey = null, - ?int $maxPayloadBytes = 8_388_608, - ): void { - self::$integrityKey = $integrityKey !== null && $integrityKey !== '' ? $integrityKey : null; - self::$maxPayloadBytes = $maxPayloadBytes === null ? null : max(1, $maxPayloadBytes); - self::$securityBootstrapped = true; + public static function toDateTime(?int $expiresAt): ?DateTimeInterface + { + return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); } - /** - * @phpstan-return array{value:mixed,expires:int|null}|null - * @param string $blob The blob argument. - */ - public static function decode(string $blob): ?array + public function decode(string $blob): ?CacheRecord { - self::bootstrapSecurityFromEnvironment(); - if (self::isPayloadTooLarge($blob)) { + if ($this->isPayloadTooLarge($blob)) { return null; } - $verifiedBlob = self::verifyAndExtractSignature($blob); - if (!is_string($verifiedBlob)) { + $verified = $this->verifyAndExtractSignature($blob); + if ($verified === null) { return null; } - $expanded = self::expandIfCompressed($verifiedBlob); - if ($expanded === null) { - return null; - } - if (self::isPayloadTooLarge($expanded)) { + $serialized = $this->expandPayload($verified); + if ($serialized === null || $this->isPayloadTooLarge($serialized)) { return null; } - $decoded = self::tryUnserialize($expanded); - if ($decoded === null) { + try { + $decoded = $this->unserializeNative($serialized); + } catch (Throwable) { return null; } - $fromItem = self::decodeCacheItem($decoded); - if ($fromItem !== null) { - return $fromItem; - } - - return self::decodeArrayPayload($decoded); + return $this->normalizeRecord($decoded); } - public static function encode(mixed $value, ?int $expiresAt): string - { - self::bootstrapSecurityFromEnvironment(); - $encoded = ValueSerializer::serialize([ - '__imx_cache' => self::FORMAT, - 'value' => $value, + /** + * @param array $tags + */ + public function encode( + mixed $value, + ?int $expiresAt, + array $tags = [], + ?int $namespaceEpoch = null, + ): string { + [$encoding, $encodedValue] = $this->encodeValue($value); + $serialized = serialize([ + 'format' => 2, + 'encoding' => $encoding, + 'value' => $encodedValue, 'expires' => $expiresAt, + 'tags' => $tags, + 'epoch' => $namespaceEpoch, ]); - - if (self::$compressionThresholdBytes === null || self::$compressionThresholdBytes < 1) { - return self::attachSignature($encoded); + if ($this->isPayloadTooLarge($serialized)) { + throw new RuntimeException('The encoded cache record exceeds the configured payload limit.'); } - if (strlen($encoded) < self::$compressionThresholdBytes || !function_exists('gzencode')) { - return self::attachSignature($encoded); + $payload = self::PLAIN_PREFIX . $serialized; + $threshold = $this->options->compressionThreshold; + if ($threshold !== null && strlen($serialized) >= $threshold && function_exists('gzencode')) { + $compressed = gzencode($serialized, $this->options->compressionLevel); + if (is_string($compressed) && strlen($compressed) < strlen($serialized)) { + $payload = self::COMPRESSED_PREFIX . base64_encode($compressed); + } } - $compressed = gzencode($encoded, self::$compressionLevel); - if (!is_string($compressed) || strlen($compressed) >= strlen($encoded)) { - return self::attachSignature($encoded); + $encoded = $this->attachSignature($payload); + if ($this->isPayloadTooLarge($encoded)) { + throw new RuntimeException('The stored cache payload exceeds the configured payload limit.'); } - return self::attachSignature(self::COMPRESSED_PREFIX . base64_encode($compressed)); + return $encoded; } - /** - * @phpstan-return array{ttl:int|null,expiresAt:int|null} - * @param CacheItemInterface $item The item argument. - */ - public static function expirationFromItem(CacheItemInterface $item): array - { - $ttl = $item instanceof AbstractCacheItem ? $item->ttlSeconds() : null; - $expiresAt = $ttl === null ? null : time() + $ttl; - - return ['ttl' => $ttl, 'expiresAt' => $expiresAt]; - } - - public static function isExpired(?int $expiresAt, ?int $now = null): bool - { - return $expiresAt !== null && $expiresAt <= ($now ?? time()); - } - - public static function toDateTime(?int $expiresAt): ?DateTimeInterface + private function assertNativeValueSupported(mixed $value): void { - return $expiresAt === null ? null : (new DateTimeImmutable())->setTimestamp($expiresAt); + if ($value instanceof Closure) { + throw new InvalidArgumentException('Closures must be cached as top-level values.'); + } + if (is_resource($value)) { + throw new InvalidArgumentException('Resource cache values are not supported.'); + } + if (is_object($value) && !$this->options->allowObjects) { + throw new InvalidArgumentException('Object cache values are disabled by security policy.'); + } + if (!is_array($value)) { + return; + } + foreach ($value as $item) { + $this->assertNativeValueSupported($item); + } } - private static function attachSignature(string $payload): string + private function attachSignature(string $payload): string { - if (self::$integrityKey === null) { + if ($this->options->integrityKey === null) { return $payload; } - $signature = hash_hmac('sha256', $payload, self::$integrityKey); + $signature = hash_hmac('sha256', $payload, $this->options->integrityKey); return self::SIGNED_PREFIX . $signature . ':' . $payload; } - private static function bootstrapSecurityFromEnvironment(): void + private function containsUnsupportedDecodedValue(mixed $value): bool { - if (self::$securityBootstrapped) { - return; + if ($value instanceof Closure || is_resource($value)) { + return true; } - - $key = getenv('CACHELAYER_PAYLOAD_INTEGRITY_KEY'); - $max = getenv('CACHELAYER_MAX_PAYLOAD_BYTES'); - - $integrityKey = is_string($key) && $key !== '' ? $key : null; - $maxBytes = null; - if (is_string($max) && $max !== '' && ctype_digit($max)) { - $maxBytes = (int) $max; + if (is_object($value)) { + return !$this->options->allowObjects; + } + if (!is_array($value)) { + return false; + } + foreach ($value as $item) { + if ($this->containsUnsupportedDecodedValue($item)) { + return true; + } } - self::configureSecurity($integrityKey, $maxBytes ?? self::$maxPayloadBytes); + return false; } /** - * @phpstan-return array{value:mixed,expires:int|null}|null - * @param mixed $decoded The decoded argument. + * @param array $record + * @return array{valid:bool, value:mixed} */ - private static function decodeArrayPayload(mixed $decoded): ?array + private function decodeValue(array $record): array { - if (!is_array($decoded)) { - return null; - } + $encoding = $record['encoding'] ?? null; + $value = $record['value'] ?? null; + if ($encoding === 'closure') { + if (!$this->options->allowClosures || !is_string($value)) { + return ['valid' => false, 'value' => null]; + } - $normalized = []; - foreach ($decoded as $key => $value) { - if (is_string($key)) { - $normalized[$key] = $value; + try { + return ['valid' => true, 'value' => ClosureSerializer::unserialize($value)]; + } catch (Throwable) { + return ['valid' => false, 'value' => null]; } } - - $fromFormatted = self::decodeFormattedPayload($normalized); - if ($fromFormatted !== null) { - return $fromFormatted; + if ($encoding !== 'native' || $this->containsUnsupportedDecodedValue($value)) { + return ['valid' => false, 'value' => null]; } - if (array_key_exists('value', $decoded) && array_key_exists('expires', $decoded)) { - return [ - 'value' => $decoded['value'], - 'expires' => self::normalizeExpires($decoded['expires']), - ]; - } - - return null; + return ['valid' => true, 'value' => $value]; } - /** - * @phpstan-return array{value:mixed,expires:int|null}|null - * @param mixed $decoded The decoded argument. - */ - private static function decodeCacheItem(mixed $decoded): ?array + /** @return array{0:'closure'|'native', 1:mixed} */ + private function encodeValue(mixed $value): array { - if (!$decoded instanceof CacheItemInterface) { - return null; - } - - return ['value' => $decoded->get(), 'expires' => null]; - } + if ($value instanceof Closure) { + if (!$this->options->allowClosures) { + throw new InvalidArgumentException('Closure cache values are disabled by security policy.'); + } - /** - * @param array $decoded The decoded argument. - * @phpstan-param array $decoded - * @phpstan-return array{value:mixed,expires:int|null}|null - */ - private static function decodeFormattedPayload(array $decoded): ?array - { - if (($decoded['__imx_cache'] ?? null) !== self::FORMAT || !array_key_exists('value', $decoded)) { - return null; + return ['closure', ClosureSerializer::serialize($value)]; } - return [ - 'value' => $decoded['value'], - 'expires' => self::normalizeExpires($decoded['expires'] ?? null), - ]; + $this->assertNativeValueSupported($value); + + return ['native', $value]; } - private static function expandIfCompressed(string $blob): ?string + private function expandPayload(string $payload): ?string { - if (!str_starts_with($blob, self::COMPRESSED_PREFIX)) { - return $blob; + if (str_starts_with($payload, self::PLAIN_PREFIX)) { + return substr($payload, strlen(self::PLAIN_PREFIX)); + } + if (!str_starts_with($payload, self::COMPRESSED_PREFIX)) { + return null; } - $payload = substr($blob, strlen(self::COMPRESSED_PREFIX)); - $raw = base64_decode($payload, true); - if ($raw === false || !function_exists('gzdecode')) { + $compressed = base64_decode(substr($payload, strlen(self::COMPRESSED_PREFIX)), true); + if (!is_string($compressed) || !function_exists('gzdecode')) { return null; } - $maximumLength = self::$maxPayloadBytes === null + $maximumLength = $this->options->maxPayloadBytes === null ? 0 - : min(self::$maxPayloadBytes, PHP_INT_MAX - 1) + 1; + : min($this->options->maxPayloadBytes, PHP_INT_MAX - 1) + 1; set_error_handler(static fn(): bool => true); try { - $decoded = gzdecode($raw, $maximumLength); + $expanded = gzdecode($compressed, $maximumLength); } finally { restore_error_handler(); } - return is_string($decoded) ? $decoded : null; + return is_string($expanded) ? $expanded : null; } - private static function isPayloadTooLarge(string $blob): bool + private function isPayloadTooLarge(string $payload): bool { - return self::$maxPayloadBytes !== null && strlen($blob) > self::$maxPayloadBytes; + return $this->options->maxPayloadBytes !== null + && strlen($payload) > $this->options->maxPayloadBytes; } - private static function normalizeExpires(mixed $expires): ?int + private function normalizeRecord(mixed $decoded): ?CacheRecord { - return is_int($expires) ? $expires : null; - } - - private static function tryUnserialize(string $blob): mixed - { - try { - return ValueSerializer::unserialize($blob); - } catch (Throwable) { + if (!is_array($decoded) || ($decoded['format'] ?? null) !== 2 || !array_key_exists('value', $decoded)) { return null; } - } - private static function verifyAndExtractSignature(string $blob): ?string - { - if (!str_starts_with($blob, self::SIGNED_PREFIX)) { - return self::$integrityKey === null ? $blob : null; + $expiresAt = $decoded['expires'] ?? null; + if ($expiresAt !== null && !is_int($expiresAt)) { + return null; } - if (self::$integrityKey === null) { + $tags = $decoded['tags'] ?? null; + if (!is_array($tags)) { return null; } + foreach ($tags as $tag => $version) { + if (!is_string($tag) || !is_int($version) || $version < 0) { + return null; + } + } - $prefixLength = strlen(self::SIGNED_PREFIX); - $rest = substr($blob, $prefixLength); - if ($rest === '') { + $epoch = $decoded['epoch'] ?? null; + if ($epoch !== null && (!is_int($epoch) || $epoch < 0)) { return null; } - $separatorPos = strpos($rest, ':'); - if ($separatorPos === false) { + $value = $this->decodeValue($decoded); + if (!$value['valid']) { return null; } - $signature = substr($rest, 0, $separatorPos); - $payload = substr($rest, $separatorPos + 1); + return new CacheRecord($value['value'], $expiresAt, $tags, $epoch); + } + + private function unserializeNative(string $payload): mixed + { + set_error_handler(static fn(): bool => true); + + try { + return unserialize($payload, [ + 'allowed_classes' => $this->options->allowObjects, + 'max_depth' => 128, + ]); + } finally { + restore_error_handler(); + } + } - if (strlen($signature) !== 64) { + private function verifyAndExtractSignature(string $blob): ?string + { + if (!str_starts_with($blob, self::SIGNED_PREFIX)) { + return $this->options->integrityKey === null ? $blob : null; + } + if ($this->options->integrityKey === null) { return null; } - if (!ctype_xdigit($signature)) { + $separator = strpos($blob, ':', strlen(self::SIGNED_PREFIX)); + if ($separator === false) { return null; } - $expected = hash_hmac('sha256', $payload, self::$integrityKey); - if (!hash_equals($expected, strtolower($signature))) { + $signature = substr($blob, strlen(self::SIGNED_PREFIX), $separator - strlen(self::SIGNED_PREFIX)); + $payload = substr($blob, $separator + 1); + if (strlen($signature) !== 64 || !ctype_xdigit($signature)) { return null; } - return $payload; + $expected = hash_hmac('sha256', $payload, $this->options->integrityKey); + + return hash_equals($expected, strtolower($signature)) ? $payload : null; } } diff --git a/src/Cache/Adapter/ChainCacheAdapter.php b/src/Cache/Adapter/ChainCacheAdapter.php index 3941a84..600e5ef 100644 --- a/src/Cache/Adapter/ChainCacheAdapter.php +++ b/src/Cache/Adapter/ChainCacheAdapter.php @@ -4,97 +4,97 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\AbstractCacheItem; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\Item\CacheItem; +use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; +use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use InvalidArgumentException; use Psr\Cache\CacheItemInterface; -use Psr\Cache\CacheItemPoolInterface; final class ChainCacheAdapter extends AbstractCacheAdapter { - /** - * @param array $pools The pools argument. - * @param bool $writeToL1 The write to l1 argument. - * @phpstan-param array $pools - */ + /** @param list $pools */ public function __construct( private readonly array $pools, private readonly bool $writeToL1 = true, + private readonly CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), ) { if ($pools === []) { - throw new InvalidArgumentException('ChainCacheAdapter requires at least one pool.'); + throw new InvalidArgumentException('A tiered cache requires at least one pool.'); } } public function clear(): bool { - $ok = true; + $cleared = true; foreach ($this->pools as $pool) { - $ok = $pool->clear() && $ok; + $cleared = $pool->clear() && $cleared; } - $this->deferred = []; - return $ok; + return $cleared; } - public function count(): int + #[\Override] + public function configureOptions(CacheOptions $options): void { - $first = $this->pools[0]; - - return $first instanceof \Countable ? count($first) : 0; + parent::configureOptions($options); + foreach ($this->pools as $pool) { + if ($pool instanceof AbstractCacheAdapter) { + $pool->configureOptions($options); + } + } } public function deleteItem(string $key): bool { - $ok = true; + $deleted = true; foreach ($this->pools as $pool) { - $ok = $pool->deleteItem($key) && $ok; + $deleted = $pool->deleteItem($key) && $deleted; } - return $ok; + return $deleted; } - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - */ + /** @param list $keys */ public function deleteItems(array $keys): bool { - $ok = true; + $deleted = true; foreach ($this->pools as $pool) { - $ok = $pool->deleteItems($keys) && $ok; + $deleted = $pool->deleteItems($keys) && $deleted; } - return $ok; + return $deleted; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { - foreach ($this->pools as $idx => $pool) { + foreach ($this->pools as $index => $pool) { $item = $pool->getItem($key); if (!$item->isHit()) { continue; } - - $value = $item->get(); - $ttl = $item instanceof AbstractCacheItem ? $item->ttlSeconds() : null; - - for ($i = 0; $i < $idx; $i++) { - $promote = $this->pools[$i]->getItem($key); - $promote->set($value); - $promote->expiresAfter($ttl); - $this->pools[$i]->save($promote); + $out = $this->copyItem($item); + if ($index > 0) { + $this->promoteOne($out, $index); } - $out = new GenericCacheItem($this, $key); - $out->set($value); - $out->expiresAfter($ttl); - return $out; } - return new GenericCacheItem($this, $key); + return $this->genericMiss($key); + } + + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(array $tags): array + { + $first = $this->pools[0]; + + return $first->getTagVersions($tags); } public function hasItem(string $key): bool @@ -102,36 +102,148 @@ 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 array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array + * @param list $keys + * @return array */ public function multiFetch(array $keys): array { - return $this->multiFetchItems($keys, $this->getItem(...)); + $remaining = array_fill_keys($keys, true); + $results = []; + foreach ($this->pools as $index => $pool) { + if ($remaining === []) { + break; + } + $wanted = array_keys($remaining); + $fetched = iterator_to_array($pool->getItems($wanted), true); + $hits = []; + foreach ($wanted as $key) { + $item = $fetched[$key] ?? null; + if (!$item instanceof CacheItemInterface || !$item->isHit()) { + continue; + } + $hits[$key] = $this->copyItem($item); + $results[$key] = $hits[$key]; + unset($remaining[$key]); + } + if ($index > 0 && $hits !== []) { + $this->promote($hits, $index); + } + } + + $ordered = []; + foreach ($keys as $key) { + $ordered[$key] = $results[$key] ?? $this->genericMiss($key); + } + + return $ordered; } public function save(CacheItemInterface $item): bool { - return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { - $ok = true; - $poolCount = count($this->pools); - $start = $this->writeToL1 || $poolCount === 1 ? 0 : 1; - for ($idx = $start; $idx < $poolCount; $idx++) { - $pool = $this->pools[$idx]; - $target = $pool->getItem($saveItem->getKey()); - $target->set($saveItem->get()); - $target->expiresAfter($expires['ttl']); - $ok = $pool->save($target) && $ok; + if (!$this->supportsItem($item)) { + return false; + } + + $written = true; + $start = $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; + for ($index = $start, $count = count($this->pools); $index < $count; $index++) { + $written = $this->saveOneIntoPool($this->pools[$index], $item) && $written; + } + + return $written; + } + + /** @param array $items */ + public function saveItems(array $items): bool + { + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; } + } - return $ok; - }); + return $this->writeBatch($items); } - protected function supportsItem(CacheItemInterface $item): bool + private function copyItem(CacheItemInterface $source): CacheItem { - return $item instanceof GenericCacheItem; + $ttl = $source instanceof CacheItem ? $source->ttlSeconds() : null; + $tags = $source instanceof CacheItem ? $source->getTagVersions() : []; + + return (new CacheItem($this, $source->getKey(), $source->get(), true)) + ->expiresAfter($ttl) + ->setTagVersions($tags); + } + + /** @param array $items */ + private function promote(array $items, int $tierIndex): void + { + for ($index = 0; $index < $tierIndex; $index++) { + if ($this->saveIntoPool($this->pools[$index], $items)) { + $this->metrics->increment(self::class, 'promotion_batch'); + $this->metrics->increment(self::class, 'promotion_keys', count($items)); + } + } + } + + private function promoteOne(CacheItemInterface $item, int $tierIndex): void + { + for ($index = 0; $index < $tierIndex; $index++) { + $this->saveOneIntoPool($this->pools[$index], $item); + } + } + + /** @param array $items */ + private function saveIntoPool(InternalCachePoolInterface $pool, array $items): bool + { + $targets = []; + foreach ($items as $key => $item) { + $target = $pool->createItem($key); + $target->set($item->get()); + $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); + if ($target instanceof CacheItem && $item instanceof CacheItem) { + $target->setTagVersions($item->getTagVersions()); + } + $targets[$key] = $target; + } + + return $pool->saveItems($targets); + } + + private function saveOneIntoPool(InternalCachePoolInterface $pool, CacheItemInterface $item): bool + { + $target = $pool->createItem($item->getKey()); + $target->set($item->get()); + $target->expiresAfter($item instanceof CacheItem ? $item->ttlSeconds() : null); + if ($target instanceof CacheItem && $item instanceof CacheItem) { + $target->setTagVersions($item->getTagVersions()); + } + + return $pool->save($target); + } + + /** @param array $items */ + private function writeBatch(array $items): bool + { + $written = true; + $start = $this->writeToL1 || count($this->pools) === 1 ? 0 : 1; + for ($index = $start, $count = count($this->pools); $index < $count; $index++) { + $written = $this->saveIntoPool($this->pools[$index], $items) && $written; + } + + return $written; } } diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 73659b5..f8f7a5e 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -4,7 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\FileCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -25,7 +25,9 @@ class FileCacheAdapter extends AbstractCacheAdapter private const string DEFAULT_BASE_DIR = 'cachelayer/files'; - private string $dir; + private string $dataDirectory; + + private string $metadataDirectory; /** * Creates a new file-based cache adapter. @@ -43,12 +45,10 @@ public function __construct(string $namespace = 'default', ?string $baseDir = nu public function clear(): bool { $ok = true; - $files = glob("$this->dir*.cache"); - if ($files === false) { - $files = []; - } - foreach ($files as $f) { - $ok = (!is_file($f) || unlink($f)) && $ok; + foreach ([$this->dataDirectory, $this->metadataDirectory] as $directory) { + foreach (glob($directory . '*') ?: [] as $file) { + $ok = (!is_file($file) || unlink($file)) && $ok; + } } $this->deferred = []; @@ -57,7 +57,7 @@ public function clear(): bool public function count(): int { - return iterator_count(new \FilesystemIterator($this->dir, \FilesystemIterator::SKIP_DOTS)); + return iterator_count(new \FilesystemIterator($this->dataDirectory, \FilesystemIterator::SKIP_DOTS)); } public function deleteItem(string $key): bool @@ -81,28 +81,36 @@ public function deleteItems(array $keys): bool return $ok; } - public function getItem(string $key): FileCacheItem + public function getItem(string $key): CacheItem { $file = $this->fileFor($key); if (is_file($file)) { $raw = file_get_contents($file); if (is_string($raw)) { - $record = CachePayloadCodec::decode($raw); - if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { - return new FileCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($record['expires']), - ); + $record = $this->decodeRecordFromBlob($raw); + if ($record !== null) { + return $this->genericItemFromRecord($key, $record); } } unlink($file); } - return new FileCacheItem($this, $key); + return new CacheItem($this, $key); + } + + /** @param list $tags */ + #[\Override] + public function getTagVersions(array $tags): array + { + $versions = []; + 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; + } + + return $versions; } public function hasItem(string $key): bool @@ -110,52 +118,92 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - public function save(CacheItemInterface $item): bool + /** @param list $tags */ + #[\Override] + public function incrementTagVersions(array $tags): bool { - if (!$this->supportsItem($item)) { - throw new CacheInvalidArgumentException('Invalid item type for FileCacheAdapter'); - } + 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); + } - $expires = CachePayloadCodec::expirationFromItem($item); - $ttl = $expires['ttl']; - if ($ttl === 0) { - return $this->deleteItem($item->getKey()); + 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; + } } - $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); - $tmp = tempnam($this->dir, 'c_'); - if ($tmp === false) { - return false; - } + return true; + } - if (file_put_contents($tmp, $blob, LOCK_EX) === false) { - if (is_file($tmp)) { - unlink($tmp); + /** + * @param list $keys + * @return array + */ + public function multiFetch(array $keys): array + { + $items = []; + $stale = []; + foreach ($keys as $key) { + $file = $this->fileFor($key); + $raw = is_file($file) ? file_get_contents($file) : false; + if (!is_string($raw)) { + $items[$key] = $this->genericMiss($key); + + continue; } + $record = $this->decodeRecordFromBlob($raw); + if ($record === null) { + $stale[] = $file; + $items[$key] = $this->genericMiss($key); - return false; + continue; + } + $items[$key] = $this->genericItemFromRecord($key, $record); } - - if (!rename($tmp, $this->fileFor($item->getKey()))) { - if (is_file($tmp)) { - unlink($tmp); + foreach ($stale as $file) { + if (is_file($file)) { + unlink($file); } - - return false; } - return true; + return $items; } - public function setNamespaceAndDirectory(string $namespace, ?string $baseDir = null): void + public function save(CacheItemInterface $item): bool { - $this->createDirectory($namespace, $baseDir); - $this->deferred = []; + if (!$this->supportsItem($item)) { + throw new CacheInvalidArgumentException('Invalid item type for FileCacheAdapter'); + } + + return $this->persistItem($item); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool { - return $item instanceof FileCacheItem; + if (!$this->supportsItems($items)) { + return false; + } + + $ok = true; + foreach ($items as $item) { + $ok = $this->persistItem($item) && $ok; + } + + return $ok; } private function assertWritableDirectory(string $path, string $message): void @@ -169,19 +217,20 @@ private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = sanitize_cache_ns($ns); - $this->dir = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; + $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; + $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; + $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; - if (is_dir($this->dir)) { - $this->assertWritableDirectory($this->dir, "Cache directory '$this->dir' exists but is not writable"); - $this->assertSecureDirectory($this->dir, 'Cache directory'); + 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'); return; } $this->ensureBaseDirectoryExists($baseDir); - $this->ensureCacheDirectoryExists($this->dir); - $this->assertWritableDirectory($this->dir, 'Cache directory ' . $this->dir . ' is not writable'); - $this->assertSecureDirectory($this->dir, 'Cache directory'); + $this->ensureCacheDirectoryExists($this->dataDirectory); + $this->ensureCacheDirectoryExists($this->metadataDirectory); } private function defaultBaseDirectory(): string @@ -227,7 +276,46 @@ private function ensureCacheDirectoryExists(string $cacheDir): void private function fileFor(string $key): string { - return $this->dir . hash('xxh128', $key) . '.cache'; + return $this->dataDirectory . hash('xxh128', $key) . '.cache'; + } + + private function metadataFileFor(string $tag): string + { + return $this->metadataDirectory . hash('xxh128', $tag) . '.version'; + } + + private function persistItem(CacheItemInterface $item): bool + { + + $expires = CachePayloadCodec::expirationFromItem($item); + $ttl = $expires['ttl']; + if ($ttl !== null && $ttl <= 0) { + return $this->deleteItem($item->getKey()); + } + + $blob = $this->encodeItem($item, $expires['expiresAt']); + $tmp = tempnam($this->dataDirectory, 'c_'); + if ($tmp === false) { + return false; + } + + if (file_put_contents($tmp, $blob, LOCK_EX) === false) { + if (is_file($tmp)) { + unlink($tmp); + } + + return false; + } + + if (!rename($tmp, $this->fileFor($item->getKey()))) { + if (is_file($tmp)) { + unlink($tmp); + } + + return false; + } + + return true; } private function throwCreationError(string $prefix): void diff --git a/src/Cache/Adapter/GenericCacheItemPoolBehavior.php b/src/Cache/Adapter/GenericCacheItemPoolBehavior.php deleted file mode 100644 index 793957a..0000000 --- a/src/Cache/Adapter/GenericCacheItemPoolBehavior.php +++ /dev/null @@ -1,32 +0,0 @@ -getItem($key)->isHit(); - } - - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array - */ - public function multiFetch(array $keys): array - { - return $this->multiFetchItems($keys, $this->getItem(...)); - } - - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof GenericCacheItem; - } -} diff --git a/src/Cache/Adapter/InternalCachePoolInterface.php b/src/Cache/Adapter/InternalCachePoolInterface.php index 1cee275..51565b3 100644 --- a/src/Cache/Adapter/InternalCachePoolInterface.php +++ b/src/Cache/Adapter/InternalCachePoolInterface.php @@ -5,15 +5,38 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Psr\Cache\CacheItemInterface; +use Psr\Cache\CacheItemPoolInterface; /** * Internal contract used by cache items to persist themselves. * * @internal */ -interface InternalCachePoolInterface +interface InternalCachePoolInterface extends CacheItemPoolInterface { + public function createItem(string $key): CacheItemInterface; + + /** + * @param list $tags + * @return array + */ + public function getTagVersions(array $tags): array; + + /** @param list $tags */ + public function incrementTagVersions(array $tags): bool; + public function internalPersist(CacheItemInterface $item): bool; public function internalQueue(CacheItemInterface $item): bool; + + /** + * @param list $keys + * @return array + */ + public function multiFetch(array $keys): array; + + /** + * @param array $items + */ + public function saveItems(array $items): bool; } diff --git a/src/Cache/Adapter/MemCacheAdapter.php b/src/Cache/Adapter/MemCacheAdapter.php deleted file mode 100644 index 2e37fbd..0000000 --- a/src/Cache/Adapter/MemCacheAdapter.php +++ /dev/null @@ -1,392 +0,0 @@ - */ - private array $knownKeys = []; - - /** - * @param string $namespace The namespace argument. - * @param array $servers The servers argument. - * @param \Memcached|null $client The client argument. - * @phpstan-param array $servers - */ - public function __construct( - string $namespace = 'default', - array $servers = [['127.0.0.1', 11211, 0]], - ?\Memcached $client = null, - ) { - if (!class_exists(\Memcached::class)) { - throw new RuntimeException('Memcached extension not loaded'); - } - - $this->ns = sanitize_cache_ns($namespace); - $this->mc = $client ?? new \Memcached(); - if (!$client) { - $this->mc->addServers($servers); - } - } - - public function clear(): bool - { - $this->mc->flush(); - $this->deferred = []; - $this->knownKeys = []; - - return true; - } - - public function count(): int - { - return count($this->fetchKeys()); - } - - public function deleteItem(string $key): bool - { - $this->mc->delete($this->map($key)); - unset($this->knownKeys[$key]); - - return true; - } - - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - */ - public function deleteItems(array $keys): bool - { - foreach ($keys as $k) { - $this->deleteItem($k); - } - - return true; - } - - public function getClient(): \Memcached - { - return $this->mc; - } - - public function getItem(string $key): MemCacheItem - { - $mappedKey = $this->map($key); - $raw = $this->mc->get($mappedKey); - if ($this->mc->getResultCode() === \Memcached::RES_SUCCESS && is_string($raw)) { - $item = $this->hitItemFromBlob($key, $raw); - if ($item instanceof MemCacheItem) { - return $item; - } - - $this->mc->delete($mappedKey); - unset($this->knownKeys[$key]); - } - - return $this->missItem($key); - } - - public function hasItem(string $key): bool - { - $this->mc->get($this->map($key)); - - return $this->mc->getResultCode() === \Memcached::RES_SUCCESS; - } - - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array - */ - public function multiFetch(array $keys): array - { - if ($keys === []) { - return []; - } - - $prefixed = array_map($this->map(...), $keys); - $raw = $this->mc->getMulti($prefixed, \Memcached::GET_PRESERVE_ORDER); - if (!is_array($raw)) { - $raw = []; - } - - $items = []; - $stale = []; - $staleLogicalKeys = []; - foreach ($keys as $k) { - $p = $this->map($k); - if (isset($raw[$p]) && $this->appendFetchedHit($items, $stale, $staleLogicalKeys, $k, $p, $raw[$p])) { - continue; - } - - $items[$k] = $this->missItem($k); - } - - if ($stale !== []) { - $this->mc->deleteMulti($stale); - foreach ($staleLogicalKeys as $key) { - unset($this->knownKeys[$key]); - } - } - - return $items; - } - - public function save(CacheItemInterface $item): bool - { - if (!$this->supportsItem($item)) { - throw new CacheInvalidArgumentException('Wrong item class'); - } - - $expires = CachePayloadCodec::expirationFromItem($item); - $ttl = $expires['ttl']; - if ($ttl === 0) { - $this->mc->delete($this->map($item->getKey())); - unset($this->knownKeys[$item->getKey()]); - - return true; - } - - $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); - $ok = $this->mc->set($this->map($item->getKey()), $blob, $ttl ?? 0); - if ($ok) { - $this->knownKeys[$item->getKey()] = true; - } - - return $ok; - } - - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof MemCacheItem; - } - - /** - * @param array $items The items argument. - * @param array $stale The stale argument. - * @param array $staleLogicalKeys The stale logical keys argument. - * @param string $logicalKey The logical key argument. - * @param string $mappedKey The mapped key argument. - * @param mixed $rawEntry The raw entry argument. - * @phpstan-param array $items - * @phpstan-param list $stale - * @phpstan-param list $staleLogicalKeys - */ - private function appendFetchedHit( - array &$items, - array &$stale, - array &$staleLogicalKeys, - string $logicalKey, - string $mappedKey, - mixed $rawEntry, - ): bool { - if (!is_string($rawEntry)) { - return false; - } - - $item = $this->hitItemFromBlob($logicalKey, $rawEntry); - if ($item instanceof MemCacheItem) { - $items[$logicalKey] = $item; - - return true; - } - - $stale[] = $mappedKey; - $staleLogicalKeys[] = $logicalKey; - - return false; - } - - /** - * @param string $server The server argument. - * @param int $slabId The slab id argument. - * @param string $pref The pref argument. - * @param array $seen The seen argument. - * @param array $out The out argument. - * @phpstan-param array $seen - * @phpstan-param list $out - */ - private function collectDumpedKeys( - string $server, - int $slabId, - string $pref, - array &$seen, - array &$out, - ): void { - $dump = $this->mc->getStats("cachedump $slabId 0"); - - if (!isset($dump[$server]) || !is_array($dump[$server])) { - return; - } - - $keys = $this->stripNamespace(array_values(array_filter( - array_map(strval(...), array_keys($dump[$server])), - static fn(string $value): bool => $value !== '', - )), $pref); - - foreach ($keys as $key) { - if (isset($seen[$key])) { - continue; - } - - $seen[$key] = true; - $out[] = $key; - } - } - - /** - * @param array $items The items argument. - * @phpstan-param array $items - * @phpstan-return list - */ - private function extractSlabIds(array $items): array - { - $ids = []; - - foreach ($items as $name => $value) { - if (!preg_match('/items:(\d+):number/', (string) $name, $m)) { - continue; - } - - $ids[] = (int) $m[1]; - } - - return array_values(array_unique($ids)); - } - - /** - * @phpstan-return list - */ - private function fastKnownKeys(): array - { - return $this->knownKeys ? array_keys($this->knownKeys) : []; - } - - /** - * @phpstan-return list - */ - private function fetchKeys(): array - { - if ($quick = $this->fastKnownKeys()) { - return $quick; - } - - $pref = $this->ns . ':'; - if ($keys = $this->keysFromGetAll($pref)) { - return $keys; - } - - return $this->keysFromSlabDump($pref); - } - - private function hitItemFromBlob(string $key, string $blob): ?MemCacheItem - { - $record = $this->decodeRecordFromBlob($blob); - if ($record === null) { - return null; - } - - return new MemCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($record['expires']), - ); - } - - /** - * @phpstan-return list - * @param string $pref The pref argument. - */ - private function keysFromGetAll(string $pref): array - { - $all = $this->mc->getAllKeys(); - if (!is_array($all)) { - return []; - } - - $keys = []; - foreach ($all as $key) { - if (is_string($key)) { - $keys[] = $key; - } - } - - return $this->stripNamespace($keys, $pref); - } - - /** - * @phpstan-return list - * @param string $pref The pref argument. - */ - private function keysFromSlabDump(string $pref): array - { - /** @var list $out */ - $out = []; - /** @var array $seen */ - $seen = []; - - foreach ($this->slabIdsByServer() as $server => $slabIds) { - foreach ($slabIds as $slabId) { - $this->collectDumpedKeys($server, $slabId, $pref, $seen, $out); - } - } - - return $out; - } - - private function map(string $key): string - { - return $this->ns . ':' . $key; - } - - private function missItem(string $key): MemCacheItem - { - return new MemCacheItem($this, $key); - } - - /** - * @phpstan-return array> - */ - private function slabIdsByServer(): array - { - $stats = $this->mc->getStats('items'); - - $mapped = []; - foreach ($stats as $server => $items) { - if (!is_string($server) || !is_array($items)) { - continue; - } - - $mapped[$server] = $this->extractSlabIds($items); - } - - return $mapped; - } - - /** - * @param array $fullKeys The full keys argument. - * @param string $pref The pref argument. - * @phpstan-param array $fullKeys - * @phpstan-return list - */ - private function stripNamespace(array $fullKeys, string $pref): array - { - return array_values(array_map( - fn(string $k) => substr($k, strlen($pref)), - array_filter($fullKeys, fn(string $k) => str_starts_with($k, $pref)), - )); - } -} diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php new file mode 100644 index 0000000..cef8280 --- /dev/null +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -0,0 +1,253 @@ + $servers + */ + public function __construct( + string $namespace = 'default', + array $servers = [['127.0.0.1', 11211, 0]], + ?\Memcached $client = null, + ) { + if (!class_exists(\Memcached::class)) { + throw new RuntimeException('Memcached extension not loaded'); + } + $this->namespace = sanitize_cache_ns($namespace); + $this->client = $client ?? new \Memcached(); + if ($client === null) { + $this->client->addServers($servers); + } + } + + public function clear(): bool + { + $this->client->add($this->epochKey(), 0); + $cleared = $this->client->increment($this->epochKey()) !== false; + $this->deferred = []; + + return $cleared; + } + + public function deleteItem(string $key): bool + { + $this->client->delete($this->mapData($key)); + + return !in_array( + $this->client->getResultCode(), + [\Memcached::RES_FAILURE, \Memcached::RES_WRITE_FAILURE], + true, + ); + } + + /** @param list $keys */ + public function deleteItems(array $keys): bool + { + if ($keys === []) { + return true; + } + + $this->client->deleteMulti(array_map($this->mapData(...), $keys)); + + return !in_array( + $this->client->getResultCode(), + [\Memcached::RES_FAILURE, \Memcached::RES_WRITE_FAILURE], + true, + ); + } + + public function getClient(): \Memcached + { + return $this->client; + } + + public function getItem(string $key): CacheItem + { + $mapped = $this->mapData($key); + $stored = $this->client->getMulti([$this->epochKey(), $mapped]); + $stored = is_array($stored) ? $stored : []; + $epoch = $this->normalizeVersion($stored[$this->epochKey()] ?? null); + $blob = $stored[$mapped] ?? null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + if ($record !== null && ($record->namespaceEpoch ?? 0) === $epoch) { + return $this->genericItemFromRecord($key, $record); + } + if (is_string($blob)) { + $this->client->delete($mapped); + } + + return $this->genericMiss($key); + } + + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(array $tags): array + { + if ($tags === []) { + return []; + } + $stored = $this->client->getMulti(array_map($this->mapTag(...), $tags)); + $stored = is_array($stored) ? $stored : []; + $versions = []; + foreach ($tags as $tag) { + $versions[$tag] = $this->normalizeVersion($stored[$this->mapTag($tag)] ?? null); + } + + return $versions; + } + + 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 + */ + public function multiFetch(array $keys): array + { + if ($keys === []) { + return []; + } + + $physical = [$this->epochKey()]; + 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); + $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) { + $items[$key] = $this->genericMiss($key); + if (is_string($blob)) { + $stale[] = $mapped; + } + + continue; + } + $items[$key] = $this->genericItemFromRecord($key, $record); + } + if ($stale !== []) { + $this->client->deleteMulti($stale); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return $this->deleteItem($item->getKey()); + } + + return $this->client->set( + $this->mapData($item->getKey()), + $this->encodeItem($item, $expiration['expiresAt'], $this->namespaceEpoch()), + $expiration['ttl'] ?? 0, + ); + } + + /** @param array $items */ + public function saveItems(array $items): bool + { + $epoch = $this->namespaceEpoch(); + $groups = []; + $expired = []; + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $item->getKey(); + + continue; + } + $ttl = $expiration['ttl'] ?? 0; + $groups[$ttl][$this->mapData($item->getKey())] = $this->encodeItem( + $item, + $expiration['expiresAt'], + $epoch, + ); + } + + if (!$this->deleteItems($expired)) { + return false; + } + foreach ($groups as $ttl => $records) { + if (!$this->client->setMulti($records, (int) $ttl)) { + return false; + } + } + + return true; + } + + private function epochKey(): string + { + return $this->namespace . ':m:epoch'; + } + + private function mapData(string $key): string + { + return $this->namespace . ':d:' . $key; + } + + private function mapTag(string $tag): string + { + return $this->namespace . ':m:tag:' . $tag; + } + + private function namespaceEpoch(): int + { + $value = $this->client->get($this->epochKey()); + + return $this->normalizeVersion($value); + } + + private function normalizeVersion(mixed $value): int + { + return is_int($value) && $value >= 0 ? $value : 0; + } +} diff --git a/src/Cache/Adapter/MongoDbCacheAdapter.php b/src/Cache/Adapter/MongoDbCacheAdapter.php index e72a7bc..ff0a19a 100644 --- a/src/Cache/Adapter/MongoDbCacheAdapter.php +++ b/src/Cache/Adapter/MongoDbCacheAdapter.php @@ -4,7 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -18,7 +18,7 @@ public function __construct( ) { $this->ns = sanitize_cache_ns($namespace); - foreach (['findOne', 'updateOne', 'deleteOne', 'deleteMany', 'countDocuments'] as $method) { + foreach (['findOne', 'find', 'updateOne', 'bulkWrite', 'deleteOne', 'deleteMany', 'countDocuments'] as $method) { if (!method_exists($this->collection, $method)) { throw new RuntimeException( sprintf('MongoDbCacheAdapter requires collection method `%s()`.', $method), @@ -55,6 +55,7 @@ public function count(): int { $count = $this->collection->countDocuments([ 'ns' => $this->ns, + 'kind' => 'data', '$or' => [ ['expires' => null], ['expires' => ['$gt' => time()]], @@ -66,7 +67,7 @@ public function count(): int public function deleteItem(string $key): bool { - $this->collection->deleteOne(['_id' => $this->map($key)]); + $this->collection->deleteOne(['_id' => $this->mapData($key)]); return true; } @@ -77,16 +78,18 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { - foreach ($keys as $key) { - $this->deleteItem((string) $key); + if ($keys !== []) { + $this->collection->deleteMany([ + '_id' => ['$in' => array_map($this->mapData(...), $keys)], + ]); } return true; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { - $doc = $this->collection->findOne(['_id' => $this->map($key)]); + $doc = $this->collection->findOne(['_id' => $this->mapData($key)]); $row = AdapterValueNormalizer::fromJsonOrArrayLike($doc); if ($row === null) { @@ -98,10 +101,39 @@ public function getItem(string $key): GenericCacheItem return $this->genericFromBase64($key, is_string($payload) ? $payload : null); } + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(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); + } + } + + return $versions; + } + public function hasItem(string $key): bool { $count = $this->collection->countDocuments([ - '_id' => $this->map($key), + '_id' => $this->mapData($key), '$or' => [ ['expires' => null], ['expires' => ['$gt' => time()]], @@ -111,25 +143,74 @@ 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { - return $this->multiFetchItems($keys, $this->getItem(...)); + $ids = array_map($this->mapData(...), $keys); + $documents = $keys === [] ? [] : $this->collection->find(['_id' => ['$in' => $ids]]); + if (!is_iterable($documents)) { + throw new RuntimeException('MongoDB find() must return an iterable result.'); + } + $byId = []; + foreach ($documents as $document) { + $row = AdapterValueNormalizer::fromJsonOrArrayLike($document); + if (is_array($row) && is_string($row['_id'] ?? null)) { + $byId[$row['_id']] = $row; + } + } + + $items = []; + $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); + $items[$key] = $item; + if (is_array($row) && !$item->isHit()) { + $stale[] = $key; + } + } + $this->deleteItems($stale); + + return $items; } public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { $this->collection->updateOne( - ['_id' => $this->map($saveItem->getKey())], + ['_id' => $this->mapData($saveItem->getKey())], [ '$set' => [ 'ns' => $this->ns, - 'payload' => base64_encode(CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt'])), + 'kind' => 'data', + 'payload' => base64_encode($this->encodeItem($saveItem, $expires['expiresAt'])), 'expires' => $expires['expiresAt'], ], ], @@ -140,13 +221,47 @@ public function save(CacheItemInterface $item): bool }); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool + { + $operations = []; + $expired = []; + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $item->getKey(); + + continue; + } + $operations[] = ['updateOne' => [ + ['_id' => $this->mapData($item->getKey())], + ['$set' => [ + 'ns' => $this->ns, + 'kind' => 'data', + 'payload' => base64_encode($this->encodeItem($item, $expiration['expiresAt'])), + 'expires' => $expiration['expiresAt'], + ]], + ['upsert' => true], + ]]; + } + $this->deleteItems($expired); + if ($operations !== []) { + $this->collection->bulkWrite($operations, ['ordered' => false]); + } + + return true; + } + + private function mapData(string $key): string { - return $item instanceof GenericCacheItem; + return $this->ns . ':d:' . $key; } - private function map(string $key): string + private function mapTag(string $tag): string { - return $this->ns . ':' . $key; + return $this->ns . ':m:tag:' . $tag; } } diff --git a/src/Cache/Adapter/NullCacheAdapter.php b/src/Cache/Adapter/NullCacheAdapter.php index 8c8f69f..e677d34 100644 --- a/src/Cache/Adapter/NullCacheAdapter.php +++ b/src/Cache/Adapter/NullCacheAdapter.php @@ -4,7 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; final class NullCacheAdapter extends AbstractCacheAdapter @@ -39,9 +39,16 @@ public function deleteItems(array $keys): bool return true; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { - return new GenericCacheItem($this, $key); + return new CacheItem($this, $key); + } + + /** @param list $tags */ + #[\Override] + public function getTagVersions(array $tags): array + { + return array_fill_keys($tags, 0); } public function hasItem(string $key): bool @@ -51,16 +58,25 @@ 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { $items = []; foreach ($keys as $key) { - $items[$key] = new GenericCacheItem($this, $key); + $items[$key] = new CacheItem($this, $key); } return $items; @@ -71,8 +87,15 @@ public function save(CacheItemInterface $item): bool return $this->supportsItem($item); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool { - return $item instanceof GenericCacheItem; + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + } + + return true; } } diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 04c0e49..6bac026 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -4,17 +4,19 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; final class PdoCacheAdapter extends AbstractCacheAdapter { + private const int BATCH_SIZE = 250; + private const string DEFAULT_SQLITE_DIR = 'cachelayer/pdo'; private readonly string $driver; - private readonly string $ns; + private readonly string $namespace; private readonly \PDO $pdo; @@ -27,96 +29,77 @@ public function __construct( ?string $password = null, ?\PDO $pdo = null, string $table = 'cachelayer_entries', + bool $initializeSchema = true, ) { - if (!preg_match('/^[A-Za-z0-9_]+$/', $table)) { + if (preg_match('/^[A-Za-z0-9_]+$/D', $table) !== 1) { throw new RuntimeException('Invalid PDO cache table name.'); } - $this->ns = sanitize_cache_ns($namespace); + $this->namespace = sanitize_cache_ns($namespace); $this->table = $table; - $resolvedDsn = $dsn; - if ($pdo === null && $resolvedDsn === null) { - $resolvedDsn = 'sqlite:' . self::defaultSqliteFileForNamespace($this->ns); - } - - if ($pdo !== null) { - $this->pdo = $pdo; - } else { - if (!is_string($resolvedDsn)) { - throw new RuntimeException('Unable to resolve PDO DSN.'); - } - - $this->pdo = new \PDO($resolvedDsn, $username, $password); - } - + $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->driver = is_string($driver) ? $driver : ''; - - $this->configureDriverDefaults(); - $this->createSchemaIfMissing(); + if ($this->driver === 'sqlite') { + $this->pdo->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL; PRAGMA busy_timeout=5000;'); + } + if ($initializeSchema) { + PdoCacheSchema::install($this->pdo, $this->table); + } } public static function defaultSqliteFileForNamespace(string $namespace): string { - $ns = sanitize_cache_ns($namespace); - $baseDir = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) + $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_SQLITE_DIR); + if (is_link($directory)) { + throw new RuntimeException("Refusing symlinked SQLite cache directory: {$directory}"); + } + 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}"); + } - self::ensureSecureDirectory($baseDir, 0700); - - return $baseDir . DIRECTORY_SEPARATOR . "cache_{$ns}.sqlite"; + return $directory . DIRECTORY_SEPARATOR . 'cache_' . sanitize_cache_ns($namespace) . '.sqlite'; } public function clear(): bool { - $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey LIKE :prefix"); - $ok = $stmt->execute([':prefix' => $this->ns . ':%']); + $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey LIKE ?"); + $cleared = $statement->execute([$this->namespace . ':%']); $this->deferred = []; - return $ok; + return $cleared; } public function count(): int { - $stmt = $this->pdo->prepare( + $statement = $this->pdo->prepare( "SELECT COUNT(*) FROM {$this->table} - WHERE ckey LIKE :prefix - AND (expires IS NULL OR expires > :now)", + WHERE ckey LIKE ? AND (expires IS NULL OR expires > ?)", ); - $stmt->execute([ - ':prefix' => $this->ns . ':%', - ':now' => time(), - ]); - - $count = $stmt->fetchColumn(); + $statement->execute([$this->namespace . ':d:%', time()]); + $count = $statement->fetchColumn(); return is_numeric($count) ? max(0, (int) $count) : 0; } public function deleteItem(string $key): bool { - $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey = :k"); + $statement = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey = ?"); - return $stmt->execute([':k' => $this->map($key)]); + return $statement->execute([$this->mapData($key)]); } - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - */ + /** @param list $keys */ public function deleteItems(array $keys): bool { - if ($keys === []) { - return true; - } - - $mapped = array_map($this->map(...), $keys); - $marks = implode(',', array_fill(0, count($mapped), '?')); - $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey IN ($marks)"); - - return $stmt->execute($mapped); + return $this->deleteMapped(array_map($this->mapData(...), $keys)); } public function getClient(): \PDO @@ -124,53 +107,46 @@ public function getClient(): \PDO return $this->pdo; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { - $stmt = $this->pdo->prepare( - "SELECT payload, expires FROM {$this->table} WHERE ckey = :k LIMIT 1", + $statement = $this->pdo->prepare( + "SELECT payload, expires FROM {$this->table} WHERE ckey = ? LIMIT 1", ); - $stmt->execute([':k' => $this->map($key)]); - $row = $stmt->fetch(\PDO::FETCH_ASSOC); - + $statement->execute([$this->mapData($key)]); + $row = $statement->fetch(\PDO::FETCH_ASSOC); if (!is_array($row)) { - return new GenericCacheItem($this, $key); + return $this->genericMiss($key); } - $expiresAt = is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null; - if (CachePayloadCodec::isExpired($expiresAt)) { - $this->deleteItem($key); - - return new GenericCacheItem($this, $key); + $item = $this->hydrate($key, $row); + if ($item !== null) { + return $item; } + $this->deleteItem($key); - $payload = $row['payload'] ?? null; - if (!is_string($payload)) { - $this->deleteItem($key); - - return new GenericCacheItem($this, $key); - } - - $blob = base64_decode($payload, true); - if (!is_string($blob)) { - $this->deleteItem($key); + return $this->genericMiss($key); + } - return new GenericCacheItem($this, $key); + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(array $tags): array + { + $mapped = []; + foreach ($tags as $tag) { + $mapped[$tag] = $this->mapTag($tag); } - - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { - $this->deleteItem($key); - - return new GenericCacheItem($this, $key); + $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; } - return new GenericCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($record['expires']), - ); + return $versions; } public function hasItem(string $key): bool @@ -178,50 +154,57 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array - */ - public function multiFetch(array $keys): array + /** @param list $tags */ + #[\Override] + public function incrementTagVersions(array $tags): bool { - if ($keys === []) { - return []; + if ($tags === []) { + return true; } - $mappedByLogical = []; - foreach ($keys as $key) { - $mappedByLogical[$key] = $this->map($key); + $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); } - $rows = $this->fetchRowsByMappedKeys(array_values($mappedByLogical)); - $items = []; - $staleMapped = []; - - foreach ($keys as $logical) { - $mapped = $mappedByLogical[$logical]; - $row = $rows[$mapped] ?? null; - - if (!is_array($row)) { - $items[$logical] = new GenericCacheItem($this, $logical); - - continue; + $statement = $this->pdo->prepare($sql); + foreach ($tags as $tag) { + if (!$statement->execute([$this->mapTag($tag)])) { + return false; } + } - $item = $this->hydrateItemFromRow($logical, $row); - if ($item instanceof GenericCacheItem) { - $items[$logical] = $item; - - continue; - } + return true; + } - $staleMapped[] = $mapped; - $items[$logical] = new GenericCacheItem($this, $logical); + /** + * @param list $keys + * @return array + */ + public function multiFetch(array $keys): array + { + $mapped = []; + foreach ($keys as $key) { + $mapped[$key] = $this->mapData($key); } - - if ($staleMapped !== []) { - $this->deleteMappedItems($staleMapped); + $rows = $this->fetchRows(array_values($mapped)); + $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); + if (is_array($row) && $item === null) { + $stale[] = $physical; + } } + $this->deleteMapped($stale); return $items; } @@ -231,247 +214,194 @@ public function save(CacheItemInterface $item): bool if (!$this->supportsItem($item)) { return false; } - - $expires = CachePayloadCodec::expirationFromItem($item); - if ($expires['ttl'] === 0) { + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { return $this->deleteItem($item->getKey()); } - $params = [ - ':k' => $this->map($item->getKey()), - ':p' => base64_encode(CachePayloadCodec::encode($item->get(), $expires['expiresAt'])), - ':e' => $expires['expiresAt'], - ]; - - return $this->upsert($params, $this->map($item->getKey())); + return $this->upsertRows([[ + $this->mapData($item->getKey()), + base64_encode($this->encodeItem($item, $expiration['expiresAt'])), + $expiration['expiresAt'], + ]]); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool { - return $item instanceof GenericCacheItem; + $rows = []; + $expired = []; + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $this->mapData($item->getKey()); + + continue; + } + $rows[] = [ + $this->mapData($item->getKey()), + base64_encode($this->encodeItem($item, $expiration['expiresAt'])), + $expiration['expiresAt'], + ]; + } + + return $this->deleteMapped($expired) && $this->upsertRows($rows); } - private static function ensureSecureDirectory(string $path, int $mode): void + /** @param list $mappedKeys */ + private function deleteMapped(array $mappedKeys): bool { - if (is_link($path)) { - throw new RuntimeException("Refusing symlinked SQLite cache directory: {$path}"); + foreach (array_chunk($mappedKeys, 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)) { + return false; + } } - if (!is_dir($path) && !mkdir($path, $mode, true) && !is_dir($path)) { - throw new RuntimeException("Unable to create SQLite cache directory: {$path}"); - } + return true; + } - if (!is_writable($path)) { - throw new RuntimeException("SQLite cache directory is not writable: {$path}"); + /** + * @param list $mappedKeys + * @return array + */ + private function fetchRows(array $mappedKeys): array + { + $rows = []; + foreach (array_chunk($mappedKeys, 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})", + ); + $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)) { + continue; + } + $rows[$row['ckey']] = [ + 'payload' => $row['payload'], + 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null, + ]; + } } - $perms = fileperms($path); - if ($perms !== false && (($perms & 0x0002) === 0x0002)) { - throw new RuntimeException("SQLite cache directory must not be world-writable: {$path}"); - } + return $rows; } - private function configureDriverDefaults(): void + /** @param array $row */ + private function hydrate(string $key, array $row): ?CacheItem { - if ($this->driver !== 'sqlite') { - return; + $expiresAt = is_numeric($row['expires']) ? (int) $row['expires'] : null; + if (CachePayloadCodec::isExpired($expiresAt) || !is_string($row['payload'])) { + return null; } - try { - $this->pdo->exec('PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;'); - } catch (\PDOException) { - // Best effort sqlite tuning. - } + $record = $this->decodeRecordFromBase64($row['payload']); + + return $record === null ? null : $this->genericItemFromRecord($key, $record); } - private function createExpiresIndexIfMissing(): void + /** @param list $tags */ + private function incrementTagsWithTransaction(array $tags): bool { - $index = "{$this->table}_expires_idx"; + $this->pdo->beginTransaction(); try { - if (in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { - $this->pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$this->table}(expires)"); - - return; + foreach ($tags as $tag) { + $key = $this->mapTag($tag); + $update = $this->pdo->prepare( + "UPDATE {$this->table} SET payload = CAST(payload AS INTEGER) + 1 WHERE ckey = ?", + ); + $update->execute([$key]); + if ($update->rowCount() === 0) { + $this->pdo->prepare( + "INSERT INTO {$this->table} (ckey, payload, expires) VALUES (?, '1', NULL)", + )->execute([$key]); + } } - $this->pdo->exec("CREATE INDEX {$index} ON {$this->table}(expires)"); - } catch (\PDOException) { - // Retry once for engines that do not support IF NOT EXISTS on indexes. - try { - $this->pdo->exec("CREATE INDEX {$index} ON {$this->table}(expires)"); - } catch (\PDOException) { - // Ignore duplicate index/feature support errors. + return $this->pdo->commit(); + } catch (\PDOException $failure) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); } + + throw $failure; } } - private function createSchemaIfMissing(): void + private function mapData(string $key): string { - $keyType = in_array($this->driver, ['mysql', 'mariadb'], true) ? 'VARCHAR(191)' : 'TEXT'; - - $this->pdo->exec( - "CREATE TABLE IF NOT EXISTS {$this->table} ( - ckey {$keyType} PRIMARY KEY, - payload TEXT NOT NULL, - expires BIGINT NULL - )", - ); - - $this->createExpiresIndexIfMissing(); + return $this->namespace . ':d:' . $key; } - /** - * @param array $mappedKeys The mapped keys argument. - * @phpstan-param array $mappedKeys - */ - private function deleteMappedItems(array $mappedKeys): void + private function mapTag(string $tag): string { - if ($mappedKeys === []) { - return; - } - - $marks = implode(',', array_fill(0, count($mappedKeys), '?')); - $stmt = $this->pdo->prepare("DELETE FROM {$this->table} WHERE ckey IN ($marks)"); - $stmt->execute($mappedKeys); + return $this->namespace . ':m:tag:' . $tag; } - /** - * @param array $mappedKeys The mapped keys argument. - * @phpstan-param array $mappedKeys - * @phpstan-return array - */ - private function fetchRowsByMappedKeys(array $mappedKeys): array + /** @param list $rows */ + private function upsertChunk(array $rows): bool { - if ($mappedKeys === []) { - return []; - } + if (!in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { + $this->pdo->beginTransaction(); - $marks = implode(',', array_fill(0, count($mappedKeys), '?')); - $stmt = $this->pdo->prepare( - "SELECT ckey, payload, expires - FROM {$this->table} - WHERE ckey IN ($marks)", - ); - $stmt->execute($mappedKeys); + try { + foreach ($rows as $row) { + $this->upsertGeneric($row); + } - $rows = []; - foreach ($stmt->fetchAll(\PDO::FETCH_ASSOC) as $row) { - if (!is_array($row)) { - continue; - } + return $this->pdo->commit(); + } catch (\PDOException $failure) { + if ($this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } - $key = $row['ckey'] ?? null; - if (!is_string($key) || $key === '' || !is_string($row['payload'] ?? null)) { - continue; + throw $failure; } - - $rows[$key] = [ - 'payload' => $row['payload'], - 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null, - ]; - } - - return $rows; - } - - /** - * @param string $key The key argument. - * @param array $row The row argument. - * @phpstan-param array{payload:string,expires:int|null} $row - */ - private function hydrateItemFromRow(string $key, array $row): ?GenericCacheItem - { - if (CachePayloadCodec::isExpired($row['expires'])) { - return null; - } - - $blob = base64_decode($row['payload'], true); - if (!is_string($blob)) { - return null; } - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { - return null; + $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 new GenericCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($record['expires']), - ); + return $this->pdo->prepare( + "INSERT INTO {$this->table} (ckey, payload, expires) VALUES {$values} {$suffix}", + )->execute($parameters); } - private function map(string $key): string + /** @param array{0:string, 1:string, 2:int|null} $row */ + private function upsertGeneric(array $row): void { - return $this->ns . ':' . $key; + $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); + } } - private function nativeUpsertSql(): ?string + /** @param list $rows */ + private function upsertRows(array $rows): bool { - return match ($this->driver) { - 'pgsql', 'sqlite' => "INSERT INTO {$this->table} (ckey, payload, expires) - VALUES (:k, :p, :e) - ON CONFLICT (ckey) - DO UPDATE SET payload = EXCLUDED.payload, expires = EXCLUDED.expires", - 'mysql', 'mariadb' => "INSERT INTO {$this->table} (ckey, payload, expires) - VALUES (:k, :p, :e) - ON DUPLICATE KEY UPDATE payload = VALUES(payload), expires = VALUES(expires)", - default => null, - }; - } - - /** - * @param array $params The params argument. - * @param string $mappedKey The mapped key argument. - * @phpstan-param array{':k':string,':p':string,':e':int|null} $params - */ - private function upsert(array $params, string $mappedKey): bool - { - $nativeSql = $this->nativeUpsertSql(); - if ($nativeSql !== null) { - $stmt = $this->pdo->prepare($nativeSql); - - return $stmt->execute($params); - } - - $update = $this->pdo->prepare( - "UPDATE {$this->table} - SET payload = :p, expires = :e - WHERE ckey = :k", - ); - - if (!$update->execute($params)) { - return false; - } - - if ($update->rowCount() > 0) { + if ($rows === []) { return true; } - - $insert = $this->pdo->prepare( - "INSERT INTO {$this->table} (ckey, payload, expires) - VALUES (:k, :p, :e)", - ); - - try { - return $insert->execute($params); - } catch (\PDOException) { - // Another process may have inserted concurrently. - $updateByKey = $this->pdo->prepare( - "UPDATE {$this->table} - SET payload = :p, expires = :e - WHERE ckey = :k", - ); - - return $updateByKey->execute([ - ':k' => $mappedKey, - ':p' => $params[':p'], - ':e' => $params[':e'], - ]); + foreach (array_chunk($rows, self::BATCH_SIZE) as $chunk) { + if (!$this->upsertChunk($chunk)) { + return false; + } } + + return true; } } diff --git a/src/Cache/Adapter/PdoCacheSchema.php b/src/Cache/Adapter/PdoCacheSchema.php new file mode 100644 index 0000000..8e47b49 --- /dev/null +++ b/src/Cache/Adapter/PdoCacheSchema.php @@ -0,0 +1,40 @@ +getAttribute(\PDO::ATTR_DRIVER_NAME); + $driver = is_string($driverValue) ? $driverValue : ''; + $keyType = in_array($driver, ['mysql', 'mariadb'], true) ? 'VARCHAR(191)' : 'TEXT'; + $pdo->exec( + "CREATE TABLE IF NOT EXISTS {$table} ( + ckey {$keyType} PRIMARY KEY, + payload TEXT NOT NULL, + expires BIGINT NULL + )", + ); + + $index = $table . '_expires_idx'; + + try { + $pdo->exec("CREATE INDEX IF NOT EXISTS {$index} ON {$table}(expires)"); + } catch (\PDOException) { + try { + $pdo->exec("CREATE INDEX {$index} ON {$table}(expires)"); + } catch (\PDOException) { + // The index already exists or the driver does not support this syntax. + } + } + } +} diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 8e73c40..0e7bb6d 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -4,7 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; @@ -14,7 +14,9 @@ final class PhpFilesCacheAdapter extends AbstractCacheAdapter private const string DEFAULT_BASE_DIR = 'cachelayer/phpfiles'; - private string $dir; + private string $dataDirectory; + + private string $metadataDirectory; public function __construct(string $namespace = 'default', ?string $baseDir = null) { @@ -24,10 +26,13 @@ public function __construct(string $namespace = 'default', ?string $baseDir = nu public function clear(): bool { $ok = true; - foreach (glob($this->dir . '*.php') ?: [] as $file) { + foreach (glob($this->dataDirectory . '*.php') ?: [] as $file) { $ok = (!is_file($file) || unlink($file)) && $ok; $this->invalidateOpcache($file); } + foreach (glob($this->metadataDirectory . '*') ?: [] as $file) { + $ok = (!is_file($file) || unlink($file)) && $ok; + } $this->deferred = []; @@ -37,7 +42,7 @@ public function clear(): bool public function count(): int { $count = 0; - foreach (glob($this->dir . '*.php') ?: [] as $file) { + foreach (glob($this->dataDirectory . '*.php') ?: [] as $file) { $row = require $file; if (!is_array($row) || !isset($row['p']) || !is_string($row['p'])) { continue; @@ -48,8 +53,8 @@ public function count(): int continue; } - $record = CachePayloadCodec::decode($blob); - if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { + $record = $this->decodeRecordFromBlob($blob); + if ($record !== null) { $count++; } } @@ -80,7 +85,7 @@ public function deleteItems(array $keys): bool return $ok; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { $file = $this->fileFor($key); if (!is_file($file)) { @@ -102,21 +107,80 @@ public function getItem(string $key): GenericCacheItem ); } + /** @param list $tags */ + #[\Override] + public function getTagVersions(array $tags): array + { + $versions = []; + 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; + } + + return $versions; + } + 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { $items = []; + $stale = []; foreach ($keys as $key) { - $items[$key] = $this->getItem($key); + $file = $this->fileFor($key); + if (!is_file($file)) { + $items[$key] = $this->genericMiss($key); + + continue; + } + $row = require $file; + $payload = is_array($row) && is_string($row['p'] ?? null) ? $row['p'] : null; + $item = $this->genericFromBase64WithInvalidator($key, $payload, static fn(): bool => true); + if (!$item->isHit()) { + $stale[] = $key; + } + $items[$key] = $item; + } + if ($stale !== []) { + $this->deleteItems($stale); } return $items; @@ -128,76 +192,58 @@ public function save(CacheItemInterface $item): bool return false; } - $expires = CachePayloadCodec::expirationFromItem($item); - if ($expires['ttl'] === 0) { - return $this->deleteItem($item->getKey()); - } - - $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); - $payload = var_export(base64_encode($blob), true); - $code = " {$payload}];\n"; - - $file = $this->fileFor($item->getKey()); - $tmp = tempnam($this->dir, 'pc_'); - if ($tmp === false) { - return false; - } - - if (file_put_contents($tmp, $code, LOCK_EX) === false) { - if (is_file($tmp)) { - unlink($tmp); - } + return $this->persistItem($item); + } + /** @param array $items */ + public function saveItems(array $items): bool + { + if (!$this->supportsItems($items)) { return false; } - if (!rename($tmp, $file)) { - if (is_file($tmp)) { - unlink($tmp); - } - - return false; + $ok = true; + foreach ($items as $item) { + $ok = $this->persistItem($item) && $ok; } - $this->invalidateOpcache($file); - - return true; - } - - public function setNamespaceAndDirectory(string $namespace, ?string $baseDir = null): void - { - $this->createDirectory($namespace, $baseDir); - $this->deferred = []; - } - - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof GenericCacheItem; + return $ok; } private function createDirectory(string $ns, ?string $baseDir): void { $baseDir = rtrim($baseDir ?? $this->defaultBaseDirectory(), DIRECTORY_SEPARATOR); $ns = sanitize_cache_ns($ns); - $this->dir = $baseDir . DIRECTORY_SEPARATOR . 'phpcache_' . $ns . DIRECTORY_SEPARATOR; + $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; + $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; + $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; $this->assertPathNotSymlink($baseDir, 'PHP cache base directory'); - $this->assertPathNotSymlink($this->dir, 'PHP cache directory'); + $this->assertPathNotSymlink($this->dataDirectory, 'PHP cache data directory'); + $this->assertPathNotSymlink($this->metadataDirectory, 'PHP cache metadata directory'); if (!is_dir($baseDir) && !mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { throw new RuntimeException("Unable to create PHP cache base directory: {$baseDir}"); } - if (!is_dir($this->dir) && !mkdir($this->dir, 0700, true) && !is_dir($this->dir)) { - throw new RuntimeException("Unable to create PHP cache directory: {$this->dir}"); + if (!is_dir($this->dataDirectory) + && !mkdir($this->dataDirectory, 0700, true) + && !is_dir($this->dataDirectory)) { + throw new RuntimeException("Unable to create PHP cache data directory: {$this->dataDirectory}"); + } + if (!is_dir($this->metadataDirectory) + && !mkdir($this->metadataDirectory, 0700, true) + && !is_dir($this->metadataDirectory)) { + throw new RuntimeException("Unable to create PHP cache metadata directory: {$this->metadataDirectory}"); } $this->assertSecureDirectory($baseDir, 'PHP cache base directory'); - if (!is_writable($this->dir)) { - throw new RuntimeException("PHP cache directory is not writable: {$this->dir}"); + if (!is_writable($this->dataDirectory) || !is_writable($this->metadataDirectory)) { + throw new RuntimeException('PHP cache directories are not writable.'); } - $this->assertSecureDirectory($this->dir, 'PHP cache directory'); + $this->assertSecureDirectory($this->dataDirectory, 'PHP cache data directory'); + $this->assertSecureDirectory($this->metadataDirectory, 'PHP cache metadata directory'); } private function defaultBaseDirectory(): string @@ -209,7 +255,7 @@ private function defaultBaseDirectory(): string private function fileFor(string $key): string { - return $this->dir . hash('xxh128', $key) . '.php'; + return $this->dataDirectory . hash('xxh128', $key) . '.php'; } private function invalidateOpcache(string $file): void @@ -220,4 +266,48 @@ private function invalidateOpcache(string $file): void } } } + + private function metadataFileFor(string $tag): string + { + return $this->metadataDirectory . hash('xxh128', $tag) . '.version'; + } + + private function persistItem(CacheItemInterface $item): bool + { + + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] !== null && $expires['ttl'] <= 0) { + return $this->deleteItem($item->getKey()); + } + + $blob = $this->encodeItem($item, $expires['expiresAt']); + $payload = var_export(base64_encode($blob), true); + $code = " {$payload}];\n"; + + $file = $this->fileFor($item->getKey()); + $tmp = tempnam($this->dataDirectory, 'pc_'); + if ($tmp === false) { + return false; + } + + if (file_put_contents($tmp, $code, LOCK_EX) === false) { + if (is_file($tmp)) { + unlink($tmp); + } + + return false; + } + + if (!rename($tmp, $file)) { + if (is_file($tmp)) { + unlink($tmp); + } + + return false; + } + + $this->invalidateOpcache($file); + + return true; + } } diff --git a/src/Cache/Adapter/RedisCacheAdapter.php b/src/Cache/Adapter/RedisCacheAdapter.php index 8f02660..73faa67 100644 --- a/src/Cache/Adapter/RedisCacheAdapter.php +++ b/src/Cache/Adapter/RedisCacheAdapter.php @@ -4,7 +4,7 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\RedisCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Infocyph\CacheLayer\Support\RedisConnection; use InvalidArgumentException; @@ -69,7 +69,7 @@ public function count(): int { $iter = null; $count = 0; - while ($keys = $this->redis->scan($iter, $this->ns . ':*', 1000)) { + while ($keys = $this->redis->scan($iter, $this->ns . ':d:*', 1000)) { $count += count($keys); } @@ -101,24 +101,37 @@ public function getClient(): \Redis return $this->redis; } - public function getItem(string $key): RedisCacheItem + public function getItem(string $key): CacheItem { $raw = $this->redis->get($this->map($key)); if (is_string($raw)) { - $record = CachePayloadCodec::decode($raw); - if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { - return new RedisCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($record['expires']), - ); + $record = $this->decodeRecordFromBlob($raw); + if ($record !== null) { + return $this->genericItemFromRecord($key, $record); } $this->redis->del($this->map($key)); } - return new RedisCacheItem($this, $key); + return new CacheItem($this, $key); + } + + /** @param list $tags */ + #[\Override] + public function getTagVersions(array $tags): array + { + if ($tags === []) { + return []; + } + + $values = $this->redis->mget(array_map($this->mapTag(...), $tags)); + $values = is_array($values) ? array_values($values) : []; + $versions = []; + foreach ($tags as $index => $tag) { + $value = $values[$index] ?? null; + $versions[$tag] = is_numeric($value) ? max(0, (int) $value) : 0; + } + + return $versions; } public function hasItem(string $key): bool @@ -126,10 +139,29 @@ 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { @@ -150,26 +182,20 @@ public function multiFetch(array $keys): array $v = $rawVals[$idx] ?? null; if ($v !== null && $v !== false) { if (!is_string($v)) { - $items[$k] = new RedisCacheItem($this, $k); + $items[$k] = new CacheItem($this, $k); continue; } - $record = CachePayloadCodec::decode($v); - if ($record !== null && !CachePayloadCodec::isExpired($record['expires'])) { - $items[$k] = new RedisCacheItem( - $this, - $k, - $record['value'], - true, - CachePayloadCodec::toDateTime($record['expires']), - ); + $record = $this->decodeRecordFromBlob($v); + if ($record !== null) { + $items[$k] = $this->genericItemFromRecord($k, $record); continue; } $stale[] = $this->map($k); } - $items[$k] = new RedisCacheItem($this, $k); + $items[$k] = new CacheItem($this, $k); } if ($stale !== []) { @@ -182,27 +208,63 @@ public function multiFetch(array $keys): array public function save(CacheItemInterface $item): bool { if (!$this->supportsItem($item)) { - throw new CacheInvalidArgumentException('RedisCacheAdapter expects RedisCacheItem'); + throw new CacheInvalidArgumentException('The cache item belongs to another pool.'); } $expires = CachePayloadCodec::expirationFromItem($item); $ttl = $expires['ttl']; - if ($ttl === 0) { + if ($ttl !== null && $ttl <= 0) { $this->redis->del($this->map($item->getKey())); return true; } - $blob = CachePayloadCodec::encode($item->get(), $expires['expiresAt']); + $blob = $this->encodeItem($item, $expires['expiresAt']); return $ttl === null ? $this->redis->set($this->map($item->getKey()), $blob) : $this->redis->setex($this->map($item->getKey()), max(1, $ttl), $blob); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool { - return $item instanceof RedisCacheItem; + if (!$this->supportsItems($items)) { + return false; + } + + $plain = []; + $expiring = []; + $expired = []; + foreach ($items as $item) { + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $this->map($item->getKey()); + + continue; + } + $blob = $this->encodeItem($item, $expiration['expiresAt']); + if ($expiration['ttl'] === null) { + $plain[$this->map($item->getKey())] = $blob; + } else { + $expiring[] = [$this->map($item->getKey()), max(1, $expiration['ttl']), $blob]; + } + } + + if ($expired !== []) { + $this->redis->del($expired); + } + + $ok = $plain === [] || $this->redis->mset($plain); + 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; } private function connect(string $dsn): \Redis @@ -216,6 +278,11 @@ private function connect(string $dsn): \Redis private function map(string $key): string { - return $this->ns . ':' . $key; + return $this->ns . ':d:' . $key; + } + + private function mapTag(string $tag): string + { + return $this->ns . ':m:tag:' . $tag; } } diff --git a/src/Cache/Adapter/RedisClusterCacheAdapter.php b/src/Cache/Adapter/RedisClusterCacheAdapter.php index 8b79d4a..dcfa838 100644 --- a/src/Cache/Adapter/RedisClusterCacheAdapter.php +++ b/src/Cache/Adapter/RedisClusterCacheAdapter.php @@ -4,25 +4,19 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; final class RedisClusterCacheAdapter extends AbstractCacheAdapter { + private const int BUCKET_COUNT = 128; + private readonly object $cluster; - private readonly string $ns; + private readonly string $namespace; - /** - * @param string $namespace The namespace argument. - * @param array $seeds The seeds argument. - * @param float $timeout The timeout argument. - * @param float $readTimeout The read timeout argument. - * @param bool $persistent The persistent argument. - * @param object|null $client The client argument. - * @phpstan-param array $seeds - */ + /** @param list $seeds */ public function __construct( string $namespace = 'default', array $seeds = ['127.0.0.1:6379'], @@ -35,60 +29,41 @@ public function __construct( if (!class_exists(\RedisCluster::class)) { throw new RuntimeException('phpredis RedisCluster support is not loaded'); } - - $client = new \RedisCluster( - null, - $seeds, - $timeout, - $readTimeout, - $persistent, - ); + $client = new \RedisCluster(null, $seeds, $timeout, $readTimeout, $persistent); } - - $this->ns = sanitize_cache_ns($namespace); - $this->assertClientShape($client); + foreach (['del', 'exists', 'get', 'incr', 'mget', 'mset', '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->cluster = $client; } public function clear(): bool { - $keys = $this->call('sMembers', $this->indexKey()); - if (is_array($keys) && $keys !== []) { - foreach ($keys as $key) { - if (is_string($key)) { - $this->call('del', $key); - } + for ($bucket = 0; $bucket < self::BUCKET_COUNT; $bucket++) { + if ($this->call('incr', $this->epochKey($bucket)) === false) { + return false; } } - $this->call('del', $this->indexKey()); $this->deferred = []; return true; } - public function count(): int - { - $count = $this->call('sCard', $this->indexKey()); - - return is_int($count) ? max(0, $count) : 0; - } - public function deleteItem(string $key): bool { - $mapped = $this->map($key); - $this->call('sRem', $this->indexKey(), $mapped); - - return $this->call('del', $mapped) !== false; + return $this->call('del', $this->mapData($key)) !== false; } - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - */ + /** @param list $keys */ public function deleteItems(array $keys): bool { - foreach ($keys as $key) { - $this->deleteItem($key); + foreach ($this->groupByBucket($keys) as $group) { + if ($this->call('del', array_map($this->mapData(...), $group)) === false) { + return false; + } } return true; @@ -99,77 +74,234 @@ public function getClient(): object return $this->cluster; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { - $mapped = $this->map($key); - $raw = $this->call('get', $mapped); + $bucket = $this->bucket($key); + $values = $this->call('mget', [$this->epochKey($bucket), $this->mapData($key)]); + $values = is_array($values) ? array_values($values) : []; + $epoch = $this->normalizeVersion($values[0] ?? null); + $blob = $values[1] ?? null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + if ($record !== null && ($record->namespaceEpoch ?? 0) === $epoch) { + return $this->genericItemFromRecord($key, $record); + } + if (is_string($blob)) { + $this->call('del', $this->mapData($key)); + } - return $this->genericFromBlob($key, is_string($raw) ? $raw : null); + return $this->genericMiss($key); + } + + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(array $tags): array + { + $versions = []; + foreach ($this->groupByBucket($tags) as $group) { + $values = $this->call('mget', array_map($this->mapTag(...), $group)); + $values = is_array($values) ? array_values($values) : []; + foreach ($group as $index => $tag) { + $versions[$tag] = $this->normalizeVersion($values[$index] ?? null); + } + } + + return $versions; } public function hasItem(string $key): bool { - $exists = $this->call('exists', $this->map($key)); + return $this->getItem($key)->isHit(); + } - return is_int($exists) && $exists > 0; + /** @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 array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array + * @param list $keys + * @return array */ public function multiFetch(array $keys): array { - return $this->multiFetchItems($keys, $this->getItem(...)); + $items = []; + $stale = []; + foreach ($this->groupByBucket($keys) as $bucket => $group) { + $bucketResult = $this->fetchBucket($bucket, $group); + $items += $bucketResult['items']; + $stale = [...$stale, ...$bucketResult['stale']]; + } + $this->deleteItems($stale); + + $ordered = []; + foreach ($keys as $key) { + $ordered[$key] = $items[$key] ?? $this->genericMiss($key); + } + + return $ordered; } public function save(CacheItemInterface $item): bool { - return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { - $mapped = $this->map($saveItem->getKey()); - $blob = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + 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); - $ok = $expires['ttl'] === null - ? $this->call('set', $mapped, $blob) - : $this->call('setex', $mapped, max(1, $expires['ttl']), $blob); + return (bool) ($expiration['ttl'] === null + ? $this->call('set', $this->mapData($item->getKey()), $blob) + : $this->call('setex', $this->mapData($item->getKey()), max(1, $expiration['ttl']), $blob)); + } - if ($ok) { - $this->call('sAdd', $this->indexKey(), $mapped); + /** @param array $items */ + public function saveItems(array $items): bool + { + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; } + } + foreach ($this->groupItemsByBucket($items) as $bucket => $group) { + if (!$this->saveBucket($bucket, $group)) { + return false; + } + } - return (bool) $ok; - }); + return true; } - protected function supportsItem(CacheItemInterface $item): bool + private function bucket(string $key): int { - return $item instanceof GenericCacheItem; + return hexdec(substr(hash('xxh3', $key), 0, 8)) % self::BUCKET_COUNT; } - private function assertClientShape(object $client): void + private function call(string $method, mixed ...$arguments): mixed { - foreach (['sMembers', 'del', 'sCard', 'get', 'exists', 'set', 'setex', 'sAdd', 'sRem'] as $method) { - if (!method_exists($client, $method)) { - throw new RuntimeException( - sprintf('RedisClusterCacheAdapter client must expose `%s()`.', $method), - ); + return $this->cluster->{$method}(...$arguments); + } + + private function epochKey(int $bucket): string + { + return $this->prefix($bucket) . ':m:epoch'; + } + + /** + * @param list $keys + * @return array{items: array, stale: list} + */ + private function fetchBucket(int $bucket, array $keys): array + { + $physical = [$this->epochKey($bucket), ...array_map($this->mapData(...), $keys)]; + $values = $this->call('mget', $physical); + $values = is_array($values) ? array_values($values) : []; + $epoch = $this->normalizeVersion($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) { + $items[$key] = $this->genericItemFromRecord($key, $record); + + continue; + } + $items[$key] = $this->genericMiss($key); + if (is_string($blob)) { + $stale[] = $key; } } + + return ['items' => $items, 'stale' => $stale]; } - private function call(string $method, mixed ...$arguments): mixed + /** + * @param list $keys + * @return array> + */ + private function groupByBucket(array $keys): array { - return $this->cluster->{$method}(...$arguments); + $groups = []; + foreach ($keys as $key) { + $groups[$this->bucket($key)][] = $key; + } + + return $groups; } - private function indexKey(): string + /** + * @param array $items + * @return array> + */ + private function groupItemsByBucket(array $items): array + { + $groups = []; + foreach ($items as $item) { + $groups[$this->bucket($item->getKey())][] = $item; + } + + return $groups; + } + + private function mapData(string $key): string + { + return $this->prefix($this->bucket($key)) . ':d:' . $key; + } + + private function mapTag(string $tag): string + { + return $this->prefix($this->bucket($tag)) . ':m:tag:' . $tag; + } + + private function normalizeVersion(mixed $value): int + { + return is_numeric($value) ? max(0, (int) $value) : 0; + } + + private function prefix(int $bucket): string { - return $this->ns . ':__keys'; + return $this->namespace . ':{' . $this->namespace . '-' . $bucket . '}'; } - private function map(string $key): string + /** @param list $items */ + private function saveBucket(int $bucket, array $items): bool { - return $this->ns . ':' . $key; + $epoch = $this->normalizeVersion($this->call('get', $this->epochKey($bucket))); + $plain = []; + foreach ($items as $item) { + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $this->call('del', $this->mapData($item->getKey())); + + continue; + } + $blob = $this->encodeItem($item, $expiration['expiresAt'], $epoch); + if ($expiration['ttl'] === null) { + $plain[$this->mapData($item->getKey())] = $blob; + + continue; + } + if (!$this->call('setex', $this->mapData($item->getKey()), $expiration['ttl'], $blob)) { + return false; + } + } + + return $plain === [] || (bool) $this->call('mset', $plain); } } diff --git a/src/Cache/Adapter/ScyllaDbCacheAdapter.php b/src/Cache/Adapter/ScyllaDbCacheAdapter.php index 5a8a2a0..e85714c 100644 --- a/src/Cache/Adapter/ScyllaDbCacheAdapter.php +++ b/src/Cache/Adapter/ScyllaDbCacheAdapter.php @@ -6,7 +6,7 @@ use Cassandra\ExecutionOptions; use Cassandra\SimpleStatement; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; use Throwable; @@ -14,6 +14,10 @@ final class ScyllaDbCacheAdapter extends AbstractCacheAdapter { + private const int WRITE_BATCH_SIZE = 50; + + private readonly string $metadataTable; + private readonly string $ns; private readonly string $qualifiedTable; @@ -26,6 +30,7 @@ public function __construct( string $keyspace = 'cachelayer', string $table = 'cachelayer_entries', string $namespace = 'default', + private readonly int $bucketCount = 128, ) { if (!$this->supportsSessionMethod('execute')) { throw new RuntimeException('ScyllaDbCacheAdapter requires session method `execute()`.'); @@ -35,16 +40,26 @@ public function __construct( $resolvedTable = self::validateIdentifier($table, 'table'); $resolvedKeyspace = self::validateIdentifier($keyspace, 'keyspace'); $this->qualifiedTable = $resolvedKeyspace . '.' . $resolvedTable; + $this->metadataTable = $this->qualifiedTable . '_metadata'; + if ($bucketCount < 1 || $bucketCount > 1024) { + throw new RuntimeException('ScyllaDB bucket count must be between 1 and 1024.'); + } $this->createSchemaIfMissing(); } public function clear(): bool { - $this->executeCql( - "DELETE FROM {$this->qualifiedTable} WHERE ns = ?", - [$this->ns], - ); + for ($bucket = 0; $bucket < $this->bucketCount; $bucket++) { + $this->executeCql( + "DELETE FROM {$this->qualifiedTable} WHERE ns = ? AND bucket = ?", + [$this->ns, $bucket], + ); + $this->executeCql( + "DELETE FROM {$this->metadataTable} WHERE ns = ? AND bucket = ?", + [$this->ns, $bucket], + ); + } $this->deferred = []; return true; @@ -52,17 +67,18 @@ public function clear(): bool public function count(): int { - $rows = $this->queryRows( - "SELECT expires FROM {$this->qualifiedTable} WHERE ns = ?", - [$this->ns], - ); $now = time(); $count = 0; - - foreach ($rows as $row) { - $expiresAt = $this->normalizeExpiry($row['expires'] ?? null); - if ($expiresAt === null || $expiresAt > $now) { - $count++; + 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++; + } } } @@ -72,8 +88,8 @@ public function count(): int public function deleteItem(string $key): bool { $this->executeCql( - "DELETE FROM {$this->qualifiedTable} WHERE ns = ? AND ckey = ?", - [$this->ns, $key], + "DELETE FROM {$this->qualifiedTable} WHERE ns = ? AND bucket = ? AND ckey = ?", + [$this->ns, $this->bucket($key), $this->mapData($key)], ); return true; @@ -85,18 +101,22 @@ public function deleteItem(string $key): bool */ public function deleteItems(array $keys): bool { - foreach ($keys as $key) { - $this->deleteItem((string) $key); + foreach ($this->groupByBucket($keys) as $bucket => $group) { + $marks = implode(',', array_fill(0, count($group), '?')); + $this->executeCql( + "DELETE FROM {$this->qualifiedTable} WHERE ns = ? AND bucket = ? AND ckey IN ({$marks})", + [$this->ns, $bucket, ...array_map($this->mapData(...), $group)], + ); } return true; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { $row = $this->firstRow( - "SELECT payload, expires FROM {$this->qualifiedTable} WHERE ns = ? AND ckey = ? LIMIT 1", - [$this->ns, $key], + "SELECT payload, expires FROM {$this->qualifiedTable} WHERE ns = ? AND bucket = ? AND ckey = ? LIMIT 1", + [$this->ns, $this->bucket($key), $this->mapData($key)], ); if ($row === null) { @@ -113,30 +133,94 @@ public function getItem(string $key): GenericCacheItem return $this->genericFromBase64($key, $payload); } + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(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); + } + } + } + + return $versions; + } + 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { - return $this->multiFetchItems($keys, $this->getItem(...)); + $items = []; + foreach ($this->groupByBucket($keys) 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)], + ); + $byKey = []; + foreach ($rows as $row) { + $physical = $this->normalizeString($row['ckey'] ?? null); + if ($physical !== null) { + $byKey[$physical] = $row; + } + } + 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; } public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { $this->executeCql( - "INSERT INTO {$this->qualifiedTable} (ns, ckey, payload, expires) VALUES (?, ?, ?, ?)", + "INSERT INTO {$this->qualifiedTable} (ns, bucket, ckey, payload, expires) VALUES (?, ?, ?, ?, ?)", [ $this->ns, - $saveItem->getKey(), - base64_encode(CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt'])), + $this->bucket($saveItem->getKey()), + $this->mapData($saveItem->getKey()), + base64_encode($this->encodeItem($saveItem, $expires['expiresAt'])), $expires['expiresAt'], ], ); @@ -145,9 +229,33 @@ public function save(CacheItemInterface $item): bool }); } - protected function supportsItem(CacheItemInterface $item): bool + /** @param array $items */ + public function saveItems(array $items): bool { - return $item instanceof GenericCacheItem; + $active = []; + $expired = []; + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $item->getKey(); + + continue; + } + $active[$this->bucket($item->getKey())][] = [$item, $expiration['expiresAt']]; + } + if (!$this->deleteItems($expired)) { + return false; + } + foreach ($active as $bucket => $group) { + foreach (array_chunk($group, self::WRITE_BATCH_SIZE) as $chunk) { + $this->saveBucket($bucket, $chunk); + } + } + + return true; } private static function validateIdentifier(string $value, string $label): string @@ -159,6 +267,11 @@ private static function validateIdentifier(string $value, string $label): string return $value; } + private function bucket(string $key): int + { + return hexdec(substr(hash('xxh3', $key), 0, 8)) % $this->bucketCount; + } + /** * @param string $method The method argument. * @param array $arguments The arguments argument. @@ -181,10 +294,20 @@ private function createSchemaIfMissing(): void $this->executeCql( "CREATE TABLE IF NOT EXISTS {$this->qualifiedTable} ( ns text, + bucket int, ckey text, payload text, expires bigint, - PRIMARY KEY (ns, ckey) + PRIMARY KEY ((ns, bucket), ckey) + )", + ); + $this->executeCql( + "CREATE TABLE IF NOT EXISTS {$this->metadataTable} ( + ns text, + bucket int, + tag text, + version counter, + PRIMARY KEY ((ns, bucket), tag) )", ); } @@ -235,6 +358,25 @@ private function firstRow(string $cql, array $arguments = []): ?array return null; } + /** + * @param list $keys + * @return array> + */ + private function groupByBucket(array $keys): array + { + $groups = []; + foreach ($keys as $key) { + $groups[$this->bucket($key)][] = $key; + } + + return $groups; + } + + private function mapData(string $key): string + { + return 'd:' . $key; + } + private function normalizeExpiry(mixed $value): ?int { if (is_int($value)) { @@ -313,6 +455,26 @@ private function queryRows(string $cql, array $arguments = []): array return []; } + /** @param list $items */ + private function saveBucket(int $bucket, array $items): void + { + $inserts = []; + $arguments = []; + foreach ($items as [$item, $expiresAt]) { + $inserts[] = "INSERT INTO {$this->qualifiedTable} " + . '(ns, bucket, ckey, payload, expires) VALUES (?, ?, ?, ?, ?);'; + array_push( + $arguments, + $this->ns, + $bucket, + $this->mapData($item->getKey()), + base64_encode($this->encodeItem($item, $expiresAt)), + $expiresAt, + ); + } + $this->executeCql('BEGIN UNLOGGED BATCH ' . implode(' ', $inserts) . ' APPLY BATCH', $arguments); + } + private function statementFor(string $cql): mixed { if ($this->supportsSessionMethod('prepare')) { diff --git a/src/Cache/Adapter/SharedMemoryCacheAdapter.php b/src/Cache/Adapter/SharedMemoryCacheAdapter.php index 56e01e3..9f3c194 100644 --- a/src/Cache/Adapter/SharedMemoryCacheAdapter.php +++ b/src/Cache/Adapter/SharedMemoryCacheAdapter.php @@ -4,13 +4,12 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; final class SharedMemoryCacheAdapter extends AbstractCacheAdapter { - use GenericCacheItemPoolBehavior; use SecuresFilesystemDirectories; private const int VAR_ID = 1; @@ -67,8 +66,11 @@ public function count(): int $count = 0; foreach ($store as $key => $blob) { - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + if (!str_starts_with($key, $this->ns . ':d:') || !is_string($blob)) { + continue; + } + $record = $this->decodeRecordFromBlob($blob); + if ($record === null) { unset($store[$key]); $changed = true; @@ -119,11 +121,15 @@ public function deleteItems(array $keys): bool }); } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { $mapped = $this->map($key); $blob = $this->withSharedLock( - fn(): ?string => $this->loadStore()[$mapped] ?? null, + function () use ($mapped): ?string { + $value = $this->loadStore()[$mapped] ?? null; + + return is_string($value) ? $value : null; + }, ); return $this->genericFromBlobWithInvalidator( @@ -133,11 +139,81 @@ public function getItem(string $key): GenericCacheItem ); } + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(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; + } + + return $versions; + }); + } + + 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 { + $store = $this->loadStore(); + $items = []; + $changed = false; + foreach ($keys as $key) { + $mapped = $this->map($key); + $blob = $store[$mapped] ?? null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $items[$key] = $record === null + ? $this->genericMiss($key) + : $this->genericItemFromRecord($key, $record); + if ($blob !== null && $record === null) { + unset($store[$mapped]); + $changed = true; + } + } + if ($changed) { + $this->store($store); + } + + return $items; + }); + } + public function save(CacheItemInterface $item): bool { return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { $mapped = $this->map($saveItem->getKey()); - $blob = CachePayloadCodec::encode($saveItem->get(), $expires['expiresAt']); + $blob = $this->encodeItem($saveItem, $expires['expiresAt']); return $this->withExclusiveLock(function () use ($mapped, $blob): bool { $store = $this->loadStore(); @@ -148,6 +224,37 @@ public function save(CacheItemInterface $item): bool }); } + /** @param array $items */ + public function saveItems(array $items): bool + { + $records = []; + $expired = []; + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $this->map($item->getKey()); + + continue; + } + $records[$this->map($item->getKey())] = $this->encodeItem($item, $expiration['expiresAt']); + } + + return $this->withExclusiveLock(function () use ($records, $expired): bool { + $store = $this->loadStore(); + foreach ($expired as $key) { + unset($store[$key]); + } + foreach ($records as $key => $blob) { + $store[$key] = $blob; + } + + return $this->store($store); + }); + } + private function attachSegment(int $segmentSize): \SysvSharedMemory { if (!function_exists('ftok')) { @@ -193,7 +300,7 @@ private function createTokenFile(): string } /** - * @phpstan-return array + * @phpstan-return array */ private function loadStore(): array { @@ -209,7 +316,7 @@ private function loadStore(): array $out = []; foreach ($store as $key => $value) { - if (is_string($key) && is_string($value)) { + if (is_string($key) && (is_string($value) || is_int($value))) { $out[$key] = $value; } } @@ -219,7 +326,12 @@ private function loadStore(): array private function map(string $key): string { - return $this->ns . ':' . $key; + return $this->ns . ':d:' . $key; + } + + private function mapTag(string $tag): string + { + return $this->ns . ':m:tag:' . $tag; } /** @phpstan-return resource */ @@ -249,7 +361,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/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index c14cf7b..bc551c9 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -4,15 +4,13 @@ namespace Infocyph\CacheLayer\Cache\Adapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use WeakMap; use WeakReference; final class WeakMapCacheAdapter extends AbstractCacheAdapter { - use GenericCacheItemPoolBehavior; - private readonly string $ns; /** @var array */ @@ -27,6 +25,9 @@ final class WeakMapCacheAdapter extends AbstractCacheAdapter /** @var array> */ private array $weakRefs = []; + /** @var array> */ + private array $weakTags = []; + public function __construct(string $namespace = 'default') { $this->ns = sanitize_cache_ns($namespace); @@ -38,8 +39,10 @@ public function clear(): bool $this->scalarStore = []; $this->weakRefs = []; $this->weakExpires = []; + $this->weakTags = []; $this->weakObjects = new WeakMap(); $this->deferred = []; + $this->resetLocalMetadata(); return true; } @@ -69,7 +72,7 @@ public function count(): int public function deleteItem(string $key): bool { $mapped = $this->map($key); - unset($this->scalarStore[$mapped], $this->weakExpires[$mapped]); + unset($this->scalarStore[$mapped], $this->weakExpires[$mapped], $this->weakTags[$mapped]); $ref = $this->weakRefs[$mapped] ?? null; if ($ref instanceof WeakReference) { @@ -97,7 +100,7 @@ public function deleteItems(array $keys): bool return true; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { $this->pruneCollected(); $mapped = $this->map($key); @@ -108,11 +111,12 @@ public function getItem(string $key): GenericCacheItem $exp = $this->weakExpires[$mapped] ?? null; if (is_object($obj) && !CachePayloadCodec::isExpired($exp)) { - $item = new GenericCacheItem($this, $key); + $item = new CacheItem($this, $key); $item->set($obj); if ($exp !== null) { $item->expiresAt(CachePayloadCodec::toDateTime($exp)); } + $item->setTagVersions($this->weakTags[$mapped] ?? []); return $item; } @@ -121,7 +125,7 @@ public function getItem(string $key): GenericCacheItem } if (!isset($this->scalarStore[$mapped])) { - return new GenericCacheItem($this, $key); + return new CacheItem($this, $key); } return $this->genericFromBlobWithInvalidator( @@ -135,32 +139,109 @@ function () use ($mapped): bool { ); } + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + /** + * @param list $keys + * @return array + */ + public function multiFetch(array $keys): array + { + $this->pruneCollected(); + $items = []; + $staleScalar = []; + foreach ($keys as $key) { + $mapped = $this->map($key); + $reference = $this->weakRefs[$mapped] ?? null; + $object = $reference instanceof WeakReference ? $reference->get() : null; + $expiresAt = $this->weakExpires[$mapped] ?? null; + if (is_object($object) && !CachePayloadCodec::isExpired($expiresAt)) { + $items[$key] = new CacheItem( + $this, + $key, + $object, + true, + CachePayloadCodec::toDateTime($expiresAt), + $this->weakTags[$mapped] ?? [], + ); + + continue; + } + $blob = $this->scalarStore[$mapped] ?? null; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + $items[$key] = $record === null + ? $this->genericMiss($key) + : $this->genericItemFromRecord($key, $record); + if ($blob !== null && $record === null) { + $staleScalar[] = $mapped; + } + } + foreach ($staleScalar as $mapped) { + unset($this->scalarStore[$mapped]); + } + + return $items; + } + public function save(CacheItemInterface $item): bool { - return $this->saveEncoded($item, function (CacheItemInterface $saveItem, array $expires): bool { - $mapped = $this->map($saveItem->getKey()); - $value = $saveItem->get(); - - if (is_object($value)) { - $ref = WeakReference::create($value); - $this->weakRefs[$mapped] = $ref; - $this->weakExpires[$mapped] = $expires['expiresAt']; - $this->weakObjects[$value] = ['key' => $mapped, 'expires' => $expires['expiresAt']]; - unset($this->scalarStore[$mapped]); + return $this->saveEncoded($item, $this->persistItem(...)); + } - return true; + /** @param array $items */ + public function saveItems(array $items): bool + { + if (!$this->supportsItems($items)) { + return false; + } + + foreach ($items as $item) { + $expires = CachePayloadCodec::expirationFromItem($item); + if ($expires['ttl'] !== null && $expires['ttl'] <= 0) { + $this->deleteItem($item->getKey()); + + continue; } - unset($this->weakRefs[$mapped], $this->weakExpires[$mapped]); - $this->scalarStore[$mapped] = CachePayloadCodec::encode($value, $expires['expiresAt']); + if (!$this->persistItem($item, $expires)) { + return false; + } + } - return true; - }); + return true; } private function map(string $key): string { - return $this->ns . ':' . $key; + return $this->ns . ':d:' . $key; + } + + /** @param array{ttl:int|null, expiresAt:int|null} $expires */ + private function persistItem(CacheItemInterface $item, array $expires): bool + { + $mapped = $this->map($item->getKey()); + $value = $item->get(); + + if (is_object($value)) { + $ref = WeakReference::create($value); + $this->weakRefs[$mapped] = $ref; + $this->weakExpires[$mapped] = $expires['expiresAt']; + $this->weakTags[$mapped] = $item instanceof CacheItem + ? $item->getTagVersions() + : []; + $this->weakObjects[$value] = ['key' => $mapped, 'expires' => $expires['expiresAt']]; + unset($this->scalarStore[$mapped]); + + return true; + } + + unset($this->weakRefs[$mapped], $this->weakExpires[$mapped], $this->weakTags[$mapped]); + $this->scalarStore[$mapped] = $this->encodeItem($item, $expires['expiresAt']); + + return true; } private function pruneCollected(): void @@ -168,7 +249,7 @@ private function pruneCollected(): void foreach ($this->weakRefs as $mapped => $ref) { $obj = $ref->get(); if (!is_object($obj) || CachePayloadCodec::isExpired($this->weakExpires[$mapped] ?? null)) { - unset($this->weakRefs[$mapped], $this->weakExpires[$mapped]); + unset($this->weakRefs[$mapped], $this->weakExpires[$mapped], $this->weakTags[$mapped]); } } } @@ -176,8 +257,8 @@ private function pruneCollected(): void private function pruneExpiredScalar(): void { foreach ($this->scalarStore as $mapped => $blob) { - $record = CachePayloadCodec::decode($blob); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $record = $this->decodeRecordFromBlob($blob); + if ($record === null) { unset($this->scalarStore[$mapped]); } } diff --git a/src/Cache/Cache.php b/src/Cache/Cache.php index 57cb72f..930586e 100644 --- a/src/Cache/Cache.php +++ b/src/Cache/Cache.php @@ -4,12 +4,10 @@ namespace Infocyph\CacheLayer\Cache; -use BadMethodCallException; use Closure; -use Countable; -use DateInterval; -use DateTime; -use Infocyph\CacheLayer\Cache\Item\AbstractCacheItem; +use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; +use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Cache\Lock\MemcachedLockProvider; @@ -19,179 +17,66 @@ use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Infocyph\CacheLayer\Cache\Tiering\TieredPoolFactory; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Infocyph\CacheLayer\Serializer\ValueSerializer; use MongoDB\Client; use Psr\Cache\CacheItemInterface; -use Psr\Cache\CacheItemPoolInterface; -use Psr\Cache\InvalidArgumentException as Psr6InvalidArgumentException; -use Psr\SimpleCache\InvalidArgumentException as SimpleCacheInvalidArgument; +use Throwable; final class Cache implements CacheInterface { - use CacheReadRememberTrait { - get as private traitGet; - getItem as private traitGetItem; - getItems as private traitGetItems; - hasItem as private traitHasItem; - remember as private traitRemember; - } - - private const int STAMPEDE_JITTER_PERCENT = 8; - - private const float STAMPEDE_LOCK_LEASE_SECONDS = 30.0; + private const float LOCK_LEASE_SECONDS = 30.0; - private const float STAMPEDE_LOCK_WAIT_SECONDS = 5.0; + private const float LOCK_WAIT_SECONDS = 5.0; - private const string TAG_META_PREFIX = '__im_tagm_'; + private const int TTL_JITTER_PERCENT = 8; - private const string TAG_VERSION_PREFIX = '__im_tagv_'; + private readonly CacheOptions $options; private ?Closure $metricsExportHook = null; - /** - * Cache constructor. - * - * @param CacheItemPoolInterface $adapter Any PSR-6 cache pool. - * @param LockProviderInterface $lockProvider The lock provider argument. - * @param CacheMetricsCollectorInterface $metrics The metrics argument. - */ public function __construct( - private readonly CacheItemPoolInterface $adapter, + private readonly InternalCachePoolInterface $adapter, private LockProviderInterface $lockProvider = new FileLockProvider(), private CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), - ) {} - - /** - * Retrieves a value from the cache using magic property access. - * - * This method allows accessing cached values using property syntax. - * It is equivalent to calling the `get()` method with the property name. - * - * - * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if the key is invalid. - * @param string $name The key for which to retrieve the value. - * @phpstan-return mixed The value associated with the given key. - */ - public function __get(string $name): mixed - { - return $this->get($name); - } - - /** - * Whether the given key is set in the cache. - * - * @throws Psr6InvalidArgumentException - * @param string $name The name argument. - */ - public function __isset(string $name): bool - { - return $this->has($name); - } - - /** - * Sets a value in the cache. - * - * Magic property setter, equivalent to calling `set($name, $value, null)`. - * - * - * @throws SimpleCacheInvalidArgument if the key is invalid - * @param string $name The name argument. - * @param mixed $value The value argument. - */ - public function __set(string $name, mixed $value): void - { - $this->set($name, $value); - } - - /** - * Magic method to unset an item in the cache. - * - * This method deletes the cache entry associated with the given name. - * - * - * @throws SimpleCacheInvalidArgument - * @param string $name The name of the cache item to unset. - */ - public function __unset(string $name): void - { - $this->delete($name); - } - - /** - * Static factory for APCu-based cache. - * - * @param string $namespace Cache prefix. Will be suffixed to each key. - */ - public static function apcu(string $namespace = 'default'): self - { - return new self(new Adapter\ApcuCacheAdapter($namespace)); - } - - /** - * @param array $pools The pools argument. - * @phpstan-param array $pools - */ - public static function chain(array $pools): self - { - return new self(new Adapter\ChainCacheAdapter($pools)); + ?CacheOptions $options = null, + ) { + $this->options = $options ?? new CacheOptions(); + if ($adapter instanceof AbstractCacheAdapter) { + $adapter->configureOptions($this->options); + } } - /** - * Static factory for file-based cache. - * - * @param string $namespace Cache prefix. Will be suffixed to each key. - * @param string|null $dir Directory to store cache files (or null → sys temp dir). - */ - public static function file(string $namespace = 'default', ?string $dir = null): self + public static function apcu(string $namespace = 'default', ?CacheOptions $options = null): self { - return new self(new Adapter\FileCacheAdapter($namespace, $dir)); + return new self(new Adapter\ApcuCacheAdapter($namespace), options: $options); } - /** - * Static factory for local cache selection. - * - * Determines the appropriate caching mechanism based on the availability of the APCu extension. - * If APCu is enabled, it returns an APCu-based cache; otherwise, it defaults to a file-based cache. - * - * @param string $namespace Cache prefix. Will be suffixed to each key. - * @param string|null $dir Directory to store cache files (or null → sys temp dir), used if APCu is not enabled. - * @phpstan-return static An instance of the cache using the selected adapter. - */ - public static function local( + public static function file( string $namespace = 'default', ?string $dir = null, + ?CacheOptions $options = null, ): self { - if (extension_loaded('apcu') && apcu_enabled()) { - return self::apcu($namespace); - } - - return self::file($namespace, $dir); + return new self(new Adapter\FileCacheAdapter($namespace, $dir), options: $options); } - /** - * Static factory for Memcached-based cache. - * - * The `weight` is a float between 0 and 1, and defaults to 0. - * @param string $namespace Cache prefix. Will be suffixed to each key. - * @param array $servers Memcached servers as an array of `[host, port, weight]`. - * @phpstan-param array $servers Memcached servers as an array of `[host, port, weight]`. - * @param \Memcached|null $client Optional preconfigured Memcached instance. - */ - public static function memcache( + /** @param list $servers */ + public static function memcached( string $namespace = 'default', array $servers = [['127.0.0.1', 11211, 0]], ?\Memcached $client = null, + ?CacheOptions $options = null, ): self { - $adapter = new Adapter\MemCacheAdapter($namespace, $servers, $client); + $adapter = new Adapter\MemcachedCacheAdapter($namespace, $servers, $client); - return (new self($adapter))->setLockProvider( + return new self( + $adapter, new MemcachedLockProvider($adapter->getClient()), + options: $options, ); } - public static function memory(string $namespace = 'default'): self + public static function memory(string $namespace = 'default', ?CacheOptions $options = null): self { - return new self(new Adapter\ArrayCacheAdapter($namespace)); + return new self(new Adapter\ArrayCacheAdapter($namespace), options: $options); } public static function mongodb( @@ -201,34 +86,29 @@ public static function mongodb( string $database = 'cachelayer', string $collectionName = 'entries', string $uri = 'mongodb://127.0.0.1:27017', + ?CacheOptions $options = null, ): self { - if ($collection === null) { - if ($client === null) { - if (!class_exists(Client::class)) { - throw new CacheInvalidArgumentException( - 'mongodb/mongodb is required unless a collection/client is provided.', - ); - } - - $client = new Client($uri); + if ($collection !== null) { + return new self(new Adapter\MongoDbCacheAdapter($collection, $namespace), options: $options); + } + if ($client === null) { + if (!class_exists(Client::class)) { + throw new CacheInvalidArgumentException( + 'mongodb/mongodb is required unless a collection/client is provided.', + ); } - - $adapter = Adapter\MongoDbCacheAdapter::fromClient( - $client, - $database, - $collectionName, - $namespace, - ); - - return new self($adapter); + $client = new Client($uri); } - return new self(new Adapter\MongoDbCacheAdapter($collection, $namespace)); + return new self( + Adapter\MongoDbCacheAdapter::fromClient($client, $database, $collectionName, $namespace), + options: $options, + ); } - public static function nullStore(): self + public static function nullStore(?CacheOptions $options = null): self { - return new self(new Adapter\NullCacheAdapter()); + return new self(new Adapter\NullCacheAdapter(), options: $options); } public static function pdo( @@ -238,53 +118,33 @@ public static function pdo( ?string $password = null, ?\PDO $pdo = null, string $table = 'cachelayer_entries', + ?CacheOptions $options = null, ): self { $adapter = new Adapter\PdoCacheAdapter($namespace, $dsn, $username, $password, $pdo, $table); - $lockProvider = new FileLockProvider(); - $pdoLockProviderClass = PdoLockProvider::class; - if (class_exists($pdoLockProviderClass)) { - $lockProvider = new $pdoLockProviderClass($adapter->getClient()); - } - return (new self($adapter))->setLockProvider( - $lockProvider, - ); + return new self($adapter, new PdoLockProvider($adapter->getClient()), options: $options); } - public static function phpFiles(string $namespace = 'default', ?string $dir = null): self - { - return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir)); + public static function phpFiles( + string $namespace = 'default', + ?string $dir = null, + ?CacheOptions $options = null, + ): self { + return new self(new Adapter\PhpFilesCacheAdapter($namespace, $dir), options: $options); } - /** - * Static factory for Redis cache. - * - * or null to use the default ('redis://127.0.0.1:6379'). - * @param string $namespace Cache prefix. - * @param string $dsn DSN for Redis connection (e.g. 'redis://127.0.0.1:6379'), - * @param \Redis|null $client Optional preconfigured Redis instance. - */ public static function redis( string $namespace = 'default', string $dsn = 'redis://127.0.0.1:6379', ?\Redis $client = null, + ?CacheOptions $options = null, ): self { $adapter = new Adapter\RedisCacheAdapter($namespace, $dsn, $client); - return (new self($adapter))->setLockProvider( - new RedisLockProvider($adapter->getClient()), - ); + return new self($adapter, new RedisLockProvider($adapter->getClient()), options: $options); } - /** - * @param string $namespace The namespace argument. - * @param array $seeds The seeds argument. - * @param float $timeout The timeout argument. - * @param float $readTimeout The read timeout argument. - * @param bool $persistent The persistent argument. - * @param object|null $client The client argument. - * @phpstan-param array $seeds - */ + /** @param list $seeds */ public static function redisCluster( string $namespace = 'default', array $seeds = ['127.0.0.1:6379'], @@ -292,6 +152,7 @@ public static function redisCluster( float $readTimeout = 1.0, bool $persistent = false, ?object $client = null, + ?CacheOptions $options = null, ): self { return new self( new Adapter\RedisClusterCacheAdapter( @@ -302,14 +163,17 @@ public static function redisCluster( $persistent, $client, ), + options: $options, ); } - public static function scyllaDb( + public static function scylla( string $namespace = 'default', ?object $session = null, string $keyspace = 'cachelayer', string $table = 'cachelayer_entries', + int $bucketCount = 128, + ?CacheOptions $options = null, ): self { if ($session === null) { if (!class_exists(\Cassandra::class)) { @@ -317,242 +181,103 @@ public static function scyllaDb( 'ext-cassandra is required unless a ScyllaDB/Cassandra session is provided.', ); } - - /** @var object $session */ $session = \Cassandra::cluster()->build()->connect($keyspace); } - return new self(new Adapter\ScyllaDbCacheAdapter($session, $keyspace, $table, $namespace)); + return new self( + new Adapter\ScyllaDbCacheAdapter($session, $keyspace, $table, $namespace, $bucketCount), + options: $options, + ); } - public static function sharedMemory(string $namespace = 'default', int $segmentSize = 16_777_216): self - { - return new self(new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize)); + public static function sharedMemory( + string $namespace = 'default', + int $segmentSize = 16_777_216, + ?CacheOptions $options = null, + ): self { + return new self(new Adapter\SharedMemoryCacheAdapter($namespace, $segmentSize), options: $options); } - /** - * Static factory for SQLite-based cache. - * - * @param string $namespace Cache prefix. Will be suffixed to each key. - * @param string|null $file Path to SQLite file (or null → cachelayer temp subdirectory). - */ - public static function sqlite(string $namespace = 'default', ?string $file = null): self - { - $dbPath = $file ?? Adapter\PdoCacheAdapter::defaultSqliteFileForNamespace($namespace); + public static function sqlite( + string $namespace = 'default', + ?string $file = null, + ?CacheOptions $options = null, + ): self { + $path = $file ?? Adapter\PdoCacheAdapter::defaultSqliteFileForNamespace($namespace); - return self::pdo( - namespace: $namespace, - dsn: 'sqlite:' . $dbPath, - ); + return self::pdo(namespace: $namespace, dsn: 'sqlite:' . $path, options: $options); } - /** - * Builds a tiered cache from pool instances and/or descriptor arrays. - * - * @param array $tiers The tiers argument. - * @param bool $writeToL1 The write to l1 argument. - * @phpstan-param array> $tiers - */ - public static function tiered(array $tiers, bool $writeToL1 = true): self - { - $pools = TieredPoolFactory::fromArray($tiers); + /** @param list> $tiers */ + public static function tiered( + array $tiers, + bool $writeToL1 = true, + ?CacheOptions $options = null, + ): self { + $metrics = new InMemoryCacheMetricsCollector(); - return new self(new Adapter\ChainCacheAdapter($pools, $writeToL1)); + return new self( + new Adapter\ChainCacheAdapter(TieredPoolFactory::fromArray($tiers), $writeToL1, $metrics), + metrics: $metrics, + options: $options, + ); } - /** - * Static factory for Valkey cache. - * - * @param string $namespace Cache prefix. - * @param string $dsn DSN for Valkey connection (e.g. 'valkey://127.0.0.1:6379'). - * @param \Redis|null $client Optional preconfigured Redis-compatible instance. - */ public static function valkey( string $namespace = 'default', string $dsn = 'valkey://127.0.0.1:6379', ?\Redis $client = null, + ?CacheOptions $options = null, ): self { $adapter = new Adapter\ValkeyCacheAdapter($namespace, $dsn, $client); - return (new self($adapter))->setLockProvider( - new RedisLockProvider($adapter->getClient()), - ); + return new self($adapter, new RedisLockProvider($adapter->getClient()), options: $options); } - public static function weakMap(string $namespace = 'default'): self + public static function weakMap(string $namespace = 'default', ?CacheOptions $options = null): self { - return new self(new Adapter\WeakMapCacheAdapter($namespace)); + return new self(new Adapter\WeakMapCacheAdapter($namespace), options: $options); } - /** - * Removes all items from the cache. - * - * True if the operation was successful, false otherwise. - */ public function clear(): bool { - return $this->adapter->clear(); - } - - /** - * Wipes out the entire cache. - */ - public function clearCache(): bool - { - return $this->clear(); + return $this->backendBool(fn(): bool => $this->adapter->clear()); } - /** - * Commits any deferred cache items. - * - * If the underlying adapter supports deferred cache items, this - * method will persist all items that have been added to the deferred - * queue. If the adapter does not support deferred cache items, this - * method is a no-op. - * - * @phpstan-return bool True if all deferred items were successfully saved, false otherwise. - */ public function commit(): bool { - return $this->adapter->commit(); - } - - public function configurePayloadCompression(?int $thresholdBytes = null, int $level = 6): self - { - Adapter\CachePayloadCodec::configureCompression($thresholdBytes, $level); - - return $this; - } - - public function configurePayloadSecurity(?string $integrityKey = null, ?int $maxPayloadBytes = 8_388_608): self - { - Adapter\CachePayloadCodec::configureSecurity($integrityKey, $maxPayloadBytes); - - return $this; - } - - public function configureSerializationSecurity( - bool $allowClosurePayloads = true, - bool $allowObjectPayloads = true, - ): self { - ValueSerializer::configureSecurity( - allowClosurePayloads: $allowClosurePayloads, - allowObjectPayloads: $allowObjectPayloads, - ); - - return $this; - } - - /** - * Returns the number of items in the cache. - * - * If the adapter implements the {@see Countable} interface, it will be - * used to retrieve the count. Otherwise, this method will use the - * {@see iterable} interface to count the items. - * - * @throws Psr6InvalidArgumentException - */ - public function count(): int - { - return $this->adapter instanceof Countable - ? count($this->adapter) - : iterator_count($this->adapter->getItems([])); + return $this->backendBool(fn(): bool => $this->adapter->commit()); } - /** - * Delete an item from the cache. - * - * @throws SimpleCacheInvalidArgument if the key is invalid - * @param string $key The key argument. - */ public function delete(string $key): bool { - $this->validateKey($key); - - try { - $deleted = $this->adapter->deleteItem($key); - } catch (Psr6InvalidArgumentException $e) { - throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); - } - - $this->clearTagMeta($key); + CacheInput::key($key); + $deleted = $this->backendBool(fn(): bool => $this->adapter->deleteItem($key)); $this->metric('delete'); return $deleted; } - /** - * Deletes a single item from the cache. - * - * This method deletes the item from the cache if it exists. If the item does - * not exist, it is silently ignored. - * - * The key of the item to delete. - * True if the item was successfully deleted, false otherwise. - * - * @throws Psr6InvalidArgumentException - * @param string $key The key argument. - */ public function deleteItem(string $key): bool { - $this->validateKey($key); - $deleted = $this->adapter->deleteItem($key); - $this->clearTagMeta($key); - $this->metric('delete'); - - return $deleted; + return $this->delete($key); } - /** - * Deletes multiple items from the cache. - * - * - * @throws Psr6InvalidArgumentException - * @param array $keys The array of keys to delete. - * @phpstan-param string[] $keys The array of keys to delete. - * @phpstan-return bool True if all items were successfully deleted, false otherwise. - */ public function deleteItems(array $keys): bool { - foreach ($keys as $k) { - $this->validateKey((string) $k); - } - $deleted = $this->adapter->deleteItems($keys); - foreach ($keys as $key) { - $this->clearTagMeta((string) $key); - } + $keys = CacheInput::keys($keys); + $deleted = $this->backendBool(fn(): bool => $this->adapter->deleteItems($keys)); $this->metric('delete_batch'); + $this->metric('delete_batch_keys', count($keys)); return $deleted; } - /** - * Deletes multiple keys from the cache. - * - * - * @throws SimpleCacheInvalidArgument if any key is invalid - * @param iterable $keys The keys argument. - * @phpstan-param iterable $keys - */ public function deleteMultiple(iterable $keys): bool { - $allSucceeded = true; - foreach ($keys as $k) { - /** @var string $k */ - $this->validateKey($k); - if (!$this->deleteItem($k)) { - $allSucceeded = false; - } - } - - return $allSucceeded; + return $this->deleteItems(CacheInput::materializeKeys($keys)); } - /** - * Returns metrics grouped by readable adapter name. - * - * @phpstan-return array> - */ public function exportMetrics(): array { $snapshot = $this->readableMetricsSnapshot($this->metrics->export()); @@ -563,311 +288,202 @@ public function exportMetrics(): array return $snapshot; } - /** - * Fetches a value from the cache. If the key does not exist, returns $default. - * - * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if the key is invalid - * @param string $key The key argument. - * @param mixed $default The default argument. - */ public function get(string $key, mixed $default = null): mixed { - return $this->traitGet($key, $default); + $this->metric('get'); + $item = $this->getItem($key); + if (!$item->isHit()) { + $this->metric('get_miss'); + + return $default; + } + $this->metric('get_hit'); + + return $item->get(); } - /** - * Retrieves a Cache Item representing the specified key. - * - * This method returns a CacheItemInterface object containing the cached value. - * - * The key of the item to retrieve. - * The retrieved Cache Item. - * - * @throws CacheInvalidArgumentException - * If the $key is invalid or if a CacheLoader is not available when - * the value is not found. - * @param string $key The key argument. - */ public function getItem(string $key): CacheItemInterface { - return $this->traitGetItem($key); - } + CacheInput::key($key); + $item = $this->backend( + fn(): CacheItemInterface => $this->adapter->getItem($key), + $this->miss($key), + ); - /** - * Returns an iterable of {@see CacheItemInterface} objects for the given - * keys. - * - * If no keys are provided, an empty iterator is returned. - * - * If the adapter supports it, the method will use the adapter's - * `multiFetch` method. Otherwise, it iterates over the keys and calls - * `getItem` on each key. - * - * An array of keys to fetch from the cache. - * An iterable of CacheItemInterface objects. - * @param array $keys The keys argument. - * @phpstan-param string[] $keys - * @phpstan-return iterable - */ - public function getItems(array $keys = []): iterable - { - return $this->traitGetItems($keys); + return $this->validateTagSnapshot($item); } - /** - * Returns an iterable of {@see CacheItemInterface} objects for the given - * keys. - * - * If no keys are provided, an empty iterator is returned. - * - * This method is a wrapper for `getItems()`, and is intended for use with - * iterators. - * - * An array of keys to fetch from the cache. - * An iterable of CacheItemInterface objects. - * @param array $keys The keys argument. - * @phpstan-param string[] $keys - * @phpstan-return iterable - */ - public function getItemsIterator(array $keys = []): iterable + /** @return array */ + public function getItems(array $keys = []): array { - return $this->getItems($keys); + $keys = CacheInput::keys($keys); + if ($keys === []) { + return []; + } + + $fetched = $this->backend(fn(): array => $this->fetchItems($keys), []); + $items = []; + foreach ($keys as $key) { + $item = $fetched[$key] ?? null; + $items[$key] = $item instanceof CacheItemInterface ? $item : $this->miss($key); + } + $items = $this->validateTagSnapshots($items); + $hits = 0; + foreach ($items as $item) { + $hits += $item->isHit() ? 1 : 0; + } + $this->metric('get_batch'); + $this->metric('get_batch_keys', count($keys)); + $this->metric('get_batch_hits', $hits); + $this->metric('get_batch_misses', count($keys) - $hits); + + return $items; } - /** - * Obtains multiple values by their keys. - * - * - * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if any key is invalid - * @param iterable $keys The keys argument. - * @param mixed $default The default argument. - * @phpstan-param iterable $keys - * @phpstan-return iterable - */ - public function getMultiple(iterable $keys, mixed $default = null): iterable + /** @return array */ + public function getMultiple(iterable $keys, mixed $default = null): array { - $result = []; - foreach ($keys as $k) { - /** @var string $k */ - $this->validateKey($k); - $result[$k] = $this->get($k, $default); + $keys = CacheInput::materializeKeys($keys); + $items = $this->getItems($keys); + $values = []; + foreach ($keys as $key) { + $item = $items[$key]; + $values[$key] = $item->isHit() ? $item->get() : $default; } - return $result; + return $values; } - /** - * Determines whether an item exists in the cache. - * - * @throws Psr6InvalidArgumentException if the key is invalid - * @param string $key The key argument. - */ public function has(string $key): bool { return $this->hasItem($key); } - /** - * Checks if an item is present in the cache. - * - * The key to check. - * True if the item exists in the cache, false otherwise. - * - * @throws Psr6InvalidArgumentException - * @param string $key The key argument. - */ public function hasItem(string $key): bool { - return $this->traitHasItem($key); + return $this->getItem($key)->isHit(); } - /** - * Invalidates all cache entries associated with a specific tag. - * - * This method removes all cache items that have been tagged with the given tag. - * It uses an internal tag index to efficiently locate and invalidate tagged entries. - * - * - * @throws CacheInvalidArgumentException If the tag is invalid. - * @throws Psr6InvalidArgumentException If there's an issue with cache operations. - * @param string $tag The tag to invalidate. All cache entries with this tag will be removed. - * @phpstan-return bool True if the operation was successful, false otherwise. - */ public function invalidateTag(string $tag): bool { - $normalized = $this->normalizeTag($tag); - $next = $this->currentTagVersion($normalized) + 1; - - return $this->writeTagVersion($normalized, $next); + return $this->invalidateTags([$tag]); } - /** - * Invalidates all cache entries associated with multiple tags. - * - * This method iterates through each tag and invalidates all cache entries - * associated with that tag. The operation is successful only if all tags - * are successfully invalidated. - * - * - * @throws CacheInvalidArgumentException If any tag is invalid. - * @throws Psr6InvalidArgumentException If there's an issue with cache operations. - * @param array $tags An array of tags to invalidate. - * @phpstan-param array $tags An array of tags to invalidate. - * @phpstan-return bool True if all tags were successfully invalidated, false if any failed. - */ public function invalidateTags(array $tags): bool { - $ok = true; - $seen = []; - - foreach ($tags as $tag) { - $normalized = $this->normalizeTag((string) $tag); - if (isset($seen[$normalized])) { - continue; - } - - $seen[$normalized] = true; - $next = $this->currentTagVersion($normalized) + 1; - $ok = $this->writeTagVersion($normalized, $next) && $ok; - } + $tags = CacheInput::tags($tags); + $invalidated = $this->backendBool(fn(): bool => $this->adapter->incrementTagVersions($tags)); + $this->metric(count($tags) === 1 ? 'tag_invalidate' : 'tag_invalidate_batch'); - return $ok; + return $invalidated; } - /** - * Required by interface ArrayAccess. - * - * {@inheritdoc} - * - * - * @throws Psr6InvalidArgumentException - * - * @see has() - * @param mixed $offset The offset argument. - * @phpstan-param string $offset - */ public function offsetExists(mixed $offset): bool { - return $this->has($offset); + return $this->has($this->requireStringOffset($offset)); } - /** - * Retrieves the value for the specified offset from the cache. - * - * This method allows the use of array-like syntax to retrieve a value - * from the cache. The offset is converted to a string before retrieval. - * - * - * @throws SimpleCacheInvalidArgument|Psr6InvalidArgumentException if the key is invalid - * @param mixed $offset The key at which to retrieve the value. - * @phpstan-param string $offset The key at which to retrieve the value. - * @phpstan-return mixed The value at the specified offset. - */ public function offsetGet(mixed $offset): mixed { - return $this->get($offset); + return $this->get($this->requireStringOffset($offset)); } - /** - * Sets a value in the cache at the specified offset. - * - * This method allows the use of array-like syntax to store a value - * in the cache. The offset is converted to a string before storing. - * The time-to-live (TTL) for the cache entry is set to null by default. - * - * - * @throws SimpleCacheInvalidArgument if the key is invalid - * @param mixed $offset The key at which to set the value. - * @phpstan-param string $offset The key at which to set the value. - * @param mixed $value The value to be stored at the specified offset. - */ public function offsetSet(mixed $offset, mixed $value): void { - $this->set($offset, $value); + $this->set($this->requireStringOffset($offset), $value); } - /** - * Unsets a key from the cache. - * - * - * @throws Psr6InvalidArgumentException|SimpleCacheInvalidArgument if the key is invalid - * @param mixed $offset The offset argument. - * @phpstan-param string $offset - */ public function offsetUnset(mixed $offset): void { - $this->delete($offset); + $this->delete($this->requireStringOffset($offset)); } - /** - * Compute-once helper with cache stampede protection. - * - * On cache miss, this acquires a host-local lock, re-checks cache, computes, - * applies jittered TTL, persists, and returns the computed value. - * - * @param string $key The key argument. - * @param callable $resolver The resolver argument. - * @param mixed $ttl The ttl argument. - * @param array $tags The tags argument. - * @phpstan-param array $tags - */ public function remember( string $key, callable $resolver, mixed $ttl = null, array $tags = [], ): mixed { - return $this->traitRemember($key, $resolver, $ttl, $tags); + CacheInput::key($key); + $tags = CacheInput::tags($tags); + $ttl = CacheInput::ttl($ttl); + + $item = $this->getItem($key); + if ($item->isHit()) { + $this->metric('remember_hit'); + + return $item->get(); + } + $this->metric('remember_miss'); + + $lock = $this->backend( + fn() => $this->lockProvider->acquire( + $this->stampedeLockKey($key), + self::LOCK_WAIT_SECONDS, + self::LOCK_LEASE_SECONDS, + ), + null, + ); + if ($lock === null) { + $this->metric('lock_timeout'); + $this->metric('remember_unlocked_compute'); + $value = $resolver($item); + $this->storeResolved($key, $value, $ttl, $tags); + + return $value; + } + $this->metric('lock_acquired'); + + try { + $item = $this->getItem($key); + if ($item->isHit()) { + $this->metric('remember_hit'); + + return $item->get(); + } + + $value = $resolver($item); + if (!$this->backend( + fn(): bool => $this->lockProvider->refresh($lock, self::LOCK_LEASE_SECONDS), + false, + )) { + $this->metric('lock_refresh_failure'); + } + $this->storeResolved($key, $value, $ttl, $tags); + + return $value; + } finally { + $this->backend(function () use ($lock): bool { + $this->lockProvider->release($lock); + + return true; + }, false); + } } - /** - * Persists a cache item immediately. - * - * This method will throw a Psr6InvalidArgumentException if the item does not - * implement CacheItemInterface. - * - * The cache item to persist. - * True if the cache item was successfully persisted, false otherwise. - * - * @throws Psr6InvalidArgumentException - * If the item does not implement CacheItemInterface. - * @param CacheItemInterface $item The item argument. - */ public function save(CacheItemInterface $item): bool { - return $this->adapter->save($item); + return $this->backendBool(fn(): bool => $this->adapter->save($item)); } - /** - * Adds a cache item to the deferred queue for later persistence. - * - * This method queues the given cache item, to be saved when the - * `commit()` method is invoked. It does not persist the item immediately. - * - * @param CacheItemInterface $item The cache item to defer. - * @phpstan-return bool True if the item was successfully deferred, false if the item type is invalid. - */ public function saveDeferred(CacheItemInterface $item): bool { - return $this->adapter->saveDeferred($item); + return $this->backendBool(fn(): bool => $this->adapter->saveDeferred($item)); } - /** - * Persists a value in the cache, optionally with a TTL. - * - * - * @throws SimpleCacheInvalidArgument if the key or TTL is invalid - * @param mixed $ttl Time-to-live in seconds or a DateInterval - * @param string $key The key argument. - * @param mixed $value The value argument. - * @phpstan-param int|DateInterval|null $ttl Time-to-live in seconds or a DateInterval - */ public function set(string $key, mixed $value, mixed $ttl = null): bool { - $this->validateKey($key); - $ttlSeconds = $this->normalizeTtl($ttl); + CacheInput::key($key); + $ttlSeconds = CacheInput::ttl($ttl); + if ($ttlSeconds !== null && $ttlSeconds <= 0) { + return $this->delete($key); + } - return $this->store($key, $value, $ttlSeconds); + $item = $this->miss($key)->set($value)->expiresAfter($ttlSeconds); + $saved = $this->save($item); + $this->metric('set'); + + return $saved; } public function setLockProvider(LockProviderInterface $lockProvider): self @@ -886,103 +502,74 @@ public function setMetricsCollector(CacheMetricsCollectorInterface $metrics): se public function setMetricsExportHook(?callable $hook): self { - $this->metricsExportHook = $hook !== null ? Closure::fromCallable($hook) : null; + $this->metricsExportHook = $hook === null ? null : Closure::fromCallable($hook); return $this; } - /** - * Persists multiple key ⇒ value pairs to the cache. - * - * - * @throws SimpleCacheInvalidArgument if any key is invalid - * @param iterable $values key ⇒ value mapping - * @phpstan-param iterable $values key ⇒ value mapping - * @param mixed $ttl TTL for all items - * @phpstan-param int|DateInterval|null $ttl TTL for all items - */ + /** @param iterable $values */ public function setMultiple(iterable $values, mixed $ttl = null): bool { - $ttlSeconds = $this->normalizeTtl($ttl); - $allSucceeded = true; - - foreach ($values as $k => $v) { - /** @var string $k */ - $this->validateKey($k); - $ok = $this->set($k, $v, $ttlSeconds); - if (!$ok) { - $allSucceeded = false; + $normalized = []; + foreach ($values as $key => $value) { + if (!is_string($key)) { + throw new CacheInvalidArgumentException('Cache keys must be strings.'); } + CacheInput::key($key); + $normalized[$key] = $value; + } + $ttlSeconds = CacheInput::ttl($ttl); + if ($ttlSeconds !== null && $ttlSeconds <= 0) { + return $this->deleteItems(array_keys($normalized)); } - return $allSucceeded; - } - - /** - * Changes the namespace and directory for the pool. - * - * If the adapter supports namespace and directory switching, this call is - * forwarded to it. Otherwise, a {@see BadMethodCallException} is thrown. - * - * @throws BadMethodCallException if the adapter does not support this method. - * @param string $namespace The new namespace. - * @param string|null $dir The new directory, or null to use the default. - */ - public function setNamespaceAndDirectory(string $namespace, ?string $dir = null): void - { - if (method_exists($this->adapter, 'setNamespaceAndDirectory')) { - $this->adapter->setNamespaceAndDirectory($namespace, $dir); - - return; + $items = []; + foreach ($normalized as $key => $value) { + $items[$key] = $this->adapter->createItem($key)->set($value)->expiresAfter($ttlSeconds); } + $saved = $this->backendBool(fn(): bool => $this->adapter->saveItems($items)); + $this->metric('set_batch'); + $this->metric('set_batch_keys', count($items)); - throw new BadMethodCallException( - sprintf('%s does not support setNamespaceAndDirectory()', $this->adapter::class), - ); + return $saved; } - /** - * Stores a value and associates it with one or more tags. - * - * This method allows you to tag cache entries for later bulk invalidation. - * Tags provide a way to group related cache items and invalidate them - * together when the underlying data changes. - * - * - * @throws CacheInvalidArgumentException If the key or tags are invalid. - * @throws SimpleCacheInvalidArgument If the key or TTL is invalid. - * @param string $key The cache key under which to store the value. - * @param mixed $value The value to store in the cache. - * @param array $tags An array of tags to associate with this cache entry. - * @phpstan-param array $tags An array of tags to associate with this cache entry. - * @param mixed $ttl Optional time-to-live for the cache entry. - * @phpstan-param int|DateInterval|null $ttl Optional time-to-live for the cache entry. - * @phpstan-return bool True if the operation was successful, false otherwise. - */ public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = null): bool { - $normalizedTags = $this->normalizeTagList($tags); - $this->validateKey($key); - $ttlSeconds = $this->normalizeTtl($ttl); - $ok = $this->store($key, $value, $ttlSeconds); - if (!$ok) { + CacheInput::key($key); + $tags = CacheInput::tags($tags); + $ttlSeconds = CacheInput::ttl($ttl); + if ($ttlSeconds !== null && $ttlSeconds <= 0) { + return $this->delete($key); + } + + $versions = $this->backend( + fn(): array => $this->adapter->getTagVersions($tags), + null, + ); + if (!is_array($versions)) { 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 $this->writeTagMeta($key, $normalizedTags, $ttlSeconds); + return $saved; } public function useMemcachedLock(?\Memcached $client = null, string $prefix = 'cachelayer:lock:'): self { - if (!$client && method_exists($this->adapter, 'getClient')) { - $candidate = $this->adapter->getClient(); - if ($candidate instanceof \Memcached) { - $client = $candidate; - } - } - if (!$client instanceof \Memcached) { - throw new CacheInvalidArgumentException('Memcached lock provider requires a Memcached client instance.'); + throw new CacheInvalidArgumentException('A Memcached client is required.'); } return $this->setLockProvider(new MemcachedLockProvider($client, $prefix)); @@ -990,15 +577,8 @@ public function useMemcachedLock(?\Memcached $client = null, string $prefix = 'c public function useRedisLock(?\Redis $client = null, string $prefix = 'cachelayer:lock:'): self { - if (!$client && method_exists($this->adapter, 'getClient')) { - $candidate = $this->adapter->getClient(); - if ($candidate instanceof \Redis) { - $client = $candidate; - } - } - if (!$client instanceof \Redis) { - throw new CacheInvalidArgumentException('Redis lock provider requires a Redis client instance.'); + throw new CacheInvalidArgumentException('A Redis client is required.'); } return $this->setLockProvider(new RedisLockProvider($client, $prefix)); @@ -1009,265 +589,180 @@ public function useValkeyLock(?\Redis $client = null, string $prefix = 'cachelay return $this->useRedisLock($client, $prefix); } - private function applyJitteredTtl(CacheItemInterface $item): void - { - if (!$item instanceof AbstractCacheItem) { - return; - } - - $ttl = $item->ttlSeconds(); - if ($ttl === null || $ttl <= 1) { - return; - } - - $maxJitter = max(1, intdiv($ttl * self::STAMPEDE_JITTER_PERCENT, 100)); - $jitter = random_int(0, $maxJitter); - $item->expiresAfter(max(1, $ttl - $jitter)); - } - - private function clearTagMeta(string $key): void + /** + * @template T + * @param callable(): T $operation + * @param T $fallback + * @return T + */ + private function backend(callable $operation, mixed $fallback): mixed { - $this->adapter->deleteItem($this->tagMetaKey($key)); - } + try { + return $operation(); + } catch (Throwable $failure) { + $this->metric('backend_failure'); + if (!$this->options->failOpen) { + throw $failure; + } - private function currentTagVersion(string $normalizedTag): int - { - $key = $this->tagVersionKey($normalizedTag); - $item = $this->adapter->getItem($key); - $version = $item->isHit() ? $item->get() : null; - if (is_int($version) && $version > 0) { - return $version; + return $fallback; } - - $item->set(1)->expiresAfter(null); - $this->adapter->save($item); - - return 1; } - private function isTagMetaValid(string $key): bool + /** @param callable(): bool $operation */ + private function backendBool(callable $operation): bool { - $metaItem = $this->adapter->getItem($this->tagMetaKey($key)); - if (!$metaItem->isHit()) { - return true; - } - - $meta = $metaItem->get(); - if (!is_array($meta)) { - return false; - } - - foreach ($meta as $tag => $expectedVersion) { - if (!is_string($tag) || !is_int($expectedVersion)) { - return false; + try { + $result = $operation(); + if (!$result) { + $this->metric('backend_failure'); } - if ($this->currentTagVersion($tag) !== $expectedVersion) { - return false; + return $result; + } catch (Throwable $failure) { + $this->metric('backend_failure'); + if (!$this->options->failOpen) { + throw $failure; } - } - - return true; - } - - private function metric(string $name): void - { - $this->metrics->increment($this->adapter::class, $name); - } - private function normalizeTag(string $tag): string - { - $tag = trim($tag); - if ($tag === '') { - throw new CacheInvalidArgumentException('Cache tag cannot be empty.'); + return false; } - - return preg_replace('/[^A-Za-z0-9_.\-]/', '_', $tag) ?? ''; } /** - * @param array $tags The tags argument. - * @phpstan-param array $tags - * @phpstan-return array + * @param list $keys + * @return array */ - private function normalizeTagList(array $tags): array + private function fetchItems(array $keys): array { - $out = []; - foreach ($tags as $tag) { - $out[] = $this->normalizeTag((string) $tag); + $items = []; + foreach ($this->adapter->getItems($keys) as $key => $item) { + if (is_string($key) && $item instanceof CacheItemInterface) { + $items[$key] = $item; + } } - return array_values(array_unique($out)); + return $items; } - /** - * Converts a PSR-16 TTL (int|DateInterval|null) into an integer number of seconds. - * @param mixed $ttl The ttl argument. - */ - private function normalizeTtl(mixed $ttl): ?int + private function jitteredTtl(?int $ttl): ?int { - if ($ttl === null) { - return null; - } - - if (is_int($ttl)) { - return $ttl >= 0 ? $ttl : throw new CacheInvalidArgumentException('Negative TTL not allowed'); - } - - if ($ttl instanceof DateInterval) { - $now = new DateTime(); - - return max(0, $now->add($ttl)->getTimestamp() - (new DateTime())->getTimestamp()); + if ($ttl === null || $ttl <= 1) { + return $ttl; } - throw new CacheInvalidArgumentException( - sprintf( - 'Invalid TTL type; expected null, int, or DateInterval, got %s', - get_debug_type($ttl), - ), - ); + return max(1, $ttl - random_int(0, max(1, intdiv($ttl * self::TTL_JITTER_PERCENT, 100)))); } - private function purgeKeyAndTagMeta(string $key): void + private function metric(string $name, int $amount = 1): void { - $this->adapter->deleteItem($key); - $this->adapter->deleteItem($this->tagMetaKey($key)); + if ($amount > 0) { + $this->metrics->increment($this->adapter::class, $name, $amount); + } } - private function readableAdapterName(string $adapterClass): string + private function miss(string $key): CacheItemInterface { - $short = $adapterClass; - if (str_contains($short, '\\')) { - $parts = explode('\\', $short); - $short = end($parts); - } - - if (str_ends_with($short, 'CacheAdapter')) { - $short = substr($short, 0, -strlen('CacheAdapter')); - } - - return match ($short) { - 'Array' => 'memory', - 'MemCache' => 'memcache', - 'Null' => 'null_store', - 'PhpFiles' => 'php_files', - 'SharedMemory' => 'shared_memory', - 'WeakMap' => 'weak_map', - 'RedisCluster' => 'redis_cluster', - 'Valkey' => 'valkey', - 'ScyllaDb' => 'scylladb', - 'MongoDb' => 'mongodb', - default => strtolower((string) preg_replace('/(?adapter->createItem($key); } /** - * @param array $snapshot The snapshot argument. - * @phpstan-param array> $snapshot - * @phpstan-return array> + * @param array> $snapshot + * @return array> */ private function readableMetricsSnapshot(array $snapshot): array { $readable = []; - foreach ($snapshot as $adapterClass => $counters) { - $name = $this->readableAdapterName((string) $adapterClass); - foreach ($counters as $metric => $count) { - $readable[$name][$metric] = ($readable[$name][$metric] ?? 0) + (int) $count; - } + $separator = strrpos($adapterClass, '\\'); + $short = $separator === false ? $adapterClass : substr($adapterClass, $separator + 1); + $short = preg_replace('/CacheAdapter$/', '', $short) ?? $short; + $name = strtolower(preg_replace('/(?adapter, 'set')) { - try { - $result = $this->adapter->set($key, $value, $ttlSeconds); - } catch (Psr6InvalidArgumentException $e) { - throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); - } - } else { - // Fall back to PSR-6 approach - try { - $item = $this->adapter->getItem($key)->set($value)->expiresAfter($ttlSeconds); - $result = $this->save($item); - } catch (Psr6InvalidArgumentException $e) { - throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); - } - } - - if ($result) { - $this->clearTagMeta($key); - $this->metric('set'); + if (!is_string($offset)) { + throw new CacheInvalidArgumentException('Cache array offsets must be strings.'); } - return (bool) $result; + return $offset; } - private function tagMetaKey(string $key): string + private function stampedeLockKey(string $key): string { - return self::TAG_META_PREFIX . hash('sha256', $key); + return 'cachelayer:lock:' . hash('xxh128', $key); } - private function tagVersionKey(string $normalizedTag): string + /** @param list $tags */ + private function storeResolved(string $key, mixed $value, mixed $ttl, array $tags): void { - return self::TAG_VERSION_PREFIX . hash('sha256', $normalizedTag); + $ttlSeconds = CacheInput::ttl($ttl); + if ($ttlSeconds !== null && $ttlSeconds <= 0) { + $this->delete($key); + + return; + } + $ttlSeconds = $this->jitteredTtl($ttlSeconds); + if ($tags === []) { + $this->set($key, $value, $ttlSeconds); + + return; + } + $this->setTagged($key, $value, $tags, $ttlSeconds); } - /** - * Validates a cache key per PSR-16 rules (and reuses for PSR-6). - * - * @throws CacheInvalidArgumentException if the key is invalid. - * @param string $key The key argument. - */ - private function validateKey(string $key): void + private function validateTagSnapshot(CacheItemInterface $item): CacheItemInterface { - if ($key === '' || !preg_match('/^[A-Za-z0-9_.\-]+$/', $key)) { - throw new CacheInvalidArgumentException( - 'Invalid cache key; allowed characters: A-Z, a-z, 0-9, _, ., -', - ); + if (!$item instanceof CacheItem || !$item->isHit() || $item->getTagVersions() === []) { + return $item; + } + $tags = array_keys($item->getTagVersions()); + $versions = $this->backend( + fn(): array => $this->adapter->getTagVersions($tags), + null, + ); + $this->metric('tag_version_fetch_batch'); + if (!is_array($versions)) { + return $this->miss($item->getKey()); } + if (CacheTagSnapshots::isCurrent($item, $versions)) { + return $item; + } + $this->backendBool(fn(): bool => $this->adapter->deleteItem($item->getKey())); + + return $this->miss($item->getKey()); } /** - * @param string $key The key argument. - * @param array $tags The tags argument. - * @param int|null $ttl The ttl argument. - * @phpstan-param array $tags + * @param array $items + * @return array */ - private function writeTagMeta(string $key, array $tags, ?int $ttl): bool + private function validateTagSnapshots(array $items): array { + $tags = CacheTagSnapshots::collectTags($items); if ($tags === []) { - $this->clearTagMeta($key); - - return true; + return $items; } - $versions = []; - foreach ($tags as $tag) { - $versions[$tag] = $this->currentTagVersion($tag); + $versions = $this->backend( + fn(): array => $this->adapter->getTagVersions($tags), + null, + ); + $this->metric('tag_version_fetch_batch'); + if (!is_array($versions)) { + return CacheTagSnapshots::missTagged($items, $this->miss(...)); + } + $validated = CacheTagSnapshots::rejectStale($items, $versions, $this->miss(...)); + $stale = $validated['stale']; + if ($stale !== []) { + $this->backendBool(fn(): bool => $this->adapter->deleteItems($stale)); } - $metaItem = $this->adapter->getItem($this->tagMetaKey($key)); - $metaItem->set($versions); - $metaItem->expiresAfter($ttl); - - return $this->adapter->save($metaItem); - } - - private function writeTagVersion(string $normalizedTag, int $version): bool - { - $item = $this->adapter->getItem($this->tagVersionKey($normalizedTag)); - $item->set(max(1, $version))->expiresAfter(null); - - return $this->adapter->save($item); + return $validated['items']; } } diff --git a/src/Cache/CacheInput.php b/src/Cache/CacheInput.php new file mode 100644 index 0000000..abcbeac --- /dev/null +++ b/src/Cache/CacheInput.php @@ -0,0 +1,97 @@ + 64 || preg_match('/^[A-Za-z0-9_.-]+$/D', $key) !== 1) { + throw new CacheInvalidArgumentException( + 'Cache keys must contain 1-64 characters from A-Z, a-z, 0-9, _, ., and -.', + ); + } + } + + /** + * @param array $keys + * @return list + */ + public static function keys(array $keys): array + { + $validated = []; + foreach ($keys as $key) { + if (!is_string($key)) { + throw new CacheInvalidArgumentException('Cache keys must be strings.'); + } + self::key($key); + $validated[] = $key; + } + + return $validated; + } + + /** + * @param iterable $keys + * @return list + */ + public static function materializeKeys(iterable $keys): array + { + $materialized = []; + foreach ($keys as $key) { + if (!is_string($key)) { + throw new CacheInvalidArgumentException('Cache keys must be strings.'); + } + $materialized[] = $key; + } + + return self::keys($materialized); + } + + /** + * @param array $tags + * @return list + */ + public static function tags(array $tags): array + { + $validated = []; + $seen = []; + foreach ($tags as $tag) { + if (!is_string($tag) + || strlen($tag) < 1 + || strlen($tag) > 64 + || preg_match('/^[A-Za-z0-9_.-]+$/D', $tag) !== 1) { + throw new CacheInvalidArgumentException( + 'Cache tags must contain 1-64 characters from A-Z, a-z, 0-9, _, ., and -.', + ); + } + if (!isset($seen[$tag])) { + $seen[$tag] = true; + $validated[] = $tag; + } + } + + return $validated; + } + + public static function ttl(mixed $ttl): ?int + { + if ($ttl === null || is_int($ttl)) { + return $ttl; + } + if ($ttl instanceof DateInterval) { + $now = new DateTimeImmutable(); + + return $now->add($ttl)->getTimestamp() - $now->getTimestamp(); + } + + throw new CacheInvalidArgumentException('TTL must be null, an integer, or DateInterval.'); + } +} diff --git a/src/Cache/CacheInterface.php b/src/Cache/CacheInterface.php index 804d405..44805de 100644 --- a/src/Cache/CacheInterface.php +++ b/src/Cache/CacheInterface.php @@ -5,66 +5,23 @@ namespace Infocyph\CacheLayer\Cache; use ArrayAccess; -use Countable; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; use Psr\Cache\CacheItemPoolInterface; use Psr\SimpleCache\CacheInterface as SimpleCacheInterface; -/** - * Unified cache interface combining PSR-6, PSR-16, and additional functionality. - * - * This interface extends multiple cache standards to provide a comprehensive - * caching solution: - * - PSR-6 CacheItemPoolInterface: Advanced caching with items and metadata - * - PSR-16 SimpleCacheInterface: Simplified caching operations - * - ArrayAccess: Array-like access to cache entries - * - Countable: Count cache entries - * - * Implementations of this interface provide a unified API for both simple - * and advanced caching use cases, supporting features like tagged cache - * invalidation, cache stampede protection, and multiple storage adapters. - * - * @extends ArrayAccess - * @return array - * @phpstan-return array> - */ -interface CacheInterface extends ArrayAccess, CacheItemPoolInterface, Countable, SimpleCacheInterface +/** @extends ArrayAccess */ +interface CacheInterface extends ArrayAccess, CacheItemPoolInterface, SimpleCacheInterface { - public function clearCache(): bool; - - public function configurePayloadCompression(?int $thresholdBytes = null, int $level = 6): self; - - public function configurePayloadSecurity(?string $integrityKey = null, ?int $maxPayloadBytes = 8_388_608): self; - - public function configureSerializationSecurity( - bool $allowClosurePayloads = true, - bool $allowObjectPayloads = true, - ): self; - - /** - * Returns metrics grouped by readable adapter name (for example ``file``, - * ``pdo``, ``redis``) and metric name. - * - * @phpstan-return array> - */ + /** @return array> */ public function exportMetrics(): array; public function invalidateTag(string $tag): bool; - /** - * @param array $tags The tags argument. - * @phpstan-param array $tags - */ + /** @param list $tags */ public function invalidateTags(array $tags): bool; - /** - * @param string $key The key argument. - * @param callable $resolver The resolver argument. - * @param mixed $ttl The ttl argument. - * @param array $tags The tags argument. - * @phpstan-param array $tags - */ + /** @param list $tags */ public function remember(string $key, callable $resolver, mixed $ttl = null, array $tags = []): mixed; public function setLockProvider(LockProviderInterface $lockProvider): self; @@ -73,13 +30,7 @@ public function setMetricsCollector(CacheMetricsCollectorInterface $metrics): se public function setMetricsExportHook(?callable $hook): self; - /** - * @param string $key The key argument. - * @param mixed $value The value argument. - * @param array $tags The tags argument. - * @param mixed $ttl The ttl argument. - * @phpstan-param array $tags - */ + /** @param list $tags */ public function setTagged(string $key, mixed $value, array $tags, mixed $ttl = null): bool; public function useMemcachedLock(?\Memcached $client = null, string $prefix = 'cachelayer:lock:'): self; diff --git a/src/Cache/CacheOptions.php b/src/Cache/CacheOptions.php new file mode 100644 index 0000000..5e9282b --- /dev/null +++ b/src/Cache/CacheOptions.php @@ -0,0 +1,46 @@ + 9) { + throw new CacheInvalidArgumentException('The compression level must be between 1 and 9.'); + } + } + + public static function fromEnvironment(): self + { + $integrityKey = getenv('CACHELAYER_PAYLOAD_INTEGRITY_KEY'); + $maxPayloadBytes = getenv('CACHELAYER_MAX_PAYLOAD_BYTES'); + + return new self( + integrityKey: is_string($integrityKey) && $integrityKey !== '' ? $integrityKey : null, + maxPayloadBytes: is_string($maxPayloadBytes) && ctype_digit($maxPayloadBytes) + ? (int) $maxPayloadBytes + : 8_388_608, + ); + } +} diff --git a/src/Cache/CacheReadRememberTrait.php b/src/Cache/CacheReadRememberTrait.php deleted file mode 100644 index 20038dc..0000000 --- a/src/Cache/CacheReadRememberTrait.php +++ /dev/null @@ -1,195 +0,0 @@ -validateKey($key); - - if (is_callable($default)) { - return $this->remember($key, $default); - } - - try { - $item = $this->adapter->getItem($key); - } catch (Psr6InvalidArgumentException $e) { - throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); - } - - if (!$item->isHit()) { - $this->metric('miss'); - - return $default; - } - - if (!$this->isTagMetaValid($key)) { - $this->purgeKeyAndTagMeta($key); - $this->metric('miss'); - - return $default; - } - - $this->metric('hit'); - - return $item->get(); - } - - public function getItem(string $key): CacheItemInterface - { - $this->validateKey($key); - $item = $this->adapter->getItem($key); - if (!$item->isHit()) { - return $item; - } - - if (!$this->isTagMetaValid($key)) { - $this->purgeKeyAndTagMeta($key); - - return $this->adapter->getItem($key); - } - - return $item; - } - - /** - * @param array $keys The keys argument. - * @phpstan-param string[] $keys - * @phpstan-return iterable - */ - public function getItems(array $keys = []): iterable - { - if ($keys === []) { - return new \EmptyIterator(); - } - - foreach ($keys as $key) { - $this->validateKey((string) $key); - } - - $fetched = method_exists($this->adapter, 'multiFetch') - ? $this->adapter->multiFetch($keys) - : iterator_to_array($this->adapter->getItems($keys), true); - - /** @var array $out */ - $out = []; - foreach ($keys as $key) { - $k = (string) $key; - $fetchedItem = is_array($fetched) ? ($fetched[$k] ?? null) : null; - $item = $fetchedItem instanceof CacheItemInterface ? $fetchedItem : $this->adapter->getItem($k); - - if (!$item->isHit()) { - $this->metric('miss'); - $out[$k] = $item; - - continue; - } - - if (!$this->isTagMetaValid($k)) { - $this->purgeKeyAndTagMeta($k); - $this->metric('miss'); - $out[$k] = $this->adapter->getItem($k); - - continue; - } - - $this->metric('hit'); - $out[$k] = $item; - } - - return $out; - } - - public function hasItem(string $key): bool - { - $this->validateKey($key); - $item = $this->adapter->getItem($key); - if (!$item->isHit()) { - $this->metric('miss'); - - return false; - } - - if (!$this->isTagMetaValid($key)) { - $this->purgeKeyAndTagMeta($key); - $this->metric('miss'); - - return false; - } - - $this->metric('hit'); - - return true; - } - - /** - * @throws Psr6InvalidArgumentException - * @param string $key The key argument. - * @param callable $resolver The resolver argument. - * @param mixed $ttl The ttl argument. - * @param array $tags The tags argument. - * @phpstan-param array $tags - */ - public function remember( - string $key, - callable $resolver, - mixed $ttl = null, - array $tags = [], - ): mixed { - try { - $item = $this->getItem($key); - } catch (Psr6InvalidArgumentException $e) { - throw new CacheInvalidArgumentException($e->getMessage(), 0, $e); - } - - if ($item->isHit()) { - $this->metric('remember_hit'); - - return $item->get(); - } - - $lockHandle = $this->lockProvider->acquire( - $this->stampedeLockKey($key), - self::STAMPEDE_LOCK_WAIT_SECONDS, - self::STAMPEDE_LOCK_LEASE_SECONDS, - ); - - try { - $lockedItem = $this->getItem($key); - if ($lockedItem->isHit()) { - $this->metric('remember_hit'); - - return $lockedItem->get(); - } - - $normalizedTtl = $this->normalizeTtl($ttl); - $normalizedTags = $this->normalizeTagList($tags); - - if ($normalizedTtl !== null) { - $lockedItem->expiresAfter($normalizedTtl); - } - - $computed = $resolver($lockedItem); - $lockedItem->set($computed); - $this->applyJitteredTtl($lockedItem); - $this->save($lockedItem); - - if ($normalizedTags !== [] && !$this->writeTagMeta($key, $normalizedTags, $normalizedTtl)) { - throw new CacheInvalidArgumentException("Unable to store tag metadata for key '$key'"); - } - - $this->metric('remember_miss'); - - return $computed; - } finally { - $this->lockProvider->release($lockHandle); - } - } -} diff --git a/src/Cache/CacheRecord.php b/src/Cache/CacheRecord.php new file mode 100644 index 0000000..b9aaa6b --- /dev/null +++ b/src/Cache/CacheRecord.php @@ -0,0 +1,19 @@ + $tags + */ + public function __construct( + public mixed $value, + public ?int $expiresAt = null, + public array $tags = [], + public ?int $namespaceEpoch = null, + ) {} +} diff --git a/src/Cache/CacheTagSnapshots.php b/src/Cache/CacheTagSnapshots.php new file mode 100644 index 0000000..e922e94 --- /dev/null +++ b/src/Cache/CacheTagSnapshots.php @@ -0,0 +1,84 @@ + $items + * @return list + */ + public static function collectTags(array $items): array + { + $tagSet = []; + foreach ($items as $item) { + if (!$item instanceof CacheItem || !$item->isHit()) { + continue; + } + foreach ($item->getTagVersions() as $tag => $_version) { + $tagSet[$tag] = true; + } + } + + return array_keys($tagSet); + } + + /** @param array $versions */ + public static function isCurrent(CacheItem $item, array $versions): bool + { + foreach ($item->getTagVersions() as $tag => $expected) { + if (($versions[$tag] ?? 0) !== $expected) { + return false; + } + } + + return true; + } + + /** + * @param array $items + * @param callable(string): CacheItemInterface $miss + * @return array + */ + public static function missTagged(array $items, callable $miss): array + { + foreach ($items as $key => $item) { + if (self::isTaggedHit($item)) { + $items[$key] = $miss($key); + } + } + + return $items; + } + + /** + * @param array $items + * @param array $versions + * @param callable(string): CacheItemInterface $miss + * @return array{items:array, stale:list} + */ + public static function rejectStale(array $items, array $versions, callable $miss): array + { + $stale = []; + foreach ($items as $key => $item) { + if (!$item instanceof CacheItem || !$item->isHit() || self::isCurrent($item, $versions)) { + continue; + } + $stale[] = $key; + $items[$key] = $miss($key); + } + + return ['items' => $items, 'stale' => $stale]; + } + + private static function isTaggedHit(CacheItemInterface $item): bool + { + return $item instanceof CacheItem && $item->isHit() && $item->getTagVersions() !== []; + } +} diff --git a/src/Cache/Item/AbstractCacheItem.php b/src/Cache/Item/AbstractCacheItem.php deleted file mode 100644 index b4cb2f9..0000000 --- a/src/Cache/Item/AbstractCacheItem.php +++ /dev/null @@ -1,136 +0,0 @@ - $this->key, - 'value' => $this->value, - 'hit' => $this->hit, - 'exp' => $this->exp?->format(DateTimeInterface::ATOM), - ]; - } - - /** - * @throws Exception - * @param array $data The data argument. - * @phpstan-param array{key:string,value:mixed,hit:bool,exp?:string|null} $data - */ - public function __unserialize(array $data): void - { - $this->key = $data['key']; - $this->value = ValueSerializer::unwrap($data['value']); - $this->hit = $data['hit']; - $this->exp = isset($data['exp']) ? new DateTime($data['exp']) : null; - $this->pool = null; - } - - public function expiresAfter(int|DateInterval|null $time): static - { - $this->exp = match (true) { - is_int($time) => (new DateTime())->add(new DateInterval("PT{$time}S")), - $time instanceof DateInterval => (new DateTime())->add($time), - default => null, - }; - - return $this; - } - - public function expiresAt(?DateTimeInterface $expiration): static - { - $this->exp = $expiration; - - return $this; - } - - public function get(): mixed - { - return $this->value; - } - - public function getKey(): string - { - return $this->key; - } - - public function isHit(): bool - { - if (!$this->hit) { - return false; - } - - return $this->exp === null || (new DateTime()) < $this->exp; - } - - public function save(): static - { - $this->pool?->internalPersist($this); - - return $this; - } - - public function saveDeferred(): static - { - $this->pool?->internalQueue($this); - - return $this; - } - - public function set(mixed $value): static - { - $this->value = ValueSerializer::wrap($value); - $this->hit = true; - - return $this; - } - - public function ttlSeconds(): ?int - { - return $this->exp ? max(0, $this->exp->getTimestamp() - time()) : null; - } -} diff --git a/src/Cache/Item/ApcuCacheItem.php b/src/Cache/Item/ApcuCacheItem.php deleted file mode 100644 index 7ce301f..0000000 --- a/src/Cache/Item/ApcuCacheItem.php +++ /dev/null @@ -1,7 +0,0 @@ - $tags + */ + public function __construct( + private readonly InternalCachePoolInterface $pool, + private readonly string $key, + private mixed $value = null, + private bool $hit = false, + private ?DateTimeInterface $expiration = null, + private array $tags = [], + ) {} + + public function belongsTo(InternalCachePoolInterface $pool): bool + { + return $this->pool === $pool; + } + + public function expiresAfter(int|DateInterval|null $time): static + { + $now = new DateTimeImmutable(); + $this->expiration = match (true) { + is_int($time) => $now->modify(sprintf('%+d seconds', $time)), + $time instanceof DateInterval => $now->add($time), + default => null, + }; + + return $this; + } + + public function expiresAt(?DateTimeInterface $expiration): static + { + $this->expiration = $expiration; + + return $this; + } + + public function get(): mixed + { + return $this->value; + } + + public function getKey(): string + { + return $this->key; + } + + /** @return array */ + public function getTagVersions(): array + { + return $this->tags; + } + + public function isHit(): bool + { + return $this->hit + && ($this->expiration === null || $this->expiration->getTimestamp() > time()); + } + + public function save(): static + { + $this->pool->internalPersist($this); + + return $this; + } + + public function saveDeferred(): static + { + $this->pool->internalQueue($this); + + return $this; + } + + public function set(mixed $value): static + { + $this->value = $value; + $this->hit = true; + + return $this; + } + + /** + * @param array $tags + */ + public function setTagVersions(array $tags): static + { + $this->tags = $tags; + + return $this; + } + + public function ttlSeconds(): ?int + { + return $this->expiration === null + ? null + : $this->expiration->getTimestamp() - time(); + } +} diff --git a/src/Cache/Item/FileCacheItem.php b/src/Cache/Item/FileCacheItem.php deleted file mode 100644 index 0438a1c..0000000 --- a/src/Cache/Item/FileCacheItem.php +++ /dev/null @@ -1,7 +0,0 @@ -counters; } - public function increment(string $adapterClass, string $metric): void + public function increment(string $adapterClass, string $metric, int $amount = 1): void { - $this->counters[$adapterClass][$metric] = ($this->counters[$adapterClass][$metric] ?? 0) + 1; + $this->counters[$adapterClass][$metric] = ($this->counters[$adapterClass][$metric] ?? 0) + $amount; } } diff --git a/src/Cache/Tiering/TieredPoolFactory.php b/src/Cache/Tiering/TieredPoolFactory.php index 484dcf9..a68b09f 100644 --- a/src/Cache/Tiering/TieredPoolFactory.php +++ b/src/Cache/Tiering/TieredPoolFactory.php @@ -5,15 +5,15 @@ namespace Infocyph\CacheLayer\Cache\Tiering; use Infocyph\CacheLayer\Cache\Adapter; +use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Psr\Cache\CacheItemPoolInterface; final class TieredPoolFactory { /** * @param array $tiers The tiers argument. * @phpstan-param array $tiers - * @phpstan-return array + * @phpstan-return list */ public static function fromArray(array $tiers): array { @@ -64,7 +64,7 @@ private static function buildScyllaSession(string $keyspace): object * @param int|string $index The index argument. * @phpstan-param array $descriptor */ - private static function descriptorToPool(array $descriptor, int|string $index): CacheItemPoolInterface + private static function descriptorToPool(array $descriptor, int|string $index): InternalCachePoolInterface { $driverValue = $descriptor['driver'] ?? $descriptor['type'] ?? null; if (!is_string($driverValue) || $driverValue === '') { @@ -84,7 +84,7 @@ private static function descriptorToPool(array $descriptor, int|string $index): 'array', 'memory' => new Adapter\ArrayCacheAdapter($namespace), 'file' => new Adapter\FileCacheAdapter($namespace, self::nullableString($descriptor, 'dir', 'base_dir')), 'php_files' => new Adapter\PhpFilesCacheAdapter($namespace, self::nullableString($descriptor, 'dir', 'base_dir')), - 'memcache', 'memcached' => new Adapter\MemCacheAdapter( + 'memcached' => new Adapter\MemcachedCacheAdapter( $namespace, self::servers($descriptor['servers'] ?? null), self::memcachedClient($client, $index), @@ -129,6 +129,7 @@ private static function descriptorToPool(array $descriptor, int|string $index): self::string($descriptor, 'keyspace', 'cachelayer'), self::string($descriptor, 'table', 'cachelayer_entries'), $namespace, + self::int($descriptor, 'bucket_count', 128), ), 'shared_memory' => new Adapter\SharedMemoryCacheAdapter( $namespace, @@ -204,7 +205,7 @@ private static function memcachedClient(mixed $client, int|string $index): ?\Mem * @param int|string $index The index argument. * @phpstan-param array $descriptor */ - private static function mongoPool(array $descriptor, string $namespace, mixed $client, int|string $index): CacheItemPoolInterface + private static function mongoPool(array $descriptor, string $namespace, mixed $client, int|string $index): InternalCachePoolInterface { $collection = $descriptor['collection'] ?? null; if (is_object($collection)) { @@ -312,16 +313,16 @@ private static function redisClient(mixed $client, int|string $index, string $dr return $client; } - private static function resolvePool(mixed $tier, int|string $index): CacheItemPoolInterface + private static function resolvePool(mixed $tier, int|string $index): InternalCachePoolInterface { - if ($tier instanceof CacheItemPoolInterface) { + if ($tier instanceof InternalCachePoolInterface) { return $tier; } if (!is_array($tier)) { throw new CacheInvalidArgumentException( sprintf( - "Invalid tier at index '%s': expected CacheItemPoolInterface or descriptor array, got %s.", + "Invalid tier at index '%s': expected a CacheLayer adapter or descriptor array, got %s.", (string) $index, get_debug_type($tier), ), @@ -332,8 +333,7 @@ private static function resolvePool(mixed $tier, int|string $index): CacheItemPo } /** - * @phpstan-return array - * @param mixed $value The value argument. + * @return list */ private static function seeds(mixed $value): array { @@ -364,8 +364,7 @@ private static function seeds(mixed $value): array } /** - * @phpstan-return array - * @param mixed $value The value argument. + * @return list */ private static function servers(mixed $value): array { diff --git a/src/Node/Adapter/NodeCacheAdapter.php b/src/Node/Adapter/NodeCacheAdapter.php index 2c83516..42b1638 100644 --- a/src/Node/Adapter/NodeCacheAdapter.php +++ b/src/Node/Adapter/NodeCacheAdapter.php @@ -5,18 +5,18 @@ namespace Infocyph\CacheLayer\Node\Adapter; use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; -use Infocyph\CacheLayer\Cache\Adapter\CachePayloadCodec; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; +use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Psr\Cache\CacheItemInterface; -use Psr\Cache\CacheItemPoolInterface; use Throwable; final class NodeCacheAdapter extends AbstractCacheAdapter { public function __construct( - private readonly ?CacheItemPoolInterface $l1, + private readonly ?InternalCachePoolInterface $l1, private readonly NodeSqliteCacheAdapter $l2, private readonly bool $failOpen = true, private readonly CacheMetricsCollectorInterface $metrics = new InMemoryCacheMetricsCollector(), @@ -24,105 +24,74 @@ public function __construct( public function clear(): bool { - return $this->runAcrossLayers(static fn(CacheItemPoolInterface $pool): bool => $pool->clear()); - } - - #[\Override] - public function commit(): bool - { - $items = array_values($this->deferred); - if ($items === []) { - return true; - } - - try { - $stored = $this->l2->saveMany($items); - } catch (Throwable $exception) { - if (!$this->failOpen) { - throw $exception; - } - - if (!$this->saveAllToL1($items)) { - return false; - } - - $this->deferred = []; - - return true; - } - - if (!$stored) { - return false; - } - + $l2 = $this->attempt(fn(): bool => $this->l2->clear(), false, 'l2_failure'); + $l1 = $this->l1 === null || $this->attempt(fn(): bool => $this->l1->clear(), false, 'l1_failure'); $this->deferred = []; - if ($this->l1 === null) { - return true; - } - $this->saveAllToL1($items); - - return true; + return $l2 && $l1; } - public function count(): int + #[\Override] + public function configureOptions(CacheOptions $options): void { - try { - return count($this->l2); - } catch (Throwable $exception) { - if (!$this->failOpen) { - throw $exception; - } - - $this->metric('sqlite_failure'); - - return 0; + parent::configureOptions($options); + $this->l2->configureOptions($options); + if ($this->l1 instanceof AbstractCacheAdapter) { + $this->l1->configureOptions($options); } } public function deleteItem(string $key): bool { - return $this->runAcrossLayers(static fn(CacheItemPoolInterface $pool): bool => $pool->deleteItem($key)); + $l2 = $this->attempt(fn(): bool => $this->l2->deleteItem($key), false, 'l2_failure'); + $l1 = $this->l1 === null + || $this->attempt(fn(): bool => $this->l1->deleteItem($key), false, 'l1_failure'); + + return $l2 && $l1; } - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - */ + /** @param list $keys */ public function deleteItems(array $keys): bool { - return $this->runAcrossLayers(static fn(CacheItemPoolInterface $pool): bool => $pool->deleteItems($keys)); + $l2 = $this->attempt(fn(): bool => $this->l2->deleteItems($keys), false, 'l2_failure'); + $l1 = $this->l1 === null + || $this->attempt(fn(): bool => $this->l1->deleteItems($keys), false, 'l1_failure'); + + return $l2 && $l1; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { - $l1Item = $this->itemFromL1($key); - if ($l1Item !== null) { - return $l1Item; - } - - try { - $l2Item = $this->l2->getItem($key); - } catch (Throwable $exception) { - if (!$this->failOpen) { - throw $exception; + if ($this->l1 !== null) { + $l1 = $this->attempt(fn(): CacheItemInterface => $this->l1->getItem($key), $this->genericMiss($key), 'l1_failure'); + if ($l1->isHit()) { + return $this->nodeItem($l1); } - - $this->metric('sqlite_failure'); - - return new GenericCacheItem($this, $key); } - - if (!$l2Item->isHit()) { - $this->metric('sqlite_miss'); - - return new GenericCacheItem($this, $key); + $l2 = $this->attempt(fn(): CacheItemInterface => $this->l2->getItem($key), $this->genericMiss($key), 'l2_failure'); + if (!$l2->isHit()) { + return $this->genericMiss($key); + } + $item = $this->nodeItem($l2); + if ($this->l1 !== null) { + $this->saveOneInto($this->l1, $item, 'l1_failure'); } - $this->metric('sqlite_hit'); - $this->saveToL1($l2Item); + return $item; + } - return $this->nodeItem($l2Item); + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(array $tags): array + { + return $this->attempt( + fn(): array => $this->l2->getTagVersions($tags), + array_fill_keys($tags, 0), + 'l2_failure', + ); } public function hasItem(string $key): bool @@ -130,14 +99,37 @@ 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 array $keys The keys argument. - * @phpstan-param list $keys - * @phpstan-return array + * @param list $keys + * @return array */ public function multiFetch(array $keys): array { - return $this->multiFetchItems($keys, $this->getItem(...)); + [$results, $misses] = $this->readL1($keys); + $promote = $this->readL2($misses, $results); + + if ($promote !== [] && $this->l1 !== null) { + $this->saveInto($this->l1, $promote, 'l1_failure'); + $this->metric('l2_batch_promote', count($promote)); + } + + $ordered = []; + foreach ($keys as $key) { + $ordered[$key] = $results[$key] ?? $this->genericMiss($key); + } + + return $ordered; } public function save(CacheItemInterface $item): bool @@ -145,143 +137,178 @@ public function save(CacheItemInterface $item): bool if (!$this->supportsItem($item)) { return false; } + $stored = $this->saveOneInto($this->l2, $item, 'l2_failure'); + if (!$stored && !$this->failOpen) { + return false; + } + if ($this->l1 === null) { + return $stored; + } - try { - $stored = $this->l2->save($item); - } catch (Throwable $exception) { - if (!$this->failOpen) { - throw $exception; - } - - $this->metric('sqlite_failure'); + return $this->saveOneInto($this->l1, $item, 'l1_failure') || $stored; + } - return $this->saveToL1($item); + /** @param array $items */ + public function saveItems(array $items): bool + { + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } } - if (!$stored) { - $this->metric('write_failure'); - + $stored = $this->saveInto($this->l2, $items, 'l2_failure'); + if (!$stored && !$this->failOpen) { return false; } + if ($this->l1 !== null) { + $l1Stored = $this->saveInto($this->l1, $items, 'l1_failure'); - $this->metric('write_success'); - $this->saveToL1($item); - - return true; - } + return $stored || $l1Stored; + } - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof GenericCacheItem; + return $stored; } - private function itemFromL1(string $key): ?GenericCacheItem + /** + * @template T + * @param callable(): T $operation + * @param T $fallback + * @return T + */ + private function attempt(callable $operation, mixed $fallback, string $failureMetric): mixed { - if ($this->l1 === null) { - return null; - } - try { - $item = $this->l1->getItem($key); - } catch (Throwable $exception) { + return $operation(); + } catch (Throwable $failure) { + $this->metric($failureMetric); if (!$this->failOpen) { - throw $exception; + throw $failure; } - $this->metric('apcu_failure'); - - return null; - } - - if (!$item->isHit()) { - $this->metric('apcu_miss'); - - return null; + return $fallback; } - - $this->metric('apcu_hit'); - - return $this->nodeItem($item); } - private function metric(string $name): void + private function metric(string $name, int $amount = 1): void { - $this->metrics->increment(self::class, $name); + if ($amount > 0) { + $this->metrics->increment(self::class, $name, $amount); + } } - private function nodeItem(CacheItemInterface $item): GenericCacheItem + private function nodeItem(CacheItemInterface $item): CacheItem { - $nodeItem = new GenericCacheItem($this, $item->getKey(), $item->get(), true); - $expires = CachePayloadCodec::expirationFromItem($item); - if ($expires['ttl'] !== null) { - $nodeItem->expiresAfter($expires['ttl']); - } + $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; + $tags = $item instanceof CacheItem ? $item->getTagVersions() : []; - return $nodeItem; + return (new CacheItem($this, $item->getKey(), $item->get(), true)) + ->expiresAfter($ttl) + ->setTagVersions($tags); } - private function runAcrossLayers(callable $operation): bool + /** + * @param list $keys + * @return array{array, list} + */ + private function readL1(array $keys): array { - $success = false; - $failure = null; - - foreach ([$this->l2, $this->l1] as $pool) { - if (!$pool instanceof CacheItemPoolInterface) { - continue; - } - - try { - $success = $operation($pool) || $success; - } catch (Throwable $exception) { - $failure ??= $exception; - } + if ($this->l1 === null || $keys === []) { + return [[], $keys]; } - if ($failure !== null && !$this->failOpen) { - throw $failure; + $l1Items = $this->attempt(fn(): array => $this->readPool($this->l1, $keys), [], 'l1_failure'); + $results = []; + $misses = []; + foreach ($keys as $key) { + $item = $l1Items[$key] ?? null; + if ($item instanceof CacheItemInterface && $item->isHit()) { + $results[$key] = $this->nodeItem($item); + } else { + $misses[] = $key; + } } + $this->metric('l1_batch_hit', count($keys) - count($misses)); + $this->metric('l1_batch_miss', count($misses)); - return $success; + return [$results, $misses]; } /** - * @param array $items The items argument. - * @phpstan-param list $items + * @param list $keys + * @param array $results + * @return array */ - private function saveAllToL1(array $items): bool + private function readL2(array $keys, array &$results): array { - $success = true; - foreach ($items as $item) { - $success = $this->saveToL1($item) && $success; + if ($keys === []) { + return []; + } + $items = $this->attempt(fn(): array => $this->l2->multiFetch($keys), [], 'l2_failure'); + $hits = []; + foreach ($keys as $key) { + $item = $items[$key] ?? null; + if ($item instanceof CacheItemInterface && $item->isHit()) { + $hits[$key] = $this->nodeItem($item); + $results[$key] = $hits[$key]; + } } + $this->metric('l2_batch_hit', count($hits)); + $this->metric('l2_batch_miss', count($keys) - count($hits)); - return $success; + return $hits; } - private function saveToL1(CacheItemInterface $item): bool + /** + * @param list $keys + * @return array + */ + private function readPool(InternalCachePoolInterface $pool, array $keys): array { - if ($this->l1 === null) { - return true; + $items = []; + foreach ($pool->getItems($keys) as $key => $item) { + if (is_string($key) && $item instanceof CacheItemInterface) { + $items[$key] = $item; + } } - try { - $target = $this->l1->getItem($item->getKey()); - $target->set($item->get()); - $expires = CachePayloadCodec::expirationFromItem($item); - $target->expiresAfter($expires['ttl']); - $stored = $this->l1->save($target); - } catch (Throwable $exception) { - if (!$this->failOpen) { - throw $exception; + return $items; + } + + /** @param array $items */ + private function saveInto( + InternalCachePoolInterface $pool, + array $items, + string $failureMetric, + ): bool { + $targets = []; + foreach ($items as $key => $item) { + $target = $pool->createItem($key)->set($item->get()); + if ($item instanceof CacheItem) { + $target->expiresAfter($item->ttlSeconds()); + if ($target instanceof CacheItem) { + $target->setTagVersions($item->getTagVersions()); + } } + $targets[$key] = $target; + } - $this->metric('apcu_failure'); + return $this->attempt(fn(): bool => $pool->saveItems($targets), false, $failureMetric); + } - return false; + private function saveOneInto( + InternalCachePoolInterface $pool, + CacheItemInterface $item, + string $failureMetric, + ): bool { + $target = $pool->createItem($item->getKey())->set($item->get()); + if ($item instanceof CacheItem) { + $target->expiresAfter($item->ttlSeconds()); + if ($target instanceof CacheItem) { + $target->setTagVersions($item->getTagVersions()); + } } - $this->metric($stored ? 'promotion_success' : 'promotion_failure'); - - return $stored; + return $this->attempt(fn(): bool => $pool->save($target), false, $failureMetric); } } diff --git a/src/Node/Adapter/NodeSqliteCacheAdapter.php b/src/Node/Adapter/NodeSqliteCacheAdapter.php index f7bc852..15cf009 100644 --- a/src/Node/Adapter/NodeSqliteCacheAdapter.php +++ b/src/Node/Adapter/NodeSqliteCacheAdapter.php @@ -6,7 +6,7 @@ use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; use Infocyph\CacheLayer\Cache\Adapter\CachePayloadCodec; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Node\Exception\NodeCacheStorageException; use PDO; use PDOException; @@ -92,7 +92,10 @@ public function count(): int public function deleteItem(string $key): bool { try { - return $this->deleteStatement->execute([':namespace' => $this->namespace, ':cache_key' => $key]); + return $this->deleteStatement->execute([ + ':namespace' => $this->namespace, + ':cache_key' => $this->mapData($key), + ]); } catch (PDOException $exception) { throw $this->storageException("Unable to delete node SQLite cache key '{$key}'.", $exception); } @@ -109,13 +112,13 @@ public function deleteItems(array $keys): bool } try { - $this->connection->beginTransaction(); - foreach ($keys as $key) { - $this->deleteStatement->execute([':namespace' => $this->namespace, ':cache_key' => $key]); - } - $this->connection->commit(); + $mapped = array_map($this->mapData(...), $keys); + $marks = implode(',', array_fill(0, count($mapped), '?')); + $statement = $this->connection->prepare( + 'DELETE FROM ' . self::TABLE . " WHERE namespace = ? AND cache_key IN ({$marks})", + ); - return true; + return $statement->execute([$this->namespace, ...$mapped]); } catch (PDOException $exception) { $this->rollBack(); @@ -123,12 +126,12 @@ public function deleteItems(array $keys): bool } } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { try { $this->lookupStatement->execute([ ':namespace' => $this->namespace, - ':cache_key' => $key, + ':cache_key' => $this->mapData($key), ':current_time' => time(), ]); $row = $this->lookupStatement->fetch(); @@ -137,21 +140,47 @@ public function getItem(string $key): GenericCacheItem } if (!is_array($row) || !is_string($row['payload'] ?? null)) { - return new GenericCacheItem($this, $key); + return new CacheItem($this, $key); } - $record = CachePayloadCodec::decode($row['payload']); - if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { - return new GenericCacheItem($this, $key); + $record = $this->decodeRecordFromBlob($row['payload']); + if ($record === null) { + return new CacheItem($this, $key); } - return new GenericCacheItem( - $this, - $key, - $record['value'], - true, - CachePayloadCodec::toDateTime($record['expires']), + return $this->genericItemFromRecord($key, $record); + } + + /** + * @param list $tags + * @return array + */ + #[\Override] + public function getTagVersions(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; + } + } + $versions = []; + foreach ($tags as $tag) { + $value = $stored[$this->mapTag($tag)] ?? null; + $versions[$tag] = is_string($value) && ctype_digit($value) ? (int) $value : 0; + } + + return $versions; } public function hasItem(string $key): bool @@ -159,19 +188,93 @@ 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 - * @phpstan-return array + * @phpstan-return array */ public function multiFetch(array $keys): array { - return $this->multiFetchItems($keys, $this->getItem(...)); + if ($keys === []) { + return []; + } + $mapped = array_map($this->mapData(...), $keys); + $marks = implode(',', array_fill(0, count($mapped), '?')); + $statement = $this->connection->prepare( + 'SELECT cache_key, payload FROM ' . self::TABLE + . " WHERE namespace = ? AND cache_key IN ({$marks})" + . ' AND (expires_at IS NULL OR expires_at > ?)', + ); + $statement->execute([$this->namespace, ...$mapped, time()]); + $rows = []; + foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { + if (is_array($row) && is_string($row['cache_key'] ?? null) && is_string($row['payload'] ?? null)) { + $rows[$row['cache_key']] = $row['payload']; + } + } + $items = []; + foreach ($keys as $key) { + $payload = $rows[$this->mapData($key)] ?? null; + $items[$key] = is_string($payload) + ? $this->genericFromBlob($key, $payload) + : $this->genericMiss($key); + } + + return $items; } public function save(CacheItemInterface $item): bool { - return $this->saveMany([$item]); + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return $this->deleteItem($item->getKey()); + } + + try { + $this->upsertStatement->bindValue(':namespace', $this->namespace, PDO::PARAM_STR); + $this->upsertStatement->bindValue(':cache_key', $this->mapData($item->getKey()), PDO::PARAM_STR); + $this->upsertStatement->bindValue( + ':payload', + $this->encodeItem($item, $expiration['expiresAt']), + PDO::PARAM_LOB, + ); + $this->upsertStatement->bindValue( + ':expires_at', + $expiration['expiresAt'], + $expiration['expiresAt'] === null ? PDO::PARAM_NULL : PDO::PARAM_INT, + ); + + return $this->upsertStatement->execute(); + } catch (PDOException $exception) { + throw $this->storageException('Unable to store a node SQLite cache entry.', $exception); + } + } + + /** @param array $items */ + public function saveItems(array $items): bool + { + return $this->saveMany(array_values($items)); } /** @@ -180,24 +283,44 @@ public function save(CacheItemInterface $item): bool */ public function saveMany(array $items): bool { + $rows = []; + $expired = []; foreach ($items as $item) { if (!$this->supportsItem($item)) { return false; } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + $expired[] = $item->getKey(); + + continue; + } + $rows[] = [ + $this->namespace, + $this->mapData($item->getKey()), + $this->encodeItem($item, $expiration['expiresAt']), + $expiration['expiresAt'], + ]; } - if ($items === []) { + if ($rows === [] && $expired === []) { return true; } try { $this->connection->beginTransaction(); - foreach ($items as $item) { - $this->persistItem($item); + if ($expired !== [] && !$this->deleteItems($expired)) { + $this->rollBack(); + + return false; } - $this->connection->commit(); + if ($rows !== [] && !$this->upsertRows($rows)) { + $this->rollBack(); - return true; + return false; + } + + return $this->connection->commit(); } catch (PDOException $exception) { $this->rollBack(); @@ -205,11 +328,6 @@ public function saveMany(array $items): bool } } - protected function supportsItem(CacheItemInterface $item): bool - { - return $item instanceof GenericCacheItem; - } - private function createSchemaIfMissing(): void { try { @@ -227,20 +345,14 @@ private function createSchemaIfMissing(): void } } - private function persistItem(CacheItemInterface $item): void + private function mapData(string $key): string { - $expires = CachePayloadCodec::expirationFromItem($item); - if ($expires['ttl'] === 0) { - $this->deleteStatement->execute([':namespace' => $this->namespace, ':cache_key' => $item->getKey()]); - - return; - } + return 'd:' . $key; + } - $this->upsertStatement->bindValue(':namespace', $this->namespace, PDO::PARAM_STR); - $this->upsertStatement->bindValue(':cache_key', $item->getKey(), PDO::PARAM_STR); - $this->upsertStatement->bindValue(':payload', CachePayloadCodec::encode($item->get(), $expires['expiresAt']), PDO::PARAM_LOB); - $this->upsertStatement->bindValue(':expires_at', $expires['expiresAt'], $expires['expiresAt'] === null ? PDO::PARAM_NULL : PDO::PARAM_INT); - $this->upsertStatement->execute(); + private function mapTag(string $tag): string + { + return 'm:tag:' . $tag; } private function rollBack(): void @@ -254,4 +366,26 @@ private function storageException(string $message, PDOException $exception): Nod { return new NodeCacheStorageException($message, 0, $exception); } + + /** @param list $rows */ + private function upsertRows(array $rows): bool + { + foreach (array_chunk($rows, 200) as $chunk) { + $values = implode(',', array_fill(0, count($chunk), '(?, ?, ?, ?)')); + $statement = $this->connection->prepare( + 'INSERT INTO ' . self::TABLE . " (namespace, cache_key, payload, expires_at) VALUES {$values} " + . 'ON CONFLICT(namespace, cache_key) DO UPDATE SET ' + . 'payload = excluded.payload, expires_at = excluded.expires_at', + ); + $parameters = []; + foreach ($chunk as $row) { + array_push($parameters, ...$row); + } + if (!$statement->execute($parameters)) { + return false; + } + } + + return true; + } } diff --git a/src/Node/NodeCache.php b/src/Node/NodeCache.php index ee167ef..ba1caa7 100644 --- a/src/Node/NodeCache.php +++ b/src/Node/NodeCache.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Cache\Adapter\ApcuCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; use Infocyph\CacheLayer\Cache\Lock\FileLockProvider; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter; @@ -31,6 +32,7 @@ public static function create(NodeCacheConfig $config): Cache $adapter, $config->lockProvider ?? new FileLockProvider($config->lockDirectory), $metrics, + new CacheOptions(failOpen: $config->failOpen), ); } diff --git a/src/Serializer/ClosureSerializer.php b/src/Serializer/ClosureSerializer.php new file mode 100644 index 0000000..8503503 --- /dev/null +++ b/src/Serializer/ClosureSerializer.php @@ -0,0 +1,62 @@ +key); + + return self::PREFIX . $signature . ':' . $payload; + } + + public function unserialize(string $payload): Closure + { + if (!str_starts_with($payload, self::PREFIX)) { + throw new InvalidArgumentException('Invalid signed Closure payload.'); + } + + $separator = strpos($payload, ':', strlen(self::PREFIX)); + if ($separator === false) { + throw new InvalidArgumentException('Invalid signed Closure payload.'); + } + + $signature = substr($payload, strlen(self::PREFIX), $separator - strlen(self::PREFIX)); + $closurePayload = substr($payload, $separator + 1); + $expected = hash_hmac('sha256', $closurePayload, $this->key); + if (strlen($signature) !== 64 || !ctype_xdigit($signature) || !hash_equals($expected, strtolower($signature))) { + throw new InvalidArgumentException('Closure payload signature verification failed.'); + } + + return ClosureSerializer::unserialize($closurePayload); + } +} diff --git a/src/Serializer/ValueSerializer.php b/src/Serializer/ValueSerializer.php deleted file mode 100644 index e9c041c..0000000 --- a/src/Serializer/ValueSerializer.php +++ /dev/null @@ -1,405 +0,0 @@ - */ - private static array $resourceHandlers = []; - - /** @var array */ - private static array $serializedClosureMemo = []; - - /** - * Clear all registered resource handlers. - * - * Use this method to reset the state of ValueSerializer in test cases, - * or when you want to ensure that no resource handlers are registered. - */ - public static function clearResourceHandlers(): void - { - self::$resourceHandlers = []; - self::$serializedClosureMemo = []; - } - - public static function configureSecurity( - bool $allowClosurePayloads = true, - bool $allowObjectPayloads = true, - ): void { - self::$allowClosurePayloads = $allowClosurePayloads; - self::$allowObjectPayloads = $allowObjectPayloads; - } - - /** - * Decode a payload produced by {@see encode()}. - * - * - * @throws InvalidArgumentException Forwarded from ::unserialize() - * @param string $payload The encoded string - * @param bool $base64 True ⇒ expect base64; false ⇒ raw - * @phpstan-return mixed Original value - */ - public static function decode(string $payload, bool $base64 = true): mixed - { - $blob = $base64 ? base64_decode($payload, true) : $payload; - - if ($blob === false) { - throw new InvalidArgumentException('Invalid base64 payload supplied to ValueSerializer::decode().'); - } - - return self::unserialize($blob); - } - - /** - * Encode any value into a transport-safe (optionally base64) string. - * - * Example: - * $token = ValueSerializer::encode($payload); // base64 by default - * $same = ValueSerializer::decode($token); - * - * - * @throws InvalidArgumentException Forwarded from ::serialize() - * @param mixed $value Any PHP value - * @param bool $base64 True ⇒ wrap with base64; false ⇒ raw - * @phpstan-return string Encoded payload - */ - public static function encode(mixed $value, bool $base64 = true): string - { - $blob = self::serialize($value); - - return $base64 ? base64_encode($blob) : $blob; - } - - /** - * Determines if a given string is a serialized Opis closure. - * - * This method checks if the provided string represents a serialized - * Opis closure by looking for specific patterns associated with - * Opis closures. - * - * @param string $str The string to check. - * @phpstan-return bool True if the string is a serialized Opis closure, false otherwise. - */ - public static function isSerializedClosure(string $str): bool - { - $memoKey = hash('sha256', $str); - if (array_key_exists($memoKey, self::$serializedClosureMemo)) { - return self::$serializedClosureMemo[$memoKey]; - } - - if (!str_contains($str, 'Opis\\Closure')) { - return self::rememberSerializedClosureMemo($memoKey, false); - } - - return self::rememberSerializedClosureMemo($memoKey, (bool) preg_match( - '/^(?:C:\d+:"Opis\\\\Closure\\\\SerializableClosure|O:\d+:"Opis\\\\Closure\\\\Box"|O:\d+:"Opis\\\\Closure\\\\Serializable")/', - $str, - )); - } - - /** - * Registers a handler for a specific resource type. - * - * The two callables provided are: - * 1. `wrapFn`: takes a resource of type `$type` and returns an array - * (or other serializable value) that represents the resource. - * 2. `restoreFn`: takes the array (or other serializable value) returned - * by `wrapFn` and returns a resource of type `$type`. - * - * - * @throws InvalidArgumentException If a handler for `$type` already exists. - * @param string $type The type of resource this handler is for. - * @param callable $wrapFn The callable that wraps the resource. - * @param callable $restoreFn The callable that restores the resource. - */ - public static function registerResourceHandler( - string $type, - callable $wrapFn, - callable $restoreFn, - ): void { - if (isset(self::$resourceHandlers[$type])) { - throw new InvalidArgumentException("Resource handler already registered for '$type'"); - } - - self::$resourceHandlers[$type] = [ - 'wrap' => $wrapFn, - 'restore' => $restoreFn, - ]; - } - - /** - * Serializes a given value into a string. - * - * This method takes a value, wraps any resources it contains using registered - * resource handlers, and serializes it into a string using Opis Closure's - * serialize function. - * - * - * @throws InvalidArgumentException If a resource type has no registered handler. - * @param mixed $value The value to be serialized, which may contain resources. - * @phpstan-return string The serialized string representation of the value. - */ - public static function serialize(mixed $value): string - { - self::assertAllowedBySecurityPolicy($value); - - $wrapped = self::wrapRecursive($value); - if (!self::requiresOpisSerialization($wrapped)) { - return serialize($wrapped); - } - - return oc_serialize($wrapped); - } - - /** - * Unserializes a given string into its original value. - * - * This method takes a serialized string and converts it back into its - * original value. It first unserializes the string using Opis Closure's - * unserialize function, then recursively unwraps any wrapped resources - * within the resulting value using registered resource handlers. - * - * @param string $blob The serialized string to be converted back to its original form. - * @phpstan-return mixed The original value, with any resources restored. - */ - public static function unserialize(string $blob): mixed - { - if (self::isNativeSerializedPayload($blob) && !self::containsOpisPayloadMarker($blob)) { - return self::unwrapRecursive(self::unserializeNative($blob)); - } - - if (self::isSerializedClosure($blob)) { - if (!self::$allowClosurePayloads) { - throw new InvalidArgumentException('Closure payload deserialization is disabled by security policy.'); - } - - return self::unwrapRecursive(oc_unserialize($blob)); - } - - if (!self::$allowObjectPayloads) { - throw new InvalidArgumentException('Object payload deserialization is disabled by security policy.'); - } - - return self::unwrapRecursive(oc_unserialize($blob)); - } - - /** - * Reverse {@see wrap} by recursively unwrapping values that were wrapped by - * {@see wrap}. This method is similar to {@see unserialize}, but it does not - * involve serialisation. - * - * @param mixed $resource A value that may contain wrapped resources. - * @phpstan-return mixed The same value with any wrapped resources restored. - */ - public static function unwrap(mixed $resource): mixed - { - return self::unwrapRecursive($resource); - } - - public static function useCompatibilitySecurity(): void - { - self::configureSecurity( - allowClosurePayloads: true, - allowObjectPayloads: true, - ); - } - - public static function useStrictSecurity(): void - { - self::configureSecurity( - allowClosurePayloads: false, - allowObjectPayloads: false, - ); - } - - /** - * Wraps resources within a given value. - * - * This method acts as a public interface to recursively wrap - * resources found within the provided value using registered - * resource handlers. - * - * @param mixed $value The value to be wrapped, which may contain resources. - * @phpstan-return mixed The value with any resources wrapped, or the original value if no resources are found. - */ - public static function wrap(mixed $value): mixed - { - return self::wrapRecursive($value); - } - - private static function assertAllowedBySecurityPolicy(mixed $value): void - { - if (!is_array($value)) { - self::assertAllowedScalarOrNode($value); - - return; - } - - foreach ($value as $item) { - self::assertAllowedBySecurityPolicy($item); - } - } - - private static function assertAllowedScalarOrNode(mixed $value): void - { - if ($value instanceof Closure) { - if (!self::$allowClosurePayloads) { - throw new InvalidArgumentException('Closure payload serialization is disabled by security policy.'); - } - - return; - } - - if (is_object($value) && !self::$allowObjectPayloads) { - throw new InvalidArgumentException('Object payload serialization is disabled by security policy.'); - } - } - - private static function containsOpisPayloadMarker(string $blob): bool - { - return str_contains($blob, 'Opis\\Closure\\'); - } - - private static function isNativeSerializedPayload(string $blob): bool - { - if (str_starts_with($blob, 'N;')) { - return true; - } - - $first = $blob[0] ?? ''; - - return ($blob[1] ?? '') === ':' && in_array($first, self::NATIVE_SERIALIZED_PREFIXES, true); - } - - private static function rememberSerializedClosureMemo(string $key, bool $value): bool - { - if (!array_key_exists($key, self::$serializedClosureMemo) - && count(self::$serializedClosureMemo) >= self::SERIALIZED_CLOSURE_MEMO_LIMIT) { - $oldest = array_key_first(self::$serializedClosureMemo); - unset(self::$serializedClosureMemo[$oldest]); - } - - self::$serializedClosureMemo[$key] = $value; - - return $value; - } - - private static function requiresOpisSerialization(mixed $value): bool - { - if (is_object($value) || is_resource($value)) { - return true; - } - - if (!is_array($value)) { - return false; - } - - return array_any($value, fn($item) => self::requiresOpisSerialization($item)); - } - - private static function unserializeNative(string $blob): mixed - { - set_error_handler( - static function (int $_severity, string $message): never { - throw new InvalidArgumentException( - "Invalid native serialized payload (error {$_severity}): {$message}", - ); - }, - ); - - try { - return unserialize($blob, [ - 'allowed_classes' => false, - 'max_depth' => 128, - ]); - } finally { - restore_error_handler(); - } - } - - /** - * Reverse {@see wrapRecursive} by recursively unwrapping values - * that were wrapped by {@see wrapRecursive}. - * - * @param mixed $resource A value that may contain wrapped resources. - * @phpstan-return mixed The same value with any wrapped resources restored. - */ - private static function unwrapRecursive(mixed $resource): mixed - { - if ( - is_array($resource) - && ($resource['__wrapped_resource'] ?? false) - && is_string($resource['type'] ?? null) - && array_key_exists('data', $resource) - && isset(self::$resourceHandlers[$resource['type']]) - ) { - return (self::$resourceHandlers[$resource['type']]['restore'])($resource['data']); - } - - if (is_array($resource)) { - foreach ($resource as $key => $item) { - $resource[$key] = self::unwrapRecursive($item); - } - } - - return $resource; - } - - /** - * Recursively wraps resources within a given value. - * - * This method checks if the provided value is a resource. If so, - * it retrieves the appropriate handler for the resource type and - * uses it to wrap the resource. The wrapped resource is returned - * as an associative array containing a flag, the resource type, - * and the wrapped data. - * - * If the value is an array, the method recursively processes each - * element in the array. - * - * - * @throws InvalidArgumentException If no handler is registered for a resource type. - * @param mixed $resource The value to be wrapped, which may contain resources. - * @phpstan-return mixed The value with any resources wrapped, or the original value if no resources are found. - */ - private static function wrapRecursive(mixed $resource): mixed - { - if (is_resource($resource)) { - $type = get_resource_type($resource); - $arr = self::$resourceHandlers[$type] ?? null; - if (!$arr) { - throw new InvalidArgumentException("No handler for resource type '$type'"); - } - - return [ - '__wrapped_resource' => true, - 'type' => $type, - 'data' => ($arr['wrap'])($resource), - ]; - } - - if (is_array($resource)) { - foreach ($resource as $key => $value) { - $resource[$key] = self::wrapRecursive($value); - } - } - - return $resource; - } -} diff --git a/tests/Cache/ApcuCachePoolTest.php b/tests/Cache/ApcuCachePoolTest.php index 93f2954..1a8bd16 100644 --- a/tests/Cache/ApcuCachePoolTest.php +++ b/tests/Cache/ApcuCachePoolTest.php @@ -11,9 +11,8 @@ */ use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\ApcuCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── skip entirely if APCu unavailable ─────────────────────────────── */ if (! extension_loaded('apcu')) { @@ -32,33 +31,6 @@ beforeEach(function () { apcu_clear_cache(); // fresh memory $this->cache = Cache::apcu('tests'); // APCu-backed pool - ValueSerializer::clearResourceHandlers(); - - /* register stream handler for resource tests */ - ValueSerializer::registerResourceHandler( - 'stream', - // ----- wrap ---------------------------------------------------- - function (mixed $res): array { - if (! is_resource($res)) { - throw new InvalidArgumentException('Expected resource'); - } - $meta = stream_get_meta_data($res); - rewind($res); - - return [ - 'mode' => $meta['mode'], - 'content' => stream_get_contents($res), - ]; - }, - // ----- restore ------------------------------------------------- - function (array $data): mixed { - $s = fopen('php://memory', $data['mode']); - fwrite($s, $data['content']); - rewind($s); - - return $s; // <- real resource - } - ); }); afterEach(function () { @@ -77,20 +49,9 @@ function (array $data): mixed { // Scalar default expect($this->cache->get('missing', 'default'))->toBe('default'); - // Callable default without prior set - $computed = $this->cache->get('dyn', function (ApcuCacheItem $item) { - $item->expiresAfter(1); - - return 'computed'; - }); - expect($computed)->toBe('computed'); - - // Now that it’s been set, get() returns the cached value - expect($this->cache->get('dyn'))->toBe('computed'); - - // After expiry, returns the new default again - usleep(2_000_000); - expect($this->cache->get('dyn', 'fallback'))->toBe('fallback'); + $default = static fn(): string => 'computed'; + expect($this->cache->get('dyn', $default))->toBe($default) + ->and($this->cache->has('dyn'))->toBeFalse(); }); test('get throws for invalid key (apcu)', function () { @@ -101,7 +62,7 @@ function (array $data): mixed { /* ─── PSR-6 behaviour ─────────────────────────────────────────────── */ test('PSR-6 getItem()/save() (apcu)', function () { $item = $this->cache->getItem('psr'); - expect($item)->toBeInstanceOf(ApcuCacheItem::class) + expect($item)->toBeInstanceOf(CacheItem::class) ->and($item->isHit())->toBeFalse(); $item->set(99)->expiresAfter(null)->save(); @@ -117,13 +78,11 @@ function (array $data): mixed { expect($this->cache->get('x'))->toBe('X'); }); -/* ─── ArrayAccess / magic props ───────────────────────────────────── */ -test('ArrayAccess & magic props (apcu)', function () { +/* ─── ArrayAccess ─────────────────────────────────────────────────── */ +test('ArrayAccess (apcu)', function () { $this->cache['k'] = 11; - expect($this->cache['k'])->toBe(11); - - $this->cache->alpha = 'β'; - expect($this->cache->alpha)->toBe('β'); + expect($this->cache['k'])->toBe(11) + ->and(method_exists($this->cache, '__get'))->toBeFalse(); }); /* ─── TTL / expiration ───────────────────────────────────────────── */ @@ -141,17 +100,6 @@ function (array $data): mixed { expect($g(10))->toBe(15); }); -/* ─── stream resource round-trip ──────────────────────────────────── */ -test('stream resource round-trip (apcu)', function () { - $s = fopen('php://memory', 'r+'); - fwrite($s, 'stream'); - rewind($s); - - $this->cache->getItem('stream')->set($s)->save(); - $restored = $this->cache->getItem('stream')->get(); - expect(stream_get_contents($restored))->toBe('stream'); -}); - /* ─── invalid key triggers exception ─────────────────────────────── */ test('invalid key throws (apcu)', function () { expect(fn () => $this->cache->set('bad key', 'v')) diff --git a/tests/Cache/ArchitectureHardeningTest.php b/tests/Cache/ArchitectureHardeningTest.php new file mode 100644 index 0000000..c30a95a --- /dev/null +++ b/tests/Cache/ArchitectureHardeningTest.php @@ -0,0 +1,268 @@ +}> */ + private array $records = []; + + /** @var array */ + private array $versions = []; + + public int $deleteBatches = 0; + + public int $readBatches = 0; + + public int $saveBatches = 0; + + public int $tagFetchBatches = 0; + + public bool $throwOnRead = false; + + public bool $throwOnTagRead = false; + + public function clear(): bool + { + $this->records = []; + $this->versions = []; + + return true; + } + + public function deleteItem(string $key): bool + { + return $this->deleteItems([$key]); + } + + public function deleteItems(array $keys): bool + { + $this->deleteBatches++; + foreach ($keys as $key) { + unset($this->records[$key]); + } + + return true; + } + + public function getItem(string $key): CacheItem + { + return $this->multiFetch([$key])[$key]; + } + + public function hasItem(string $key): bool + { + return $this->getItem($key)->isHit(); + } + + /** @return array */ + public function multiFetch(array $keys): array + { + $this->readBatches++; + if ($this->throwOnRead) { + throw new RuntimeException('backend read failed'); + } + $items = []; + foreach ($keys as $key) { + $record = $this->records[$key] ?? null; + if ($record === null || CachePayloadCodec::isExpired($record['expires'])) { + $items[$key] = new CacheItem($this, $key); + + continue; + } + $items[$key] = (new CacheItem($this, $key, $record['value'], true)) + ->expiresAt(CachePayloadCodec::toDateTime($record['expires'])) + ->setTagVersions($record['tags']); + } + + return $items; + } + + public function save(CacheItemInterface $item): bool + { + return $this->saveItems([$item->getKey() => $item]); + } + + public function saveItems(array $items): bool + { + $this->saveBatches++; + foreach ($items as $item) { + if (!$this->supportsItem($item)) { + return false; + } + } + foreach ($items as $item) { + $ttl = $item instanceof CacheItem ? $item->ttlSeconds() : null; + if ($ttl !== null && $ttl <= 0) { + unset($this->records[$item->getKey()]); + + continue; + } + $this->records[$item->getKey()] = [ + 'value' => $item->get(), + 'expires' => $ttl === null ? null : time() + $ttl, + 'tags' => $item instanceof CacheItem ? $item->getTagVersions() : [], + ]; + } + + return true; + } + + public function getTagVersions(array $tags): array + { + $this->tagFetchBatches++; + if ($this->throwOnTagRead) { + throw new RuntimeException('backend tag read failed'); + } + $versions = []; + foreach ($tags as $tag) { + $versions[$tag] = $this->versions[$tag] ?? 0; + } + + return $versions; + } + + public function incrementTagVersions(array $tags): bool + { + foreach ($tags as $tag) { + $this->versions[$tag] = ($this->versions[$tag] ?? 0) + 1; + } + + return true; + } + + public function resetOperationCounts(): void + { + $this->deleteBatches = 0; + $this->readBatches = 0; + $this->saveBatches = 0; + $this->tagFetchBatches = 0; + } +} + +test('facade bulk methods call one adapter bulk path', function () { + $adapter = new ArchitectureHardeningTest(); + $cache = new Cache($adapter); + + expect($cache->setMultiple(['a' => 1, 'b' => 2, 'c' => 3]))->toBeTrue() + ->and($adapter->saveBatches)->toBe(1); + + $adapter->resetOperationCounts(); + expect($cache->getMultiple(['c', 'missing', 'a'], 'default')) + ->toBe(['c' => 3, 'missing' => 'default', 'a' => 1]) + ->and($adapter->readBatches)->toBe(1); + + expect($cache->deleteMultiple(['a', 'b']))->toBeTrue() + ->and($adapter->deleteBatches)->toBe(1); +}); + +test('bulk validation completes before any storage mutation', function () { + $adapter = new ArchitectureHardeningTest(); + $cache = new Cache($adapter); + + expect(fn() => $cache->setMultiple(['valid' => 1, 'bad key' => 2])) + ->toThrow(CacheInvalidArgumentException::class) + ->and($adapter->saveBatches)->toBe(0); + expect(fn() => $cache->setMultiple([1 => 'numeric key'])) + ->toThrow(CacheInvalidArgumentException::class) + ->and($adapter->saveBatches)->toBe(0); + expect(fn() => $cache->deleteMultiple(['valid', 'bad:key'])) + ->toThrow(CacheInvalidArgumentException::class) + ->and($adapter->deleteBatches)->toBe(0); +}); + +test('bulk tagged reads fetch tag versions once and reject whole stale records', function () { + $adapter = new ArchitectureHardeningTest(); + $cache = new Cache($adapter); + $cache->setTagged('one', 1, ['group', 'shared']); + $cache->setTagged('two', 2, ['group']); + $adapter->resetOperationCounts(); + + expect($cache->getMultiple(['one', 'two']))->toBe(['one' => 1, 'two' => 2]) + ->and($adapter->tagFetchBatches)->toBe(1); + + $cache->invalidateTag('group'); + $adapter->resetOperationCounts(); + expect($cache->getMultiple(['one', 'two']))->toBe(['one' => null, 'two' => null]) + ->and($adapter->tagFetchBatches)->toBe(1) + ->and($adapter->deleteBatches)->toBe(1); +}); + +test('cache items can only be persisted by their exact owning pool', function () { + $first = new ArchitectureHardeningTest(); + $second = new ArchitectureHardeningTest(); + $foreign = $first->createItem('owned')->set('value'); + $local = $second->createItem('local')->set('local-value'); + + expect($second->save($foreign))->toBeFalse() + ->and($second->saveDeferred($foreign))->toBeFalse() + ->and($second->saveItems(['local' => $local, 'owned' => $foreign]))->toBeFalse() + ->and($second->getItem('local')->isHit())->toBeFalse(); +}); + +test('zero and negative ttl delete through single and bulk APIs', function () { + $cache = new Cache(new ArchitectureHardeningTest()); + $cache->setMultiple(['zero' => 1, 'negative' => 2]); + + expect($cache->set('zero', 3, 0))->toBeTrue() + ->and($cache->setMultiple(['negative' => 4], -1))->toBeTrue() + ->and($cache->getMultiple(['zero', 'negative'])) + ->toBe(['zero' => null, 'negative' => null]); +}); + +test('runtime failures are fail-open by default and optionally propagate', function () { + $openAdapter = new ArchitectureHardeningTest(); + $open = new Cache($openAdapter); + $openAdapter->throwOnRead = true; + + expect($open->get('key', 'fallback'))->toBe('fallback') + ->and($open->exportMetrics()['architecture_hardening_test']['backend_failure'] ?? 0)->toBe(1); + + $closedAdapter = new ArchitectureHardeningTest(); + $closed = new Cache($closedAdapter, options: new CacheOptions(failOpen: false)); + $closedAdapter->throwOnRead = true; + expect(fn() => $closed->get('key'))->toThrow(RuntimeException::class); +}); + +test('tag metadata failures cannot expose or create tagged values', function () { + $adapter = new ArchitectureHardeningTest(); + $cache = new Cache($adapter); + $cache->setTagged('tagged', 'value', ['group']); + $cache->set('plain', 'plain-value'); + $adapter->resetOperationCounts(); + $adapter->throwOnTagRead = true; + + expect($cache->get('tagged', 'fallback'))->toBe('fallback') + ->and($cache->getMultiple(['tagged', 'plain'], 'fallback')) + ->toBe(['tagged' => 'fallback', 'plain' => 'plain-value']) + ->and($cache->setTagged('new-tagged', 'value', ['group']))->toBeFalse() + ->and($adapter->saveBatches)->toBe(0); +}); + +test('tiered reads perform one batch per needed tier and one promotion batch', function () { + $l1 = new ArchitectureHardeningTest(); + $l2 = new ArchitectureHardeningTest(); + $cache = Cache::tiered([$l1, $l2]); + $l1->set('l1', 1); + $l2->set('l2a', 2); + $l2->set('l2b', 3); + $l1->resetOperationCounts(); + $l2->resetOperationCounts(); + + expect($cache->getMultiple(['l1', 'l2a', 'missing', 'l2b'])) + ->toBe(['l1' => 1, 'l2a' => 2, 'missing' => null, 'l2b' => 3]) + ->and($l1->readBatches)->toBe(1) + ->and($l2->readBatches)->toBe(1) + ->and($l1->saveBatches)->toBe(1); +}); diff --git a/tests/Cache/ArrayCachePoolTest.php b/tests/Cache/ArrayCachePoolTest.php index 2f27ee7..928f49e 100644 --- a/tests/Cache/ArrayCachePoolTest.php +++ b/tests/Cache/ArrayCachePoolTest.php @@ -4,7 +4,7 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Adapter\AbstractCacheAdapter; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; beforeEach(function () { @@ -18,10 +18,10 @@ ->and($this->cache->get('alpha'))->toBeNull(); }); -test('array adapter getItem returns GenericCacheItem', function () { +test('array adapter getItem returns the shared CacheItem', function () { $item = $this->cache->getItem('x'); - expect($item)->toBeInstanceOf(GenericCacheItem::class) + expect($item)->toBeInstanceOf(CacheItem::class) ->and($item->isHit())->toBeFalse(); }); @@ -44,11 +44,10 @@ ->and($items['c']->isHit())->toBeFalse(); }); -test('deferred commit attempts every queued item after a save failure', function () { +test('deferred commit uses one bulk persistence call and retains failures', function () { $adapter = new class extends AbstractCacheAdapter { - /** @var list */ - public array $attempted = []; + public int $bulkCalls = 0; public function clear(): bool { @@ -76,9 +75,15 @@ public function deleteItems(array $keys): bool return true; } - public function getItem(string $key): GenericCacheItem + public function getItem(string $key): CacheItem { - return new GenericCacheItem($this, $key); + return new CacheItem($this, $key); + } + + /** @return array */ + public function multiFetch(array $keys): array + { + return array_fill_keys($keys, new CacheItem($this, 'unused')); } public function hasItem(string $key): bool @@ -88,14 +93,17 @@ public function hasItem(string $key): bool public function save(CacheItemInterface $item): bool { - $this->attempted[] = $item->getKey(); + unset($item); - return $item->getKey() !== 'first'; + return true; } - protected function supportsItem(CacheItemInterface $item): bool + public function saveItems(array $items): bool { - return $item instanceof GenericCacheItem; + unset($items); + $this->bulkCalls++; + + return $this->bulkCalls > 1; } }; @@ -103,6 +111,9 @@ protected function supportsItem(CacheItemInterface $item): bool $adapter->saveDeferred($adapter->getItem('second')->set(2)); expect($adapter->commit())->toBeFalse() - ->and($adapter->attempted)->toBe(['first', 'second']) - ->and($adapter->commit())->toBeTrue(); + ->and($adapter->bulkCalls)->toBe(1) + ->and($adapter->commit())->toBeTrue() + ->and($adapter->bulkCalls)->toBe(2) + ->and($adapter->commit())->toBeTrue() + ->and($adapter->bulkCalls)->toBe(2); }); diff --git a/tests/Cache/CacheFeaturesTest.php b/tests/Cache/CacheFeaturesTest.php index 294e0a5..22197cc 100644 --- a/tests/Cache/CacheFeaturesTest.php +++ b/tests/Cache/CacheFeaturesTest.php @@ -3,6 +3,8 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; +use Infocyph\CacheLayer\Cache\Adapter\FileCacheAdapter; use Infocyph\CacheLayer\Cache\Lock\LockHandle; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; @@ -74,24 +76,16 @@ function () use (&$count) { ->and($this->cache->get('hot'))->toBeNull(); }); -test('get callable path still computes once on miss', function () { +test('get returns callable defaults without executing or caching them', function () { $count = 0; - - $a = $this->cache->get('compute', function ($item) use (&$count) { + $default = function () use (&$count) { $count++; - $item->expiresAfter(30); - return 99; - }); - $b = $this->cache->get('compute', function () use (&$count) { - $count++; - - return 11; - }); + }; - expect($a)->toBe(99) - ->and($b)->toBe(99) - ->and($count)->toBe(1); + expect($this->cache->get('compute', $default))->toBe($default) + ->and($this->cache->has('compute'))->toBeFalse() + ->and($count)->toBe(0); }); test('invalidateTags removes value when duplicate tags are passed', function () { @@ -145,6 +139,44 @@ function () use (&$count) { expect($this->cache->get('article'))->toBe('v2'); }); +test('valid user keys cannot collide with internal tag metadata', function () { + $this->cache->set('tag.group', 'plain'); + $this->cache->setTagged('tagged', 'versioned', ['group']); + $this->cache->invalidateTag('group'); + + expect($this->cache->get('tag.group'))->toBe('plain') + ->and($this->cache->get('tagged'))->toBeNull(); +}); + +test('file tag increments do not lose concurrent updates', function () { + if (!function_exists('pcntl_fork') || !function_exists('pcntl_exec')) { + $this->markTestSkipped('pcntl is required for the concurrency test.'); + } + + $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']); + } + pcntl_exec(PHP_BINARY, ['-r', '']); + throw new RuntimeException('Unable to terminate concurrency-test worker.'); + } + if ($pid > 0) { + $children[] = $pid; + } + } + foreach ($children as $pid) { + pcntl_waitpid($pid, $status); + expect(pcntl_wexitstatus($status))->toBe(0); + } + + $adapter = new FileCacheAdapter('features', $this->cacheDir); + expect($adapter->getTagVersions(['concurrent']))->toBe(['concurrent' => 100]); +}); + test('remember uses configured lock provider', function () { $calls = ['acquire' => 0, 'release' => 0]; @@ -193,8 +225,8 @@ public function release(?LockHandle $handle): void $metrics = $this->cache->exportMetrics(); $adapter = 'file'; - expect($metrics[$adapter]['miss'] ?? 0)->toBeGreaterThanOrEqual(1) - ->and($metrics[$adapter]['hit'] ?? 0)->toBeGreaterThanOrEqual(1) + expect($metrics[$adapter]['get_miss'] ?? 0)->toBeGreaterThanOrEqual(1) + ->and($metrics[$adapter]['get_hit'] ?? 0)->toBeGreaterThanOrEqual(1) ->and($metrics[$adapter]['set'] ?? 0)->toBeGreaterThanOrEqual(1); }); @@ -215,11 +247,12 @@ public function release(?LockHandle $handle): void test('payload compression can be enabled without changing values', function () { $payload = str_repeat('cache-layer-payload-', 128); + $cache = Cache::file( + 'compressed-features', + $this->cacheDir, + new CacheOptions(compressionThreshold: 128, compressionLevel: 6), + ); + $cache->set('big', $payload); - $this->cache->configurePayloadCompression(128, 6); - $this->cache->set('big', $payload); - - expect($this->cache->get('big'))->toBe($payload); - - $this->cache->configurePayloadCompression(null); + expect($cache->get('big'))->toBe($payload); }); diff --git a/tests/Cache/CachePayloadCodecSecurityTest.php b/tests/Cache/CachePayloadCodecSecurityTest.php index ef41c49..383a5b9 100644 --- a/tests/Cache/CachePayloadCodecSecurityTest.php +++ b/tests/Cache/CachePayloadCodecSecurityTest.php @@ -3,50 +3,83 @@ declare(strict_types=1); use Infocyph\CacheLayer\Cache\Adapter\CachePayloadCodec; +use Infocyph\CacheLayer\Cache\Cache; +use Infocyph\CacheLayer\Cache\CacheOptions; -beforeEach(function () { - CachePayloadCodec::configureSecurity(null, 8_388_608); +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]); + expect(str_starts_with($blob, 'cl2-sig:'))->toBeTrue(); + + $record = $codec->decode($blob); + expect($record?->value)->toBe(['k' => 'v']) + ->and($record?->tags)->toBe(['group' => 2]); }); -afterEach(function () { - CachePayloadCodec::configureSecurity(null, 8_388_608); +test('payload codec rejects tampered signed payload', function () { + $codec = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); + $blob = $codec->encode('value', null); + + expect($codec->decode($blob . 'x'))->toBeNull(); }); -test('payload codec signs and verifies payload integrity when key is configured', function () { - CachePayloadCodec::configureSecurity('secret-key-123', 8_388_608); +test('payload codec policies are isolated between instances', function () { + $unsigned = new CachePayloadCodec(); + $signed = new CachePayloadCodec(new CacheOptions(integrityKey: 'secret-key-123')); + $blob = $unsigned->encode('value', null); - $blob = CachePayloadCodec::encode(['k' => 'v'], null); - expect(str_starts_with($blob, 'imx-sig-v1:'))->toBeTrue(); + expect($unsigned->decode($blob)?->value)->toBe('value') + ->and($signed->decode($blob))->toBeNull() + ->and($unsigned->decode($blob)?->value)->toBe('value'); +}); + +test('long-running cache instances do not leak serialization policy', function () { + $strict = Cache::memory('strict-worker', new CacheOptions(allowObjects: false)); + $permissive = Cache::memory('permissive-worker', new CacheOptions(allowObjects: true)); - $decoded = CachePayloadCodec::decode($blob); - expect($decoded)->toBeArray() - ->and($decoded['value'])->toBe(['k' => 'v']); + expect($strict->set('object', new stdClass()))->toBeFalse() + ->and($permissive->set('object', new stdClass()))->toBeTrue() + ->and($permissive->get('object'))->toBeInstanceOf(stdClass::class) + ->and($strict->set('scalar', 'still-valid'))->toBeTrue() + ->and($strict->get('scalar'))->toBe('still-valid'); }); -test('payload codec rejects tampered signed payload', function () { - CachePayloadCodec::configureSecurity('secret-key-123', 8_388_608); +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); + $closure = $codec->decode($blob)?->value; + $resource = fopen('php://memory', 'r+'); - $blob = CachePayloadCodec::encode('value', null); - $tampered = $blob.'x'; + expect($closure)->toBeInstanceOf(Closure::class) + ->and($closure(4))->toBe(5) + ->and(fn() => $codec->encode(['nested' => static fn(): int => 1], null)) + ->toThrow(InvalidArgumentException::class) + ->and(fn() => $codec->encode($resource, null)) + ->toThrow(InvalidArgumentException::class); - expect(CachePayloadCodec::decode($tampered))->toBeNull(); + fclose($resource); }); -test('payload codec rejects unsigned payload when integrity key is configured', function () { - $unsigned = CachePayloadCodec::encode('legacy', null); +test('payload codec can disable closure serialization per cache instance', function () { + $codec = new CachePayloadCodec(new CacheOptions(allowClosures: false)); - CachePayloadCodec::configureSecurity('secret-key-123', 8_388_608); - expect(CachePayloadCodec::decode($unsigned))->toBeNull(); + expect(fn() => $codec->encode(static fn(): int => 1, null)) + ->toThrow(InvalidArgumentException::class); }); test('payload codec bounds decompressed payload size', function () { - CachePayloadCodec::configureCompression(1, 9); - CachePayloadCodec::configureSecurity(null, null); - $compressed = CachePayloadCodec::encode(str_repeat('A', 8_192), null); + $writer = new CachePayloadCodec(new CacheOptions(compressionThreshold: 1, compressionLevel: 9)); + $compressed = $writer->encode(str_repeat('A', 8_192), null); + $reader = new CachePayloadCodec(new CacheOptions(maxPayloadBytes: 512, compressionThreshold: 1)); - CachePayloadCodec::configureSecurity(null, 512); + expect(str_starts_with($compressed, 'cl2-gz:'))->toBeTrue() + ->and($reader->decode($compressed))->toBeNull(); +}); - expect(CachePayloadCodec::decode($compressed))->toBeNull(); +test('payload codec does not decode legacy payload markers', function () { + $codec = new CachePayloadCodec(); - CachePayloadCodec::configureCompression(null); + expect($codec->decode('imx-gz:payload'))->toBeNull() + ->and($codec->decode('imx-sig-v1:payload'))->toBeNull(); }); diff --git a/tests/Cache/ChainCachePoolTest.php b/tests/Cache/ChainCachePoolTest.php index 915e9e0..1c36614 100644 --- a/tests/Cache/ChainCachePoolTest.php +++ b/tests/Cache/ChainCachePoolTest.php @@ -9,7 +9,7 @@ beforeEach(function () { $this->l1 = new ArrayCacheAdapter('l1'); $this->l2 = new ArrayCacheAdapter('l2'); - $this->cache = Cache::chain([$this->l1, $this->l2]); + $this->cache = Cache::tiered([$this->l1, $this->l2]); }); test('chain adapter writes through all pools', function () { diff --git a/tests/Cache/FileCachePoolTest.php b/tests/Cache/FileCachePoolTest.php index 58328b8..578051e 100644 --- a/tests/Cache/FileCachePoolTest.php +++ b/tests/Cache/FileCachePoolTest.php @@ -5,43 +5,15 @@ /** tests/FileCachePoolTest.php */ use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\FileCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Infocyph\CacheLayer\Serializer\ValueSerializer; beforeEach(function () { - ValueSerializer::clearResourceHandlers(); - /* fresh temp directory for each run */ $this->cacheDir = sys_get_temp_dir().'/pest_cache_'.uniqid(); /* build a file-backed cachepool via static factory */ $this->cache = Cache::file('tests', $this->cacheDir); - /* register stream handler only for the test run */ - ValueSerializer::registerResourceHandler( - 'stream', - // ----- wrap ---------------------------------------------------- - function (mixed $res): array { - if (! is_resource($res)) { - throw new InvalidArgumentException('Expected resource'); - } - $meta = stream_get_meta_data($res); - rewind($res); - - return [ - 'mode' => $meta['mode'], - 'content' => stream_get_contents($res), - ]; - }, - // ----- restore ------------------------------------------------- - function (array $data): mixed { - $s = fopen('php://memory', $data['mode']); - fwrite($s, $data['content']); - rewind($s); - - return $s; // <- real resource - } - ); }); afterEach(function () { @@ -73,18 +45,9 @@ function (array $data): mixed { // Scalar default expect($this->cache->get('missing', 'def'))->toBe('def'); - // Callable default - $computed = $this->cache->get('x', function (FileCacheItem $item) { - $item->expiresAfter(1); - - return 'xyz'; - }); - expect($computed)->toBe('xyz'); - expect($this->cache->get('x'))->toBe('xyz'); - - // After TTL expires, fallback - usleep(2_000_000); - expect($this->cache->get('x', 'fallback'))->toBe('fallback'); + $default = static fn(): string => 'xyz'; + expect($this->cache->get('x', $default))->toBe($default) + ->and($this->cache->has('x'))->toBeFalse(); }); test('get throws for invalid key (file)', function () { @@ -96,7 +59,7 @@ function (array $data): mixed { test('PSR-6 getItem()/save()', function () { $item = $this->cache->getItem('psr'); - expect($item)->toBeInstanceOf(FileCacheItem::class) + expect($item)->toBeInstanceOf(CacheItem::class) ->and($item->isHit())->toBeFalse(); $item->set(123)->expiresAfter(null)->save(); @@ -130,40 +93,9 @@ function (array $data): mixed { expect(isset($this->cache['x']))->toBeFalse(); }); -test('magic __get/__set/__isset/__unset', function () { - $this->cache->alpha = 'beta'; - - expect(isset($this->cache->alpha))->toBeTrue() - ->and($this->cache->alpha)->toBe('beta'); - - unset($this->cache->alpha); - expect(isset($this->cache->alpha))->toBeFalse(); -}); -test('runtime re-namespace and directory swap', function () { - $newDir = sys_get_temp_dir().'/pest_cache_new_'.uniqid(); - - $this->cache->setNamespaceAndDirectory('newns', $newDir); - - expect($this->cache->set('foo', 'bar'))->toBeTrue() - ->and($this->cache->get('foo'))->toBe('bar'); - - $namespaceDir = $newDir.'/cache_newns'; - expect(is_dir($namespaceDir)) - ->toBeTrue() - ->and(glob($namespaceDir.'/*.cache'))->not->toBeEmpty(); - - /* manual clean-up of this secondary dir (afterEach cleans only first dir) */ - foreach (glob($namespaceDir.'/*') as $f) { - if (is_file($f)) { - unlink($f); - } - } - if (is_dir($namespaceDir)) { - rmdir($namespaceDir); - } - if (is_dir($newDir)) { - rmdir($newDir); - } +test('runtime storage configuration is immutable', function () { + expect(method_exists($this->cache, '__get'))->toBeFalse() + ->and(method_exists($this->cache, 'setNamespaceAndDirectory'))->toBeFalse(); }); test('expiration honours TTL', function () { @@ -172,43 +104,13 @@ function (array $data): mixed { expect($this->cache->getItem('ttl')->isHit())->toBeFalse(); }); -test('closure round-trips via ValueSerializer', function () { +test('closure round-trips via ClosureSerializer', function () { $double = fn (int $n) => $n * 2; $this->cache->getItem('cb')->set($double)->save(); $restored = $this->cache->getItem('cb')->get(); expect($restored(7))->toBe(14); }); -test('stream resource round-trip', function () { - - $s = fopen('php://memory', 'r+'); - fwrite($s, 'hello'); - rewind($s); - $this->cache->getItem('stream')->set($s)->save(); - $r = $this->cache->getItem('stream')->get(); - expect(stream_get_contents($r))->toBe('hello'); -}); - -test('custom resource handler works', function () { - $dirPath = __DIR__; // path we will open/restore - $dirRes = opendir($dirPath); - $resType = get_resource_type($dirRes); // "stream" - ValueSerializer::clearResourceHandlers(); - - // register handler *capturing* $dirPath - ValueSerializer::registerResourceHandler( - $resType, - fn ($r) => ['path' => is_resource($r) ? $dirPath : $dirPath], // wrap - fn (array $data) => opendir($data['path']) // restore - ); - - $this->cache->getItem('dirRes')->set($dirRes)->save(); - - $restored = $this->cache->getItem('dirRes')->get(); - expect(is_resource($restored))->toBeTrue() - ->and(get_resource_type($restored))->toBe($resType); -}); - test('invalid cache key throws', function () { expect(fn () => $this->cache->set('space key', 'v')) ->toThrow(InvalidArgumentException::class); diff --git a/tests/Cache/MemCachePoolTest.php b/tests/Cache/MemcachedCachePoolTest.php similarity index 63% rename from tests/Cache/MemCachePoolTest.php rename to tests/Cache/MemcachedCachePoolTest.php index 3809f5e..a16f3d6 100644 --- a/tests/Cache/MemCachePoolTest.php +++ b/tests/Cache/MemcachedCachePoolTest.php @@ -3,17 +3,15 @@ declare(strict_types=1); /** - * tests/MemCachePoolTest.php + * tests/MemcachedCachePoolTest.php * * Runs only when the Memcached extension is loaded *and* * a Memcached daemon is reachable at 127.0.0.1:11211. */ -use Infocyph\CacheLayer\Cache\Adapter\MemCacheAdapter; use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\MemCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── Skip suite if Memcached unavailable ─────────────────────────── */ @@ -41,48 +39,18 @@ $client = new Memcached; $client->addServer($memcachedHost, $memcachedPort); $client->flush(); // fresh slate - ValueSerializer::clearResourceHandlers(); - $this->cache = Cache::memcache( + $this->client = $client; + $this->cache = Cache::memcached( 'tests', [[$memcachedHost, $memcachedPort, 0]], $client ); - /* register stream handler for resource test */ - ValueSerializer::registerResourceHandler( - 'stream', - // ----- wrap ---------------------------------------------------- - function (mixed $res): array { - if (! is_resource($res)) { - throw new InvalidArgumentException('Expected resource'); - } - $meta = stream_get_meta_data($res); - rewind($res); - - return [ - 'mode' => $meta['mode'], - 'content' => stream_get_contents($res), - ]; - }, - // ----- restore ------------------------------------------------- - function (array $data): mixed { - $s = fopen('php://memory', $data['mode']); - fwrite($s, $data['content']); - rewind($s); - - return $s; // <- real resource - } - ); }); afterEach(function () { - /** @var MemCacheAdapter $adapt */ - $adapt = (new ReflectionObject($this->cache)) - ->getProperty('adapter')->getValue($this->cache); - (new ReflectionProperty($adapt, 'mc')) - ->getValue($adapt) - ->flush(); + $this->client->flush(); }); /* ── Convenience helpers ────────────────────────────────────────── */ @@ -99,17 +67,9 @@ function (array $data): mixed { // Scalar expect($this->cache->get('nobody', 'dflt'))->toBe('dflt'); - // Callable - $val = $this->cache->get('call', function (MemCacheItem $item) { - $item->expiresAfter(3); - - return 'hello'; - }); - expect($val)->toBe('hello'); - expect($this->cache->get('call'))->toBe('hello'); - - usleep(4_000_000); - expect($this->cache->get('call', 'again'))->toBe('again'); + $default = static fn(): string => 'hello'; + expect($this->cache->get('call', $default))->toBe($default) + ->and($this->cache->has('call'))->toBeFalse(); }); test('get throws for invalid key (memcached)', function () { @@ -120,7 +80,7 @@ function (array $data): mixed { /* ─── PSR-6 getItem()/save() ───────────────────────────────────── */ test('PSR-6 getItem()/save()', function () { $it = $this->cache->getItem('psr'); - expect($it)->toBeInstanceOf(MemCacheItem::class) + expect($it)->toBeInstanceOf(CacheItem::class) ->and($it->isHit())->toBeFalse(); $it->set(321)->save(); @@ -135,12 +95,10 @@ function (array $data): mixed { expect($this->cache->get('a'))->toBe('A'); }); -test('ArrayAccess & magic props', function () { +test('ArrayAccess is the only property-like access', function () { $this->cache['x'] = 7; - expect($this->cache['x'])->toBe(7); - - $this->cache->alpha = 'ω'; - expect($this->cache->alpha)->toBe('ω'); + expect($this->cache['x'])->toBe(7) + ->and(method_exists($this->cache, '__get'))->toBeFalse(); }); test('TTL expiration', function () { @@ -156,24 +114,18 @@ function (array $data): mixed { expect($g(3))->toBe(9); }); -test('stream resource round-trip', function () { - $s = fopen('php://memory', 'r+'); - fwrite($s, 'data'); - rewind($s); - $this->cache->getItem('stream')->set($s)->save(); - $r = $this->cache->getItem('stream')->get(); - expect(stream_get_contents($r))->toBe('data'); -}); - test('invalid key throws', function () { expect(fn () => $this->cache->set('bad key', 'v')) ->toThrow(InvalidArgumentException::class); }); -test('clear() flushes cache', function () { +test('clear only advances this namespace epoch', function () use ($memcachedHost, $memcachedPort) { + $other = Cache::memcached('other', [[$memcachedHost, $memcachedPort, 0]], $this->client); $this->cache->set('z', 9); + $other->set('z', 10); $this->cache->clear(); - expect($this->cache->hasItem('z'))->toBeFalse(); + expect($this->cache->hasItem('z'))->toBeFalse() + ->and($other->get('z'))->toBe(10); }); test('Memcached adapter multiFetch()', function () { diff --git a/tests/Cache/MongoDbCachePoolTest.php b/tests/Cache/MongoDbCachePoolTest.php index c6049fc..84f37e9 100644 --- a/tests/Cache/MongoDbCachePoolTest.php +++ b/tests/Cache/MongoDbCachePoolTest.php @@ -11,6 +11,10 @@ /** @var array> */ public array $docs = []; + public int $bulkWrites = 0; + + public int $findCalls = 0; + public function countDocuments(array $filter): int { $count = 0; @@ -35,7 +39,10 @@ public function countDocuments(array $filter): int public function deleteMany(array $filter): void { foreach ($this->docs as $key => $doc) { - if (($doc['ns'] ?? null) === ($filter['ns'] ?? null)) { + $ids = $filter['_id']['$in'] ?? null; + if (is_array($ids) && in_array($key, $ids, true)) { + unset($this->docs[$key]); + } elseif (isset($filter['ns']) && ($doc['ns'] ?? null) === $filter['ns']) { unset($this->docs[$key]); } } @@ -51,10 +58,38 @@ public function findOne(array $filter): ?array return $this->docs[$filter['_id']] ?? null; } + /** @return list> */ + public function find(array $filter): array + { + $this->findCalls++; + $ids = $filter['_id']['$in'] ?? []; + + return array_values(array_filter( + $this->docs, + static fn(array $doc): bool => in_array($doc['_id'] ?? null, $ids, true), + )); + } + public function updateOne(array $filter, array $update, array $options = []): void { unset($options); - $this->docs[$filter['_id']] = $update['$set']; + $id = $filter['_id']; + $document = $this->docs[$id] ?? ['_id' => $id]; + $document = [...$document, ...($update['$setOnInsert'] ?? []), ...($update['$set'] ?? [])]; + foreach ($update['$inc'] ?? [] as $field => $amount) { + $document[$field] = (int) ($document[$field] ?? 0) + (int) $amount; + } + $this->docs[$id] = $document; + } + + public function bulkWrite(array $operations, array $options = []): void + { + $this->bulkWrites++; + unset($options); + foreach ($operations as $operation) { + [$filter, $update, $writeOptions] = $operation['updateOne']; + $this->updateOne($filter, $update, $writeOptions); + } } }; @@ -64,8 +99,7 @@ public function updateOne(array $filter, array $update, array $options = []): vo test('mongo adapter stores and retrieves values', function () { $this->cache->set('k', 'value'); - expect($this->cache->get('k'))->toBe('value') - ->and($this->cache->count())->toBe(1); + expect($this->cache->get('k'))->toBe('value'); }); test('mongo adapter honors ttl', function () { @@ -81,3 +115,15 @@ public function updateOne(array $filter, array $update, array $options = []): vo expect($cache->get('f'))->toBe('ok'); }); + +test('mongodb uses one native bulk write and one $in read', function () { + $this->cache->setMultiple(['a' => 1, 'b' => 2, 'c' => 3]); + $writes = $this->collection->bulkWrites; + $reads = $this->collection->findCalls; + + expect($this->cache->getMultiple(['c', 'a', 'missing'])) + ->toBe(['c' => 3, 'a' => 1, 'missing' => null]) + ->and($this->collection->bulkWrites)->toBe($writes) + ->and($this->collection->findCalls)->toBe($reads + 1) + ->and($writes)->toBeGreaterThanOrEqual(1); +}); diff --git a/tests/Cache/PdoCachePoolTest.php b/tests/Cache/PdoCachePoolTest.php index d693a45..af7148e 100644 --- a/tests/Cache/PdoCachePoolTest.php +++ b/tests/Cache/PdoCachePoolTest.php @@ -28,15 +28,12 @@ expect($this->cache->get('ttl'))->toBeNull(); }); -test('pdo adapter delete and count with sqlite', function () { +test('pdo adapter bulk delete with sqlite', function () { $this->cache->set('a', 'A'); $this->cache->set('b', 'B'); + $this->cache->deleteMultiple(['a']); - expect($this->cache->count())->toBe(2); - - $this->cache->delete('a'); - expect($this->cache->count())->toBe(1) - ->and($this->cache->get('a'))->toBeNull() + expect($this->cache->get('a'))->toBeNull() ->and($this->cache->get('b'))->toBe('B'); }); diff --git a/tests/Cache/PdoMysqlCachePoolTest.php b/tests/Cache/PdoMysqlCachePoolTest.php index 7ee8053..b435930 100644 --- a/tests/Cache/PdoMysqlCachePoolTest.php +++ b/tests/Cache/PdoMysqlCachePoolTest.php @@ -48,14 +48,11 @@ expect($this->cache->get('ttl'))->toBeNull(); }); -test('pdo adapter delete and count on mysql', function () { +test('pdo adapter bulk delete on mysql', function () { $this->cache->set('a', 'A'); $this->cache->set('b', 'B'); - expect($this->cache->count())->toBe(2); - - $this->cache->delete('a'); - expect($this->cache->count())->toBe(1) - ->and($this->cache->get('a'))->toBeNull() + $this->cache->deleteMultiple(['a']); + expect($this->cache->get('a'))->toBeNull() ->and($this->cache->get('b'))->toBe('B'); }); diff --git a/tests/Cache/PdoPgsqlCachePoolTest.php b/tests/Cache/PdoPgsqlCachePoolTest.php index 6a53d61..70bf954 100644 --- a/tests/Cache/PdoPgsqlCachePoolTest.php +++ b/tests/Cache/PdoPgsqlCachePoolTest.php @@ -46,15 +46,12 @@ expect($this->cache->get('ttl'))->toBeNull(); }); -test('pdo adapter delete and count on pgsql', function () { +test('pdo adapter bulk delete on pgsql', function () { $this->cache->set('a', 'A'); $this->cache->set('b', 'B'); - expect($this->cache->count())->toBe(2); - - $this->cache->delete('a'); - expect($this->cache->count())->toBe(1) - ->and($this->cache->get('a'))->toBeNull() + $this->cache->deleteMultiple(['a']); + expect($this->cache->get('a'))->toBeNull() ->and($this->cache->get('b'))->toBe('B'); }); diff --git a/tests/Cache/RedisCachePoolTest.php b/tests/Cache/RedisCachePoolTest.php index c6ebf85..ef52daf 100644 --- a/tests/Cache/RedisCachePoolTest.php +++ b/tests/Cache/RedisCachePoolTest.php @@ -12,9 +12,8 @@ */ use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\RedisCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── skip whole file when Redis unavailable ───────────────────────── */ if (! class_exists(Redis::class)) { @@ -57,7 +56,6 @@ $client->auth($redisPassword); } $client->flushDB(); // fresh DB 0 - ValueSerializer::clearResourceHandlers(); $this->cache = Cache::redis( 'tests', @@ -65,30 +63,6 @@ $client ); - ValueSerializer::registerResourceHandler( - 'stream', - // ----- wrap ---------------------------------------------------- - function (mixed $res): array { - if (! is_resource($res)) { - throw new InvalidArgumentException('Expected resource'); - } - $meta = stream_get_meta_data($res); - rewind($res); - - return [ - 'mode' => $meta['mode'], - 'content' => stream_get_contents($res), - ]; - }, - // ----- restore ------------------------------------------------- - function (array $data): mixed { - $s = fopen('php://memory', $data['mode']); - fwrite($s, $data['content']); - rewind($s); - - return $s; // <- real resource - } - ); }); afterEach(function () { @@ -106,16 +80,9 @@ function (array $data): mixed { test('get returns default when key missing (redis)', function () { expect($this->cache->get('nobody', 'dflt'))->toBe('dflt'); - $val = $this->cache->get('dynamic', function (RedisCacheItem $item) { - $item->expiresAfter(1); - - return 'xyz'; - }); - expect($val)->toBe('xyz'); - expect($this->cache->get('dynamic'))->toBe('xyz'); - - usleep(2_000_000); - expect($this->cache->get('dynamic', 'again'))->toBe('again'); + $default = static fn(): string => 'xyz'; + expect($this->cache->get('dynamic', $default))->toBe($default) + ->and($this->cache->has('dynamic'))->toBeFalse(); }); test('get throws for invalid key (redis)', function () { @@ -126,7 +93,7 @@ function (array $data): mixed { /* ── 2. PSR-6 behaviour ─────────────────────────────────────────── */ test('getItem()/save() (redis)', function () { $it = $this->cache->getItem('psr'); - expect($it)->toBeInstanceOf(RedisCacheItem::class) + expect($it)->toBeInstanceOf(CacheItem::class) ->and($it->isHit())->toBeFalse(); $it->set(777)->save(); @@ -142,13 +109,11 @@ function (array $data): mixed { expect($this->cache->get('a'))->toBe('A'); }); -/* ── 4. ArrayAccess & magic props ───────────────────────────────── */ -test('ArrayAccess & magic (redis)', function () { +/* ── 4. ArrayAccess ─────────────────────────────────────────────── */ +test('ArrayAccess (redis)', function () { $this->cache['k'] = 12; - expect($this->cache['k'])->toBe(12); - - $this->cache->alpha = 'ζ'; - expect($this->cache->alpha)->toBe('ζ'); + expect($this->cache['k'])->toBe(12) + ->and(method_exists($this->cache, '__get'))->toBeFalse(); }); /* ── 6. TTL expiration ─────────────────────────────────────────── */ @@ -166,16 +131,6 @@ function (array $data): mixed { expect($fn(5))->toBe(10); }); -/* ── 8. stream resource round-trip ─────────────────────────────── */ -test('stream resource round-trip (redis)', function () { - $s = fopen('php://memory', 'r+'); - fwrite($s, 'blob'); - rewind($s); - $this->cache->getItem('stream')->set($s)->save(); - $rest = $this->cache->getItem('stream')->get(); - expect(stream_get_contents($rest))->toBe('blob'); -}); - /* ── 9. invalid key guard ───────────────────────────────────────── */ test('invalid key throws (redis)', function () { expect(fn () => $this->cache->set('bad key', 'v')) diff --git a/tests/Cache/RedisClusterCachePoolTest.php b/tests/Cache/RedisClusterCachePoolTest.php index 4b19f8a..d9b3f62 100644 --- a/tests/Cache/RedisClusterCachePoolTest.php +++ b/tests/Cache/RedisClusterCachePoolTest.php @@ -8,22 +8,16 @@ $this->cluster = new class { /** @var array */ - private array $kv = []; + private array $values = []; - /** @var array> */ - private array $sets = []; + public int $mgetCalls = 0; public function del(string|array $keys): int { - $keys = is_array($keys) ? $keys : [$keys]; $deleted = 0; - foreach ($keys as $key) { - if (isset($this->kv[$key])) { - unset($this->kv[$key]); - $deleted++; - } - if (isset($this->sets[$key])) { - unset($this->sets[$key]); + foreach (is_array($keys) ? $keys : [$keys] as $key) { + if (isset($this->values[$key])) { + unset($this->values[$key]); $deleted++; } } @@ -33,82 +27,88 @@ public function del(string|array $keys): int public function exists(string $key): int { - $this->pruneKey($key); - - return isset($this->kv[$key]) ? 1 : 0; + return $this->get($key) === false ? 0 : 1; } public function get(string $key): string|false { - $this->pruneKey($key); + $this->prune($key); - return $this->kv[$key]['value'] ?? false; + return $this->values[$key]['value'] ?? false; } - public function sAdd(string $key, string $member): int + public function incr(string $key): int { - $exists = isset($this->sets[$key][$member]); - $this->sets[$key][$member] = true; + $next = (int) ($this->get($key) ?: 0) + 1; + $this->set($key, (string) $next); - return $exists ? 0 : 1; + return $next; } - public function sCard(string $key): int + /** @param list $keys */ + public function mget(array $keys): array { - return count($this->sets[$key] ?? []); - } + $this->mgetCalls++; - public function sMembers(string $key): array - { - return array_keys($this->sets[$key] ?? []); + return array_map(fn(string $key): string|false => $this->get($key), $keys); } - public function sRem(string $key, string $member): int + /** @param array $values */ + public function mset(array $values): bool { - if (! isset($this->sets[$key][$member])) { - return 0; + foreach ($values as $key => $value) { + $this->set($key, $value); } - unset($this->sets[$key][$member]); - - return 1; + return true; } public function set(string $key, string $value): bool { - $this->kv[$key] = ['value' => $value, 'expires' => null]; + $this->values[$key] = ['value' => $value, 'expires' => null]; return true; } public function setex(string $key, int $ttl, string $value): bool { - $this->kv[$key] = ['value' => $value, 'expires' => time() + max(1, $ttl)]; + $this->values[$key] = ['value' => $value, 'expires' => time() + max(1, $ttl)]; return true; } - private function pruneKey(string $key): void + /** @return list */ + public function keys(): array { - if (! isset($this->kv[$key])) { - return; - } + return array_keys($this->values); + } - $expires = $this->kv[$key]['expires']; + private function prune(string $key): void + { + $expires = $this->values[$key]['expires'] ?? null; if ($expires !== null && $expires <= time()) { - unset($this->kv[$key]); + unset($this->values[$key]); } } }; - $this->cache = Cache::redisCluster('cluster-tests', ['127.0.0.1:7000'], 1.0, 1.0, false, $this->cluster); + $this->cache = Cache::redisCluster( + 'cluster-tests', + ['127.0.0.1:7000'], + 1.0, + 1.0, + false, + $this->cluster, + ); }); -test('redis cluster adapter stores and retrieves values', function () { - $this->cache->set('k', 'value'); +test('redis cluster adapter bulk-fetches cross-slot values', function () { + $this->cache->setMultiple(['alpha' => 'A', 'beta' => 'B', 'gamma' => 'C']); + $before = $this->cluster->mgetCalls; - expect($this->cache->get('k'))->toBe('value') - ->and($this->cache->count())->toBe(1); + expect($this->cache->getMultiple(['alpha', 'missing', 'beta'])) + ->toBe(['alpha' => 'A', 'missing' => null, 'beta' => 'B']) + ->and($this->cluster->mgetCalls)->toBeGreaterThan($before); }); test('redis cluster adapter honors ttl', function () { @@ -118,13 +118,10 @@ private function pruneKey(string $key): void expect($this->cache->get('ttl'))->toBeNull(); }); -test('redis cluster adapter clear removes cached values', function () { - $this->cache->set('a', 1); - $this->cache->set('b', 2); - +test('redis cluster clear uses bucket epochs without a permanent key index', function () { + $this->cache->setMultiple(['a' => 1, 'b' => 2]); $this->cache->clear(); - expect($this->cache->count())->toBe(0) - ->and($this->cache->get('a'))->toBeNull() - ->and($this->cache->get('b'))->toBeNull(); + expect($this->cache->getMultiple(['a', 'b']))->toBe(['a' => null, 'b' => null]) + ->and(implode('|', $this->cluster->keys()))->not->toContain('__keys'); }); diff --git a/tests/Cache/ScyllaDbCachePoolTest.php b/tests/Cache/ScyllaDbCachePoolTest.php index 14fe080..544a5aa 100644 --- a/tests/Cache/ScyllaDbCachePoolTest.php +++ b/tests/Cache/ScyllaDbCachePoolTest.php @@ -9,9 +9,13 @@ beforeEach(function () { $this->session = new class { - /** @var array> */ + /** @var array */ private array $rows = []; + public int $bucketReads = 0; + + public int $writeBatches = 0; + public function prepare(string $cql): string { return $cql; @@ -30,47 +34,77 @@ public function execute(mixed $statement, mixed $options = []): array } if (str_starts_with($cql, 'DELETE FROM') && str_contains($cql, 'AND ckey = ?')) { + unset($this->rows[$this->rowKey($args)]); + + return []; + } + + if (str_starts_with($cql, 'DELETE FROM') && str_contains($cql, 'ckey IN')) { $ns = (string) ($args[0] ?? ''); - $key = (string) ($args[1] ?? ''); - unset($this->rows[$ns][$key]); + $bucket = (int) ($args[1] ?? 0); + foreach (array_slice($args, 2) as $key) { + unset($this->rows[$ns . ':' . $bucket . ':' . $key]); + } return []; } if (str_starts_with($cql, 'DELETE FROM')) { - $ns = (string) ($args[0] ?? ''); - unset($this->rows[$ns]); + $prefix = (string) ($args[0] ?? '') . ':' . (int) ($args[1] ?? 0) . ':'; + foreach (array_keys($this->rows) as $key) { + if (str_starts_with($key, $prefix)) { + unset($this->rows[$key]); + } + } return []; } if (str_starts_with($cql, 'SELECT expires')) { $ns = (string) ($args[0] ?? ''); + $bucket = (int) ($args[1] ?? 0); + $matching = []; + foreach ($this->rows as $key => $row) { + if (str_starts_with($key, $ns . ':' . $bucket . ':')) { + $matching[] = $row; + } + } return array_map( static fn (array $row): array => ['expires' => $row['expires']], - array_values($this->rows[$ns] ?? []), + $matching, ); } if (str_starts_with($cql, 'SELECT payload, expires')) { - $ns = (string) ($args[0] ?? ''); - $key = (string) ($args[1] ?? ''); - $row = $this->rows[$ns][$key] ?? null; + $row = $this->rows[$this->rowKey($args)] ?? null; return is_array($row) ? [$row] : []; } - if (str_starts_with($cql, 'INSERT INTO')) { + if (str_starts_with($cql, 'SELECT ckey, payload, expires')) { + $this->bucketReads++; $ns = (string) ($args[0] ?? ''); - $key = (string) ($args[1] ?? ''); - $payload = (string) ($args[2] ?? ''); - $expires = $args[3] ?? null; + $bucket = (int) ($args[1] ?? 0); + $keys = array_map('strval', array_slice($args, 2)); + + return array_values(array_filter( + $this->rows, + static fn(array $row): bool => in_array($row['ckey'], $keys, true), + )); + } - $this->rows[$ns][$key] = [ - 'payload' => $payload, - 'expires' => is_numeric($expires) ? (int) $expires : null, - ]; + if (str_starts_with($cql, 'BEGIN UNLOGGED BATCH')) { + $this->writeBatches++; + foreach (array_chunk($args, 5) as $row) { + $this->store($row); + } + + return []; + } + + if (str_starts_with($cql, 'INSERT INTO')) { + $this->store($args); return []; } @@ -89,6 +123,23 @@ private function extractArguments(mixed $options): array return []; } + + /** @param array $row */ + private function rowKey(array $row): string + { + return (string) ($row[0] ?? '') . ':' . (int) ($row[1] ?? 0) . ':' . (string) ($row[2] ?? ''); + } + + /** @param array $row */ + private function store(array $row): void + { + $key = $this->rowKey($row); + $this->rows[$key] = [ + 'ckey' => (string) ($row[2] ?? ''), + 'payload' => (string) ($row[3] ?? ''), + 'expires' => is_numeric($row[4] ?? null) ? (int) $row[4] : null, + ]; + } }; $this->cache = new Cache(new ScyllaDbCacheAdapter( @@ -102,8 +153,7 @@ private function extractArguments(mixed $options): array test('scylladb adapter stores and retrieves values', function () { $this->cache->set('k', 'value'); - expect($this->cache->get('k'))->toBe('value') - ->and($this->cache->count())->toBe(1); + expect($this->cache->get('k'))->toBe('value'); }); test('scylladb adapter clears namespace entries', function () { @@ -112,11 +162,11 @@ private function extractArguments(mixed $options): array $this->cache->clear(); - expect($this->cache->count())->toBe(0); + expect($this->cache->getMultiple(['a', 'b']))->toBe(['a' => null, 'b' => null]); }); test('scylladb cache factory accepts injected session', function () { - $cache = Cache::scyllaDb('scylla-tests', $this->session, 'cachelayer', 'cachelayer_entries'); + $cache = Cache::scylla('scylla-tests', $this->session, 'cachelayer', 'cachelayer_entries'); $cache->set('x', 'X'); expect($cache->get('x'))->toBe('X'); @@ -127,10 +177,21 @@ private function extractArguments(mixed $options): array $this->markTestSkipped('Cassandra extension loaded in this environment.'); } - expect(fn () => Cache::scyllaDb('scylla-tests')) + expect(fn () => Cache::scylla('scylla-tests')) ->toThrow(CacheInvalidArgumentException::class); }); +test('scylladb groups bulk reads and writes by configured bucket', function () { + $cache = Cache::scylla('bucket-tests', $this->session, 'cachelayer', 'cachelayer_entries', 1); + $cache->setMultiple(['a' => 1, 'b' => 2, 'c' => 3]); + $reads = $this->session->bucketReads; + + expect($cache->getMultiple(['c', 'missing', 'a'])) + ->toBe(['c' => 3, 'missing' => null, 'a' => 1]) + ->and($this->session->writeBatches)->toBeGreaterThanOrEqual(1) + ->and($this->session->bucketReads)->toBe($reads + 1); +}); + /** * @return array{endpoint:string}|null */ diff --git a/tests/Cache/SqliteCachePoolTest.php b/tests/Cache/SqliteCachePoolTest.php index bd1280c..61df44b 100644 --- a/tests/Cache/SqliteCachePoolTest.php +++ b/tests/Cache/SqliteCachePoolTest.php @@ -9,9 +9,8 @@ */ use Infocyph\CacheLayer\Cache\Cache; -use Infocyph\CacheLayer\Cache\Item\GenericCacheItem; +use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; -use Infocyph\CacheLayer\Serializer\ValueSerializer; /* ── Skip entire suite if SQLite missing ─────────────────────────── */ if (! in_array('sqlite', PDO::getAvailableDrivers(), true)) { @@ -24,33 +23,6 @@ beforeEach(function () { $this->dbFile = sys_get_temp_dir().'/pest_sqlite_'.uniqid().'.sqlite'; $this->cache = Cache::sqlite('tests', $this->dbFile); - ValueSerializer::clearResourceHandlers(); - - /* stream handler for resource test */ - ValueSerializer::registerResourceHandler( - 'stream', - // ----- wrap ---------------------------------------------------- - function (mixed $res): array { - if (! is_resource($res)) { - throw new InvalidArgumentException('Expected resource'); - } - $meta = stream_get_meta_data($res); - rewind($res); - - return [ - 'mode' => $meta['mode'], - 'content' => stream_get_contents($res), - ]; - }, - // ----- restore ------------------------------------------------- - function (array $data): mixed { - $s = fopen('php://memory', $data['mode']); - fwrite($s, $data['content']); - rewind($s); - - return $s; // <- real resource - } - ); }); afterEach(function () { @@ -73,17 +45,9 @@ function (array $data): mixed { test('get returns default when key missing (sqlite)', function () { expect($this->cache->get('none', 'dflt'))->toBe('dflt'); - $val = $this->cache->get('compute', function (GenericCacheItem $item) { - $item->expiresAfter(1); - - return 'val'; - }); - expect($val) - ->toBe('val') - ->and($this->cache->get('compute'))->toBe('val'); - - usleep(2_000_000); - expect($this->cache->get('compute', 'again'))->toBe('again'); + $default = static fn(): string => 'val'; + expect($this->cache->get('compute', $default))->toBe($default) + ->and($this->cache->has('compute'))->toBeFalse(); }); test('get throws for invalid key (sqlite)', function () { @@ -94,7 +58,7 @@ function (array $data): mixed { /* ── 2. PSR-6 behaviour ─────────────────────────────────────────── */ test('getItem()/save() (sqlite)', function () { $item = $this->cache->getItem('psr'); - expect($item)->toBeInstanceOf(GenericCacheItem::class) + expect($item)->toBeInstanceOf(CacheItem::class) ->and($item->isHit())->toBeFalse(); $item->set(42)->save(); @@ -110,13 +74,11 @@ function (array $data): mixed { expect($this->cache->get('a'))->toBe('A'); }); -/* ── 4. ArrayAccess & magic props ───────────────────────────────── */ -test('ArrayAccess & magic (sqlite)', function () { +/* ── 4. ArrayAccess ─────────────────────────────────────────────── */ +test('ArrayAccess (sqlite)', function () { $this->cache['x'] = 5; - expect($this->cache['x'])->toBe(5); - - $this->cache->alpha = 'ω'; - expect($this->cache->alpha)->toBe('ω'); + expect($this->cache['x'])->toBe(5) + ->and(method_exists($this->cache, '__get'))->toBeFalse(); }); /* ── 6. TTL expiration ─────────────────────────────────────────── */ @@ -133,16 +95,6 @@ function (array $data): mixed { expect(($this->cache->getItem('cb')->get())(4))->toBe(7); }); -/* ── 8. stream resource round-trip ─────────────────────────────── */ -test('stream resource round-trip (sqlite)', function () { - $s = fopen('php://memory', 'r+'); - fwrite($s, 'hello'); - rewind($s); - $this->cache->getItem('stream')->set($s)->save(); - $rest = $this->cache->getItem('stream')->get(); - expect(stream_get_contents($rest))->toBe('hello'); -}); - /* ── 9. invalid key guard ───────────────────────────────────────── */ test('invalid key throws (sqlite)', function () { expect(fn () => $this->cache->set('bad key', 'v')) diff --git a/tests/Node/NodeCacheTest.php b/tests/Node/NodeCacheTest.php index fe8a47e..90212ba 100644 --- a/tests/Node/NodeCacheTest.php +++ b/tests/Node/NodeCacheTest.php @@ -6,6 +6,7 @@ use Infocyph\CacheLayer\Cache\Cache; use Infocyph\CacheLayer\Cache\Lock\LockHandle; use Infocyph\CacheLayer\Cache\Lock\LockProviderInterface; +use Infocyph\CacheLayer\Cache\Metrics\InMemoryCacheMetricsCollector; use Infocyph\CacheLayer\Node\Adapter\NodeCacheAdapter; use Infocyph\CacheLayer\Node\Adapter\NodeSqliteCacheAdapter; use Infocyph\CacheLayer\Node\Connection\NodeSqliteConnection; @@ -121,6 +122,26 @@ public function release(?LockHandle $handle): void ->and($l2->getItem('promoted')->get())->toBe('changed'); }); +test('node bulk reads fetch only L1 misses from SQLite and promote as one batch', function () { + $connection = NodeSqliteConnection::create($this->nodeConfig); + $l1 = new ArrayCacheAdapter($this->nodeConfig->namespace); + $l2 = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); + $metrics = new InMemoryCacheMetricsCollector(); + $cache = new Cache(new NodeCacheAdapter($l1, $l2, false, $metrics), metrics: $metrics); + $cache->setMultiple(['hot' => 1, 'cold.a' => 2, 'cold.b' => 3]); + $l1->deleteItems(['cold.a', 'cold.b']); + $before = $metrics->export()[NodeCacheAdapter::class] ?? []; + + expect($cache->getMultiple(['hot', 'cold.a', 'missing', 'cold.b'])) + ->toBe(['hot' => 1, 'cold.a' => 2, 'missing' => null, 'cold.b' => 3]); + + $after = $metrics->export()[NodeCacheAdapter::class] ?? []; + expect(($after['l1_batch_hit'] ?? 0) - ($before['l1_batch_hit'] ?? 0))->toBe(1) + ->and(($after['l1_batch_miss'] ?? 0) - ($before['l1_batch_miss'] ?? 0))->toBe(3) + ->and(($after['l2_batch_hit'] ?? 0) - ($before['l2_batch_hit'] ?? 0))->toBe(2) + ->and(($after['l2_batch_promote'] ?? 0) - ($before['l2_batch_promote'] ?? 0))->toBe(2); +}); + test('expired rows remain outside the read path until bounded pruning', function () { $connection = NodeSqliteConnection::create($this->nodeConfig); $adapter = new NodeSqliteCacheAdapter($connection, $this->nodeConfig->namespace); diff --git a/tests/Serializer/ClosureSerializerTest.php b/tests/Serializer/ClosureSerializerTest.php new file mode 100644 index 0000000..3d68478 --- /dev/null +++ b/tests/Serializer/ClosureSerializerTest.php @@ -0,0 +1,39 @@ + $value + 2; + $payload = ClosureSerializer::serialize($closure); + $restored = ClosureSerializer::unserialize($payload); + + expect(ClosureSerializer::isSerialized($payload))->toBeTrue() + ->and($restored(5))->toBe(7); +}); + +it('rejects malformed and non-closure payloads', function () { + expect(ClosureSerializer::isSerialized('not-a-closure'))->toBeFalse() + ->and(ClosureSerializer::isSerialized('cls1:'))->toBeFalse() + ->and(fn() => ClosureSerializer::unserialize('not-a-closure')) + ->toThrow(InvalidArgumentException::class) + ->and(fn() => ClosureSerializer::unserialize('cls1:' . base64_encode(serialize(42)))) + ->toThrow(InvalidArgumentException::class); +}); + +it('signs and verifies closure payloads', function () { + $serializer = ClosureSerializer::signed('closure-test-key'); + $payload = $serializer->serialize(static fn(int $value): int => $value * 3); + $restored = $serializer->unserialize($payload); + + expect($restored(4))->toBe(12) + ->and(fn() => ClosureSerializer::signed('wrong-key')->unserialize($payload)) + ->toThrow(InvalidArgumentException::class) + ->and(fn() => $serializer->unserialize($payload . 'tampered')) + ->toThrow(InvalidArgumentException::class); +}); + +it('rejects an empty signing key', function () { + expect(fn() => ClosureSerializer::signed(''))->toThrow(InvalidArgumentException::class); +}); diff --git a/tests/Serializer/ValueSerializerTest.php b/tests/Serializer/ValueSerializerTest.php deleted file mode 100644 index a85f155..0000000 --- a/tests/Serializer/ValueSerializerTest.php +++ /dev/null @@ -1,84 +0,0 @@ - 'x', 'b' => ['nested' => true]], - ]; - - foreach ($values as $v) { - $blob = ValueSerializer::serialize($v); - $out = ValueSerializer::unserialize($blob); - expect($out)->toBe($v); - } -}); - -it('round-trips closures', function () { - $fn = fn (int $x): int => $x + 2; - $blob = ValueSerializer::serialize($fn); - $rest = ValueSerializer::unserialize($blob); - - expect(is_callable($rest)) - ->toBeTrue() - ->and($rest(5))->toBe(7); -}); - -it('wraps and unwraps without full serialization', function () { - $data = ['foo' => 'bar', 'baz' => [1, 2, 3]]; - $wrapped = ValueSerializer::wrap($data); - expect($wrapped)->toBe($data); - - $unwrapped = ValueSerializer::unwrap($wrapped); - expect($unwrapped)->toBe($data); -}); - -it('throws when wrapping a resource with no handler', function () { - $s = fopen('php://memory', 'r+'); - - expect(fn () => ValueSerializer::wrap($s)) - ->toThrow(InvalidArgumentException::class) - ->and(fn () => ValueSerializer::serialize($s)) - ->toThrow(InvalidArgumentException::class); - - fclose($s); -}); - -it('throws when registering the same resource handler twice', function () { - ValueSerializer::registerResourceHandler('stream', fn ($r) => $r, fn ($d) => $d); - - expect(fn () => ValueSerializer::registerResourceHandler('stream', fn ($r) => $r, fn ($d) => $d)) - ->toThrow(InvalidArgumentException::class); -}); - -it('keeps serialized closure memo cache bounded', function () { - for ($i = 0; $i < 2200; $i++) { - ValueSerializer::isSerializedClosure('x'.$i); - } - - $ref = new ReflectionClass(ValueSerializer::class); - $memo = $ref->getProperty('serializedClosureMemo'); - - expect(count($memo->getValue()))->toBeLessThanOrEqual(2048); -}); - -it('strict security mode blocks closure payloads', function () { - ValueSerializer::useStrictSecurity(); - - expect(fn () => ValueSerializer::serialize(fn () => 1)) - ->toThrow(InvalidArgumentException::class); - - ValueSerializer::useCompatibilitySecurity(); -});