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.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-memcached-client.php

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

237 lines 7.4 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 return false;
60 }
61 stream_set_timeout( $sock, (int) $this->timeout, (int) ( ( $this->timeout - (int) $this->timeout ) * 1000000 ) );
62 $this->sock = $sock;
63 return true;
64 }
65
66 public function is_connected(): bool {
67 return is_resource( $this->sock );
68 }
69
70 /** A liveness probe — returns the server version string, or false. */
71 public function version() {
72 if ( ! $this->write( "version\r\n" ) ) {
73 return false;
74 }
75 $line = $this->read_line();
76 // Reply: "VERSION 1.6.21".
77 if ( is_string( $line ) && 0 === strpos( $line, 'VERSION' ) ) {
78 return trim( substr( $line, 8 ) );
79 }
80 return false;
81 }
82
83 /**
84 * Store a value. $exptime is seconds (0 = never expire). Memcached caps the
85 * relative form at 30 days; beyond that it's treated as a unix timestamp —
86 * the object cache passes small TTLs so this is fine.
87 */
88 public function set( string $key, string $value, int $exptime = 0 ): bool {
89 $key = $this->sanitize_key( $key );
90 $bytes = strlen( $value );
91 $cmd = "set {$key} 0 {$exptime} {$bytes}\r\n{$value}\r\n";
92 if ( ! $this->write( $cmd ) ) {
93 return false;
94 }
95 return 'STORED' === $this->read_line();
96 }
97
98 /**
99 * Atomic add — stores only if the key does NOT already exist (the native
100 * memcached `add` storage command). Returns true on STORED, false on
101 * NOT_STORED (key present) or error. Used by the drop-in's wp_cache_add
102 * so it honours add semantics across requests, not just the runtime
103 * cache. (FBS-82111 Bug 2)
104 */
105 public function add( string $key, string $value, int $exptime = 0 ): bool {
106 $key = $this->sanitize_key( $key );
107 $bytes = strlen( $value );
108 $cmd = "add {$key} 0 {$exptime} {$bytes}\r\n{$value}\r\n";
109 if ( ! $this->write( $cmd ) ) {
110 return false;
111 }
112 return 'STORED' === $this->read_line();
113 }
114
115 /** @return string|false The value, or false when the key is missing. */
116 public function get( string $key ) {
117 $key = $this->sanitize_key( $key );
118 if ( ! $this->write( "get {$key}\r\n" ) ) {
119 return false;
120 }
121 $line = $this->read_line();
122 if ( ! is_string( $line ) || 0 !== strpos( $line, 'VALUE' ) ) {
123 return false; // END = miss.
124 }
125 // "VALUE <key> <flags> <bytes>".
126 $parts = explode( ' ', $line );
127 $bytes = isset( $parts[3] ) ? (int) $parts[3] : 0;
128 $data = $this->read_bytes( $bytes + 2 ); // +2 for trailing CRLF.
129 // Consume the trailing "END".
130 $this->read_line();
131 return false === $data ? false : substr( $data, 0, $bytes );
132 }
133
134 public function delete( string $key ): bool {
135 $key = $this->sanitize_key( $key );
136 if ( ! $this->write( "delete {$key}\r\n" ) ) {
137 return false;
138 }
139 $r = $this->read_line();
140 return 'DELETED' === $r || 'NOT_FOUND' === $r;
141 }
142
143 /** @return int|false New value, or false on error / missing key. */
144 public function incr( string $key, int $offset ) {
145 $key = $this->sanitize_key( $key );
146 if ( ! $this->write( "incr {$key} {$offset}\r\n" ) ) {
147 return false;
148 }
149 $r = $this->read_line();
150 return is_numeric( $r ) ? (int) $r : false;
151 }
152
153 /** @return int|false New value, or false on error / missing key. */
154 public function decr( string $key, int $offset ) {
155 $key = $this->sanitize_key( $key );
156 if ( ! $this->write( "decr {$key} {$offset}\r\n" ) ) {
157 return false;
158 }
159 $r = $this->read_line();
160 return is_numeric( $r ) ? (int) $r : false;
161 }
162
163 public function flush_all(): bool {
164 if ( ! $this->write( "flush_all\r\n" ) ) {
165 return false;
166 }
167 return 'OK' === $this->read_line();
168 }
169
170 public function close(): void {
171 if ( is_resource( $this->sock ) ) {
172 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Closing a raw TCP socket opened with stream_socket_client.
173 @fclose( $this->sock ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- best-effort close on shutdown.
174 }
175 $this->sock = null;
176 }
177
178 // --- Protocol primitives ------------------------------------------------
179
180 /**
181 * Memcached keys must be <=250 bytes and contain no control chars or
182 * spaces. The object cache's keys can contain colons/spaces via the salt,
183 * so hash anything risky to a safe fixed-length token.
184 */
185 private function sanitize_key( string $key ): string {
186 if ( strlen( $key ) > 250 || preg_match( '/[\x00-\x20\x7f]/', $key ) ) {
187 return 'xs_' . md5( $key );
188 }
189 return $key;
190 }
191
192 private function write( string $payload ): bool {
193 if ( ! is_resource( $this->sock ) ) {
194 return false;
195 }
196 // 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.
197 $ok = @fwrite( $this->sock, $payload );
198 if ( false === $ok ) {
199 $this->sock = null;
200 return false;
201 }
202 return true;
203 }
204
205 private function read_line() {
206 if ( ! is_resource( $this->sock ) ) {
207 return false;
208 }
209 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fgets, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading a line from the Memcached TCP socket.
210 $line = @fgets( $this->sock );
211 if ( false === $line ) {
212 return false;
213 }
214 return rtrim( $line, "\r\n" );
215 }
216
217 private function read_bytes( int $n ) {
218 if ( ! is_resource( $this->sock ) ) {
219 return false;
220 }
221 $buf = '';
222 while ( strlen( $buf ) < $n ) {
223 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread, WordPress.PHP.NoSilencedErrors.Discouraged -- Reading the value body from the Memcached TCP socket.
224 $chunk = @fread( $this->sock, $n - strlen( $buf ) );
225 if ( false === $chunk || '' === $chunk ) {
226 $meta = stream_get_meta_data( $this->sock );
227 if ( ! empty( $meta['timed_out'] ) ) {
228 return false;
229 }
230 break;
231 }
232 $buf .= $chunk;
233 }
234 return $buf;
235 }
236 }
237