| @@ -1,9 +1,9 @@ | ||
| 1 | 1 | <?php |
| 2 | 2 | /** |
| 3 | 3 | * XSpeed_Redis_Client — a minimal, dependency-free Redis client. |
| 4 | 4 | * |
| 5 | - * Speaks the Redis wire protocol (RESP) directly over a TCP socket. It exists | |
| 5 | + * Speaks the Redis wire protocol (RESP) directly over a TCP or unix socket. It exists | |
| 6 | 6 | * so xSpeed's object cache can talk to Redis WITHOUT the phpredis extension and |
| 7 | 7 | * WITHOUT bundling a heavyweight library (Predis ships 700+ files for cluster / |
| 8 | 8 | * sentinel / pub-sub / transactions we never use). This implements exactly the |
| 9 | 9 | * commands the object cache needs and nothing more: |
| @@ -49,8 +49,30 @@ | ||
| 49 | 49 | $this->persistent = $persistent; |
| 50 | 50 | } |
| 51 | 51 | |
| 52 | 52 | /** |
| 53 | + * The unix socket path a Redis host names, or '' for a network host. | |
| 54 | + * | |
| 55 | + * Hosts that run Redis on a socket only (Hetzner managed servers, shared | |
| 56 | + * LiteSpeed hosting) hand out a path rather than an address, written as | |
| 57 | + * `/run/redis/redis.sock`, `unix:/run/redis/redis.sock` (Hetzner's own | |
| 58 | + * docs) or `unix:///run/redis/redis.sock`. A socket has no port, so the | |
| 59 | + * caller ignores the configured one when this returns a path. (#566) | |
| 60 | + * | |
| 61 | + * The drop-in carries a copy as xspeed_oc_redis_socket(), because it loads | |
| 62 | + * before this class can; ObjectCacheSocketTest runs both over the same | |
| 63 | + * inputs so they cannot drift. | |
| 64 | + */ | |
| 65 | + public static function socket_path( string $host ): string { | |
| 66 | + $host = trim( $host ); | |
| 67 | + if ( 0 === stripos( $host, 'unix:' ) ) { | |
| 68 | + // unix:///path and unix:/path both name /path. | |
| 69 | + $host = '/' . ltrim( substr( $host, 5 ), '/' ); | |
| 70 | + } | |
| 71 | + return ( '' !== $host && '/' === $host[0] && '/' !== $host ) ? $host : ''; | |
| 72 | + } | |
| 73 | + | |
| 74 | + /** | |
| 53 | 75 | * Open the socket. Returns true on success. Never throws — callers check |
| 54 | 76 | * the boolean and fall back to a non-persistent cache on failure. |
| 55 | 77 | */ |
| 56 | 78 | public function connect(): bool { |
| @@ -56,11 +78,12 @@ | ||
| 56 | 78 | public function connect(): bool { |
| 57 | 79 | $flags = STREAM_CLIENT_CONNECT | ( $this->persistent ? STREAM_CLIENT_PERSISTENT : 0 ); |
| 58 | 80 | $errno = 0; |
| 59 | 81 | $errstr = ''; |
| 82 | + $path = self::socket_path( $this->host ); | |
| 60 | 83 | // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- A raw stream socket is the only way to speak the Redis protocol; WP_Filesystem cannot open TCP sockets. Errors are captured via $errno/$errstr and surfaced as a boolean. |
| 61 | 84 | $sock = @stream_socket_client( |
| 62 | - "tcp://{$this->host}:{$this->port}", | |
| 85 | + '' !== $path ? "unix://{$path}" : "tcp://{$this->host}:{$this->port}", | |
| 63 | 86 | $errno, |
| 64 | 87 | $errstr, |
| 65 | 88 | $this->timeout, |
| 66 | 89 | $flags |
| @@ -65,8 +88,19 @@ | ||
| 65 | 88 | $this->timeout, |
| 66 | 89 | $flags |
| 67 | 90 | ); |
| 68 | 91 | if ( ! $sock ) { |
| 92 | + // The `@` above hides the warning from output/logs, but a failed | |
| 93 | + // connect (e.g. an unreachable/unresolvable host) still leaves a | |
| 94 | + // PHP warning in `error_get_last()`. WP reads that at | |
| 95 | + // `admin_body_class` time and tags the page `php-error` — which | |
| 96 | + // renders an empty banner above the admin menu even though we | |
| 97 | + // handle the failure gracefully (caller falls back to a | |
| 98 | + // non-persistent cache). Clear it so a degraded-but-handled Redis | |
| 99 | + // backend doesn't masquerade as a site error. | |
| 100 | + if ( function_exists( 'error_clear_last' ) ) { | |
| 101 | + error_clear_last(); | |
| 102 | + } | |
| 69 | 103 | return false; |
| 70 | 104 | } |
| 71 | 105 | stream_set_timeout( $sock, (int) $this->timeout, (int) ( ( $this->timeout - (int) $this->timeout ) * 1000000 ) ); |
| 72 | 106 | $this->sock = $sock; |
| @@ -78,9 +112,23 @@ | ||
| 78 | 112 | } |
| 79 | 113 | |
| 80 | 114 | // --- Commands ----------------------------------------------------------- |
| 81 | 115 | |
| 82 | - public function auth( string $password ) { | |
| 116 | + /** | |
| 117 | + * Authenticate. With a username (Redis 6+ ACL) emit the two-argument | |
| 118 | + * `AUTH <user> <pass>`; without one fall back to the legacy | |
| 119 | + * single-argument `AUTH <pass>` (which authenticates as the built-in | |
| 120 | + * `default` user). Managed hosts that provision a dedicated ACL user | |
| 121 | + * and disable `default` reject the one-arg form with WRONGPASS, so the | |
| 122 | + * username must be threaded through here. (FBS-83118) | |
| 123 | + * | |
| 124 | + * @param string $password Redis password. | |
| 125 | + * @param string $username Optional ACL username; '' = legacy default user. | |
| 126 | + */ | |
| 127 | + public function auth( string $password, string $username = '' ) { | |
| 128 | + if ( '' !== $username ) { | |
| 129 | + return $this->command( array( 'AUTH', $username, $password ) ); | |
| 130 | + } | |
| 83 | 131 | return $this->command( array( 'AUTH', $password ) ); |
| 84 | 132 | } |
| 85 | 133 | |
| 86 | 134 | public function select( int $db ) { |
| @@ -144,8 +192,40 @@ | ||
| 144 | 192 | |
| 145 | 193 | public function flushDB(): bool { |
| 146 | 194 | $r = $this->command( array( 'FLUSHDB' ) ); |
| 147 | 195 | return '+OK' === $r || 'OK' === $r; |
| 196 | + } | |
| 197 | + | |
| 198 | + /** | |
| 199 | + * Delete every key matching a glob-style pattern, using a non-blocking | |
| 200 | + * SCAN cursor (never KEYS, which blocks the whole server on large | |
| 201 | + * datasets). Returns the number of keys deleted. Used for prefix-scoped | |
| 202 | + * flushes so we only ever touch this site's namespace, never the whole | |
| 203 | + * Redis DB. (FBS-83119) | |
| 204 | + * | |
| 205 | + * @param string $pattern e.g. "salt:*" or "salt:*:options:*". | |
| 206 | + * @param int $count SCAN COUNT hint (batch size per round trip). | |
| 207 | + */ | |
| 208 | + public function delete_by_pattern( string $pattern, int $count = 500 ): int { | |
| 209 | + $deleted = 0; | |
| 210 | + $cursor = '0'; | |
| 211 | + do { | |
| 212 | + $reply = $this->command( array( 'SCAN', $cursor, 'MATCH', $pattern, 'COUNT', (string) $count ) ); | |
| 213 | + // Expected: [ next_cursor, [ key, key, ... ] ]. Anything else | |
| 214 | + // (false on socket error, malformed) ends the loop safely. | |
| 215 | + if ( ! is_array( $reply ) || count( $reply ) < 2 || ! is_array( $reply[1] ) ) { | |
| 216 | + break; | |
| 217 | + } | |
| 218 | + $cursor = (string) $reply[0]; | |
| 219 | + $keys = $reply[1]; | |
| 220 | + if ( ! empty( $keys ) ) { | |
| 221 | + // DEL accepts variadic keys — one round trip per batch. | |
| 222 | + $args = array_merge( array( 'DEL' ), array_map( 'strval', $keys ) ); | |
| 223 | + $n = $this->command( $args ); | |
| 224 | + $deleted += is_int( $n ) ? $n : 0; | |
| 225 | + } | |
| 226 | + } while ( '0' !== $cursor ); | |
| 227 | + return $deleted; | |
| 148 | 228 | } |
| 149 | 229 | |
| 150 | 230 | public function close(): void { |
| 151 | 231 | if ( is_resource( $this->sock ) && ! $this->persistent ) { |