From ace2f2e83b21972da252e06b856d8d2a97e5de68 Mon Sep 17 00:00:00 2001 From: Zankaria Date: Wed, 2 Oct 2024 21:49:51 +0200 Subject: [PATCH] driver: break up cache drivers --- inc/Data/Driver/ApcuCacheDriver.php | 28 ++ inc/Data/Driver/ArrayCacheDriver.php | 28 ++ inc/Data/Driver/CacheDriver.php | 38 +++ inc/Data/Driver/FsCachedriver.php | 150 ++++++++++ inc/Data/Driver/MemcacheCacheDriver.php | 43 +++ inc/Data/Driver/NoneCacheDriver.php | 26 ++ inc/Data/Driver/RedisCacheDriver.php | 48 ++++ inc/Data/Driver/cache-driver.php | 352 ------------------------ 8 files changed, 361 insertions(+), 352 deletions(-) create mode 100644 inc/Data/Driver/ApcuCacheDriver.php create mode 100644 inc/Data/Driver/ArrayCacheDriver.php create mode 100644 inc/Data/Driver/CacheDriver.php create mode 100644 inc/Data/Driver/FsCachedriver.php create mode 100644 inc/Data/Driver/MemcacheCacheDriver.php create mode 100644 inc/Data/Driver/NoneCacheDriver.php create mode 100644 inc/Data/Driver/RedisCacheDriver.php delete mode 100644 inc/Data/Driver/cache-driver.php diff --git a/inc/Data/Driver/ApcuCacheDriver.php b/inc/Data/Driver/ApcuCacheDriver.php new file mode 100644 index 00000000..a39bb656 --- /dev/null +++ b/inc/Data/Driver/ApcuCacheDriver.php @@ -0,0 +1,28 @@ +prefix . $key; + } + + private function sharedLockCache(): void { + \flock($this->lock_fd, LOCK_SH); + } + + private function exclusiveLockCache(): void { + \flock($this->lock_fd, LOCK_EX); + } + + private function unlockCache(): void { + \flock($this->lock_fd, LOCK_UN); + } + + private function collectImpl(): int { + // A read lock is ok, since it's alright if we delete expired items from under the feet of other processes. + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + $count = 0; + foreach ($files as $file) { + $data = \file_get_contents($file); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + if (@\unlink($file)) { + $count++; + } + } + } + return $count; + } + + private function maybeCollect(): void { + if ($this->collect_chance_den !== false && \mt_rand(0, $this->collect_chance_den - 1) === 0) { + $this->collect_chance_den = false; // Collect only once per instance (aka process). + $this->collectImpl(); + } + } + + public function __construct(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) { + if ($base_path[\strlen($base_path) - 1] !== '/') { + $base_path = "$base_path/"; + } + + if (!\is_dir($base_path)) { + throw new \RuntimeException("$base_path is not a directory!"); + } + + if (!\is_writable($base_path)) { + throw new \RuntimeException("$base_path is not writable!"); + } + + $this->lock_fd = \fopen($base_path . $lock_file, 'w'); + if ($this->lock_fd === false) { + throw new \RuntimeException('Unable to open the lock file!'); + } + + $this->prefix = $prefix; + $this->base_path = $base_path; + $this->collect_chance_den = $collect_chance_den; + } + + public function __destruct() { + $this->close(); + } + + public function get(string $key): mixed { + $key = $this->prepareKey($key); + + $this->sharedLockCache(); + + $fd = \fopen($this->base_path . $key, 'r'); + if ($fd === false) { + $this->unlockCache(); + return null; + } + + $data = \stream_get_contents($fd); + \fclose($fd); + $this->maybeCollect(); + $this->unlockCache(); + $wrapped = \json_decode($data, true, 512, \JSON_THROW_ON_ERROR); + + if ($wrapped['expires'] !== false && $wrapped['expires'] <= \time()) { + // Already expired, leave it there since we already released the lock and pretend it doesn't exist. + return null; + } else { + return $wrapped['inner']; + } + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $key = $this->prepareKey($key); + + $wrapped = [ + 'expires' => $expires ? \time() + $expires : false, + 'inner' => $value + ]; + + $data = \json_encode($wrapped); + $this->exclusiveLockCache(); + \file_put_contents($this->base_path . $key, $data); + $this->maybeCollect(); + $this->unlockCache(); + } + + public function delete(string $key): void { + $key = $this->prepareKey($key); + + $this->exclusiveLockCache(); + @\unlink($this->base_path . $key); + $this->maybeCollect(); + $this->unlockCache(); + } + + public function collect(): int { + $this->sharedLockCache(); + $count = $this->collectImpl(); + $this->unlockCache(); + return $count; + } + + public function flush(): void { + $this->exclusiveLockCache(); + $files = \glob($this->base_path . $this->prefix . '*', \GLOB_NOSORT); + foreach ($files as $file) { + @\unlink($file); + } + $this->unlockCache(); + } + + public function close(): void { + \fclose($this->lock_fd); + } +} diff --git a/inc/Data/Driver/MemcacheCacheDriver.php b/inc/Data/Driver/MemcacheCacheDriver.php new file mode 100644 index 00000000..04f62895 --- /dev/null +++ b/inc/Data/Driver/MemcacheCacheDriver.php @@ -0,0 +1,43 @@ +inner = new \Memcached(); + if (!$this->inner->setOption(\Memcached::OPT_BINARY_PROTOCOL, true)) { + throw new \RuntimeException('Unable to set the memcached protocol!'); + } + if (!$this->inner->setOption(\Memcached::OPT_PREFIX_KEY, $prefix)) { + throw new \RuntimeException('Unable to set the memcached prefix!'); + } + if (!$this->inner->addServers($memcached_server)) { + throw new \RuntimeException('Unable to add the memcached server!'); + } + } + + public function get(string $key): mixed { + $ret = $this->inner->get($key); + // If the returned value is false but the retrival was a success, then the value stored was a boolean false. + if ($ret === false && $this->inner->getResultCode() !== \Memcached::RES_SUCCESS) { + return null; + } + return $ret; + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + $this->inner->set($key, $value, (int)$expires); + } + + public function delete(string $key): void { + $this->inner->delete($key); + } + + public function flush(): void { + $this->inner->flush(); + } +} diff --git a/inc/Data/Driver/NoneCacheDriver.php b/inc/Data/Driver/NoneCacheDriver.php new file mode 100644 index 00000000..8b260a50 --- /dev/null +++ b/inc/Data/Driver/NoneCacheDriver.php @@ -0,0 +1,26 @@ +inner = new \Redis(); + $this->inner->connect($host, $port); + if ($password) { + $this->inner->auth($password); + } + if (!$this->inner->select($database)) { + throw new \RuntimeException('Unable to connect to Redis!'); + } + + $$this->prefix = $prefix; + } + + public function get(string $key): mixed { + $ret = $this->inner->get($this->prefix . $key); + if ($ret === false) { + return null; + } + return \json_decode($ret, true); + } + + public function set(string $key, mixed $value, mixed $expires = false): void { + if ($expires === false) { + $this->inner->set($this->prefix . $key, \json_encode($value)); + } else { + $expires = $expires * 1000; // Seconds to milliseconds. + $this->inner->setex($this->prefix . $key, $expires, \json_encode($value)); + } + } + + public function delete(string $key): void { + $this->inner->del($this->prefix . $key); + } + + public function flush(): void { + $this->inner->flushDB(); + } +} diff --git a/inc/Data/Driver/cache-driver.php b/inc/Data/Driver/cache-driver.php deleted file mode 100644 index b943ee71..00000000 --- a/inc/Data/Driver/cache-driver.php +++ /dev/null @@ -1,352 +0,0 @@ -setOption(Memcached::OPT_BINARY_PROTOCOL, true)) { - throw new RuntimeException('Unable to set the memcached protocol!'); - } - if (!$memcached->setOption(Memcached::OPT_PREFIX_KEY, $prefix)) { - throw new RuntimeException('Unable to set the memcached prefix!'); - } - if (!$memcached->addServers($memcached_server)) { - throw new RuntimeException('Unable to add the memcached server!'); - } - - return new class($memcached) implements CacheDriver { - private Memcached $inner; - - public function __construct(Memcached $inner) { - $this->inner = $inner; - } - - public function get(string $key): mixed { - $ret = $this->inner->get($key); - // If the returned value is false but the retrival was a success, then the value stored was a boolean false. - if ($ret === false && $this->inner->getResultCode() !== Memcached::RES_SUCCESS) { - return null; - } - return $ret; - } - - public function set(string $key, mixed $value, mixed $expires = false): void { - $this->inner->set($key, $value, (int)$expires); - } - - public function delete(string $key): void { - $this->inner->delete($key); - } - - public function flush(): void { - $this->inner->flush(); - } - }; - } - - public static function redis(string $prefix, string $host, int $port, string $password, string $database) { - $redis = new Redis(); - $redis->connect($host, $port); - if ($password) { - $redis->auth($password); - } - if (!$redis->select($database)) { - throw new RuntimeException('Unable to connect to Redis!'); - } - - return new class($prefix, $redis) implements CacheDriver { - private string $prefix; - private Redis $inner; - - public function __construct(string $prefix, Redis $inner) { - $$this->prefix = $prefix; - $this->inner = $inner; - } - - public function get(string $key): mixed { - $ret = $this->inner->get($this->prefix . $key); - if ($ret === false) { - return null; - } - return json_decode($ret, true); - } - - public function set(string $key, mixed $value, mixed $expires = false): void { - if ($expires === false) { - $this->inner->set($this->prefix . $key, json_encode($value)); - } else { - $expires = $expires * 1000; // Seconds to milliseconds. - $this->inner->setex($this->prefix . $key, $expires, json_encode($value)); - } - } - - public function delete(string $key): void { - $this->inner->del($this->prefix . $key); - } - - public function flush(): void { - $this->inner->flushDB(); - } - }; - } - - public static function apcu() { - return new class implements CacheDriver { - public function get(string $key): mixed { - $success = false; - $ret = apcu_fetch($key, $success); - if ($success === false) { - return null; - } - return $ret; - } - - public function set(string $key, mixed $value, mixed $expires = false): void { - apcu_store($key, $value, (int)$expires); - } - - public function delete(string $key): void { - apcu_delete($key); - } - - public function flush(): void { - apcu_clear_cache(); - } - }; - } - - public static function filesystem(string $prefix, string $base_path, string $lock_file, int|false $collect_chance_den) { - if ($base_path[strlen($base_path) - 1] !== '/') { - $base_path = "$base_path/"; - } - - if (!is_dir($base_path)) { - throw new RuntimeException("$base_path is not a directory!"); - } - - if (!is_writable($base_path)) { - throw new RuntimeException("$base_path is not writable!"); - } - - $lock_file = $base_path . $lock_file; - - $lock_fd = fopen($lock_file, 'w'); - if ($lock_fd === false) { - throw new RuntimeException('Unable to open the lock file!'); - } - - $ret = new class($prefix, $base_path, $lock_file) implements CacheDriver { - private string $prefix; - private string $base_path; - private mixed $lock_fd; - private int|false $collect_chance_den; - - - private function prepareKey(string $key): string { - $key = str_replace('/', '::', $key); - $key = str_replace("\0", '', $key); - return $this->prefix . $key; - } - - private function sharedLockCache(): void { - flock($this->lock_fd, LOCK_SH); - } - - private function exclusiveLockCache(): void { - flock($this->lock_fd, LOCK_EX); - } - - private function unlockCache(): void { - flock($this->lock_fd, LOCK_UN); - } - - private function collectImpl(): int { - // A read lock is ok, since it's alright if we delete expired items from under the feet of other processes. - $files = glob($this->base_path . $this->prefix . '*', GLOB_NOSORT); - $count = 0; - foreach ($files as $file) { - $data = file_get_contents($file); - $wrapped = json_decode($data, true, 512, JSON_THROW_ON_ERROR); - if ($wrapped['expires'] !== false && $wrapped['expires'] <= time()) { - if (@unlink($file)) { - $count++; - } - } - } - return $count; - } - - private function maybeCollect() { - if ($this->collect_chance_den !== false && rand(0, $this->collect_chance_den) === 0) { - $this->collect_chance_den = false; // Collect only once per instance (aka process$this->collect_chance_den !== false$this->collect_chance_den !== false). - $this->collectImpl(); - } - } - - public function __construct(string $prefix, string $base_path, mixed $lock_fd, int|false $collect_chance_den) { - $this->prefix = $prefix; - $this->base_path = $base_path; - $this->lock_fd = $lock_fd; - $this->collect_chance_den = $collect_chance_den; - } - - public function get(string $key): mixed { - $key = $this->prepareKey($key); - - $this->sharedLockCache(); - - $fd = fopen($this->base_path . $key, 'r'); - if ($fd === false) { - $this->unlockCache(); - return null; - } - - $data = stream_get_contents($fd); - fclose($fd); - $this->maybeCollect(); - $this->unlockCache(); - $wrapped = json_decode($data, true, 512, JSON_THROW_ON_ERROR); - - if ($wrapped['expires'] !== false && $wrapped['expires'] <= time()) { - // Already, expired, pretend it doesn't exist. - return null; - } else { - return $wrapped['inner']; - } - } - - public function set(string $key, mixed $value, mixed $expires = false): void { - $key = $this->prepareKey($key); - - $wrapped = [ - 'expires' => $expires ? time() + $expires : false, - 'inner' => $value - ]; - - $data = json_encode($wrapped); - $this->exclusiveLockCache(); - file_put_contents($this->base_path . $key, $data); - $this->maybeCollect(); - $this->unlockCache(); - } - - public function delete(string $key): void { - $key = $this->prepareKey($key); - - $this->exclusiveLockCache(); - @unlink($this->base_path . $key); - $this->maybeCollect(); - $this->unlockCache(); - } - - public function collect() { - $this->sharedLockCache(); - $count = $this->collectImpl(); - $this->unlockCache(); - return $count; - } - - public function flush(): void { - $this->exclusiveLockCache(); - $files = glob($this->base_path . $this->prefix . '*', GLOB_NOSORT); - foreach ($files as $file) { - @unlink($file); - } - $this->unlockCache(); - } - - public function close() { - fclose($this->lock_fd); - } - }; - - register_shutdown_function([$ret, 'close']); - - return $ret; - } - - public static function phpArray() { - return new class implements CacheDriver { - private static array $inner = []; - - public function get(string $key): mixed { - return isset(self::$inner[$key]) ? self::$inner[$key] : null; - } - - public function set(string $key, mixed $value, mixed $expires = false): void { - self::$inner[$key] = $value; - } - - public function delete(string $key): void { - unset(self::$inner[$key]); - } - - public function flush(): void { - self::$inner = []; - } - }; - } - - /** - * No-op cache. Useful for testing. - */ - public static function none() { - return new class implements CacheDriver { - public function get(string $key): mixed { - return null; - } - - public function set(string $key, mixed $value, mixed $expires = false): void { - // No-op. - } - - public function delete(string $key): void { - // No-op. - } - - public function flush(): void { - // No-op. - } - }; - } -} - -interface CacheDriver { - /** - * Get the value of associated with the key. - * - * @param string $key The key of the value. - * @return mixed|null The value associated with the key, or null if there is none. - */ - public function get(string $key): mixed; - - /** - * Set a key-value pair. - * - * @param string $key The key. - * @param mixed $value The value. - * @param int|false $expires After how many seconds the pair will expire. Use false or ignore this parameter to keep - * the value until it gets evicted to make space for more items. Some drivers will always - * ignore this parameter and store the pair until it's removed. - */ - public function set(string $key, mixed $value, mixed $expires = false): void; - - /** - * Delete a key-value pair. - * - * @param string $key The key. - */ - public function delete(string $key): void; - - /** - * Delete all the key-value pairs. - */ - public function flush(): void; -}