PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.4 1.3.3 1.3.2 1.3.1 1.3.0 1.2.4 trunk 1.0.0 1.0.1 1.0.2 1.0.3 1.0.4 1.0.5 1.0.6 1.0.7 1.0.8 1.0.9 1.1.0 1.1.1 All 36 releases
xspeed / includes / class-redis-client.php

class-redis-client.php in xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN 1.4.2, at includes/class-redis-client.php

344 lines 11.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * XSpeed_Redis_Client — a minimal, dependency-free Redis client.
4 *
5 * Speaks the Redis wire protocol (RESP) directly over a TCP or unix socket. It exists
6 * so xSpeed's object cache can talk to Redis WITHOUT the phpredis extension and
7 * WITHOUT bundling a heavyweight library (Predis ships 700+ files for cluster /
8 * sentinel / pub-sub / transactions we never use). This implements exactly the
9 * commands the object cache needs and nothing more:
10 *
11 * AUTH, SELECT, PING, GET, SET, SETEX, DEL, INCRBY, DECRBY, FLUSHDB
12 *
13 * It is intentionally NOT a general-purpose client. Every method maps to one
14 * Redis command. Errors never throw past connect(); read/write failures return
15 * false so the object cache degrades gracefully instead of fataling the site.
16 *
17 * RESP reference: https://redis.io/docs/reference/protocol-spec/
18 *
19 * @package XSpeed
20 */
21
22 declare(strict_types=1);
23
24 namespace XSpeed;
25
26 defined( 'ABSPATH' ) || exit;
27
28 class Redis_Client {
29
30 /** @var resource|null Socket handle. */
31 private $sock = null;
32
33 /** @var string */
34 private $host;
35
36 /** @var int */
37 private $port;
38
39 /** @var float */
40 private $timeout;
41
42 /** @var bool Persistent connection (pconnect-style). */
43 private $persistent;
44
45 public function __construct( string $host = '127.0.0.1', int $port = 6379, float $timeout = 1.0, bool $persistent = false ) {
46 $this->host = $host;
47 $this->port = $port;
48 $this->timeout = $timeout > 0 ? $timeout : 1.0;
49 $this->persistent = $persistent;
50 }
51
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 /**
75 * Open the socket. Returns true on success. Never throws — callers check
76 * the boolean and fall back to a non-persistent cache on failure.
77 */
78 public function connect(): bool {
79 $flags = STREAM_CLIENT_CONNECT | ( $this->persistent ? STREAM_CLIENT_PERSISTENT : 0 );
80 $errno = 0;
81 $errstr = '';
82 $path = self::socket_path( $this->host );
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.
84 $sock = @stream_socket_client(
85 '' !== $path ? "unix://{$path}" : "tcp://{$this->host}:{$this->port}",
86 $errno,
87 $errstr,
88 $this->timeout,
89 $flags
90 );
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 }
103 return false;
104 }
105 stream_set_timeout( $sock, (int) $this->timeout, (int) ( ( $this->timeout - (int) $this->timeout ) * 1000000 ) );
106 $this->sock = $sock;
107 return true;
108 }
109
110 public function is_connected(): bool {
111 return is_resource( $this->sock );
112 }
113
114 // --- Commands -----------------------------------------------------------
115
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 }
131 return $this->command( array( 'AUTH', $password ) );
132 }
133
134 public function select( int $db ) {
135 return $this->command( array( 'SELECT', (string) $db ) );
136 }
137
138 /** @return string|bool '+PONG' on success, false on failure. */
139 public function ping() {
140 $r = $this->command( array( 'PING' ) );
141 return ( null === $r || false === $r ) ? false : $r;
142 }
143
144 /** @return string|false The value, or false if the key is missing. */
145 public function get( string $key ) {
146 $r = $this->command( array( 'GET', $key ) );
147 return null === $r ? false : $r;
148 }
149
150 public function set( string $key, string $value ): bool {
151 $r = $this->command( array( 'SET', $key, $value ) );
152 return '+OK' === $r || 'OK' === $r;
153 }
154
155 public function setex( string $key, int $ttl, string $value ): bool {
156 $r = $this->command( array( 'SETEX', $key, (string) $ttl, $value ) );
157 return '+OK' === $r || 'OK' === $r;
158 }
159
160 /**
161 * Atomic add — SET ... NX, which stores only if the key does NOT exist.
162 * Returns true when stored, false when the key already existed (Redis
163 * replies nil → null here) or on error. With $ttl > 0 the EX option makes
164 * the store + expiry atomic. Used by the drop-in's wp_cache_add so add()
165 * honours its "fail if the key is present" contract across requests, not
166 * just the per-request runtime cache. (FBS-82111 Bug 2)
167 */
168 public function add( string $key, string $value, int $ttl = 0 ): bool {
169 $args = array( 'SET', $key, $value, 'NX' );
170 if ( $ttl > 0 ) {
171 $args[] = 'EX';
172 $args[] = (string) $ttl;
173 }
174 $r = $this->command( $args );
175 return '+OK' === $r || 'OK' === $r;
176 }
177
178 /** @return int Number of keys removed. */
179 public function del( string $key ): int {
180 return (int) $this->command( array( 'DEL', $key ) );
181 }
182
183 /** @return int|false New value, or false on error. */
184 public function incrBy( string $key, int $offset ) {
185 return $this->command( array( 'INCRBY', $key, (string) $offset ) );
186 }
187
188 /** @return int|false New value, or false on error. */
189 public function decrBy( string $key, int $offset ) {
190 return $this->command( array( 'DECRBY', $key, (string) $offset ) );
191 }
192
193 public function flushDB(): bool {
194 $r = $this->command( array( 'FLUSHDB' ) );
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;
228 }
229
230 public function close(): void {
231 if ( is_resource( $this->sock ) && ! $this->persistent ) {
232 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing a raw TCP socket opened with stream_socket_client; not a WP_Filesystem-managed handle.
233 @fclose( $this->sock ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort close on shutdown.
234 }
235 $this->sock = null;
236 }
237
238 // --- RESP protocol ------------------------------------------------------
239
240 /**
241 * Encode a command as a RESP array of bulk strings, write it, read one
242 * reply. Returns the decoded reply, or false on any socket error.
243 *
244 * @param string[] $args
245 * @return mixed
246 */
247 private function command( array $args ) {
248 if ( ! is_resource( $this->sock ) ) {
249 return false;
250 }
251
252 $payload = '*' . count( $args ) . "\r\n";
253 foreach ( $args as $a ) {
254 $a = (string) $a;
255 $payload .= '$' . strlen( $a ) . "\r\n" . $a . "\r\n";
256 }
257
258 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.PHP.NoSilencedErrors.Discouraged -- Writing to the Redis TCP socket; WP_Filesystem has no socket transport. Failure returns false and the cache degrades.
259 if ( false === @fwrite( $this->sock, $payload ) ) {
260 $this->sock = null;
261 return false;
262 }
263
264 return $this->read_reply();
265 }
266
267 /**
268 * Read and decode a single RESP reply from the socket.
269 *
270 * @return mixed string|int|null|array|false
271 */
272 private function read_reply() {
273 $line = $this->read_line();
274 if ( false === $line || '' === $line ) {
275 return false;
276 }
277
278 $type = $line[0];
279 $body = substr( $line, 1 );
280
281 switch ( $type ) {
282 case '+': // Simple string.
283 return $body;
284 case '-': // Error.
285 return false;
286 case ':': // Integer.
287 return (int) $body;
288 case '$': // Bulk string.
289 $len = (int) $body;
290 if ( $len < 0 ) {
291 return null; // Null bulk = key missing.
292 }
293 $data = $this->read_bytes( $len + 2 ); // +2 for trailing CRLF.
294 return false === $data ? false : substr( $data, 0, $len );
295 case '*': // Array.
296 $count = (int) $body;
297 if ( $count < 0 ) {
298 return null;
299 }
300 $out = array();
301 for ( $i = 0; $i < $count; $i++ ) {
302 $out[] = $this->read_reply();
303 }
304 return $out;
305 default:
306 return false;
307 }
308 }
309
310 /** Read one CRLF-terminated line (without the CRLF). */
311 private function read_line() {
312 if ( ! is_resource( $this->sock ) ) {
313 return false;
314 }
315 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgets, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading a line from the Redis TCP socket.
316 $line = @fgets( $this->sock );
317 if ( false === $line ) {
318 return false;
319 }
320 return rtrim( $line, "\r\n" );
321 }
322
323 /** Read exactly $n bytes from the socket. */
324 private function read_bytes( int $n ) {
325 if ( ! is_resource( $this->sock ) ) {
326 return false;
327 }
328 $buf = '';
329 while ( strlen( $buf ) < $n ) {
330 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading the bulk-string body from the Redis TCP socket.
331 $chunk = @fread( $this->sock, $n - strlen( $buf ) );
332 if ( false === $chunk || '' === $chunk ) {
333 $meta = stream_get_meta_data( $this->sock );
334 if ( ! empty( $meta['timed_out'] ) ) {
335 return false;
336 }
337 break;
338 }
339 $buf .= $chunk;
340 }
341 return $buf;
342 }
343 }
344