PluginProbe
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN / 1.4.2
xSpeed Cache: AI-Powered Performance Hub with MCP, Caching & CDN v1.4.2
1.4.2 1.4.1 1.4.0 1.3.7 1.3.6 1.3.5 1.3.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 All 36 releases
← All changes | includes/class-redis-client.php +83 -3 1.0.4 → 1.4.2 View file →
@@ -1,9 +1,9 @@
1 1 <?php
2 2 /**
3 3 * XSpeed_Redis_Client — a minimal, dependency-free Redis client.
4 4 *
5 - * Speaks the Redis wire protocol (RESP) directly over a TCP socket. It exists
5 + * Speaks the Redis wire protocol (RESP) directly over a TCP or unix socket. It exists
6 6 * so xSpeed's object cache can talk to Redis WITHOUT the phpredis extension and
7 7 * WITHOUT bundling a heavyweight library (Predis ships 700+ files for cluster /
8 8 * sentinel / pub-sub / transactions we never use). This implements exactly the
9 9 * commands the object cache needs and nothing more:
@@ -49,8 +49,30 @@
49 49 $this->persistent = $persistent;
50 50 }
51 51
52 52 /**
53 + * The unix socket path a Redis host names, or '' for a network host.
54 + *
55 + * Hosts that run Redis on a socket only (Hetzner managed servers, shared
56 + * LiteSpeed hosting) hand out a path rather than an address, written as
57 + * `/run/redis/redis.sock`, `unix:/run/redis/redis.sock` (Hetzner's own
58 + * docs) or `unix:///run/redis/redis.sock`. A socket has no port, so the
59 + * caller ignores the configured one when this returns a path. (#566)
60 + *
61 + * The drop-in carries a copy as xspeed_oc_redis_socket(), because it loads
62 + * before this class can; ObjectCacheSocketTest runs both over the same
63 + * inputs so they cannot drift.
64 + */
65 + public static function socket_path( string $host ): string {
66 + $host = trim( $host );
67 + if ( 0 === stripos( $host, 'unix:' ) ) {
68 + // unix:///path and unix:/path both name /path.
69 + $host = '/' . ltrim( substr( $host, 5 ), '/' );
70 + }
71 + return ( '' !== $host && '/' === $host[0] && '/' !== $host ) ? $host : '';
72 + }
73 +
74 + /**
53 75 * Open the socket. Returns true on success. Never throws — callers check
54 76 * the boolean and fall back to a non-persistent cache on failure.
55 77 */
56 78 public function connect(): bool {
@@ -56,11 +78,12 @@
56 78 public function connect(): bool {
57 79 $flags = STREAM_CLIENT_CONNECT | ( $this->persistent ? STREAM_CLIENT_PERSISTENT : 0 );
58 80 $errno = 0;
59 81 $errstr = '';
82 + $path = self::socket_path( $this->host );
60 83 // 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 84 $sock = @stream_socket_client(
62 - "tcp://{$this->host}:{$this->port}",
85 + '' !== $path ? "unix://{$path}" : "tcp://{$this->host}:{$this->port}",
63 86 $errno,
64 87 $errstr,
65 88 $this->timeout,
66 89 $flags
@@ -65,8 +88,19 @@
65 88 $this->timeout,
66 89 $flags
67 90 );
68 91 if ( ! $sock ) {
92 + // The `@` above hides the warning from output/logs, but a failed
93 + // connect (e.g. an unreachable/unresolvable host) still leaves a
94 + // PHP warning in `error_get_last()`. WP reads that at
95 + // `admin_body_class` time and tags the page `php-error` — which
96 + // renders an empty banner above the admin menu even though we
97 + // handle the failure gracefully (caller falls back to a
98 + // non-persistent cache). Clear it so a degraded-but-handled Redis
99 + // backend doesn't masquerade as a site error.
100 + if ( function_exists( 'error_clear_last' ) ) {
101 + error_clear_last();
102 + }
69 103 return false;
70 104 }
71 105 stream_set_timeout( $sock, (int) $this->timeout, (int) ( ( $this->timeout - (int) $this->timeout ) * 1000000 ) );
72 106 $this->sock = $sock;
@@ -78,9 +112,23 @@
78 112 }
79 113
80 114 // --- Commands -----------------------------------------------------------
81 115
82 - public function auth( string $password ) {
116 + /**
117 + * Authenticate. With a username (Redis 6+ ACL) emit the two-argument
118 + * `AUTH <user> <pass>`; without one fall back to the legacy
119 + * single-argument `AUTH <pass>` (which authenticates as the built-in
120 + * `default` user). Managed hosts that provision a dedicated ACL user
121 + * and disable `default` reject the one-arg form with WRONGPASS, so the
122 + * username must be threaded through here. (FBS-83118)
123 + *
124 + * @param string $password Redis password.
125 + * @param string $username Optional ACL username; '' = legacy default user.
126 + */
127 + public function auth( string $password, string $username = '' ) {
128 + if ( '' !== $username ) {
129 + return $this->command( array( 'AUTH', $username, $password ) );
130 + }
83 131 return $this->command( array( 'AUTH', $password ) );
84 132 }
85 133
86 134 public function select( int $db ) {
@@ -144,8 +192,40 @@
144 192
145 193 public function flushDB(): bool {
146 194 $r = $this->command( array( 'FLUSHDB' ) );
147 195 return '+OK' === $r || 'OK' === $r;
196 + }
197 +
198 + /**
199 + * Delete every key matching a glob-style pattern, using a non-blocking
200 + * SCAN cursor (never KEYS, which blocks the whole server on large
201 + * datasets). Returns the number of keys deleted. Used for prefix-scoped
202 + * flushes so we only ever touch this site's namespace, never the whole
203 + * Redis DB. (FBS-83119)
204 + *
205 + * @param string $pattern e.g. "salt:*" or "salt:*:options:*".
206 + * @param int $count SCAN COUNT hint (batch size per round trip).
207 + */
208 + public function delete_by_pattern( string $pattern, int $count = 500 ): int {
209 + $deleted = 0;
210 + $cursor = '0';
211 + do {
212 + $reply = $this->command( array( 'SCAN', $cursor, 'MATCH', $pattern, 'COUNT', (string) $count ) );
213 + // Expected: [ next_cursor, [ key, key, ... ] ]. Anything else
214 + // (false on socket error, malformed) ends the loop safely.
215 + if ( ! is_array( $reply ) || count( $reply ) < 2 || ! is_array( $reply[1] ) ) {
216 + break;
217 + }
218 + $cursor = (string) $reply[0];
219 + $keys = $reply[1];
220 + if ( ! empty( $keys ) ) {
221 + // DEL accepts variadic keys — one round trip per batch.
222 + $args = array_merge( array( 'DEL' ), array_map( 'strval', $keys ) );
223 + $n = $this->command( $args );
224 + $deleted += is_int( $n ) ? $n : 0;
225 + }
226 + } while ( '0' !== $cursor );
227 + return $deleted;
148 228 }
149 229
150 230 public function close(): void {
151 231 if ( is_resource( $this->sock ) && ! $this->persistent ) {