PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.3.1
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.3.1
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-memcached-client.php

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

244 lines 7.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * XSpeed_Memcached_Client — a minimal, dependency-free Memcached client.
4 *
5 * Speaks the Memcached text protocol directly over a TCP socket, so xSpeed's
6 * object cache can use Memcached WITHOUT the PHP `memcached`/`memcache`
7 * extension and WITHOUT bundling a library. Implements exactly the commands the
8 * object cache needs:
9 *
10 * set, get, delete, incr, decr, flush_all, version (ping)
11 *
12 * Counterpart to Redis_Client. Like it, this is intentionally minimal — one
13 * method per command — and never throws past connect(); failures return false
14 * so the object cache degrades gracefully instead of fataling the site.
15 *
16 * Protocol: https://github.com/memcached/memcached/blob/master/doc/protocol.txt
17 *
18 * @package XSpeed
19 */
20
21 declare(strict_types=1);
22
23 namespace XSpeed;
24
25 defined( 'ABSPATH' ) || exit;
26
27 class Memcached_Client {
28
29 /** @var resource|null */
30 private $sock = null;
31
32 /** @var string */
33 private $host;
34
35 /** @var int */
36 private $port;
37
38 /** @var float */
39 private $timeout;
40
41 public function __construct( string $host = '127.0.0.1', int $port = 11211, float $timeout = 1.0 ) {
42 $this->host = $host;
43 $this->port = $port;
44 $this->timeout = $timeout > 0 ? $timeout : 1.0;
45 }
46
47 public function connect(): bool {
48 $errno = 0;
49 $errstr = '';
50 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen, WordPress.PHP.NoSilencedErrors.Discouraged -- A raw stream socket is the only way to speak the Memcached protocol; WP_Filesystem cannot open TCP sockets. Errors are captured via $errno/$errstr and surfaced as a boolean.
51 $sock = @stream_socket_client(
52 "tcp://{$this->host}:{$this->port}",
53 $errno,
54 $errstr,
55 $this->timeout,
56 STREAM_CLIENT_CONNECT
57 );
58 if ( ! $sock ) {
59 // See class-redis-client.php: the `@`-suppressed warning still
60 // lingers in error_get_last(), which WP reads to tag the admin
61 // page `php-error` (empty banner above the menu). We handle the
62 // failure gracefully, so clear it.
63 if ( function_exists( 'error_clear_last' ) ) {
64 error_clear_last();
65 }
66 return false;
67 }
68 stream_set_timeout( $sock, (int) $this->timeout, (int) ( ( $this->timeout - (int) $this->timeout ) * 1000000 ) );
69 $this->sock = $sock;
70 return true;
71 }
72
73 public function is_connected(): bool {
74 return is_resource( $this->sock );
75 }
76
77 /** A liveness probe — returns the server version string, or false. */
78 public function version() {
79 if ( ! $this->write( "version\r\n" ) ) {
80 return false;
81 }
82 $line = $this->read_line();
83 // Reply: "VERSION 1.6.21".
84 if ( is_string( $line ) && 0 === strpos( $line, 'VERSION' ) ) {
85 return trim( substr( $line, 8 ) );
86 }
87 return false;
88 }
89
90 /**
91 * Store a value. $exptime is seconds (0 = never expire). Memcached caps the
92 * relative form at 30 days; beyond that it's treated as a unix timestamp —
93 * the object cache passes small TTLs so this is fine.
94 */
95 public function set( string $key, string $value, int $exptime = 0 ): bool {
96 $key = $this->sanitize_key( $key );
97 $bytes = strlen( $value );
98 $cmd = "set {$key} 0 {$exptime} {$bytes}\r\n{$value}\r\n";
99 if ( ! $this->write( $cmd ) ) {
100 return false;
101 }
102 return 'STORED' === $this->read_line();
103 }
104
105 /**
106 * Atomic add — stores only if the key does NOT already exist (the native
107 * memcached `add` storage command). Returns true on STORED, false on
108 * NOT_STORED (key present) or error. Used by the drop-in's wp_cache_add
109 * so it honours add semantics across requests, not just the runtime
110 * cache. (FBS-82111 Bug 2)
111 */
112 public function add( string $key, string $value, int $exptime = 0 ): bool {
113 $key = $this->sanitize_key( $key );
114 $bytes = strlen( $value );
115 $cmd = "add {$key} 0 {$exptime} {$bytes}\r\n{$value}\r\n";
116 if ( ! $this->write( $cmd ) ) {
117 return false;
118 }
119 return 'STORED' === $this->read_line();
120 }
121
122 /** @return string|false The value, or false when the key is missing. */
123 public function get( string $key ) {
124 $key = $this->sanitize_key( $key );
125 if ( ! $this->write( "get {$key}\r\n" ) ) {
126 return false;
127 }
128 $line = $this->read_line();
129 if ( ! is_string( $line ) || 0 !== strpos( $line, 'VALUE' ) ) {
130 return false; // END = miss.
131 }
132 // "VALUE <key> <flags> <bytes>".
133 $parts = explode( ' ', $line );
134 $bytes = isset( $parts[3] ) ? (int) $parts[3] : 0;
135 $data = $this->read_bytes( $bytes + 2 ); // +2 for trailing CRLF.
136 // Consume the trailing "END".
137 $this->read_line();
138 return false === $data ? false : substr( $data, 0, $bytes );
139 }
140
141 public function delete( string $key ): bool {
142 $key = $this->sanitize_key( $key );
143 if ( ! $this->write( "delete {$key}\r\n" ) ) {
144 return false;
145 }
146 $r = $this->read_line();
147 return 'DELETED' === $r || 'NOT_FOUND' === $r;
148 }
149
150 /** @return int|false New value, or false on error / missing key. */
151 public function incr( string $key, int $offset ) {
152 $key = $this->sanitize_key( $key );
153 if ( ! $this->write( "incr {$key} {$offset}\r\n" ) ) {
154 return false;
155 }
156 $r = $this->read_line();
157 return is_numeric( $r ) ? (int) $r : false;
158 }
159
160 /** @return int|false New value, or false on error / missing key. */
161 public function decr( string $key, int $offset ) {
162 $key = $this->sanitize_key( $key );
163 if ( ! $this->write( "decr {$key} {$offset}\r\n" ) ) {
164 return false;
165 }
166 $r = $this->read_line();
167 return is_numeric( $r ) ? (int) $r : false;
168 }
169
170 public function flush_all(): bool {
171 if ( ! $this->write( "flush_all\r\n" ) ) {
172 return false;
173 }
174 return 'OK' === $this->read_line();
175 }
176
177 public function close(): void {
178 if ( is_resource( $this->sock ) ) {
179 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing a raw TCP socket opened with stream_socket_client.
180 @fclose( $this->sock ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort close on shutdown.
181 }
182 $this->sock = null;
183 }
184
185 // --- Protocol primitives ------------------------------------------------
186
187 /**
188 * Memcached keys must be <=250 bytes and contain no control chars or
189 * spaces. The object cache's keys can contain colons/spaces via the salt,
190 * so hash anything risky to a safe fixed-length token.
191 */
192 private function sanitize_key( string $key ): string {
193 if ( strlen( $key ) > 250 || preg_match( '/[\x00-\x20\x7f]/', $key ) ) {
194 return 'xs_' . md5( $key );
195 }
196 return $key;
197 }
198
199 private function write( string $payload ): bool {
200 if ( ! is_resource( $this->sock ) ) {
201 return false;
202 }
203 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite, WordPress.PHP.NoSilencedErrors.Discouraged -- Writing to the Memcached TCP socket; WP_Filesystem has no socket transport. Failure returns false and the cache degrades.
204 $ok = @fwrite( $this->sock, $payload );
205 if ( false === $ok ) {
206 $this->sock = null;
207 return false;
208 }
209 return true;
210 }
211
212 private function read_line() {
213 if ( ! is_resource( $this->sock ) ) {
214 return false;
215 }
216 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgets, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading a line from the Memcached TCP socket.
217 $line = @fgets( $this->sock );
218 if ( false === $line ) {
219 return false;
220 }
221 return rtrim( $line, "\r\n" );
222 }
223
224 private function read_bytes( int $n ) {
225 if ( ! is_resource( $this->sock ) ) {
226 return false;
227 }
228 $buf = '';
229 while ( strlen( $buf ) < $n ) {
230 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading the value body from the Memcached TCP socket.
231 $chunk = @fread( $this->sock, $n - strlen( $buf ) );
232 if ( false === $chunk || '' === $chunk ) {
233 $meta = stream_get_meta_data( $this->sock );
234 if ( ! empty( $meta['timed_out'] ) ) {
235 return false;
236 }
237 break;
238 }
239 $buf .= $chunk;
240 }
241 return $buf;
242 }
243 }
244