From ea839438781bad1c7ff9486c5e6a8b967ff2998e Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Thu, 19 Dec 2024 19:14:12 -0600 Subject: [PATCH 1/4] Add GarbageCollectorInterface.php --- README.md | 2 +- src/CacheLockInterface.php | 24 ----- src/GarbageCollectorInterface.php | 10 +++ src/Psr16/ArrayCacheEngine.php | 33 +++++-- src/Psr16/FileSystemCacheEngine.php | 130 +++++++++++++++------------- src/Psr16/NoCacheEngine.php | 3 +- src/Psr16/ShmopCacheEngine.php | 1 + src/Psr16/TmpfsCacheEngine.php | 5 -- tests/BaseCacheTest.php | 3 + tests/CachePSR16Test.php | 31 +++++++ 10 files changed, 143 insertions(+), 99 deletions(-) delete mode 100644 src/CacheLockInterface.php create mode 100644 src/GarbageCollectorInterface.php diff --git a/README.md b/README.md index 4369e56..1dfdb2f 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ PSR-6 Getting Started: [here](docs/basic-usage-psr6-cachepool.md) | [\ByJG\Cache\Psr16\TmpfsCacheEngine](docs/class-tmpfs-cache-engine.md) | Uses the Tmpfs as the cache engine | | [\ByJG\Cache\Psr16\RedisCachedEngine](docs/class-redis-cache-engine.md) | uses the Redis as cache | | [\ByJG\Cache\Psr16\SessionCachedEngine](docs/class-session-cache-engine.md) | uses the PHP session as cache | -| [\ByJG\Cache\Psr16\ShmopCachedEngine](docs/class-shmop-cache-engine.md) | uses the shared memory area for cache | +| [\ByJG\Cache\Psr16\ShmopCacheEngine](docs/class-shmop-cache-engine.md) (deprecated) | uses the shared memory area for cache. Use TmpfsCacheEngine. | ## Logging cache commands diff --git a/src/CacheLockInterface.php b/src/CacheLockInterface.php deleted file mode 100644 index e6ce2aa..0000000 --- a/src/CacheLockInterface.php +++ /dev/null @@ -1,24 +0,0 @@ - [] + ]; protected LoggerInterface|null $logger = null; @@ -41,7 +44,7 @@ public function has(string $key): bool { $key = $this->getKeyFromContainer($key); if (isset($this->cache[$key])) { - if (isset($this->cache["$key.ttl"]) && time() >= $this->cache["$key.ttl"]) { + if (isset($this->cache['ttl']["$key"]) && time() >= $this->cache["ttl"]["$key"]) { $this->delete($key); return false; } @@ -93,7 +96,7 @@ public function set(string $key, mixed $value, DateInterval|int|null $ttl = null $this->cache[$key] = serialize($value); if (!empty($ttl)) { - $this->cache["$key.ttl"] = $this->addToNow($ttl); + $this->cache["ttl"]["$key"] = $this->addToNow($ttl); } return true; @@ -116,7 +119,7 @@ public function delete(string $key): bool $key = $this->getKeyFromContainer($key); unset($this->cache[$key]); - unset($this->cache["$key.ttl"]); + unset($this->cache["ttl"]["$key"]); return true; } @@ -124,4 +127,24 @@ public function isAvailable(): bool { return true; } + + public function collectGarbage() + { + foreach ($this->cache["ttl"] as $key => $ttl) { + if (time() >= $ttl) { + unset($this->cache[$key]); + unset($this->cache["ttl"]["$key"]); + } + } + } + + public function getTtl(string $key): ?int + { + $key = $this->getKeyFromContainer($key); + if (isset($this->cache["ttl"]["$key"])) { + return $this->cache["ttl"]["$key"]; + } + + return null; + } } diff --git a/src/Psr16/FileSystemCacheEngine.php b/src/Psr16/FileSystemCacheEngine.php index 5fdeca7..d4c8e74 100644 --- a/src/Psr16/FileSystemCacheEngine.php +++ b/src/Psr16/FileSystemCacheEngine.php @@ -2,16 +2,15 @@ namespace ByJG\Cache\Psr16; -use ByJG\Cache\CacheLockInterface; +use ByJG\Cache\GarbageCollectorInterface; use DateInterval; use Exception; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -use Psr\SimpleCache\InvalidArgumentException; -class FileSystemCacheEngine extends BaseCacheEngine implements CacheLockInterface +class FileSystemCacheEngine extends BaseCacheEngine implements GarbageCollectorInterface { protected ?LoggerInterface $logger = null; @@ -45,29 +44,11 @@ public function get(string $key, mixed $default = null): mixed { // Check if file is Locked $fileKey = $this->fixKey($key); - $lockFile = $fileKey . ".lock"; - if (file_exists($lockFile)) { - $this->logger->info("[Filesystem cache] Locked! $key. Waiting..."); - $lockTime = filemtime($lockFile); - - while (true) { - if (!file_exists($lockFile)) { - $this->logger->info("[Filesystem cache] Lock released for '$key'"); - break; - } - if (intval(time() - $lockTime) > 20) { // Wait for 10 seconds - $this->logger->info("[Filesystem cache] Gave up to wait unlock. Release lock for '$key'"); - $this->unlock($key); - return $default; - } - sleep(1); // 1 second - } - } // Check if file exists if ($this->has($key)) { $this->logger->info("[Filesystem cache] Get '$key'"); - return unserialize(file_get_contents($fileKey)); + return $this->getContents($fileKey, $default); } else { $this->logger->info("[Filesystem cache] Not found '$key'"); return $default; @@ -108,12 +89,7 @@ public function set(string $key, mixed $value, DateInterval|int|null $ttl = null if (is_string($value) && (strlen($value) === 0)) { touch($fileKey); } else { - file_put_contents($fileKey, serialize($value)); - } - - $validUntil = $this->addToNow($ttl); - if (!empty($validUntil)) { - file_put_contents($fileKey . ".ttl", (string)$validUntil); + $this->putContents($fileKey, $value, $this->addToNow($ttl)); } } catch (Exception $ex) { $this->logger->warning("[Filesystem cache] I could not write to cache on file '" . basename($key) . "'. Switching to nocache=true mode."); @@ -133,39 +109,6 @@ public function delete(string $key): bool return true; } - /** - * Lock resource before set it. - * @param string $key - */ - public function lock(string $key): void - { - $this->logger->info("[Filesystem cache] Lock '$key'"); - - $lockFile = $this->fixKey($key) . ".lock"; - - try { - file_put_contents($lockFile, date('c')); - } catch (Exception $ex) { - // Ignoring... Set will cause an error - } - } - - /** - * UnLock resource after set it. - * @param string $key - */ - public function unlock(string $key): void - { - - $this->logger->info("[Filesystem cache] Unlock '$key'"); - - $lockFile = $this->fixKey($key) . ".lock"; - - if (file_exists($lockFile)) { - unlink($lockFile); - } - } - /** * @throws ContainerExceptionInterface * @throws NotFoundExceptionInterface @@ -227,9 +170,10 @@ public function clear(): bool public function has(string $key): bool { $fileKey = $this->fixKey($key); + $fileTtl = null; if (file_exists($fileKey)) { if (file_exists("$fileKey.ttl")) { - $fileTtl = intval(file_get_contents("$fileKey.ttl")); + $fileTtl = intval($this->getContents("$fileKey.ttl")); } if (!empty($fileTtl) && time() >= $fileTtl) { @@ -244,4 +188,66 @@ public function has(string $key): bool return false; } + + protected function getContents(string $fileKey, mixed $default = null): mixed + { + if (!file_exists($fileKey)) { + return $default; + } + + $fo = fopen($fileKey, 'r'); + $waitIfLocked = 1; + $lock = flock($fo, LOCK_EX, $waitIfLocked); + try { + $content = unserialize(file_get_contents($fileKey)); + } finally { + flock($fo, LOCK_UN); + fclose($fo); + } + + return $content; + } + + protected function putContents(string $fileKey, mixed $value, ?string $ttl): void + { + $fo = fopen($fileKey, 'w'); + $waitIfLocked = 1; + $lock = flock($fo, LOCK_EX, $waitIfLocked); + try { + file_put_contents($fileKey, serialize($value)); + if (!is_null($ttl)) { + file_put_contents("$fileKey.ttl", serialize($ttl)); + } + } finally { + flock($fo, LOCK_UN); + fclose($fo); + } + } + + public function collectGarbage() + { + $patternKey = $this->fixKey('*'); + $list = glob("$patternKey.ttl"); + foreach ($list as $file) { + $fileTtl = intval($this->getContents($file)); + if (time() >= $fileTtl) { + $fileContent = str_replace('.ttl', '', $file); + if (file_exists($fileContent)) { + unlink($fileContent); + } + unlink($file); + } + } + return true; + } + + + public function getTtl(string $key): ?int + { + $fileKey = $this->fixKey($key); + if (file_exists("$fileKey.ttl")) { + return intval($this->getContents("$fileKey.ttl")); + } + return null; + } } diff --git a/src/Psr16/NoCacheEngine.php b/src/Psr16/NoCacheEngine.php index c384277..19f3d8b 100644 --- a/src/Psr16/NoCacheEngine.php +++ b/src/Psr16/NoCacheEngine.php @@ -2,13 +2,12 @@ namespace ByJG\Cache\Psr16; -use ByJG\Cache\CacheLockInterface; use ByJG\Cache\Exception\InvalidArgumentException; use DateInterval; use Psr\Container\ContainerExceptionInterface; use Psr\Container\NotFoundExceptionInterface; -class NoCacheEngine extends BaseCacheEngine implements CacheLockInterface +class NoCacheEngine extends BaseCacheEngine { /** * @param string $key diff --git a/src/Psr16/ShmopCacheEngine.php b/src/Psr16/ShmopCacheEngine.php index d2ea023..1f0ab58 100644 --- a/src/Psr16/ShmopCacheEngine.php +++ b/src/Psr16/ShmopCacheEngine.php @@ -24,6 +24,7 @@ * min seg size (bytes) = 1 * * + * @deprecated Use TmpfsCacheEngine instead */ class ShmopCacheEngine extends BaseCacheEngine { diff --git a/src/Psr16/TmpfsCacheEngine.php b/src/Psr16/TmpfsCacheEngine.php index 4476fbb..4a47b2b 100644 --- a/src/Psr16/TmpfsCacheEngine.php +++ b/src/Psr16/TmpfsCacheEngine.php @@ -2,11 +2,6 @@ namespace ByJG\Cache\Psr16; -use ByJG\Cache\CacheLockInterface; -use ByJG\Cache\Exception\InvalidArgumentException; -use DateInterval; -use Psr\Container\ContainerExceptionInterface; -use Psr\Container\NotFoundExceptionInterface; use Psr\Log\LoggerInterface; class TmpfsCacheEngine extends FileSystemCacheEngine diff --git a/tests/BaseCacheTest.php b/tests/BaseCacheTest.php index 956ee61..26fc427 100644 --- a/tests/BaseCacheTest.php +++ b/tests/BaseCacheTest.php @@ -33,6 +33,9 @@ public function CachePoolProvider() 'FileSystem' => [ new \ByJG\Cache\Psr16\FileSystemCacheEngine() ], + 'Tmpfs' => [ + new \ByJG\Cache\Psr16\TmpfsCacheEngine() + ], 'ShmopCache' => [ new \ByJG\Cache\Psr16\ShmopCacheEngine() ], diff --git a/tests/CachePSR16Test.php b/tests/CachePSR16Test.php index 2890a8c..a464c9e 100644 --- a/tests/CachePSR16Test.php +++ b/tests/CachePSR16Test.php @@ -3,6 +3,7 @@ namespace Tests; use ByJG\Cache\Exception\InvalidArgumentException; +use ByJG\Cache\GarbageCollectorInterface; use ByJG\Cache\Psr16\BaseCacheEngine; use ByJG\Cache\Psr16\NoCacheEngine; @@ -238,4 +239,34 @@ public function testCacheContainerKey(BaseCacheEngine $cacheEngine) } } + /** + * @dataProvider CachePoolProvider + */ + public function testGarbageCollector(BaseCacheEngine $cacheEngine) + { + $this->cacheEngine = $cacheEngine; + + if ($cacheEngine->isAvailable() && ($cacheEngine instanceof GarbageCollectorInterface)) { + // First time + $cacheEngine->set('chave', "ok"); + $this->assertTrue($cacheEngine->has('chave')); + $this->assertNull($cacheEngine->getTtl('chave')); + $cacheEngine->delete('chave'); + $this->assertFalse($cacheEngine->has('chave')); + + // Set TTL + $cacheEngine->set('chave', "ok", 1); + $this->assertTrue($cacheEngine->has('chave')); + $this->assertNotNull($cacheEngine->getTtl('chave')); + $cacheEngine->collectGarbage(); + $this->assertTrue($cacheEngine->has('chave')); + $this->assertNotNull($cacheEngine->getTtl('chave')); // Should not delete yet + sleep(1); + $cacheEngine->collectGarbage(); + $this->assertNull($cacheEngine->getTtl('chave')); // Should be deleted + $this->assertFalse($cacheEngine->has('chave')); + } else { + $this->markTestIncomplete('Does not support garbage collector or it is native'); + } + } } From 54c397df8d5d980418144081c07732eefb6a8357 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Fri, 20 Dec 2024 15:20:14 -0600 Subject: [PATCH 2/4] Add AtomicOperationInterface.php --- .github/workflows/phpunit.yml | 4 + README.md | 11 +++ docker-compose.yml | 17 +++++ docs/atomic-operations.md | 56 ++++++++++++++ docs/garbage-collection.md | 30 ++++++++ src/AtomicOperationInterface.php | 14 ++++ src/Psr16/FileSystemCacheEngine.php | 73 ++++++++++++++---- src/Psr16/MemcachedEngine.php | 76 ++++++++++++++++++- src/Psr16/RedisCacheEngine.php | 82 ++++++++++++++++++-- tests/BaseCacheTest.php | 4 +- tests/CachePSR16Test.php | 114 ++++++++++++++++++++++++++++ 11 files changed, 456 insertions(+), 25 deletions(-) create mode 100644 docker-compose.yml create mode 100644 docs/atomic-operations.md create mode 100644 docs/garbage-collection.md create mode 100644 src/AtomicOperationInterface.php diff --git a/.github/workflows/phpunit.yml b/.github/workflows/phpunit.yml index 6dda630..82e2934 100644 --- a/.github/workflows/phpunit.yml +++ b/.github/workflows/phpunit.yml @@ -24,8 +24,12 @@ jobs: services: memcached: image: memcached + ports: + - "11211:11211" redis: image: redis + ports: + - "6379:6379" options: >- --health-cmd "redis-cli ping" --health-interval 10s diff --git a/README.md b/README.md index 1dfdb2f..6610c1a 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,17 @@ You can use a PSR-11 compatible to retrieve the cache keys. See more [here](docs/psr11-usage.md) +## Beyond the PSR protocol + +The PSR protocol is a good way to standardize the cache access, +but sometimes you need to go beyond the protocol. + +Some cache engines have additional features that are not covered by the PSR protocol. + +Some examples are: +- [Atomic Operations](docs/atomic-operations.md) +- [Garbage Collection](docs/garbage-collection.md) + ## Install Just type: diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..4f0506f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,17 @@ +services: + memcached: + image: memcached + container_name: memcached + ports: + - "11211:11211" + + redis: + image: redis + container_name: redis + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 \ No newline at end of file diff --git a/docs/atomic-operations.md b/docs/atomic-operations.md new file mode 100644 index 0000000..88aa954 --- /dev/null +++ b/docs/atomic-operations.md @@ -0,0 +1,56 @@ +# Atomic Operations + +Some cache engines allow you to do atomic operations such as incrementing or decrementing a value. + +Besides this is not cache operation, it is a common operation in cache engines. + +The advantage of using atomic operations is that you can avoid race conditions when multiple processes +are trying to update the same value. + +The atomic operations are: +- Increment: Increment a value by a given number +- Decrement: Decrement a value by a given number +- Add: Add a value to a list in the cache + +The engines that support atomic operations have to implement the `AtomicOperationInterface`. + +Some engines that support atomic operations are: +- RedisCachedEngine +- MemcachedEngine +- TmpfsCacheEngine +- FileSystemCacheEngine + +## Increment + +The increment operation is used to increment a value by a given number. + +```php +increment('my-key', 1); +``` + +## Decrement + +The decrement operation is used to decrement a value by a given number. + +```php +decrement('my-key', 1); +``` + +## Add + +The add operation is used to add a value to a list in the cache. + +```php +add('my-key', 'value1'); +$cache->add('my-key', 'value2'); +$cache->add('my-key', 'value3'); + +print_r($cache->get('my-key')); // ['value1', 'value2', 'value3'] +``` + diff --git a/docs/garbage-collection.md b/docs/garbage-collection.md new file mode 100644 index 0000000..6b32428 --- /dev/null +++ b/docs/garbage-collection.md @@ -0,0 +1,30 @@ +# Garbage Collection + +Some cache engines need to have a garbage collection process to remove the expired keys. + +In some engines like `Memcached` and `Redis` the garbage collection is done automatically by the engine itself. + +In other engines like `FileSystem` and `Array` there is no such process. The current implementation +is based on the Best Effort. It means an expired key is removed only when you try to access it. + +If the cache engine has a low hit rate, it is recommended to run a garbage collection process +to avoid the cache to grow indefinitely. + +The classes that implement the `GarbageCollectionInterface` have the method `collectGarbage()`. + +Some engines that support garbage collection are: +- FileSystemCacheEngine +- ArrayCacheEngine +- TmpfsCacheEngine + +## Example + +```php +collectGarbage(); +``` + +Note: The garbage collection process is blocking. +It means the process will be slow if you have a lot of keys to remove. + diff --git a/src/AtomicOperationInterface.php b/src/AtomicOperationInterface.php new file mode 100644 index 0000000..b9b2806 --- /dev/null +++ b/src/AtomicOperationInterface.php @@ -0,0 +1,14 @@ +logger->info("[Filesystem cache] Set '$key' in FileSystem"); try { - if (file_exists($fileKey)) { - unlink($fileKey); - } - if (file_exists("$fileKey.ttl")) { - unlink("$fileKey.ttl"); - } - - if (is_null($value)) { - return false; - } - if (is_string($value) && (strlen($value) === 0)) { touch($fileKey); } else { - $this->putContents($fileKey, $value, $this->addToNow($ttl)); + return $this->putContents($fileKey, $value, $this->addToNow($ttl)); } } catch (Exception $ex) { $this->logger->warning("[Filesystem cache] I could not write to cache on file '" . basename($key) . "'. Switching to nocache=true mode."); @@ -208,12 +199,34 @@ protected function getContents(string $fileKey, mixed $default = null): mixed return $content; } - protected function putContents(string $fileKey, mixed $value, ?string $ttl): void + protected function putContents(string $fileKey, mixed $value, ?int $ttl, ?Closure $operation = null): mixed { - $fo = fopen($fileKey, 'w'); + $returnValue = true; + + if (file_exists("$fileKey.ttl")) { + unlink("$fileKey.ttl"); + } + + if (is_null($value)) { + if (file_exists($fileKey)) { + unlink($fileKey); + } + return false; + } + + $fo = fopen($fileKey, 'a+'); $waitIfLocked = 1; $lock = flock($fo, LOCK_EX, $waitIfLocked); try { + if (!is_null($operation)) { + if (!file_exists($fileKey)) { + $currentValue = 0; + } else { + $content = file_get_contents($fileKey); + $currentValue = !empty($content) ? unserialize($content) : $content; + } + $value = $returnValue = $operation($currentValue, $value); + } file_put_contents($fileKey, serialize($value)); if (!is_null($ttl)) { file_put_contents("$fileKey.ttl", serialize($ttl)); @@ -222,6 +235,8 @@ protected function putContents(string $fileKey, mixed $value, ?string $ttl): voi flock($fo, LOCK_UN); fclose($fo); } + + return $returnValue; } public function collectGarbage() @@ -250,4 +265,32 @@ public function getTtl(string $key): ?int } return null; } + + public function increment(string $key, int $value = 1, DateInterval|int|null $ttl = null): int + { + return $this->putContents($this->fixKey($key), $value, $ttl, function ($currentValue, $value) { + return intval($currentValue) + $value; + }); + } + + public function decrement(string $key, int $value = 1, DateInterval|int|null $ttl = null): int + { + return $this->putContents($this->fixKey($key), $value, $ttl, function ($currentValue, $value) { + return intval($currentValue) - $value; + }); + } + + public function add(string $key, $value, DateInterval|int|null $ttl = null): array + { + return $this->putContents($this->fixKey($key), $value, $ttl, function ($currentValue, $value) { + if (empty($currentValue)) { + return [$value]; + } + if (!is_array($currentValue)) { + return [$currentValue, $value]; + } + $currentValue[] = $value; + return $currentValue; + }); + } } diff --git a/src/Psr16/MemcachedEngine.php b/src/Psr16/MemcachedEngine.php index 299092a..117cc38 100644 --- a/src/Psr16/MemcachedEngine.php +++ b/src/Psr16/MemcachedEngine.php @@ -2,6 +2,7 @@ namespace ByJG\Cache\Psr16; +use ByJG\Cache\AtomicOperationInterface; use ByJG\Cache\Exception\InvalidArgumentException; use ByJG\Cache\Exception\StorageErrorException; use DateInterval; @@ -11,7 +12,7 @@ use Psr\Log\LoggerInterface; use Psr\Log\NullLogger; -class MemcachedEngine extends BaseCacheEngine +class MemcachedEngine extends BaseCacheEngine implements AtomicOperationInterface { /** @@ -88,7 +89,7 @@ public function get(string $key, mixed $default = null): mixed return $default; } - return unserialize($value); + return $value; } /** @@ -107,7 +108,7 @@ public function set(string $key, mixed $value, DateInterval|int|null $ttl = null $ttl = $this->convertToSeconds($ttl); - $this->memCached->set($this->fixKey($key), serialize($value), is_null($ttl) ? 0 : $ttl); + $this->memCached->set($this->fixKey($key), $value, is_null($ttl) ? 0 : $ttl); $this->logger->info("[Memcached] Set '$key' result " . $this->memCached->getResultCode()); if ($this->memCached->getResultCode() !== Memcached::RES_SUCCESS) { $this->logger->error("[Memcached] Set '$key' failed with status " . $this->memCached->getResultCode()); @@ -172,4 +173,73 @@ public function has(string $key): bool $this->memCached->get($this->fixKey($key)); return ($this->memCached->getResultCode() === Memcached::RES_SUCCESS); } + + public function increment(string $key, int $value = 1, DateInterval|int|null $ttl = null): int + { + $this->lazyLoadMemCachedServers(); + + $ttl = $this->convertToSeconds($ttl); + + if ($this->memCached->get($this->fixKey($key)) === false) { + $this->memCached->set($this->fixKey($key), 0, is_null($ttl) ? 0 : $ttl); + } + + $result = $this->memCached->increment($this->fixKey($key), $value); + $this->logger->info("[Memcached] Increment '$key' result " . $this->memCached->getResultCode()); + if ($this->memCached->getResultCode() !== Memcached::RES_SUCCESS) { + $this->logger->error("[Memcached] Set '$key' failed with status " . $this->memCached->getResultCode()); + } + + return $result; + } + + public function decrement(string $key, int $value = 1, DateInterval|int|null $ttl = null): int + { + $this->lazyLoadMemCachedServers(); + + $ttl = $this->convertToSeconds($ttl); + + if ($this->memCached->get($this->fixKey($key)) === false) { + $this->memCached->set($this->fixKey($key), 0, is_null($ttl) ? 0 : $ttl); + } + + $result = $this->memCached->decrement($this->fixKey($key), $value); + $this->logger->info("[Memcached] Decrement '$key' result " . $this->memCached->getResultCode()); + if ($this->memCached->getResultCode() !== Memcached::RES_SUCCESS) { + $this->logger->error("[Memcached] Set '$key' failed with status " . $this->memCached->getResultCode()); + } + + return $result; + } + + public function add(string $key, $value, DateInterval|int|null $ttl = null): array + { + $this->lazyLoadMemCachedServers(); + + $ttl = $this->convertToSeconds($ttl); + $fixKey = $this->fixKey($key); + + if ($this->memCached->get($fixKey) === false) { + $this->memCached->set($fixKey, [], is_null($ttl) ? 0 : $ttl); + } + + do { + $data = $this->memCached->get($fixKey, null, Memcached::GET_EXTENDED); + $casToken = $data['cas']; + $currentValue = $data['value']; + + if ($currentValue === false) { + $currentValue = []; + } + + if (!is_array($currentValue)) { + $currentValue = [$currentValue]; + } + + $currentValue[] = $value; + $success = $this->memCached->cas($casToken, $fixKey, $currentValue, is_null($ttl) ? 0 : $ttl); + } while (!$success); + + return $currentValue; + } } diff --git a/src/Psr16/RedisCacheEngine.php b/src/Psr16/RedisCacheEngine.php index 703b2b9..d2dde57 100644 --- a/src/Psr16/RedisCacheEngine.php +++ b/src/Psr16/RedisCacheEngine.php @@ -2,6 +2,7 @@ namespace ByJG\Cache\Psr16; +use ByJG\Cache\AtomicOperationInterface; use ByJG\Cache\Exception\InvalidArgumentException; use DateInterval; use Psr\Container\ContainerExceptionInterface; @@ -11,7 +12,7 @@ use Redis; use RedisException; -class RedisCacheEngine extends BaseCacheEngine +class RedisCacheEngine extends BaseCacheEngine implements AtomicOperationInterface { /** @@ -84,10 +85,29 @@ public function get(string $key, mixed $default = null): mixed { $this->lazyLoadRedisServer(); - $value = $this->redis->get($this->fixKey($key)); - $this->logger->info("[Redis Cache] Get '$key' result "); + $fixKey = $this->fixKey($key); + $type = $this->redis->type($fixKey); - return ($value === false ? $default : unserialize($value)); + if ($type === Redis::REDIS_STRING) { + $value = $this->redis->get($fixKey); + if (is_string($value) && preg_match('/^[Oa]:\d+:["{]/', $value)) { + $value = unserialize($value); + } + } else if ($type === Redis::REDIS_LIST) { + $value = $this->redis->lRange($fixKey, 0, -1); + } else { + $value = $default; + } + + if (is_array($value)) { + foreach ($value as $k => $v) { + if (is_string($v) && preg_match('/^[Oa]:\d+:["{]/', $v)) { + $value[$k] = unserialize($v); + } + } + } + + return $value; } /** @@ -106,7 +126,7 @@ public function set(string $key, mixed $value, DateInterval|int|null $ttl = null $ttl = $this->convertToSeconds($ttl); - $this->redis->set($this->fixKey($key), serialize($value), $ttl); + $this->redis->set($this->fixKey($key), is_object($value) || is_array($value) ? serialize($value) : $value, $ttl); $this->logger->info("[Redis Cache] Set '$key' result "); return true; @@ -178,4 +198,56 @@ public function isAvailable(): bool return false; } } + + public function increment(string $key, int $value = 1, DateInterval|int|null $ttl = null): int + { + $this->lazyLoadRedisServer(); + + $result = $this->redis->incr($this->fixKey($key), $value); + + if ($ttl) { + $this->redis->expire($this->fixKey($key), $this->convertToSeconds($ttl)); + } + + return is_int($result) ? $result : -1; + } + + public function decrement(string $key, int $value = 1, DateInterval|int|null $ttl = null): int + { + $this->lazyLoadRedisServer(); + + $result = $this->redis->decr($this->fixKey($key), $value); + + if ($ttl) { + $this->redis->expire($this->fixKey($key), $this->convertToSeconds($ttl)); + } + + return is_int($result) ? $result : -1; + } + + public function add(string $key, $value, DateInterval|int|null $ttl = null): array + { + $this->lazyLoadRedisServer(); + + $fixKey = $this->fixKey($key); + $type = $this->redis->type($fixKey); + + if ($type === Redis::REDIS_STRING) { + $currValue = $this->redis->get($fixKey); + if (is_string($currValue) && preg_match('/^[Oa]:\d+:["{]/', $currValue)) { + $currValue = unserialize($currValue); + } + if (is_object($currValue)) { + $currValue = [$currValue]; + } + $this->redis->del($fixKey); + foreach ((array)$currValue as $items) { + $this->add($key, $items); + } + } + + $result = $this->redis->rPush($fixKey, is_object($value) || is_array($value) ? serialize($value) : $value); + + return $result ? $this->get($key) : []; + } } diff --git a/tests/BaseCacheTest.php b/tests/BaseCacheTest.php index 26fc427..af7e54a 100644 --- a/tests/BaseCacheTest.php +++ b/tests/BaseCacheTest.php @@ -22,8 +22,8 @@ protected function tearDown(): void public function CachePoolProvider() { - $memcachedServer = ['memcached:11211']; - $redisCacheServer = 'redis:6379'; + $memcachedServer = ['127.0.0.1:11211']; + $redisCacheServer = '127.0.0.1:6379'; $redisPassword = ''; return [ diff --git a/tests/CachePSR16Test.php b/tests/CachePSR16Test.php index a464c9e..cd64da0 100644 --- a/tests/CachePSR16Test.php +++ b/tests/CachePSR16Test.php @@ -2,6 +2,7 @@ namespace Tests; +use ByJG\Cache\AtomicOperationInterface; use ByJG\Cache\Exception\InvalidArgumentException; use ByJG\Cache\GarbageCollectorInterface; use ByJG\Cache\Psr16\BaseCacheEngine; @@ -156,6 +157,44 @@ public function testCacheObject(BaseCacheEngine $cacheEngine) } } + /** + * @dataProvider CachePoolProvider + * @param BaseCacheEngine $cacheEngine + * @throws \Psr\SimpleCache\InvalidArgumentException + */ + public function testCacheArray(BaseCacheEngine $cacheEngine) + { + $this->cacheEngine = $cacheEngine; + + if ($cacheEngine->isAvailable()) { + // First time + $item = $cacheEngine->get('chave'); + $this->assertNull($item); + + // Get Object + if (($cacheEngine instanceof NoCacheEngine)) { + return; + } + + // Set object + $cacheEngine->set('chave', [ 'a' => 10, 'b' => 20 ]); + $this->assertEquals([ 'a' => 10, 'b' => 20 ], $cacheEngine->get('chave')); + + $cacheEngine->set('chave', [ 10, 20 ]); + $this->assertEquals([ 10, 20 ], $cacheEngine->get('chave')); + + $cacheEngine->set('chave', [ 'a' => 10, 'b' => new Model(1, 2) ]); + $this->assertEquals([ 'a' => 10, 'b' => new Model(1, 2) ], $cacheEngine->get('chave')); + + // Delete + $cacheEngine->delete('chave'); + $item = $cacheEngine->get('chave'); + $this->assertNull($item); + } else { + $this->markTestIncomplete('Object is not fully functional'); + } + } + /** * @dataProvider CachePoolProvider * @param BaseCacheEngine $cacheEngine @@ -269,4 +308,79 @@ public function testGarbageCollector(BaseCacheEngine $cacheEngine) $this->markTestIncomplete('Does not support garbage collector or it is native'); } } + + /** + * @dataProvider CachePoolProvider + */ + public function testAtomicIncrement(BaseCacheEngine $cacheEngine) + { + $this->cacheEngine = $cacheEngine; + + if ($cacheEngine->isAvailable() && ($cacheEngine instanceof AtomicOperationInterface)) { + $cacheEngine->set('chave', 10); + $this->assertEquals(11, $cacheEngine->increment('chave')); + $this->assertEquals(12, $cacheEngine->increment('chave')); + $this->assertEquals(13, $cacheEngine->increment('chave')); + $this->assertEquals(14, $cacheEngine->increment('chave')); + $this->assertEquals(15, $cacheEngine->increment('chave')); + $this->assertEquals(15, $cacheEngine->get('chave')); + } else { + $this->markTestIncomplete('Does not support atomic increment or it is native'); + } + } + + /** + * @dataProvider CachePoolProvider + */ + public function testAtomicDecrement(BaseCacheEngine $cacheEngine) + { + $this->cacheEngine = $cacheEngine; + + if ($cacheEngine->isAvailable() && ($cacheEngine instanceof AtomicOperationInterface)) { + $cacheEngine->set('chave', 10); + $this->assertEquals(9, $cacheEngine->decrement('chave')); + $this->assertEquals(8, $cacheEngine->decrement('chave')); + $this->assertEquals(7, $cacheEngine->decrement('chave')); + $this->assertEquals(6, $cacheEngine->decrement('chave')); + $this->assertEquals(5, $cacheEngine->decrement('chave')); + $this->assertEquals(5, $cacheEngine->get('chave')); + } else { + $this->markTestIncomplete('Does not support atomic decrement or it is native'); + } + } + + /** + * @dataProvider CachePoolProvider + */ + public function testAtomicAdd(BaseCacheEngine $cacheEngine) + { + $this->cacheEngine = $cacheEngine; + + if ($cacheEngine->isAvailable() && ($cacheEngine instanceof AtomicOperationInterface)) { + $this->assertEquals([10], $cacheEngine->add('chave', 10)); + $this->assertEquals([10, 20], $cacheEngine->add('chave', 20)); + $this->assertEquals([10, 20, "value"], $cacheEngine->add('chave', "value")); + $this->assertEquals([10, 20, "value"], $cacheEngine->get('chave')); + + $cacheEngine->set('chave', 10); + $this->assertEquals(10, $cacheEngine->get('chave')); + $this->assertEquals([10, 20], $cacheEngine->add('chave', 20)); + + $cacheEngine->set('chave', ["A", "B"]); + $this->assertEquals(["A", "B"], $cacheEngine->get('chave')); + $this->assertEquals(["A", "B", "C"], $cacheEngine->add('chave', "C")); + + $cacheEngine->set('chave', new Model(10, 20)); + $this->assertEquals(new Model(10, 20), $cacheEngine->get('chave')); + $this->assertEquals([new Model(10, 20), new Model(20, 30)], $cacheEngine->add('chave', new Model(20, 30))); + + $cacheEngine->set('chave', [new Model(10, 20)]); + $this->assertEquals([new Model(10, 20)], $cacheEngine->get('chave')); + $this->assertEquals([new Model(10, 20), new Model(20, 30)], $cacheEngine->add('chave', new Model(20, 30))); + + + } else { + $this->markTestIncomplete('Does not support atomic add or it is native'); + } + } } From 63801a7ab10e573cb8676a380323431f9cc992ed Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 22 Dec 2024 18:30:32 -0600 Subject: [PATCH 3/4] Allow change prefix in TmpfsCacheEngine.php --- src/Psr16/TmpfsCacheEngine.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Psr16/TmpfsCacheEngine.php b/src/Psr16/TmpfsCacheEngine.php index 4a47b2b..c2ebbfb 100644 --- a/src/Psr16/TmpfsCacheEngine.php +++ b/src/Psr16/TmpfsCacheEngine.php @@ -7,8 +7,8 @@ class TmpfsCacheEngine extends FileSystemCacheEngine { - public function __construct(?LoggerInterface $logger = null) + public function __construct(string $prefix = "cache", ?LoggerInterface $logger = null) { - parent::__construct('cache', '/dev/shm', $logger); + parent::__construct($prefix, '/dev/shm', $logger); } } From 62e0d2e04a62aabe8dce1095942c7f5a168e20f0 Mon Sep 17 00:00:00 2001 From: Joao Gilberto Magalhaes Date: Sun, 22 Dec 2024 21:26:29 -0600 Subject: [PATCH 4/4] Allow change prefix in TmpfsCacheEngine.php --- src/Factory.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Factory.php b/src/Factory.php index e381b9d..61c6148 100644 --- a/src/Factory.php +++ b/src/Factory.php @@ -70,10 +70,10 @@ public static function createRedisCacheEngine(?string $servers = null, ?string $ ); } - public static function createTmpfsCachePool(?LoggerInterface $logger = null): CachePool + public static function createTmpfsCachePool(string $prefix = 'cache', ?LoggerInterface $logger = null): CachePool { return new CachePool( - new TmpfsCacheEngine($logger) + new TmpfsCacheEngine($prefix, $logger) ); }