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
[](https://github.com/infocyph/CacheLayer/actions/workflows/security-standards.yml)
-
+
[](https://opensource.org/licenses/MIT)


-
-[](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.
+
---
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