From 9ae5b89aedc15cfaf10887315c49b6916b809cfd Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:15:13 +0600 Subject: [PATCH 01/19] feat(cache): add WeakMap atomic capability --- src/Cache/Adapter/WeakMapCacheAdapter.php | 54 ++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/WeakMapCacheAdapter.php b/src/Cache/Adapter/WeakMapCacheAdapter.php index a805f1a..def3b3e 100644 --- a/src/Cache/Adapter/WeakMapCacheAdapter.php +++ b/src/Cache/Adapter/WeakMapCacheAdapter.php @@ -10,7 +10,7 @@ use Psr\Cache\CacheItemInterface; use WeakReference; -final class WeakMapCacheAdapter extends AbstractCacheAdapter +final class WeakMapCacheAdapter extends AbstractCacheAdapter implements AtomicCachePoolInterface { private readonly string $ns; @@ -31,6 +31,58 @@ public function __construct(string $namespace = 'default') $this->ns = CacheInput::namespace($namespace); } + public function atomicCompareAndSet( + string $key, + mixed $expected, + CacheItemInterface $replacement, + ): bool { + if (!$this->supportsItem($replacement)) { + return false; + } + + $expiration = CachePayloadCodec::expirationFromItem($replacement); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + $current = $this->getItem($key); + if (!$current->isHit() || $current->get() !== $expected) { + return false; + } + + return $this->persistItem($replacement, $expiration); + } + + public function atomicGetAndDelete(string $key): CacheItemInterface + { + $current = $this->getItem($key); + if (!$current->isHit()) { + return $current; + } + + $this->deleteItem($key); + + return $current; + } + + public function atomicSetIfAbsent(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + if ($this->getItem($item->getKey())->isHit()) { + return false; + } + + return $this->persistItem($item, $expiration); + } + public function clear(): bool { $this->scalarStore = []; From f2d1a44c9ae3b77b82e2a654336264752e088b91 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:16:37 +0600 Subject: [PATCH 02/19] feat(cache): add Memcached atomic capability --- src/Cache/Adapter/MemcachedCacheAdapter.php | 155 +++++++++++++++++++- 1 file changed, 154 insertions(+), 1 deletion(-) diff --git a/src/Cache/Adapter/MemcachedCacheAdapter.php b/src/Cache/Adapter/MemcachedCacheAdapter.php index 4f97257..2578b50 100644 --- a/src/Cache/Adapter/MemcachedCacheAdapter.php +++ b/src/Cache/Adapter/MemcachedCacheAdapter.php @@ -5,12 +5,15 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\CacheInput; +use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; -final class MemcachedCacheAdapter extends AbstractCacheAdapter +final class MemcachedCacheAdapter extends AbstractCacheAdapter implements AtomicCachePoolInterface { + private const string ATOMIC_TOMBSTONE = "\0cachelayer:atomic:tombstone:v1\0"; + private readonly \Memcached $client; private readonly string $namespace; @@ -33,6 +36,117 @@ public function __construct( } } + public function atomicCompareAndSet( + string $key, + mixed $expected, + CacheItemInterface $replacement, + ): bool { + if (!$this->supportsItem($replacement)) { + return false; + } + + $expiration = CachePayloadCodec::expirationFromItem($replacement); + $ttl = $expiration['ttl']; + if ($ttl !== null && $ttl <= 0) { + return false; + } + + $mapped = $this->mapData($key); + $extended = $this->extendedGet($mapped); + if ($extended === null) { + return false; + } + + $blob = $extended['value']; + if ($blob === self::ATOMIC_TOMBSTONE) { + return false; + } + + $record = $this->decodeRecordFromBlob($blob); + if (!$record instanceof CacheRecord + || $record->namespaceGeneration !== $this->namespaceGeneration() + || $record->tags !== [] + || $record->value !== $expected) { + return false; + } + + $replacementBlob = $this->encodeItem( + $replacement, + $expiration['expiresAt'], + $this->namespaceGeneration(), + ); + + return $this->client->cas( + $extended['cas'], + $mapped, + $replacementBlob, + $ttl ?? 0, + ); + } + + public function atomicGetAndDelete(string $key): CacheItemInterface + { + $mapped = $this->mapData($key); + $extended = $this->extendedGet($mapped); + if ($extended === null || $extended['value'] === self::ATOMIC_TOMBSTONE) { + return $this->genericMiss($key); + } + + $record = $this->decodeRecordFromBlob($extended['value']); + if (!$record instanceof CacheRecord + || $record->namespaceGeneration !== $this->namespaceGeneration() + || !$this->recordTagsAreCurrent($record)) { + return $this->genericMiss($key); + } + + if (!$this->client->cas($extended['cas'], $mapped, self::ATOMIC_TOMBSTONE, 1)) { + return $this->genericMiss($key); + } + + return $this->genericItemFromRecord($key, $record); + } + + public function atomicSetIfAbsent(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + + $expiration = CachePayloadCodec::expirationFromItem($item); + $ttl = $expiration['ttl']; + if ($ttl !== null && $ttl <= 0) { + return false; + } + + $mapped = $this->mapData($item->getKey()); + $blob = $this->encodeItem( + $item, + $expiration['expiresAt'], + $this->namespaceGeneration(), + ); + + if ($this->client->add($mapped, $blob, $ttl ?? 0)) { + return true; + } + + $extended = $this->extendedGet($mapped); + if ($extended === null) { + return $this->client->add($mapped, $blob, $ttl ?? 0); + } + + $current = $extended['value']; + $record = $current === self::ATOMIC_TOMBSTONE + ? null + : $this->decodeRecordFromBlob($current); + if ($record instanceof CacheRecord + && $record->namespaceGeneration === $this->namespaceGeneration() + && $this->recordTagsAreCurrent($record)) { + return false; + } + + return $this->client->cas($extended['cas'], $mapped, $blob, $ttl ?? 0); + } + public function clear(): bool { $cleared = $this->client->set($this->generationKey(), self::newGeneration()); @@ -80,6 +194,9 @@ public function getItem(string $key): CacheItem $stored = is_array($stored) ? $stored : []; $generation = $this->namespaceGeneration($stored[$this->generationKey()] ?? null); $blob = $stored[$mapped] ?? null; + if ($blob === self::ATOMIC_TOMBSTONE) { + return $this->genericMiss($key); + } $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; if ($record !== null && $record->namespaceGeneration === $generation) { return $this->genericItemFromRecord($key, $record); @@ -139,6 +256,11 @@ public function multiFetch(array $keys): array foreach ($keys as $key) { $mapped = $this->mapData($key); $blob = $stored[$mapped] ?? null; + if ($blob === self::ATOMIC_TOMBSTONE) { + $items[$key] = $this->genericMiss($key); + + continue; + } $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; if ($record === null || $record->namespaceGeneration !== $generation) { $items[$key] = $this->genericMiss($key); @@ -222,6 +344,21 @@ public function saveItems(array $items): bool return true; } + /** @return array{value:string, cas:int|float}|null */ + private function extendedGet(string $key): ?array + { + $value = $this->client->get($key, null, \Memcached::GET_EXTENDED); + if (!is_array($value) || !is_string($value['value'] ?? null)) { + return null; + } + $cas = $value['cas'] ?? null; + if (!is_int($cas) && !is_float($cas)) { + return null; + } + + return ['value' => $value['value'], 'cas' => $cas]; + } + private function generationKey(): string { return $this->namespace . ':m:generation'; @@ -260,6 +397,22 @@ private function namespaceGeneration(mixed $value = null): string return $generation; } + private function recordTagsAreCurrent(CacheRecord $record): bool + { + if ($record->tags === []) { + return true; + } + + $current = $this->getTagGenerations(array_keys($record->tags)); + foreach ($record->tags as $tag => $generation) { + if (($current[$tag] ?? null) !== $generation) { + return false; + } + } + + return true; + } + private function tagGeneration(string $key, mixed $value): string { $generation = self::normalizeGeneration($value); From 1dd3ebeb2b99ddb2277e209c659d810d303b2b38 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:17:24 +0600 Subject: [PATCH 03/19] feat(cache): add atomic file cache capability --- src/Cache/Adapter/FileCacheAdapter.php | 228 ++++++++++++++----------- 1 file changed, 128 insertions(+), 100 deletions(-) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index bf595d0..81a96ed 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -5,22 +5,13 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\CacheInput; +use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Infocyph\CacheLayer\Exceptions\CacheInvalidArgumentException; use Psr\Cache\CacheItemInterface; use RuntimeException; -/** - * File-based cache adapter implementation. - * - * This adapter stores cache data as individual files in a specified directory. - * Each cache entry is serialized and stored with a .cache extension. - * It provides a simple filesystem-based caching solution suitable for - * development environments or applications without access to dedicated cache systems. - * @param string $namespace A namespace prefix for cache files to avoid collisions. - * @param string|null $baseDir The base directory for cache files. If null, uses system temp directory. - */ -class FileCacheAdapter extends AbstractCacheAdapter +class FileCacheAdapter extends AbstractCacheAdapter implements AtomicCachePoolInterface { use SecuresFilesystemDirectories; @@ -28,21 +19,67 @@ class FileCacheAdapter extends AbstractCacheAdapter private string $dataDirectory; + private string $lockDirectory; + private string $metadataDirectory; - /** - * Creates a new file-based cache adapter. - * - * - * @throws RuntimeException If the cache directory cannot be created or is not writable. - * @param string $namespace A namespace prefix for cache files to avoid collisions. - * @param string|null $baseDir The base directory for cache files. If null, uses system temp directory. - */ public function __construct(string $namespace = 'default', ?string $baseDir = null) { $this->createDirectory($namespace, $baseDir); } + public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInterface $replacement): bool + { + if (!$this->supportsItem($replacement)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($replacement); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->withKeyLock($key, function () use ($key, $expected, $replacement): bool { + $record = $this->readLiveRecordUnlocked($key); + if (!$record instanceof CacheRecord || $record->value !== $expected) { + return false; + } + + return $this->persistItemUnlocked($replacement); + }); + } + + public function atomicGetAndDelete(string $key): CacheItemInterface + { + return $this->withKeyLock($key, function () use ($key): CacheItemInterface { + $record = $this->readLiveRecordUnlocked($key); + if (!$record instanceof CacheRecord) { + return $this->genericMiss($key); + } + $this->deleteItemUnlocked($key); + + return $this->genericItemFromRecord($key, $record); + }); + } + + public function atomicSetIfAbsent(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->withKeyLock($item->getKey(), function () use ($item): bool { + if ($this->readLiveRecordUnlocked($item->getKey()) instanceof CacheRecord) { + return false; + } + + return $this->persistItemUnlocked($item); + }); + } + public function clear(): bool { $ok = true; @@ -58,15 +95,9 @@ public function clear(): bool public function deleteItem(string $key): bool { - $file = $this->fileFor($key); - - return !is_file($file) || unlink($file); + return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - */ public function deleteItems(array $keys): bool { $ok = true; @@ -79,23 +110,14 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { - $file = $this->fileFor($key); - - if (is_file($file)) { - $raw = file_get_contents($file); - if (is_string($raw)) { - $record = $this->decodeRecordFromBlob($raw); - if ($record !== null) { - return $this->genericItemFromRecord($key, $record); - } - } - unlink($file); + $record = $this->readLiveRecordUnlocked($key); + if ($record instanceof CacheRecord) { + return $this->genericItemFromRecord($key, $record); } return new CacheItem($this, $key); } - /** @param list $tags */ #[\Override] public function getTagGenerations(array $tags): array { @@ -120,41 +142,16 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** - * @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); - - continue; - } - $items[$key] = $this->genericItemFromRecord($key, $record); - } - foreach ($stale as $file) { - if (is_file($file)) { - unlink($file); - } + $items[$key] = $this->getItem($key); } return $items; } - /** @param list $tags */ #[\Override] public function rotateTagGenerations(array $tags): bool { @@ -177,7 +174,6 @@ public function save(CacheItemInterface $item): bool return $this->persistItem($item); } - /** @param array $items */ public function saveItems(array $items): bool { if (!$this->supportsItems($items)) { @@ -199,18 +195,12 @@ private function createDirectory(string $ns, ?string $baseDir): void $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; - - if (is_dir($this->dataDirectory) && is_dir($this->metadataDirectory)) { - $this->assertSecureDirectory($baseDir, 'Cache base directory'); - $this->assertSecureDirectory($this->dataDirectory, 'Cache data directory'); - $this->assertSecureDirectory($this->metadataDirectory, 'Cache metadata directory'); - - return; - } + $this->lockDirectory = $root . 'locks' . DIRECTORY_SEPARATOR; $this->ensureBaseDirectoryExists($baseDir); - $this->ensureCacheDirectoryExists($this->dataDirectory); - $this->ensureCacheDirectoryExists($this->metadataDirectory); + foreach ([$this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { + $this->ensureCacheDirectoryExists($directory); + } } private function defaultBaseDirectory(): string @@ -220,37 +210,34 @@ private function defaultBaseDirectory(): string . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_BASE_DIR); } + private function deleteItemUnlocked(string $key): bool + { + $file = $this->fileFor($key); + + return !is_file($file) || unlink($file); + } + private function ensureBaseDirectoryExists(string $baseDir): void { $this->assertPathNotSymlink($baseDir, 'Cache base directory'); - if (file_exists($baseDir) && !is_dir($baseDir)) { - throw new RuntimeException( - 'Cache base path ' . realpath($baseDir) . ' exists and is *not* a directory', - ); + throw new RuntimeException('Cache base path ' . realpath($baseDir) . ' exists and is *not* a directory'); } - if (!is_dir($baseDir) && !mkdir($baseDir, 0700, true) && !is_dir($baseDir)) { $this->throwCreationError('Failed to create base directory ' . $baseDir); } - $this->assertSecureDirectory($baseDir, 'Cache base directory'); } private function ensureCacheDirectoryExists(string $cacheDir): void { $this->assertPathNotSymlink($cacheDir, 'Cache directory'); - if (file_exists($cacheDir) && !is_dir($cacheDir)) { - throw new RuntimeException( - realpath($cacheDir) . ' exists and is not a directory', - ); + throw new RuntimeException(realpath($cacheDir) . ' exists and is not a directory'); } - - if (!mkdir($cacheDir, 0700, true) && !is_dir($cacheDir)) { + if (!is_dir($cacheDir) && !mkdir($cacheDir, 0700, true) && !is_dir($cacheDir)) { $this->throwCreationError('Failed to create cache directory ' . $cacheDir); } - $this->assertSecureDirectory($cacheDir, 'Cache directory'); } @@ -266,11 +253,15 @@ private function metadataFileFor(string $tag): string private function persistItem(CacheItemInterface $item): bool { + return $this->withKeyLock($item->getKey(), fn(): bool => $this->persistItemUnlocked($item)); + } + private function persistItemUnlocked(CacheItemInterface $item): bool + { $expires = CachePayloadCodec::expirationFromItem($item); $ttl = $expires['ttl']; if ($ttl !== null && $ttl <= 0) { - return $this->deleteItem($item->getKey()); + return $this->deleteItemUnlocked($item->getKey()); } $blob = $this->encodeItem($item, $expires['expiresAt']); @@ -278,30 +269,67 @@ private function persistItem(CacheItemInterface $item): bool if ($tmp === false) { return false; } - if (file_put_contents($tmp, $blob, LOCK_EX) === false) { - if (is_file($tmp)) { - unlink($tmp); - } - + @unlink($tmp); return false; } - if (!rename($tmp, $this->fileFor($item->getKey()))) { - if (is_file($tmp)) { - unlink($tmp); - } - + @unlink($tmp); return false; } return true; } + private function readLiveRecordUnlocked(string $key): ?CacheRecord + { + $file = $this->fileFor($key); + $raw = is_file($file) ? file_get_contents($file) : false; + $record = is_string($raw) ? $this->decodeRecordFromBlob($raw) : null; + if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { + return null; + } + + return $record; + } + + private function recordTagsAreCurrent(CacheRecord $record): bool + { + foreach ($record->tags as $tag => $generation) { + $current = is_file($this->metadataFileFor($tag)) + ? file_get_contents($this->metadataFileFor($tag)) + : false; + if (!is_string($current) || strtolower($current) !== $generation) { + return false; + } + } + + return true; + } + private function throwCreationError(string $prefix): void { $err = error_get_last()['message'] ?? 'unknown error'; - throw new RuntimeException($prefix . ": $err"); } + + /** @template T @param callable():T $callback @return T */ + private function withKeyLock(string $key, callable $callback): mixed + { + $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; + $handle = fopen($path, 'c'); + if (!is_resource($handle) || !flock($handle, LOCK_EX)) { + if (is_resource($handle)) { + fclose($handle); + } + throw new RuntimeException('Unable to acquire file cache key lock.'); + } + + try { + return $callback(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } } From 03657f79c08646cb89158ce0e01389aa99e9799a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:17:54 +0600 Subject: [PATCH 04/19] feat(cache): add atomic PHP-files capability --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 222 +++++++++++++-------- 1 file changed, 136 insertions(+), 86 deletions(-) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index 3fac0cb..f57b2ad 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -5,11 +5,12 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\CacheInput; +use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use Psr\Cache\CacheItemInterface; use RuntimeException; -final class PhpFilesCacheAdapter extends AbstractCacheAdapter +final class PhpFilesCacheAdapter extends AbstractCacheAdapter implements AtomicCachePoolInterface { use SecuresFilesystemDirectories; @@ -17,6 +18,8 @@ final class PhpFilesCacheAdapter extends AbstractCacheAdapter private string $dataDirectory; + private string $lockDirectory; + private string $metadataDirectory; public function __construct(string $namespace = 'default', ?string $baseDir = null) @@ -24,6 +27,58 @@ public function __construct(string $namespace = 'default', ?string $baseDir = nu $this->createDirectory($namespace, $baseDir); } + public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInterface $replacement): bool + { + if (!$this->supportsItem($replacement)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($replacement); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->withKeyLock($key, function () use ($key, $expected, $replacement): bool { + $record = $this->readLiveRecordUnlocked($key); + if (!$record instanceof CacheRecord || $record->value !== $expected) { + return false; + } + + return $this->persistItemUnlocked($replacement); + }); + } + + public function atomicGetAndDelete(string $key): CacheItemInterface + { + return $this->withKeyLock($key, function () use ($key): CacheItemInterface { + $record = $this->readLiveRecordUnlocked($key); + if (!$record instanceof CacheRecord) { + return $this->genericMiss($key); + } + $this->deleteItemUnlocked($key); + + return $this->genericItemFromRecord($key, $record); + }); + } + + public function atomicSetIfAbsent(CacheItemInterface $item): bool + { + if (!$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->withKeyLock($item->getKey(), function () use ($item): bool { + if ($this->readLiveRecordUnlocked($item->getKey()) instanceof CacheRecord) { + return false; + } + + return $this->persistItemUnlocked($item); + }); + } + public function clear(): bool { $ok = true; @@ -34,7 +89,6 @@ public function clear(): bool foreach (glob($this->metadataDirectory . '*') ?: [] as $file) { $ok = (!is_file($file) || unlink($file)) && $ok; } - $this->deferred = []; return $ok; @@ -42,16 +96,9 @@ public function clear(): bool public function deleteItem(string $key): bool { - $file = $this->fileFor($key); - $this->invalidateOpcache($file); - - return !is_file($file) || unlink($file); + return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } - /** - * @param array $keys The keys argument. - * @phpstan-param list $keys - */ public function deleteItems(array $keys): bool { $ok = true; @@ -64,27 +111,14 @@ public function deleteItems(array $keys): bool public function getItem(string $key): CacheItem { - $file = $this->fileFor($key); - if (!is_file($file)) { - return $this->genericMiss($key); - } - - $row = require $file; - $payload = is_array($row) && is_string($row['p'] ?? null) - ? $row['p'] - : null; - if (!is_string($payload)) { - return $this->genericDeleteAndMiss($key); + $record = $this->readLiveRecordUnlocked($key); + if ($record instanceof CacheRecord) { + return $this->genericItemFromRecord($key, $record); } - return $this->genericFromBase64WithInvalidator( - $key, - $payload, - fn(): bool => $this->deleteItem($key), - ); + return $this->genericMiss($key); } - /** @param list $tags */ #[\Override] public function getTagGenerations(array $tags): array { @@ -109,38 +143,16 @@ 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 { $items = []; - $stale = []; foreach ($keys as $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); + $items[$key] = $this->getItem($key); } return $items; } - /** @param list $tags */ #[\Override] public function rotateTagGenerations(array $tags): bool { @@ -162,7 +174,6 @@ public function save(CacheItemInterface $item): bool return $this->persistItem($item); } - /** @param array $items */ public function saveItems(array $items): bool { if (!$this->supportsItems($items)) { @@ -184,33 +195,18 @@ private function createDirectory(string $ns, ?string $baseDir): void $root = $baseDir . DIRECTORY_SEPARATOR . 'cache_' . $ns . DIRECTORY_SEPARATOR; $this->dataDirectory = $root . 'data' . DIRECTORY_SEPARATOR; $this->metadataDirectory = $root . 'meta' . DIRECTORY_SEPARATOR; + $this->lockDirectory = $root . 'locks' . DIRECTORY_SEPARATOR; - $this->assertPathNotSymlink($baseDir, 'PHP cache base 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->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}"); + foreach ([$baseDir, $this->dataDirectory, $this->metadataDirectory, $this->lockDirectory] as $directory) { + $this->assertPathNotSymlink($directory, 'PHP cache directory'); + if (!is_dir($directory) && !mkdir($directory, 0700, true) && !is_dir($directory)) { + throw new RuntimeException("Unable to create PHP cache directory: {$directory}"); + } + $this->assertSecureDirectory($directory, 'PHP cache directory'); } - - $this->assertSecureDirectory($baseDir, 'PHP cache base directory'); - if (!is_writable($this->dataDirectory) || !is_writable($this->metadataDirectory)) { + if (!is_writable($this->dataDirectory) || !is_writable($this->metadataDirectory) || !is_writable($this->lockDirectory)) { throw new RuntimeException('PHP cache directories are not writable.'); } - - $this->assertSecureDirectory($this->dataDirectory, 'PHP cache data directory'); - $this->assertSecureDirectory($this->metadataDirectory, 'PHP cache metadata directory'); } private function defaultBaseDirectory(): string @@ -220,6 +216,14 @@ private function defaultBaseDirectory(): string . str_replace('/', DIRECTORY_SEPARATOR, self::DEFAULT_BASE_DIR); } + private function deleteItemUnlocked(string $key): bool + { + $file = $this->fileFor($key); + $this->invalidateOpcache($file); + + return !is_file($file) || unlink($file); + } + private function fileFor(string $key): string { return $this->dataDirectory . hash('xxh128', $key) . '.php'; @@ -239,39 +243,85 @@ private function metadataFileFor(string $tag): string private function persistItem(CacheItemInterface $item): bool { + return $this->withKeyLock($item->getKey(), fn(): bool => $this->persistItemUnlocked($item)); + } + private function persistItemUnlocked(CacheItemInterface $item): bool + { $expires = CachePayloadCodec::expirationFromItem($item); if ($expires['ttl'] !== null && $expires['ttl'] <= 0) { - return $this->deleteItem($item->getKey()); + return $this->deleteItemUnlocked($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); - } - + @unlink($tmp); return false; } - $this->invalidateOpcache($file); if (!rename($tmp, $file)) { - if (is_file($tmp)) { - unlink($tmp); - } - + @unlink($tmp); return false; } return true; } + + private function readLiveRecordUnlocked(string $key): ?CacheRecord + { + $file = $this->fileFor($key); + if (!is_file($file)) { + return null; + } + $row = require $file; + $payload = is_array($row) && is_string($row['p'] ?? null) ? $row['p'] : null; + $blob = is_string($payload) ? base64_decode($payload, true) : false; + $record = is_string($blob) ? $this->decodeRecordFromBlob($blob) : null; + if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { + return null; + } + + return $record; + } + + private function recordTagsAreCurrent(CacheRecord $record): bool + { + foreach ($record->tags as $tag => $generation) { + $current = is_file($this->metadataFileFor($tag)) + ? file_get_contents($this->metadataFileFor($tag)) + : false; + if (!is_string($current) || strtolower($current) !== $generation) { + return false; + } + } + + return true; + } + + /** @template T @param callable():T $callback @return T */ + private function withKeyLock(string $key, callable $callback): mixed + { + $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; + $handle = fopen($path, 'c'); + if (!is_resource($handle) || !flock($handle, LOCK_EX)) { + if (is_resource($handle)) { + fclose($handle); + } + throw new RuntimeException('Unable to acquire PHP-file cache key lock.'); + } + + try { + return $callback(); + } finally { + flock($handle, LOCK_UN); + fclose($handle); + } + } } From 1dccd76532957fd354ce830f289d5e62ec6058f0 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:18:27 +0600 Subject: [PATCH 05/19] refactor(cache): support conditional atomic adapters --- .../Adapter/ConditionalAtomicCachePoolInterface.php | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 src/Cache/Adapter/ConditionalAtomicCachePoolInterface.php diff --git a/src/Cache/Adapter/ConditionalAtomicCachePoolInterface.php b/src/Cache/Adapter/ConditionalAtomicCachePoolInterface.php new file mode 100644 index 0000000..6a9c1b7 --- /dev/null +++ b/src/Cache/Adapter/ConditionalAtomicCachePoolInterface.php @@ -0,0 +1,11 @@ + Date: Mon, 7 Sep 2026 15:18:46 +0600 Subject: [PATCH 06/19] refactor(cache): gate conditional atomic capabilities --- src/Cache/AtomicCache.php | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Cache/AtomicCache.php b/src/Cache/AtomicCache.php index 762ac03..807d3aa 100644 --- a/src/Cache/AtomicCache.php +++ b/src/Cache/AtomicCache.php @@ -7,6 +7,7 @@ use DateInterval; use DateTimeInterface; use Infocyph\CacheLayer\Cache\Adapter\AtomicCachePoolInterface; +use Infocyph\CacheLayer\Cache\Adapter\ConditionalAtomicCachePoolInterface; use Infocyph\CacheLayer\Cache\Adapter\InternalCachePoolInterface; use Infocyph\CacheLayer\Cache\Metrics\CacheMetricsCollectorInterface; use Infocyph\CacheLayer\Exceptions\CacheBackendException; @@ -30,6 +31,9 @@ public static function fromAdapter( if (!$adapter instanceof AtomicCachePoolInterface) { return null; } + if ($adapter instanceof ConditionalAtomicCachePoolInterface && !$adapter->supportsAtomicCache()) { + return null; + } return new self($adapter, $options, $metrics); } From 59e2a19255a7dac6c66406e4d288c09818fc168b Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:19:54 +0600 Subject: [PATCH 07/19] feat(cache): add driver-qualified PDO atomics --- src/Cache/Adapter/PdoCacheAdapter.php | 245 ++++++++++++++++++++------ 1 file changed, 192 insertions(+), 53 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index 0fb4ba9..d6e36af 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -5,28 +5,24 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\CacheInput; +use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use PDO; use PDOException; use Psr\Cache\CacheItemInterface; use RuntimeException; +use Throwable; -final class PdoCacheAdapter extends AbstractCacheAdapter +final class PdoCacheAdapter extends AbstractCacheAdapter implements ConditionalAtomicCachePoolInterface { private const int BATCH_SIZE = 250; - private const string DEFAULT_SQLITE_DIR = 'cachelayer/pdo'; - private const string KIND_DATA = 'data'; - private const string KIND_TAG = 'tag'; private readonly string $driver; - private readonly string $namespace; - private readonly PDO $pdo; - private readonly string $table; public function __construct( @@ -57,6 +53,87 @@ public function __construct( } } + public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInterface $replacement): bool + { + if (!$this->supportsAtomicCache() || !$this->supportsItem($replacement)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($replacement); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->atomicTransaction(function () use ($key, $expected, $replacement, $expiration): bool { + $row = $this->atomicFetchRow($key); + $record = is_array($row) ? $this->recordFromRow($row) : null; + if (!$record instanceof CacheRecord + || !$this->recordTagsAreCurrent($record) + || $record->value !== $expected) { + return false; + } + + return $this->atomicUpdateExisting( + $key, + $this->encodeItem($replacement, $expiration['expiresAt']), + $expiration['expiresAt'], + ); + }); + } + + public function atomicGetAndDelete(string $key): CacheItemInterface + { + if (!$this->supportsAtomicCache()) { + return $this->genericMiss($key); + } + + return $this->atomicTransaction(function () use ($key): CacheItemInterface { + $row = $this->atomicFetchRow($key); + if (!is_array($row)) { + return $this->genericMiss($key); + } + + $record = $this->recordFromRow($row); + $this->deleteDataRow($key); + if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { + return $this->genericMiss($key); + } + + return $this->genericItemFromRecord($key, $record); + }); + } + + public function atomicSetIfAbsent(CacheItemInterface $item): bool + { + if (!$this->supportsAtomicCache() || !$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->atomicTransaction(function () use ($item, $expiration): bool { + $key = $item->getKey(); + $row = $this->atomicFetchRow($key); + $record = is_array($row) ? $this->recordFromRow($row) : null; + if ($record instanceof CacheRecord && $this->recordTagsAreCurrent($record)) { + return false; + } + + $payload = $this->encodeItem($item, $expiration['expiresAt']); + if (is_array($row)) { + return $this->atomicUpdateExisting($key, $payload, $expiration['expiresAt']); + } + + return $this->atomicInsertIfMissing($key, $payload, $expiration['expiresAt']); + }); + } + + public function supportsAtomicCache(): bool + { + return in_array($this->driver, ['sqlite', 'pgsql', 'mysql', 'mariadb'], true); + } + public static function defaultSqliteFileForNamespace(string $namespace): string { $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) @@ -87,14 +164,9 @@ public function clear(): bool public function deleteItem(string $key): bool { - $statement = $this->pdo->prepare( - "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", - ); - - return $statement->execute([$this->namespace, self::KIND_DATA, $key]); + return $this->deleteDataRow($key); } - /** @param list $keys */ public function deleteItems(array $keys): bool { return $this->deleteByKind(self::KIND_DATA, $keys); @@ -122,10 +194,6 @@ public function getItem(string $key): CacheItem return $this->genericMiss($key); } - /** - * @param list $tags - * @return array - */ #[\Override] public function getTagGenerations(array $tags): array { @@ -157,10 +225,6 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } - /** - * @param list $keys - * @return array - */ public function multiFetch(array $keys): array { $rows = $this->fetchRows(self::KIND_DATA, $keys); @@ -199,7 +263,6 @@ public function pruneExpired(int $limit = 1000): int return $this->deleteByKind(self::KIND_DATA, $keys) ? count($keys) : 0; } - /** @param list $tags */ #[\Override] public function rotateTagGenerations(array $tags): bool { @@ -229,7 +292,6 @@ public function save(CacheItemInterface $item): bool ]]); } - /** @param array $items */ public function saveItems(array $items): bool { $rows = []; @@ -241,21 +303,90 @@ public function saveItems(array $items): bool $expiration = CachePayloadCodec::expirationFromItem($item); if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { $expired[] = $item->getKey(); - continue; } - $rows[] = [ - self::KIND_DATA, - $item->getKey(), - $this->encodeItem($item, $expiration['expiresAt']), - $expiration['expiresAt'], - ]; + $rows[] = [self::KIND_DATA, $item->getKey(), $this->encodeItem($item, $expiration['expiresAt']), $expiration['expiresAt']]; } return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } - /** @param list $keys */ + private function atomicFetchRow(string $key): ?array + { + $suffix = in_array($this->driver, ['pgsql', 'mysql', 'mariadb'], true) ? ' FOR UPDATE' : ''; + $statement = $this->pdo->prepare( + "SELECT payload, expires FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?{$suffix}", + ); + $statement->execute([$this->namespace, self::KIND_DATA, $key]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + return null; + } + $payload = $row['payload'] ?? null; + if (is_resource($payload)) { + $payload = stream_get_contents($payload); + } + if (!is_string($payload)) { + return null; + } + + return ['payload' => $payload, 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null]; + } + + private function atomicInsertIfMissing(string $key, string $payload, ?int $expires): bool + { + if (in_array($this->driver, ['pgsql', 'sqlite'], true)) { + $sql = "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, ?) " + . 'ON CONFLICT (namespace, kind, cache_key) DO NOTHING'; + } else { + $sql = "INSERT IGNORE INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, ?)"; + } + $statement = $this->pdo->prepare($sql); + $statement->execute([$this->namespace, self::KIND_DATA, $key, $payload, $expires]); + + return $statement->rowCount() === 1; + } + + private function atomicTransaction(callable $callback): mixed + { + if ($this->pdo->inTransaction()) { + throw new RuntimeException('Atomic PDO cache operations require ownership of the PDO transaction.'); + } + $sqlite = $this->driver === 'sqlite'; + if ($sqlite) { + $this->pdo->exec('BEGIN IMMEDIATE'); + } else { + $this->pdo->beginTransaction(); + } + + try { + $result = $callback(); + $sqlite ? $this->pdo->exec('COMMIT') : $this->pdo->commit(); + return $result; + } catch (Throwable $failure) { + if ($this->pdo->inTransaction()) { + $sqlite ? $this->pdo->exec('ROLLBACK') : $this->pdo->rollBack(); + } + throw $failure; + } + } + + private function atomicUpdateExisting(string $key, string $payload, ?int $expires): bool + { + $statement = $this->pdo->prepare( + "UPDATE {$this->table} SET payload = ?, expires = ? WHERE namespace = ? AND kind = ? AND cache_key = ?", + ); + return $statement->execute([$payload, $expires, $this->namespace, self::KIND_DATA, $key]); + } + + private function deleteDataRow(string $key): bool + { + $statement = $this->pdo->prepare( + "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", + ); + return $statement->execute([$this->namespace, self::KIND_DATA, $key]); + } + private function deleteByKind(string $kind, array $keys): bool { foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { @@ -271,18 +402,13 @@ private function deleteByKind(string $kind, array $keys): bool return true; } - /** - * @param list $keys - * @return array - */ private function fetchRows(string $kind, array $keys): array { $rows = []; foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { $marks = implode(',', array_fill(0, count($chunk), '?')); $statement = $this->pdo->prepare( - "SELECT cache_key, payload, expires FROM {$this->table} " - . "WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", + "SELECT cache_key, payload, expires FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", ); $statement->execute([$this->namespace, $kind, ...$chunk]); foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { @@ -306,19 +432,41 @@ private function fetchRows(string $kind, array $keys): array return $rows; } - /** @param array{payload:string, expires:int|null} $row */ private function hydrate(string $key, array $row): ?CacheItem { if (CachePayloadCodec::isExpired($row['expires'])) { return null; } - $record = $this->decodeRecordFromBlob($row['payload']); return $record === null ? null : $this->genericItemFromRecord($key, $record); } - /** @param list $rows */ + private function recordFromRow(array $row): ?CacheRecord + { + if (CachePayloadCodec::isExpired($row['expires'])) { + return null; + } + $record = $this->decodeRecordFromBlob($row['payload']); + + return $record instanceof CacheRecord ? $record : null; + } + + private function recordTagsAreCurrent(CacheRecord $record): bool + { + if ($record->tags === []) { + return true; + } + $rows = $this->fetchRows(self::KIND_TAG, array_keys($record->tags)); + foreach ($record->tags as $tag => $generation) { + if (($rows[$tag]['payload'] ?? null) !== $generation) { + return false; + } + } + + return true; + } + private function upsertChunk(array $rows): bool { if (!in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { @@ -327,8 +475,7 @@ private function upsertChunk(array $rows): bool $values = implode(',', array_fill(0, count($rows), '(?, ?, ?, ?, ?)')); $suffix = in_array($this->driver, ['pgsql', 'sqlite'], true) - ? 'ON CONFLICT (namespace, kind, cache_key) DO UPDATE SET ' - . 'payload = EXCLUDED.payload, expires = EXCLUDED.expires' + ? 'ON CONFLICT (namespace, kind, cache_key) DO UPDATE SET payload = EXCLUDED.payload, expires = EXCLUDED.expires' : 'ON DUPLICATE KEY UPDATE payload = VALUES(payload), expires = VALUES(expires)'; $parameters = []; foreach ($rows as [$kind, $key, $payload, $expires]) { @@ -336,21 +483,17 @@ private function upsertChunk(array $rows): bool } return $this->pdo->prepare( - "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) " - . "VALUES {$values} {$suffix}", + "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES {$values} {$suffix}", )->execute($parameters); } - /** @param list $rows */ private function upsertGenericRows(array $rows): bool { $this->pdo->beginTransaction(); - try { foreach ($rows as [$kind, $key, $payload, $expires]) { $update = $this->pdo->prepare( - "UPDATE {$this->table} SET payload = ?, expires = ? " - . 'WHERE namespace = ? AND kind = ? AND cache_key = ?', + "UPDATE {$this->table} SET payload = ?, expires = ? WHERE namespace = ? AND kind = ? AND cache_key = ?", ); $update->execute([$payload, $expires, $this->namespace, $kind, $key]); if ($update->rowCount() === 0) { @@ -362,23 +505,19 @@ private function upsertGenericRows(array $rows): bool continue; } $this->pdo->prepare( - "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) " - . 'VALUES (?, ?, ?, ?, ?)', + "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, ?)", )->execute([$this->namespace, $kind, $key, $payload, $expires]); } } - return $this->pdo->commit(); } catch (PDOException $failure) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } - throw $failure; } } - /** @param list $rows */ private function upsertRows(array $rows): bool { foreach (array_chunk($rows, self::BATCH_SIZE) as $chunk) { From 780df861142667c21ecd50faec00b9da813dc4e9 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:20:46 +0600 Subject: [PATCH 08/19] test(cache): expand atomic capability boundaries --- tests/Cache/AtomicCacheCapabilityTest.php | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/tests/Cache/AtomicCacheCapabilityTest.php b/tests/Cache/AtomicCacheCapabilityTest.php index 9b0ef77..3d35e00 100644 --- a/tests/Cache/AtomicCacheCapabilityTest.php +++ b/tests/Cache/AtomicCacheCapabilityTest.php @@ -12,24 +12,21 @@ test('cache exposes atomic capability only when the adapter guarantees it', function () { $memory = Cache::memory('atomic-memory'); + $weakMap = Cache::weakMap('atomic-weak-map'); $null = Cache::nullStore(); expect($memory)->toBeInstanceOf(AtomicCacheProviderInterface::class) ->and($memory->atomic())->toBeInstanceOf(AtomicCacheInterface::class) + ->and($weakMap->atomic())->toBeInstanceOf(AtomicCacheInterface::class) ->and($null->atomic())->toBeNull(); }); -test('unsupported and composite stores do not advertise atomic capability', function () { +test('supported SQLite PDO store advertises atomic capability', function () { $sqliteFile = sys_get_temp_dir() . '/cachelayer-atomic-' . uniqid('', true) . '.sqlite'; $sqlite = Cache::sqlite('atomic-sqlite', $sqliteFile); - $tiered = Cache::tiered([ - new ArrayCacheAdapter('atomic-tier-l1'), - new ArrayCacheAdapter('atomic-tier-l2'), - ], namespace: 'atomic-tiered'); try { - expect($sqlite->atomic())->toBeNull() - ->and($tiered->atomic())->toBeNull(); + expect($sqlite->atomic())->toBeInstanceOf(AtomicCacheInterface::class); } finally { unset($sqlite); if (is_file($sqliteFile)) { @@ -38,6 +35,15 @@ } }); +test('composite stores do not advertise atomic capability', function () { + $tiered = Cache::tiered([ + new ArrayCacheAdapter('atomic-tier-l1'), + new ArrayCacheAdapter('atomic-tier-l2'), + ], namespace: 'atomic-tiered'); + + expect($tiered->atomic())->toBeNull(); +}); + test('set if absent has one-winner semantics and preserves existing values', function () { $cache = Cache::memory('atomic-set-if-absent'); $atomic = $cache->atomic(); From 1301063ca724a0f5565ddd11f30f79e6dccb04c5 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:21:27 +0600 Subject: [PATCH 09/19] test(cache): cover expanded atomic backends --- tests/Cache/AtomicBackendExpansionTest.php | 97 ++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/Cache/AtomicBackendExpansionTest.php diff --git a/tests/Cache/AtomicBackendExpansionTest.php b/tests/Cache/AtomicBackendExpansionTest.php new file mode 100644 index 0000000..c137373 --- /dev/null +++ b/tests/Cache/AtomicBackendExpansionTest.php @@ -0,0 +1,97 @@ +isDir() ? rmdir($entry->getPathname()) : unlink($entry->getPathname()); + } + rmdir($directory); +}; + +test('WeakMap supports the full atomic cache contract', function () { + $cache = Cache::weakMap('atomic-weak-map-contract'); + $atomic = $cache->atomic(); + + expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) + ->and($atomic->setIfAbsent('claim', 1, 30))->toBeTrue() + ->and($atomic->setIfAbsent('claim', 2, 30))->toBeFalse() + ->and($atomic->compareAndSet('claim', '1', 2, 30))->toBeFalse() + ->and($atomic->compareAndSet('claim', 1, 2, 30))->toBeTrue() + ->and($atomic->getAndDelete('claim', 'missing'))->toBe(2) + ->and($atomic->getAndDelete('claim', 'missing'))->toBe('missing'); +}); + +test('file and PHP-file stores support the full atomic cache contract', function () use ($cleanupTree) { + foreach (['file', 'phpFiles'] as $factory) { + $directory = sys_get_temp_dir() . '/cachelayer-atomic-' . strtolower($factory) . '-' . uniqid('', true); + try { + $cache = Cache::{$factory}('atomic-files', $directory); + $atomic = $cache->atomic(); + + expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) + ->and($atomic->setIfAbsent('claim', 'first', 30))->toBeTrue() + ->and($atomic->setIfAbsent('claim', 'second', 30))->toBeFalse() + ->and($atomic->compareAndSet('claim', 'first', 'updated', 30))->toBeTrue() + ->and($cache->get('claim'))->toBe('updated') + ->and($atomic->getAndDelete('claim', 'missing'))->toBe('updated') + ->and($cache->has('claim'))->toBeFalse(); + } finally { + $cleanupTree($directory); + } + } +}); + +test('SQLite PDO supports the full atomic cache contract', function () { + $file = sys_get_temp_dir() . '/cachelayer-atomic-pdo-' . uniqid('', true) . '.sqlite'; + try { + $cache = Cache::sqlite('atomic-pdo', $file); + $atomic = $cache->atomic(); + + expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) + ->and($atomic->setIfAbsent('claim', null, 30))->toBeTrue() + ->and($atomic->compareAndSet('claim', null, 'updated', 30))->toBeTrue() + ->and($atomic->getAndDelete('claim', 'missing'))->toBe('updated') + ->and($atomic->getAndDelete('claim', 'missing'))->toBe('missing'); + } finally { + if (is_file($file)) { + unlink($file); + } + } +}); + +if (class_exists(Memcached::class)) { + $host = getenv('IC_MEMCACHED_HOST') ?: getenv('CACHELAYER_MEMCACHED_HOST') ?: '127.0.0.1'; + $port = (int) (getenv('IC_MEMCACHED_PORT') ?: getenv('CACHELAYER_MEMCACHED_PORT') ?: '11211'); + $probe = new Memcached(); + $probe->addServer($host, $port); + $available = $probe->set('cachelayer-atomic-probe', 'ok') + && $probe->getResultCode() === Memcached::RES_SUCCESS; + + test('Memcached supports CAS-backed atomic cache operations', function () use ($host, $port) { + $client = new Memcached(); + $client->addServer($host, $port); + $client->flush(); + $cache = Cache::memcached('atomic-memcached', [[$host, $port, 0]], $client); + $atomic = $cache->atomic(); + + expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) + ->and($atomic->setIfAbsent('claim', 'first', 30))->toBeTrue() + ->and($atomic->setIfAbsent('claim', 'second', 30))->toBeFalse() + ->and($atomic->compareAndSet('claim', 'first', 'updated', 30))->toBeTrue() + ->and($atomic->getAndDelete('claim', 'missing'))->toBe('updated') + ->and($cache->has('claim'))->toBeFalse() + ->and($atomic->setIfAbsent('claim', 'reclaimed', 30))->toBeTrue() + ->and($cache->get('claim'))->toBe('reclaimed'); + })->skip(!$available, 'No Memcached server available.'); +} From f30b0c8b62f2ea5f66eaf3ec3e95b069ef07e3c8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:22:17 +0600 Subject: [PATCH 10/19] test(cache): keep backend expansion tests explicit --- tests/Cache/AtomicBackendExpansionTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/Cache/AtomicBackendExpansionTest.php b/tests/Cache/AtomicBackendExpansionTest.php index c137373..bbd41a3 100644 --- a/tests/Cache/AtomicBackendExpansionTest.php +++ b/tests/Cache/AtomicBackendExpansionTest.php @@ -36,7 +36,9 @@ foreach (['file', 'phpFiles'] as $factory) { $directory = sys_get_temp_dir() . '/cachelayer-atomic-' . strtolower($factory) . '-' . uniqid('', true); try { - $cache = Cache::{$factory}('atomic-files', $directory); + $cache = $factory === 'file' + ? Cache::file('atomic-files', $directory) + : Cache::phpFiles('atomic-files', $directory); $atomic = $cache->atomic(); expect($atomic)->toBeInstanceOf(AtomicCacheInterface::class) From 97dad4e0fa834ee915520fdc6fce182be6955c31 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:23:26 +0600 Subject: [PATCH 11/19] docs(cache): document expanded atomic backends --- docs/cache.rst | 66 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 22 deletions(-) diff --git a/docs/cache.rst b/docs/cache.rst index d97a129..1537c55 100644 --- a/docs/cache.rst +++ b/docs/cache.rst @@ -107,32 +107,54 @@ never implements these methods as public ``has()/get()`` followed by The supported facade backends are: -================= ====== ================================================ -Backend Atomic Consistency domain -================= ====== ================================================ -Array memory yes one PHP process -Shared memory yes one host / shared SysV segment -Redis / Valkey yes the supplied authoritative Redis-compatible store -Redis Cluster yes the key's stable CacheLayer hash-slot bucket -MongoDB yes the supplied authoritative MongoDB collection -APCu no no full atomic consume primitive -Memcached no no full atomic consume primitive -PDO / SQLite no no cross-driver contract is claimed in 3.3 -File / PHP files no ordinary writers do not share an atomic key lock -ScyllaDB no no full three-operation contract is exposed -WeakMap no process-local object cache, not coordination -Null store no non-authoritative sink -Tiered cache no tiers cannot form one linearizable authority -================= ====== ================================================ +========================== ====== ==================================================== +Backend Atomic Consistency domain +========================== ====== ==================================================== +Array memory yes one PHP process +WeakMap yes one PHP process / adapter instance +Shared memory yes one host / shared SysV segment +Redis / Valkey yes the supplied authoritative Redis-compatible store +Redis Cluster yes the key's stable CacheLayer hash-slot bucket +MongoDB yes the supplied authoritative MongoDB collection +Memcached yes the supplied authoritative Memcached CAS domain +PDO: SQLite yes the supplied SQLite database +PDO: PostgreSQL yes the supplied PostgreSQL database +PDO: MySQL / MariaDB yes the supplied transactional database +File yes one reliable filesystem ``flock()`` domain +PHP files yes one reliable filesystem ``flock()`` domain +APCu no no arbitrary-value CAS/consume primitive +PDO: other drivers no no portable transaction/locking contract is claimed +ScyllaDB no LWT is not mixed with ordinary cache writes +Null store no non-authoritative sink +Tiered cache no tiers cannot form one linearizable authority +========================== ====== ==================================================== + +Memcached uses native CAS tokens for replacement. Atomic consume linearizes by +CAS-replacing the value with an internal short-lived tombstone; ordinary reads +treat the tombstone as a miss, while ``setIfAbsent()`` can immediately reclaim +it through CAS. The tombstone is never exposed as a logical cache value. + +File and PHP-file stores use deterministic per-key ``flock()`` files. Ordinary +``save()`` and ``deleteItem()`` mutations take the same key lock as atomic +operations, while reads remain lock-free over atomic rename replacement. Their +atomic guarantee therefore applies only inside a filesystem domain where +``flock()`` semantics are reliable. + +PDO atomic capability is driver-qualified. SQLite uses an immediate writer +transaction; PostgreSQL and MySQL/MariaDB use transactional row locking plus +conflict-safe inserts. Other PDO drivers return no atomic capability rather than +claiming a cross-driver guarantee. Atomic PDO operations require ownership of +the PDO transaction and reject execution inside an already-active caller +transaction. Use dedicated, untagged keys for portable replay claims, nonces, challenges, state transitions, one-time state, and similar coordination. Tag invalidation is a separate cache coordination mechanism and is not part of the portable -atomic linearization boundary. Redis/Valkey, Redis Cluster, and MongoDB reject -``compareAndSet()`` on tagged records because their tag metadata cannot join the -same atomic replacement condition. Array memory and SharedMemory can validate -tag generations inside their local atomic domain, but portable protocols should -not depend on tagged CAS behavior. +atomic linearization boundary. Redis/Valkey, Redis Cluster, MongoDB, and +Memcached reject ``compareAndSet()`` on tagged records because their tag +metadata cannot join the same atomic replacement condition. Process-local and +single-lock-domain backends can validate tag generations inside their atomic +domain, but portable protocols should not depend on tagged CAS behavior. ``atomic()`` returns ``null`` rather than emulating missing backend primitives. This remains true for a tiered facade even when one or more individual tiers From faf6ff28a06b073b089797d2a9e21316ddbb600a Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:33:01 +0600 Subject: [PATCH 12/19] fix(cache): satisfy atomic file cache quality gates --- src/Cache/Adapter/FileCacheAdapter.php | 27 +++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 81a96ed..7182dde 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -98,6 +98,7 @@ public function deleteItem(string $key): bool return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } + /** @param list $keys */ public function deleteItems(array $keys): bool { $ok = true; @@ -118,6 +119,10 @@ public function getItem(string $key): CacheItem return new CacheItem($this, $key); } + /** + * @param list $tags + * @return array + */ #[\Override] public function getTagGenerations(array $tags): array { @@ -142,6 +147,10 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { $items = []; @@ -152,6 +161,7 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ #[\Override] public function rotateTagGenerations(array $tags): bool { @@ -174,6 +184,7 @@ public function save(CacheItemInterface $item): bool return $this->persistItem($item); } + /** @param array $items */ public function saveItems(array $items): bool { if (!$this->supportsItems($items)) { @@ -270,11 +281,17 @@ private function persistItemUnlocked(CacheItemInterface $item): bool return false; } if (file_put_contents($tmp, $blob, LOCK_EX) === false) { - @unlink($tmp); + if (is_file($tmp)) { + unlink($tmp); + } + return false; } if (!rename($tmp, $this->fileFor($item->getKey()))) { - @unlink($tmp); + if (is_file($tmp)) { + unlink($tmp); + } + return false; } @@ -313,7 +330,11 @@ private function throwCreationError(string $prefix): void throw new RuntimeException($prefix . ": $err"); } - /** @template T @param callable():T $callback @return T */ + /** + * @template T + * @param callable(): T $callback + * @return T + */ private function withKeyLock(string $key, callable $callback): mixed { $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; From 10b2b8e28356d12b91c163111f5075996261bdad Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:33:33 +0600 Subject: [PATCH 13/19] fix(cache): satisfy atomic PHP-file quality gates --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 29 +++++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index f57b2ad..c4c5412 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -99,11 +99,12 @@ public function deleteItem(string $key): bool return $this->withKeyLock($key, fn(): bool => $this->deleteItemUnlocked($key)); } + /** @param list $keys */ public function deleteItems(array $keys): bool { $ok = true; foreach ($keys as $key) { - $ok = $this->deleteItem((string) $key) && $ok; + $ok = $this->deleteItem($key) && $ok; } return $ok; @@ -119,6 +120,10 @@ public function getItem(string $key): CacheItem return $this->genericMiss($key); } + /** + * @param list $tags + * @return array + */ #[\Override] public function getTagGenerations(array $tags): array { @@ -143,6 +148,10 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { $items = []; @@ -153,6 +162,7 @@ public function multiFetch(array $keys): array return $items; } + /** @param list $tags */ #[\Override] public function rotateTagGenerations(array $tags): bool { @@ -174,6 +184,7 @@ public function save(CacheItemInterface $item): bool return $this->persistItem($item); } + /** @param array $items */ public function saveItems(array $items): bool { if (!$this->supportsItems($items)) { @@ -262,12 +273,18 @@ private function persistItemUnlocked(CacheItemInterface $item): bool return false; } if (file_put_contents($tmp, $code, LOCK_EX) === false) { - @unlink($tmp); + if (is_file($tmp)) { + unlink($tmp); + } + return false; } $this->invalidateOpcache($file); if (!rename($tmp, $file)) { - @unlink($tmp); + if (is_file($tmp)) { + unlink($tmp); + } + return false; } @@ -305,7 +322,11 @@ private function recordTagsAreCurrent(CacheRecord $record): bool return true; } - /** @template T @param callable():T $callback @return T */ + /** + * @template T + * @param callable(): T $callback + * @return T + */ private function withKeyLock(string $key, callable $callback): mixed { $path = $this->lockDirectory . hash('xxh128', $key) . '.lock'; From d1909901e48370484a2c55919c2471be4f5fcb95 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:34:16 +0600 Subject: [PATCH 14/19] refactor(cache): isolate PDO atomic operations --- src/Cache/Adapter/PdoAtomicOperations.php | 218 ++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 src/Cache/Adapter/PdoAtomicOperations.php diff --git a/src/Cache/Adapter/PdoAtomicOperations.php b/src/Cache/Adapter/PdoAtomicOperations.php new file mode 100644 index 0000000..6b8b379 --- /dev/null +++ b/src/Cache/Adapter/PdoAtomicOperations.php @@ -0,0 +1,218 @@ +supportsAtomicCache() || !$this->supportsItem($replacement)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($replacement); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->atomicTransaction(function () use ($key, $expected, $replacement, $expiration): bool { + $row = $this->atomicFetchRow($key); + $record = $row === null ? null : $this->atomicRecordFromRow($row); + if (!$record instanceof CacheRecord + || !$this->atomicRecordTagsAreCurrent($record) + || $record->value !== $expected) { + return false; + } + + return $this->atomicUpdateExisting( + $key, + $this->encodeItem($replacement, $expiration['expiresAt']), + $expiration['expiresAt'], + ); + }); + } + + public function atomicGetAndDelete(string $key): CacheItemInterface + { + if (!$this->supportsAtomicCache()) { + return $this->genericMiss($key); + } + + return $this->atomicTransaction(function () use ($key): CacheItemInterface { + $row = $this->atomicFetchRow($key); + if ($row === null) { + return $this->genericMiss($key); + } + + $record = $this->atomicRecordFromRow($row); + $this->deleteItem($key); + if (!$record instanceof CacheRecord || !$this->atomicRecordTagsAreCurrent($record)) { + return $this->genericMiss($key); + } + + return $this->genericItemFromRecord($key, $record); + }); + } + + public function atomicSetIfAbsent(CacheItemInterface $item): bool + { + if (!$this->supportsAtomicCache() || !$this->supportsItem($item)) { + return false; + } + $expiration = CachePayloadCodec::expirationFromItem($item); + if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { + return false; + } + + return $this->atomicTransaction(function () use ($item, $expiration): bool { + $key = $item->getKey(); + $row = $this->atomicFetchRow($key); + $record = $row === null ? null : $this->atomicRecordFromRow($row); + if ($record instanceof CacheRecord && $this->atomicRecordTagsAreCurrent($record)) { + return false; + } + + $payload = $this->encodeItem($item, $expiration['expiresAt']); + if ($row !== null) { + return $this->atomicUpdateExisting($key, $payload, $expiration['expiresAt']); + } + + return $this->atomicInsertIfMissing($key, $payload, $expiration['expiresAt']); + }); + } + + public function supportsAtomicCache(): bool + { + return in_array($this->driver, ['sqlite', 'pgsql', 'mysql', 'mariadb'], true); + } + + /** @return array{payload:string, expires:int|null}|null */ + private function atomicFetchRow(string $key): ?array + { + $suffix = in_array($this->driver, ['pgsql', 'mysql', 'mariadb'], true) ? ' FOR UPDATE' : ''; + $statement = $this->pdo->prepare( + "SELECT payload, expires FROM {$this->table} " + . "WHERE namespace = ? AND kind = ? AND cache_key = ?{$suffix}", + ); + $statement->execute([$this->namespace, self::KIND_DATA, $key]); + $row = $statement->fetch(PDO::FETCH_ASSOC); + if (!is_array($row)) { + return null; + } + + $payload = $row['payload'] ?? null; + if (is_resource($payload)) { + $payload = stream_get_contents($payload); + } + if (!is_string($payload)) { + return null; + } + + return [ + 'payload' => $payload, + 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null, + ]; + } + + private function atomicInsertIfMissing(string $key, string $payload, ?int $expires): bool + { + if (in_array($this->driver, ['pgsql', 'sqlite'], true)) { + $sql = "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) " + . 'VALUES (?, ?, ?, ?, ?) ON CONFLICT (namespace, kind, cache_key) DO NOTHING'; + } else { + $sql = "INSERT IGNORE INTO {$this->table} " + . '(namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, ?)'; + } + $statement = $this->pdo->prepare($sql); + $statement->execute([$this->namespace, self::KIND_DATA, $key, $payload, $expires]); + + return $statement->rowCount() === 1; + } + + /** @param array{payload:string, expires:int|null} $row */ + private function atomicRecordFromRow(array $row): ?CacheRecord + { + if (CachePayloadCodec::isExpired($row['expires'])) { + return null; + } + + $record = $this->decodeRecordFromBlob($row['payload']); + + return $record instanceof CacheRecord ? $record : null; + } + + private function atomicRecordTagsAreCurrent(CacheRecord $record): bool + { + if ($record->tags === []) { + return true; + } + + $rows = $this->fetchRows(self::KIND_TAG, array_keys($record->tags)); + foreach ($record->tags as $tag => $generation) { + if (($rows[$tag]['payload'] ?? null) !== $generation) { + return false; + } + } + + return true; + } + + /** + * @template T + * @param callable(): T $callback + * @return T + */ + private function atomicTransaction(callable $callback): mixed + { + if ($this->pdo->inTransaction()) { + throw new RuntimeException('Atomic PDO cache operations require ownership of the PDO transaction.'); + } + + $sqlite = $this->driver === 'sqlite'; + if ($sqlite) { + $this->pdo->exec('BEGIN IMMEDIATE'); + } else { + $this->pdo->beginTransaction(); + } + + try { + $result = $callback(); + if ($sqlite) { + $this->pdo->exec('COMMIT'); + } else { + $this->pdo->commit(); + } + + return $result; + } catch (Throwable $failure) { + if ($sqlite) { + $this->pdo->exec('ROLLBACK'); + } else { + $this->pdo->rollBack(); + } + + throw $failure; + } + } + + private function atomicUpdateExisting(string $key, string $payload, ?int $expires): bool + { + $statement = $this->pdo->prepare( + "UPDATE {$this->table} SET payload = ?, expires = ? " + . 'WHERE namespace = ? AND kind = ? AND cache_key = ?', + ); + + return $statement->execute([$payload, $expires, $this->namespace, self::KIND_DATA, $key]); + } +} From 69bea1f28750792c4339e319141d0724bdbb04d8 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:35:00 +0600 Subject: [PATCH 15/19] refactor(cache): restore focused PDO adapter --- src/Cache/Adapter/PdoCacheAdapter.php | 245 ++++++-------------------- 1 file changed, 54 insertions(+), 191 deletions(-) diff --git a/src/Cache/Adapter/PdoCacheAdapter.php b/src/Cache/Adapter/PdoCacheAdapter.php index d6e36af..0433f84 100644 --- a/src/Cache/Adapter/PdoCacheAdapter.php +++ b/src/Cache/Adapter/PdoCacheAdapter.php @@ -5,24 +5,30 @@ namespace Infocyph\CacheLayer\Cache\Adapter; use Infocyph\CacheLayer\Cache\CacheInput; -use Infocyph\CacheLayer\Cache\CacheRecord; use Infocyph\CacheLayer\Cache\Item\CacheItem; use PDO; use PDOException; use Psr\Cache\CacheItemInterface; use RuntimeException; -use Throwable; final class PdoCacheAdapter extends AbstractCacheAdapter implements ConditionalAtomicCachePoolInterface { + use PdoAtomicOperations; + private const int BATCH_SIZE = 250; + private const string DEFAULT_SQLITE_DIR = 'cachelayer/pdo'; + private const string KIND_DATA = 'data'; + private const string KIND_TAG = 'tag'; private readonly string $driver; + private readonly string $namespace; + private readonly PDO $pdo; + private readonly string $table; public function __construct( @@ -53,87 +59,6 @@ public function __construct( } } - public function atomicCompareAndSet(string $key, mixed $expected, CacheItemInterface $replacement): bool - { - if (!$this->supportsAtomicCache() || !$this->supportsItem($replacement)) { - return false; - } - $expiration = CachePayloadCodec::expirationFromItem($replacement); - if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { - return false; - } - - return $this->atomicTransaction(function () use ($key, $expected, $replacement, $expiration): bool { - $row = $this->atomicFetchRow($key); - $record = is_array($row) ? $this->recordFromRow($row) : null; - if (!$record instanceof CacheRecord - || !$this->recordTagsAreCurrent($record) - || $record->value !== $expected) { - return false; - } - - return $this->atomicUpdateExisting( - $key, - $this->encodeItem($replacement, $expiration['expiresAt']), - $expiration['expiresAt'], - ); - }); - } - - public function atomicGetAndDelete(string $key): CacheItemInterface - { - if (!$this->supportsAtomicCache()) { - return $this->genericMiss($key); - } - - return $this->atomicTransaction(function () use ($key): CacheItemInterface { - $row = $this->atomicFetchRow($key); - if (!is_array($row)) { - return $this->genericMiss($key); - } - - $record = $this->recordFromRow($row); - $this->deleteDataRow($key); - if (!$record instanceof CacheRecord || !$this->recordTagsAreCurrent($record)) { - return $this->genericMiss($key); - } - - return $this->genericItemFromRecord($key, $record); - }); - } - - public function atomicSetIfAbsent(CacheItemInterface $item): bool - { - if (!$this->supportsAtomicCache() || !$this->supportsItem($item)) { - return false; - } - $expiration = CachePayloadCodec::expirationFromItem($item); - if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { - return false; - } - - return $this->atomicTransaction(function () use ($item, $expiration): bool { - $key = $item->getKey(); - $row = $this->atomicFetchRow($key); - $record = is_array($row) ? $this->recordFromRow($row) : null; - if ($record instanceof CacheRecord && $this->recordTagsAreCurrent($record)) { - return false; - } - - $payload = $this->encodeItem($item, $expiration['expiresAt']); - if (is_array($row)) { - return $this->atomicUpdateExisting($key, $payload, $expiration['expiresAt']); - } - - return $this->atomicInsertIfMissing($key, $payload, $expiration['expiresAt']); - }); - } - - public function supportsAtomicCache(): bool - { - return in_array($this->driver, ['sqlite', 'pgsql', 'mysql', 'mariadb'], true); - } - public static function defaultSqliteFileForNamespace(string $namespace): string { $directory = rtrim(sys_get_temp_dir(), DIRECTORY_SEPARATOR) @@ -164,9 +89,14 @@ public function clear(): bool public function deleteItem(string $key): bool { - return $this->deleteDataRow($key); + $statement = $this->pdo->prepare( + "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", + ); + + return $statement->execute([$this->namespace, self::KIND_DATA, $key]); } + /** @param list $keys */ public function deleteItems(array $keys): bool { return $this->deleteByKind(self::KIND_DATA, $keys); @@ -194,6 +124,10 @@ public function getItem(string $key): CacheItem return $this->genericMiss($key); } + /** + * @param list $tags + * @return array + */ #[\Override] public function getTagGenerations(array $tags): array { @@ -225,6 +159,10 @@ public function hasItem(string $key): bool return $this->getItem($key)->isHit(); } + /** + * @param list $keys + * @return array + */ public function multiFetch(array $keys): array { $rows = $this->fetchRows(self::KIND_DATA, $keys); @@ -263,6 +201,7 @@ public function pruneExpired(int $limit = 1000): int return $this->deleteByKind(self::KIND_DATA, $keys) ? count($keys) : 0; } + /** @param list $tags */ #[\Override] public function rotateTagGenerations(array $tags): bool { @@ -292,6 +231,7 @@ public function save(CacheItemInterface $item): bool ]]); } + /** @param array $items */ public function saveItems(array $items): bool { $rows = []; @@ -303,90 +243,21 @@ public function saveItems(array $items): bool $expiration = CachePayloadCodec::expirationFromItem($item); if ($expiration['ttl'] !== null && $expiration['ttl'] <= 0) { $expired[] = $item->getKey(); + continue; } - $rows[] = [self::KIND_DATA, $item->getKey(), $this->encodeItem($item, $expiration['expiresAt']), $expiration['expiresAt']]; + $rows[] = [ + self::KIND_DATA, + $item->getKey(), + $this->encodeItem($item, $expiration['expiresAt']), + $expiration['expiresAt'], + ]; } return $this->deleteByKind(self::KIND_DATA, $expired) && $this->upsertRows($rows); } - private function atomicFetchRow(string $key): ?array - { - $suffix = in_array($this->driver, ['pgsql', 'mysql', 'mariadb'], true) ? ' FOR UPDATE' : ''; - $statement = $this->pdo->prepare( - "SELECT payload, expires FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?{$suffix}", - ); - $statement->execute([$this->namespace, self::KIND_DATA, $key]); - $row = $statement->fetch(PDO::FETCH_ASSOC); - if (!is_array($row)) { - return null; - } - $payload = $row['payload'] ?? null; - if (is_resource($payload)) { - $payload = stream_get_contents($payload); - } - if (!is_string($payload)) { - return null; - } - - return ['payload' => $payload, 'expires' => is_numeric($row['expires'] ?? null) ? (int) $row['expires'] : null]; - } - - private function atomicInsertIfMissing(string $key, string $payload, ?int $expires): bool - { - if (in_array($this->driver, ['pgsql', 'sqlite'], true)) { - $sql = "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, ?) " - . 'ON CONFLICT (namespace, kind, cache_key) DO NOTHING'; - } else { - $sql = "INSERT IGNORE INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, ?)"; - } - $statement = $this->pdo->prepare($sql); - $statement->execute([$this->namespace, self::KIND_DATA, $key, $payload, $expires]); - - return $statement->rowCount() === 1; - } - - private function atomicTransaction(callable $callback): mixed - { - if ($this->pdo->inTransaction()) { - throw new RuntimeException('Atomic PDO cache operations require ownership of the PDO transaction.'); - } - $sqlite = $this->driver === 'sqlite'; - if ($sqlite) { - $this->pdo->exec('BEGIN IMMEDIATE'); - } else { - $this->pdo->beginTransaction(); - } - - try { - $result = $callback(); - $sqlite ? $this->pdo->exec('COMMIT') : $this->pdo->commit(); - return $result; - } catch (Throwable $failure) { - if ($this->pdo->inTransaction()) { - $sqlite ? $this->pdo->exec('ROLLBACK') : $this->pdo->rollBack(); - } - throw $failure; - } - } - - private function atomicUpdateExisting(string $key, string $payload, ?int $expires): bool - { - $statement = $this->pdo->prepare( - "UPDATE {$this->table} SET payload = ?, expires = ? WHERE namespace = ? AND kind = ? AND cache_key = ?", - ); - return $statement->execute([$payload, $expires, $this->namespace, self::KIND_DATA, $key]); - } - - private function deleteDataRow(string $key): bool - { - $statement = $this->pdo->prepare( - "DELETE FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key = ?", - ); - return $statement->execute([$this->namespace, self::KIND_DATA, $key]); - } - + /** @param list $keys */ private function deleteByKind(string $kind, array $keys): bool { foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { @@ -402,13 +273,18 @@ private function deleteByKind(string $kind, array $keys): bool return true; } + /** + * @param list $keys + * @return array + */ private function fetchRows(string $kind, array $keys): array { $rows = []; foreach (array_chunk($keys, self::BATCH_SIZE) as $chunk) { $marks = implode(',', array_fill(0, count($chunk), '?')); $statement = $this->pdo->prepare( - "SELECT cache_key, payload, expires FROM {$this->table} WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", + "SELECT cache_key, payload, expires FROM {$this->table} " + . "WHERE namespace = ? AND kind = ? AND cache_key IN ({$marks})", ); $statement->execute([$this->namespace, $kind, ...$chunk]); foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) { @@ -432,41 +308,19 @@ private function fetchRows(string $kind, array $keys): array return $rows; } + /** @param array{payload:string, expires:int|null} $row */ private function hydrate(string $key, array $row): ?CacheItem { if (CachePayloadCodec::isExpired($row['expires'])) { return null; } - $record = $this->decodeRecordFromBlob($row['payload']); - - return $record === null ? null : $this->genericItemFromRecord($key, $record); - } - private function recordFromRow(array $row): ?CacheRecord - { - if (CachePayloadCodec::isExpired($row['expires'])) { - return null; - } $record = $this->decodeRecordFromBlob($row['payload']); - return $record instanceof CacheRecord ? $record : null; - } - - private function recordTagsAreCurrent(CacheRecord $record): bool - { - if ($record->tags === []) { - return true; - } - $rows = $this->fetchRows(self::KIND_TAG, array_keys($record->tags)); - foreach ($record->tags as $tag => $generation) { - if (($rows[$tag]['payload'] ?? null) !== $generation) { - return false; - } - } - - return true; + return $record === null ? null : $this->genericItemFromRecord($key, $record); } + /** @param list $rows */ private function upsertChunk(array $rows): bool { if (!in_array($this->driver, ['pgsql', 'sqlite', 'mysql', 'mariadb'], true)) { @@ -475,7 +329,8 @@ private function upsertChunk(array $rows): bool $values = implode(',', array_fill(0, count($rows), '(?, ?, ?, ?, ?)')); $suffix = in_array($this->driver, ['pgsql', 'sqlite'], true) - ? 'ON CONFLICT (namespace, kind, cache_key) DO UPDATE SET payload = EXCLUDED.payload, expires = EXCLUDED.expires' + ? 'ON CONFLICT (namespace, kind, cache_key) DO UPDATE SET ' + . 'payload = EXCLUDED.payload, expires = EXCLUDED.expires' : 'ON DUPLICATE KEY UPDATE payload = VALUES(payload), expires = VALUES(expires)'; $parameters = []; foreach ($rows as [$kind, $key, $payload, $expires]) { @@ -483,17 +338,21 @@ private function upsertChunk(array $rows): bool } return $this->pdo->prepare( - "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES {$values} {$suffix}", + "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) " + . "VALUES {$values} {$suffix}", )->execute($parameters); } + /** @param list $rows */ private function upsertGenericRows(array $rows): bool { $this->pdo->beginTransaction(); + try { foreach ($rows as [$kind, $key, $payload, $expires]) { $update = $this->pdo->prepare( - "UPDATE {$this->table} SET payload = ?, expires = ? WHERE namespace = ? AND kind = ? AND cache_key = ?", + "UPDATE {$this->table} SET payload = ?, expires = ? " + . 'WHERE namespace = ? AND kind = ? AND cache_key = ?', ); $update->execute([$payload, $expires, $this->namespace, $kind, $key]); if ($update->rowCount() === 0) { @@ -505,19 +364,23 @@ private function upsertGenericRows(array $rows): bool continue; } $this->pdo->prepare( - "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) VALUES (?, ?, ?, ?, ?)", + "INSERT INTO {$this->table} (namespace, kind, cache_key, payload, expires) " + . 'VALUES (?, ?, ?, ?, ?)', )->execute([$this->namespace, $kind, $key, $payload, $expires]); } } + return $this->pdo->commit(); } catch (PDOException $failure) { if ($this->pdo->inTransaction()) { $this->pdo->rollBack(); } + throw $failure; } } + /** @param list $rows */ private function upsertRows(array $rows): bool { foreach (array_chunk($rows, self::BATCH_SIZE) as $chunk) { From 1399cb13ab02af2795791b09ab5f3aea9a10400c Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:39:14 +0600 Subject: [PATCH 16/19] style(cache): align atomic file formatting --- src/Cache/Adapter/FileCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 7182dde..6b921a4 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -343,6 +343,7 @@ private function withKeyLock(string $key, callable $callback): mixed if (is_resource($handle)) { fclose($handle); } + throw new RuntimeException('Unable to acquire file cache key lock.'); } From 171e07145336e4f36850ecbd72888923ec4803f3 Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:39:47 +0600 Subject: [PATCH 17/19] style(cache): align atomic PHP-file formatting --- src/Cache/Adapter/PhpFilesCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/PhpFilesCacheAdapter.php b/src/Cache/Adapter/PhpFilesCacheAdapter.php index c4c5412..f72d6ef 100644 --- a/src/Cache/Adapter/PhpFilesCacheAdapter.php +++ b/src/Cache/Adapter/PhpFilesCacheAdapter.php @@ -335,6 +335,7 @@ private function withKeyLock(string $key, callable $callback): mixed if (is_resource($handle)) { fclose($handle); } + throw new RuntimeException('Unable to acquire PHP-file cache key lock.'); } From 4dff8e1edd5a08670391483923550b8ab18ee32f Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 15:43:12 +0600 Subject: [PATCH 18/19] style(cache): finish file adapter formatting --- src/Cache/Adapter/FileCacheAdapter.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Cache/Adapter/FileCacheAdapter.php b/src/Cache/Adapter/FileCacheAdapter.php index 6b921a4..fc7a14b 100644 --- a/src/Cache/Adapter/FileCacheAdapter.php +++ b/src/Cache/Adapter/FileCacheAdapter.php @@ -327,6 +327,7 @@ private function recordTagsAreCurrent(CacheRecord $record): bool private function throwCreationError(string $prefix): void { $err = error_get_last()['message'] ?? 'unknown error'; + throw new RuntimeException($prefix . ": $err"); } From 20cc22639f6ea4b8ec70ead5423f6cc47d09156d Mon Sep 17 00:00:00 2001 From: "A. B. M. Mahmudul Hasan" Date: Mon, 7 Sep 2026 17:04:46 +0600 Subject: [PATCH 19/19] docs: update atomic backend capability matrix --- README.md | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index ab0aa85..0b12b08 100644 --- a/README.md +++ b/README.md @@ -101,15 +101,22 @@ $state = $atomic->getAndDelete('oauth.state.42'); | Backend | Atomic cache coordination | Scope | |---|---|---| | Array memory | Yes | one PHP process | +| WeakMap | Yes | one PHP process / adapter instance | | Shared memory | Yes | one host / shared SysV segment | | Redis / Valkey | Yes | supplied authoritative Redis-compatible store | | Redis Cluster | Yes | stable CacheLayer hash-slot bucket | | MongoDB | Yes | supplied authoritative collection | -| APCu / Memcached | No | no full atomic consume primitive | -| PDO / SQLite | No | no cross-driver atomic contract in 3.3 | -| File / PHP files | No | ordinary writers do not share one atomic key lock | -| ScyllaDB | No | no full three-operation contract exposed | -| WeakMap / Null / Tiered | No | not one authoritative coordination domain | +| Memcached | Yes | supplied Memcached key/CAS domain | +| SQLite PDO | Yes | supplied SQLite database | +| PostgreSQL PDO | Yes | supplied PostgreSQL database | +| MySQL / MariaDB PDO | Yes | supplied transactional database | +| File / PHP files | Yes | one reliable filesystem lock domain | +| APCu | No | no arbitrary-value CAS / consume primitive | +| Generic / unknown PDO | No | no portable cross-driver atomic contract | +| ScyllaDB | No | ordinary cache writes do not use the LWT architecture required for the full contract | +| Null / Tiered | No | no single retained authoritative coordination domain | + +Memcached uses native `add()`/CAS primitives and a reserved tombstone for linearizable logical consumption. File and PHP-file caches route ordinary key writers and atomic mutations through the same deterministic per-key `flock()`; their guarantee is therefore limited to a filesystem where that lock domain is reliable. PDO capability is runtime-qualified: SQLite, PostgreSQL, and MySQL/MariaDB expose it, while unknown PDO drivers do not. SQLite coordinates writers with `BEGIN IMMEDIATE`; PostgreSQL and MySQL/MariaDB use transactional row locking. Atomic PDO calls require CacheLayer to own the transaction and reject execution inside a caller-owned active transaction. Use dedicated, untagged keys for portable replay claims, nonces, challenges, state transitions, and one-time state. Tag rotation is a separate invalidation mechanism and is not part of the portable atomic linearization boundary. Redis/Valkey, Redis Cluster, and MongoDB therefore reject `compareAndSet()` on tagged records. Array memory and SharedMemory can validate tag generations inside their own local atomic domain, but callers should not depend on tagged CAS when code must be portable across backends. Tiered caches remain non-atomic even when an individual tier supports the capability.