PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.7
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.7
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 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.2.0 All 28 releases
xspeed / includes / class-redis-client.php

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

321 lines 10.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 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 * Open the socket. Returns true on success. Never throws — callers check
54 * the boolean and fall back to a non-persistent cache on failure.
55 */
56 public function connect(): bool {
57 $flags = STREAM_CLIENT_CONNECT | ( $this->persistent ? STREAM_CLIENT_PERSISTENT : 0 );
58 $errno = 0;
59 $errstr = '';
60 // 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 $sock = @stream_socket_client(
62 "tcp://{$this->host}:{$this->port}",
63 $errno,
64 $errstr,
65 $this->timeout,
66 $flags
67 );
68 if ( ! $sock ) {
69 // The `@` above hides the warning from output/logs, but a failed
70 // connect (e.g. an unreachable/unresolvable host) still leaves a
71 // PHP warning in `error_get_last()`. WP reads that at
72 // `admin_body_class` time and tags the page `php-error` — which
73 // renders an empty banner above the admin menu even though we
74 // handle the failure gracefully (caller falls back to a
75 // non-persistent cache). Clear it so a degraded-but-handled Redis
76 // backend doesn't masquerade as a site error.
77 if ( function_exists( 'error_clear_last' ) ) {
78 error_clear_last();
79 }
80 return false;
81 }
82 stream_set_timeout( $sock, (int) $this->timeout, (int) ( ( $this->timeout - (int) $this->timeout ) * 1000000 ) );
83 $this->sock = $sock;
84 return true;
85 }
86
87 public function is_connected(): bool {
88 return is_resource( $this->sock );
89 }
90
91 // --- Commands -----------------------------------------------------------
92
93 /**
94 * Authenticate. With a username (Redis 6+ ACL) emit the two-argument
95 * `AUTH <user> <pass>`; without one fall back to the legacy
96 * single-argument `AUTH <pass>` (which authenticates as the built-in
97 * `default` user). Managed hosts that provision a dedicated ACL user
98 * and disable `default` reject the one-arg form with WRONGPASS, so the
99 * username must be threaded through here. (FBS-83118)
100 *
101 * @param string $password Redis password.
102 * @param string $username Optional ACL username; '' = legacy default user.
103 */
104 public function auth( string $password, string $username = '' ) {
105 if ( '' !== $username ) {
106 return $this->command( array( 'AUTH', $username, $password ) );
107 }
108 return $this->command( array( 'AUTH', $password ) );
109 }
110
111 public function select( int $db ) {
112 return $this->command( array( 'SELECT', (string) $db ) );
113 }
114
115 /** @return string|bool '+PONG' on success, false on failure. */
116 public function ping() {
117 $r = $this->command( array( 'PING' ) );
118 return ( null === $r || false === $r ) ? false : $r;
119 }
120
121 /** @return string|false The value, or false if the key is missing. */
122 public function get( string $key ) {
123 $r = $this->command( array( 'GET', $key ) );
124 return null === $r ? false : $r;
125 }
126
127 public function set( string $key, string $value ): bool {
128 $r = $this->command( array( 'SET', $key, $value ) );
129 return '+OK' === $r || 'OK' === $r;
130 }
131
132 public function setex( string $key, int $ttl, string $value ): bool {
133 $r = $this->command( array( 'SETEX', $key, (string) $ttl, $value ) );
134 return '+OK' === $r || 'OK' === $r;
135 }
136
137 /**
138 * Atomic add — SET ... NX, which stores only if the key does NOT exist.
139 * Returns true when stored, false when the key already existed (Redis
140 * replies nil → null here) or on error. With $ttl > 0 the EX option makes
141 * the store + expiry atomic. Used by the drop-in's wp_cache_add so add()
142 * honours its "fail if the key is present" contract across requests, not
143 * just the per-request runtime cache. (FBS-82111 Bug 2)
144 */
145 public function add( string $key, string $value, int $ttl = 0 ): bool {
146 $args = array( 'SET', $key, $value, 'NX' );
147 if ( $ttl > 0 ) {
148 $args[] = 'EX';
149 $args[] = (string) $ttl;
150 }
151 $r = $this->command( $args );
152 return '+OK' === $r || 'OK' === $r;
153 }
154
155 /** @return int Number of keys removed. */
156 public function del( string $key ): int {
157 return (int) $this->command( array( 'DEL', $key ) );
158 }
159
160 /** @return int|false New value, or false on error. */
161 public function incrBy( string $key, int $offset ) {
162 return $this->command( array( 'INCRBY', $key, (string) $offset ) );
163 }
164
165 /** @return int|false New value, or false on error. */
166 public function decrBy( string $key, int $offset ) {
167 return $this->command( array( 'DECRBY', $key, (string) $offset ) );
168 }
169
170 public function flushDB(): bool {
171 $r = $this->command( array( 'FLUSHDB' ) );
172 return '+OK' === $r || 'OK' === $r;
173 }
174
175 /**
176 * Delete every key matching a glob-style pattern, using a non-blocking
177 * SCAN cursor (never KEYS, which blocks the whole server on large
178 * datasets). Returns the number of keys deleted. Used for prefix-scoped
179 * flushes so we only ever touch this site's namespace, never the whole
180 * Redis DB. (FBS-83119)
181 *
182 * @param string $pattern e.g. "salt:*" or "salt:*:options:*".
183 * @param int $count SCAN COUNT hint (batch size per round trip).
184 */
185 public function delete_by_pattern( string $pattern, int $count = 500 ): int {
186 $deleted = 0;
187 $cursor = '0';
188 do {
189 $reply = $this->command( array( 'SCAN', $cursor, 'MATCH', $pattern, 'COUNT', (string) $count ) );
190 // Expected: [ next_cursor, [ key, key, ... ] ]. Anything else
191 // (false on socket error, malformed) ends the loop safely.
192 if ( ! is_array( $reply ) || count( $reply ) < 2 || ! is_array( $reply[1] ) ) {
193 break;
194 }
195 $cursor = (string) $reply[0];
196 $keys = $reply[1];
197 if ( ! empty( $keys ) ) {
198 // DEL accepts variadic keys — one round trip per batch.
199 $args = array_merge( array( 'DEL' ), array_map( 'strval', $keys ) );
200 $n = $this->command( $args );
201 $deleted += is_int( $n ) ? $n : 0;
202 }
203 } while ( '0' !== $cursor );
204 return $deleted;
205 }
206
207 public function close(): void {
208 if ( is_resource( $this->sock ) && ! $this->persistent ) {
209 // 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.
210 @fclose( $this->sock ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort close on shutdown.
211 }
212 $this->sock = null;
213 }
214
215 // --- RESP protocol ------------------------------------------------------
216
217 /**
218 * Encode a command as a RESP array of bulk strings, write it, read one
219 * reply. Returns the decoded reply, or false on any socket error.
220 *
221 * @param string[] $args
222 * @return mixed
223 */
224 private function command( array $args ) {
225 if ( ! is_resource( $this->sock ) ) {
226 return false;
227 }
228
229 $payload = '*' . count( $args ) . "\r\n";
230 foreach ( $args as $a ) {
231 $a = (string) $a;
232 $payload .= '$' . strlen( $a ) . "\r\n" . $a . "\r\n";
233 }
234
235 // 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.
236 if ( false === @fwrite( $this->sock, $payload ) ) {
237 $this->sock = null;
238 return false;
239 }
240
241 return $this->read_reply();
242 }
243
244 /**
245 * Read and decode a single RESP reply from the socket.
246 *
247 * @return mixed string|int|null|array|false
248 */
249 private function read_reply() {
250 $line = $this->read_line();
251 if ( false === $line || '' === $line ) {
252 return false;
253 }
254
255 $type = $line[0];
256 $body = substr( $line, 1 );
257
258 switch ( $type ) {
259 case '+': // Simple string.
260 return $body;
261 case '-': // Error.
262 return false;
263 case ':': // Integer.
264 return (int) $body;
265 case '$': // Bulk string.
266 $len = (int) $body;
267 if ( $len < 0 ) {
268 return null; // Null bulk = key missing.
269 }
270 $data = $this->read_bytes( $len + 2 ); // +2 for trailing CRLF.
271 return false === $data ? false : substr( $data, 0, $len );
272 case '*': // Array.
273 $count = (int) $body;
274 if ( $count < 0 ) {
275 return null;
276 }
277 $out = array();
278 for ( $i = 0; $i < $count; $i++ ) {
279 $out[] = $this->read_reply();
280 }
281 return $out;
282 default:
283 return false;
284 }
285 }
286
287 /** Read one CRLF-terminated line (without the CRLF). */
288 private function read_line() {
289 if ( ! is_resource( $this->sock ) ) {
290 return false;
291 }
292 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgets, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading a line from the Redis TCP socket.
293 $line = @fgets( $this->sock );
294 if ( false === $line ) {
295 return false;
296 }
297 return rtrim( $line, "\r\n" );
298 }
299
300 /** Read exactly $n bytes from the socket. */
301 private function read_bytes( int $n ) {
302 if ( ! is_resource( $this->sock ) ) {
303 return false;
304 }
305 $buf = '';
306 while ( strlen( $buf ) < $n ) {
307 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading the bulk-string body from the Redis TCP socket.
308 $chunk = @fread( $this->sock, $n - strlen( $buf ) );
309 if ( false === $chunk || '' === $chunk ) {
310 $meta = stream_get_meta_data( $this->sock );
311 if ( ! empty( $meta['timed_out'] ) ) {
312 return false;
313 }
314 break;
315 }
316 $buf .= $chunk;
317 }
318 return $buf;
319 }
320 }
321