> */ private array $runtime = []; /** * @var array */ private array $global_groups = []; /** * @var array */ private array $non_persistent_groups = []; private bool $connected = true; private ?string $error = null; private int $hits = 0; private int $misses = 0; private float $backend_ms = 0.0; /** * @param CacheBackend $backend Persistent store. * @param string $prefix Key prefix for this site, ending with ":". * @param int $blog_id Current blog. * @param int $max_ttl Upper bound for TTLs; 0 means none. */ public function __construct( private readonly CacheBackend $backend, private readonly string $prefix, private int $blog_id, private readonly int $max_ttl = 0, ) { } /** * Whether a WordPress cache feature is supported. * * @param string $feature Feature. */ public static function supports( string $feature ): bool { return in_array( $feature, self::FEATURES, true ); } /** * Reads a value. * * @param int|string $key Key. * @param string $group Group. * @param bool $force Skip the runtime cache. * @param bool|null $found Whether it was found. */ public function get( int|string $key, string $group = 'default', bool $force = false, ?bool &$found = null ): mixed { $group = $this->normalizeGroup( $group ); $key = (string) $key; $scoped = $this->scopedGroup( $group ); if ( ! $force && array_key_exists( $key, $this->runtime[ $scoped ] ?? [] ) ) { return $this->fromRuntime( $scoped, $key, $found ); } if ( $this->isNonPersistent( $group ) || ! $this->connected ) { $found = false; ++$this->misses; return false; } $hit = false; $full = $this->fullKey( $scoped, $key ); $value = $this->call( function () use ( $full, &$hit ) { return $this->backend->get( $full, $hit ); }, false ); $found = true === $hit; $this->remember( $scoped, $key, $found, $value ); $found ? ++$this->hits : ++$this->misses; return $found ? $this->copy( $value ) : false; } /** * Reads several values. * * @param list $keys Keys. * @param string $group Group. * @param bool $force Skip runtime. * @return array Value or false per key. */ public function get_multiple( array $keys, string $group = 'default', bool $force = false ): array { $group = $this->normalizeGroup( $group ); $scoped = $this->scopedGroup( $group ); $result = []; $pending = []; foreach ( $keys as $key ) { $key = (string) $key; if ( ! $force && array_key_exists( $key, $this->runtime[ $scoped ] ?? [] ) ) { $result[ $key ] = $this->fromRuntime( $scoped, $key, $found ); } elseif ( $this->isNonPersistent( $group ) || ! $this->connected ) { $result[ $key ] = false; ++$this->misses; } else { $pending[ $this->fullKey( $scoped, $key ) ] = $key; $result[ $key ] = false; } } if ( [] !== $pending ) { $values = $this->call( fn () => $this->backend->getMany( array_keys( $pending ) ), [] ); foreach ( $pending as $full => $key ) { $found = array_key_exists( $full, $values ); $this->remember( $scoped, $key, $found, $values[ $full ] ?? null ); $found ? ++$this->hits : ++$this->misses; $result[ $key ] = $found ? $this->copy( $values[ $full ] ) : false; } } return $result; } /** * Writes a value. * * @param int|string $key Key. * @param mixed $data Value. * @param string $group Group. * @param int $expire Seconds; 0 for no expiry. */ public function set( int|string $key, mixed $data, string $group = 'default', int $expire = 0 ): bool { $group = $this->normalizeGroup( $group ); $scoped = $this->scopedGroup( $group ); $key = (string) $key; $this->remember( $scoped, $key, true, $data ); if ( $this->isNonPersistent( $group ) || ! $this->connected ) { return true; } $this->call( fn () => $this->backend->set( $this->fullKey( $scoped, $key ), $data, $this->ttl( $expire ) ), false ); return true; } /** * Writes several values. * * @param array $data Values. * @param string $group Group. * @param int $expire Seconds. * @return array */ public function set_multiple( array $data, string $group = 'default', int $expire = 0 ): array { $group = $this->normalizeGroup( $group ); $scoped = $this->scopedGroup( $group ); $persist = []; $result = []; foreach ( $data as $key => $value ) { $this->remember( $scoped, (string) $key, true, $value ); $persist[ $this->fullKey( $scoped, (string) $key ) ] = $value; $result[ $key ] = true; } if ( [] !== $persist && ! $this->isNonPersistent( $group ) && $this->connected ) { $this->call( fn () => $this->backend->setMany( $persist, $this->ttl( $expire ) ), null ); } return $result; } /** * Writes only when the key does not exist. * * @param int|string $key Key. * @param mixed $data Value. * @param string $group Group. * @param int $expire Seconds. */ public function add( int|string $key, mixed $data, string $group = 'default', int $expire = 0 ): bool { $group = $this->normalizeGroup( $group ); $scoped = $this->scopedGroup( $group ); $key = (string) $key; if ( $this->runtimeHas( $scoped, $key ) ) { return false; } if ( $this->isNonPersistent( $group ) || ! $this->connected ) { $this->remember( $scoped, $key, true, $data ); return true; } $added = (bool) $this->call( fn () => $this->backend->add( $this->fullKey( $scoped, $key ), $data, $this->ttl( $expire ) ), false ); if ( $added || ! $this->connected ) { $this->remember( $scoped, $key, true, $data ); return true; } unset( $this->runtime[ $scoped ][ $key ] ); return false; } /** * Adds several values. * * @param array $data Values. * @param string $group Group. * @param int $expire Seconds. * @return array */ public function add_multiple( array $data, string $group = 'default', int $expire = 0 ): array { $result = []; foreach ( $data as $key => $value ) { $result[ $key ] = $this->add( $key, $value, $group, $expire ); } return $result; } /** * Writes only when the key exists. * * @param int|string $key Key. * @param mixed $data Value. * @param string $group Group. * @param int $expire Seconds. */ public function replace( int|string $key, mixed $data, string $group = 'default', int $expire = 0 ): bool { $group = $this->normalizeGroup( $group ); $scoped = $this->scopedGroup( $group ); $key = (string) $key; if ( $this->isNonPersistent( $group ) || ! $this->connected ) { if ( ! $this->runtimeHas( $scoped, $key ) ) { return false; } $this->remember( $scoped, $key, true, $data ); return true; } $replaced = (bool) $this->call( fn () => $this->backend->replace( $this->fullKey( $scoped, $key ), $data, $this->ttl( $expire ) ), false ); if ( $replaced ) { $this->remember( $scoped, $key, true, $data ); } return $replaced; } /** * Deletes a key. * * @param int|string $key Key. * @param string $group Group. */ public function delete( int|string $key, string $group = 'default' ): bool { $group = $this->normalizeGroup( $group ); $scoped = $this->scopedGroup( $group ); $key = (string) $key; $existed = $this->runtimeHas( $scoped, $key ); unset( $this->runtime[ $scoped ][ $key ] ); if ( $this->isNonPersistent( $group ) || ! $this->connected ) { return $existed; } return (bool) $this->call( fn () => $this->backend->delete( $this->fullKey( $scoped, $key ) ), $existed ); } /** * Deletes several keys. * * @param list $keys Keys. * @param string $group Group. * @return array */ public function delete_multiple( array $keys, string $group = 'default' ): array { $result = []; foreach ( $keys as $key ) { $result[ $key ] = $this->delete( $key, $group ); } return $result; } /** * Increments a numeric value. * * @param int|string $key Key. * @param int $offset Amount. * @param string $group Group. * @return int|false */ public function incr( int|string $key, int $offset = 1, string $group = 'default' ): int|false { return $this->adjust( (string) $key, $offset, $this->normalizeGroup( $group ) ); } /** * Decrements a numeric value, never below zero. * * @param int|string $key Key. * @param int $offset Amount. * @param string $group Group. * @return int|false */ public function decr( int|string $key, int $offset = 1, string $group = 'default' ): int|false { return $this->adjust( (string) $key, -$offset, $this->normalizeGroup( $group ) ); } /** * Removes every key of this site. */ public function flush(): bool { $this->runtime = []; if ( ! $this->connected ) { return false; } return null !== $this->call( fn () => $this->backend->deleteByPrefix( $this->prefix ), null ); } /** * Removes every key of one group in the current blog (or globally for global groups). * * @param string $group Group. */ public function flush_group( string $group ): bool { $group = $this->normalizeGroup( $group ); $scoped = $this->scopedGroup( $group ); unset( $this->runtime[ $scoped ] ); if ( $this->isNonPersistent( $group ) ) { return true; } if ( ! $this->connected ) { return false; } return null !== $this->call( fn () => $this->backend->deleteByPrefix( $this->prefix . $scoped . ':' ), null ); } /** * Clears the per-request cache only. */ public function flush_runtime(): bool { $this->runtime = []; return true; } /** * Groups shared by every blog of a network. * * @param string|list $groups Groups. */ public function add_global_groups( string|array $groups ): void { foreach ( (array) $groups as $group ) { $this->global_groups[ $this->normalizeGroup( $group ) ] = true; } } /** * Groups kept only in memory. * * @param string|list $groups Groups. */ public function add_non_persistent_groups( string|array $groups ): void { foreach ( (array) $groups as $group ) { $this->non_persistent_groups[ $this->normalizeGroup( $group ) ] = true; } } /** * Changes the current blog. * * @param int $blog_id Blog ID. */ public function switch_to_blog( int $blog_id ): void { $this->blog_id = $blog_id; } /** * Deprecated by WordPress; clears runtime values. */ public function reset(): void { $this->runtime = []; } /** * Nothing to close; phpredis connections are reused. */ public function close(): bool { return true; } /** * Request statistics. * * @return array{hits: int, misses: int, backend_ms: float, connected: bool} */ public function stats(): array { return [ 'hits' => $this->hits, 'misses' => $this->misses, 'backend_ms' => round( $this->backend_ms, 2 ), 'connected' => $this->connected, ]; } /** * Whether the backend is usable in this request. */ public function isConnected(): bool { return $this->connected; } /** * Message of the failure that disconnected the backend, if any. */ public function lastError(): ?string { return $this->error; } /** * Adds this request's counters to the per-minute metrics hash. * * @param int $now Unix time. */ public function recordMetrics( int $now ): void { if ( ! $this->connected || 0 === $this->hits + $this->misses ) { return; } $fields = [ 'hits' => $this->hits, 'misses' => $this->misses, 'ms' => (int) round( $this->backend_ms ), 'requests' => 1, ]; $this->call( fn () => $this->backend->recordMetrics( $this->metricsKey( $now ), $fields, self::METRICS_TTL ), null ); } /** * Metrics for the last minutes, oldest first. * * @param int $now Unix time. * @param int $minutes Window. * @return array> Keyed by "YmdHi". */ public function metrics( int $now, int $minutes = 60 ): array { $keys = []; for ( $i = $minutes - 1; $i >= 0; --$i ) { $keys[ gmdate( 'YmdHi', $now - $i * 60 ) ] = $this->metricsKey( $now - $i * 60 ); } $values = $this->connected ? (array) $this->call( fn () => $this->backend->readMetrics( array_values( $keys ) ), [] ) : []; return array_map( static fn ( string $key ) => $values[ $key ] ?? [], $keys ); } /** * Server information, or an empty array when unavailable. * * @return array */ public function info(): array { return $this->connected ? (array) $this->call( fn () => $this->backend->info(), [] ) : []; } /** * Shared implementation of incr/decr. * * @param string $key Key. * @param int $offset Signed amount. * @param string $group Normalised group. * @return int|false */ private function adjust( string $key, int $offset, string $group ): int|false { $scoped = $this->scopedGroup( $group ); if ( $this->isNonPersistent( $group ) || ! $this->connected ) { if ( ! $this->runtimeHas( $scoped, $key ) ) { return false; } $current = $this->runtime[ $scoped ][ $key ]; $value = max( 0, ( is_numeric( $current ) ? (int) $current : 0 ) + $offset ); $this->remember( $scoped, $key, true, $value ); return $value; } $full = $this->fullKey( $scoped, $key ); $value = $this->call( fn () => $this->backend->increment( $full, $offset ), null ); if ( null === $value ) { return false; } if ( $value < 0 ) { $value = 0; $this->call( fn () => $this->backend->set( $full, 0, $this->ttl( 0 ) ), false ); } $this->remember( $scoped, $key, true, $value ); return $value; } /** * Runs a backend call, disconnecting on failure. * * @param callable(): mixed $operation Operation. * @param mixed $fallback Value returned on failure. */ private function call( callable $operation, mixed $fallback ): mixed { if ( ! $this->connected ) { return $fallback; } $start = hrtime( true ); try { return $operation(); } catch ( BackendUnavailable $error ) { $this->connected = false; $this->error = $error->getMessage(); return $fallback; } finally { $this->backend_ms += ( hrtime( true ) - $start ) / 1e6; } } /** * Stores a value or a miss in the runtime cache. * * @param string $scoped Scoped group. * @param string $key Key. * @param bool $found Found. * @param mixed $value Value. */ private function remember( string $scoped, string $key, bool $found, mixed $value ): void { $this->runtime[ $scoped ][ $key ] = $found ? $this->copy( $value ) : Miss::instance(); } /** * Reads from runtime. * * @param string $scoped Scoped group. * @param string $key Key. * @param bool|null $found Found. */ private function fromRuntime( string $scoped, string $key, ?bool &$found ): mixed { $value = $this->runtime[ $scoped ][ $key ]; $found = ! $value instanceof Miss; $found ? ++$this->hits : ++$this->misses; return $found ? $this->copy( $value ) : false; } /** * Whether runtime holds a real value (not a remembered miss). * * @param string $scoped Scoped group. * @param string $key Key. */ private function runtimeHas( string $scoped, string $key ): bool { return array_key_exists( $key, $this->runtime[ $scoped ] ?? [] ) && ! $this->runtime[ $scoped ][ $key ] instanceof Miss; } /** * Objects are cloned so callers cannot mutate cached values. * * @param mixed $value Value. */ private function copy( mixed $value ): mixed { return is_object( $value ) && ! $value instanceof Miss ? clone $value : $value; } /** * Effective TTL. * * @param int $expire Requested seconds. */ private function ttl( int $expire ): int { if ( $this->max_ttl > 0 ) { return $expire > 0 ? min( $expire, $this->max_ttl ) : $this->max_ttl; } return max( 0, $expire ); } /** * "global:group" or ":group". * * @param string $group Group. */ private function scopedGroup( string $group ): string { return ( isset( $this->global_groups[ $group ] ) ? self::GLOBAL : (string) $this->blog_id ) . ':' . $group; } /** * Full backend key. * * @param string $scoped Scoped group. * @param string $key Key. */ private function fullKey( string $scoped, string $key ): string { return $this->prefix . $scoped . ':' . $key; } /** * Metrics hash key. * * @param int $time Unix time. */ private function metricsKey( int $time ): string { return $this->prefix . 'akantio:metrics:' . gmdate( 'YmdHi', $time ); } /** * Empty group means "default". * * @param string $group Group. */ private function normalizeGroup( string $group ): string { return '' === $group ? 'default' : $group; } /** * Whether a group stays in memory. * * @param string $group Group. */ private function isNonPersistent( string $group ): bool { return isset( $this->non_persistent_groups[ $group ] ); } }