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

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

275 lines 8.7 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 public function auth( string $password ) {
94 return $this->command( array( 'AUTH', $password ) );
95 }
96
97 public function select( int $db ) {
98 return $this->command( array( 'SELECT', (string) $db ) );
99 }
100
101 /** @return string|bool '+PONG' on success, false on failure. */
102 public function ping() {
103 $r = $this->command( array( 'PING' ) );
104 return ( null === $r || false === $r ) ? false : $r;
105 }
106
107 /** @return string|false The value, or false if the key is missing. */
108 public function get( string $key ) {
109 $r = $this->command( array( 'GET', $key ) );
110 return null === $r ? false : $r;
111 }
112
113 public function set( string $key, string $value ): bool {
114 $r = $this->command( array( 'SET', $key, $value ) );
115 return '+OK' === $r || 'OK' === $r;
116 }
117
118 public function setex( string $key, int $ttl, string $value ): bool {
119 $r = $this->command( array( 'SETEX', $key, (string) $ttl, $value ) );
120 return '+OK' === $r || 'OK' === $r;
121 }
122
123 /**
124 * Atomic add — SET ... NX, which stores only if the key does NOT exist.
125 * Returns true when stored, false when the key already existed (Redis
126 * replies nil → null here) or on error. With $ttl > 0 the EX option makes
127 * the store + expiry atomic. Used by the drop-in's wp_cache_add so add()
128 * honours its "fail if the key is present" contract across requests, not
129 * just the per-request runtime cache. (FBS-82111 Bug 2)
130 */
131 public function add( string $key, string $value, int $ttl = 0 ): bool {
132 $args = array( 'SET', $key, $value, 'NX' );
133 if ( $ttl > 0 ) {
134 $args[] = 'EX';
135 $args[] = (string) $ttl;
136 }
137 $r = $this->command( $args );
138 return '+OK' === $r || 'OK' === $r;
139 }
140
141 /** @return int Number of keys removed. */
142 public function del( string $key ): int {
143 return (int) $this->command( array( 'DEL', $key ) );
144 }
145
146 /** @return int|false New value, or false on error. */
147 public function incrBy( string $key, int $offset ) {
148 return $this->command( array( 'INCRBY', $key, (string) $offset ) );
149 }
150
151 /** @return int|false New value, or false on error. */
152 public function decrBy( string $key, int $offset ) {
153 return $this->command( array( 'DECRBY', $key, (string) $offset ) );
154 }
155
156 public function flushDB(): bool {
157 $r = $this->command( array( 'FLUSHDB' ) );
158 return '+OK' === $r || 'OK' === $r;
159 }
160
161 public function close(): void {
162 if ( is_resource( $this->sock ) && ! $this->persistent ) {
163 // 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.
164 @fclose( $this->sock ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort close on shutdown.
165 }
166 $this->sock = null;
167 }
168
169 // --- RESP protocol ------------------------------------------------------
170
171 /**
172 * Encode a command as a RESP array of bulk strings, write it, read one
173 * reply. Returns the decoded reply, or false on any socket error.
174 *
175 * @param string[] $args
176 * @return mixed
177 */
178 private function command( array $args ) {
179 if ( ! is_resource( $this->sock ) ) {
180 return false;
181 }
182
183 $payload = '*' . count( $args ) . "\r\n";
184 foreach ( $args as $a ) {
185 $a = (string) $a;
186 $payload .= '$' . strlen( $a ) . "\r\n" . $a . "\r\n";
187 }
188
189 // 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.
190 if ( false === @fwrite( $this->sock, $payload ) ) {
191 $this->sock = null;
192 return false;
193 }
194
195 return $this->read_reply();
196 }
197
198 /**
199 * Read and decode a single RESP reply from the socket.
200 *
201 * @return mixed string|int|null|array|false
202 */
203 private function read_reply() {
204 $line = $this->read_line();
205 if ( false === $line || '' === $line ) {
206 return false;
207 }
208
209 $type = $line[0];
210 $body = substr( $line, 1 );
211
212 switch ( $type ) {
213 case '+': // Simple string.
214 return $body;
215 case '-': // Error.
216 return false;
217 case ':': // Integer.
218 return (int) $body;
219 case '$': // Bulk string.
220 $len = (int) $body;
221 if ( $len < 0 ) {
222 return null; // Null bulk = key missing.
223 }
224 $data = $this->read_bytes( $len + 2 ); // +2 for trailing CRLF.
225 return false === $data ? false : substr( $data, 0, $len );
226 case '*': // Array.
227 $count = (int) $body;
228 if ( $count < 0 ) {
229 return null;
230 }
231 $out = array();
232 for ( $i = 0; $i < $count; $i++ ) {
233 $out[] = $this->read_reply();
234 }
235 return $out;
236 default:
237 return false;
238 }
239 }
240
241 /** Read one CRLF-terminated line (without the CRLF). */
242 private function read_line() {
243 if ( ! is_resource( $this->sock ) ) {
244 return false;
245 }
246 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgets, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading a line from the Redis TCP socket.
247 $line = @fgets( $this->sock );
248 if ( false === $line ) {
249 return false;
250 }
251 return rtrim( $line, "\r\n" );
252 }
253
254 /** Read exactly $n bytes from the socket. */
255 private function read_bytes( int $n ) {
256 if ( ! is_resource( $this->sock ) ) {
257 return false;
258 }
259 $buf = '';
260 while ( strlen( $buf ) < $n ) {
261 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading the bulk-string body from the Redis TCP socket.
262 $chunk = @fread( $this->sock, $n - strlen( $buf ) );
263 if ( false === $chunk || '' === $chunk ) {
264 $meta = stream_get_meta_data( $this->sock );
265 if ( ! empty( $meta['timed_out'] ) ) {
266 return false;
267 }
268 break;
269 }
270 $buf .= $chunk;
271 }
272 return $buf;
273 }
274 }
275