CacheDriver: moved to subdirectory

This commit is contained in:
Zankaria 2025-07-26 23:34:58 +02:00
parent 8ee471c868
commit 311a5477f8
8 changed files with 8 additions and 8 deletions

View file

@ -0,0 +1,28 @@
<?php
namespace Vichan\Data\Driver\Cache;
defined('TINYBOARD') or exit;
class ApcuCacheDriver 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();
}
}

View file

@ -0,0 +1,28 @@
<?php
namespace Vichan\Data\Driver\Cache;
defined('TINYBOARD') or exit;
/**
* A simple process-wide PHP array.
*/
class ArrayCacheDriver 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 = [];
}
}

View file

@ -0,0 +1,38 @@
<?php
namespace Vichan\Data\Driver\Cache;
defined('TINYBOARD') or exit;
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;
}

View file

@ -0,0 +1,20 @@
<?php
namespace Vichan\Data\Driver\Cache;
trait CacheDriverTrait {
/**
* Tries to interpret the uri as a path to a unix socket.
*
* @param string $uri
* @return ?string The path to the socket, null if it cannot be interpreted as such.
*/
private static function asUnixSocketPath(string $uri): ?string {
if (str_starts_with($uri, 'unix:')) {
return \substr($uri, 5);
} elseif (str_starts_with($uri, ':')) {
return \substr($uri, 1);
}
return null;
}
}

View file

@ -0,0 +1,155 @@
<?php
namespace Vichan\Data\Driver\Cache;
defined('TINYBOARD') or exit;
class FsCacheDriver 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, and
* no other process add new cache items or refresh existing ones.
*/
$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();
// Collect expired items first so if the target key is expired we shortcut to failure in the next lines.
$this->maybeCollect();
$fd = \fopen($this->base_path . $key, 'r');
if ($fd === false) {
$this->unlockCache();
return null;
}
$data = \stream_get_contents($fd);
\fclose($fd);
$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();
$this->maybeCollect();
\file_put_contents($this->base_path . $key, $data);
$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);
}
}

View file

@ -0,0 +1,74 @@
<?php
namespace Vichan\Data\Driver\Cache;
defined('TINYBOARD') or exit;
class MemcachedCacheDriver implements CacheDriver {
use CacheDriverTrait;
private \Memcached $inner;
public function __construct(string $prefix, string $server_uri, int $server_port, int $server_weight) {
$this->inner = new \Memcached();
if (!$this->inner->setOption(\Memcached::OPT_BINARY_PROTOCOL, true)) {
$err = $this->inner->getResultMessage();
throw new \RuntimeException("Unable to set the memcached protocol: '$err'");
}
if (!$this->inner->setOption(\Memcached::OPT_PREFIX_KEY, $prefix)) {
$err = $this->inner->getResultMessage();
throw new \RuntimeException("Unable to set the memcached prefix: '$err'");
}
$maybe_unix_path = self::asUnixSocketPath($server_uri);
$is_unix = $maybe_unix_path !== null;
if ($is_unix) {
$server_uri = $maybe_unix_path;
}
// Memcached keeps the server connections open across requests.
$current_servers = $this->inner->getServerList();
$found_in_curr = false;
foreach ($current_servers as $curr) {
// Ignore the port if the server is connected with a unix socket.
if ($curr['host'] === $server_uri && ($is_unix || $curr['port'] === $server_port)) {
$found_in_curr = true;
}
}
if (!$found_in_curr) {
if (!empty($current_servers)) {
if (!$this->inner->resetServerList()) {
$err = $this->inner->getResultMessage();
throw new \RuntimeException("Unable to reset the memcached server list: '$err'");
}
}
if (!$this->inner->addServer($server_uri, $server_port, $server_weight)) {
$err = $this->inner->getResultMessage();
throw new \RuntimeException("Unable to add memcached servers: '$err'");
}
}
}
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();
}
}

View file

@ -0,0 +1,26 @@
<?php
namespace Vichan\Data\Driver\Cache;
defined('TINYBOARD') or exit;
/**
* No-op cache. Useful for testing.
*/
class NoneCacheDriver 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.
}
}

View file

@ -0,0 +1,69 @@
<?php
namespace Vichan\Data\Driver\Cache;
defined('TINYBOARD') or exit;
class RedisCacheDriver implements CacheDriver {
use CacheDriverTrait;
private string $prefix;
private \Redis $inner;
public function __construct(string $prefix, string $host, ?int $port, ?string $password, int $database) {
$this->inner = new \Redis();
$maybe_unix = self::asUnixSocketPath($host);
if ($maybe_unix !== null) {
$this->inner->connect($maybe_unix);
} elseif ($port === null) {
$this->inner->connect($host);
} else {
// IP + port.
$this->inner->connect($host, $port);
}
if ($password) {
$this->inner->auth($password);
}
if (!$this->inner->setOption(\Redis::OPT_SERIALIZER, \Redis::SERIALIZER_JSON)) {
throw new \RuntimeException('Unable to configure Redis serializer');
}
if (!$this->inner->select($database)) {
throw new \RuntimeException('Unable to connect to Redis database!');
}
$this->prefix = $prefix;
}
public function get(string $key): mixed {
$ret = $this->inner->get($this->prefix . $key);
if ($ret === false) {
return null;
}
if ($ret === null) {
return false;
}
return $ret;
}
public function set(string $key, mixed $value, mixed $expires = false): void {
$value = $value === false ? null : $value;
if ($expires === false) {
$this->inner->set($this->prefix . $key, $value);
} else {
$this->inner->setEx($this->prefix . $key, $expires, $value);
}
}
public function delete(string $key): void {
$this->inner->del($this->prefix . $key);
}
public function flush(): void {
if (empty($this->prefix)) {
$this->inner->flushDB();
} else {
$this->inner->unlink($this->inner->keys("{$this->prefix}*"));
}
}
}