PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.0.4
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.0.4
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.4, at includes/class-redis-client.php

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