PluginProbe
ezCache / 2.6.4
ezCache v2.6.4
2.6.5 2.6.4 2.6.2 2.6.3 2.6.1 2.6.0 2.5.6 2.5.5 2.5.4 2.5.3 2.5.2 2.5.1 2.5 2.2.1 2.2.2 trunk 1.2 1.2.1 1.2.2 1.2.3 1.2.4 1.3 1.3.1 1.3.10 1.3.11 All 49 releases
ezcache / includes / RedisObjectCache.php

RedisObjectCache.php in ezCache 2.6.4, at includes/RedisObjectCache.php

320 lines 12.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Redis Object Cache Manager
4 * @package Upress\EzCache
5 */
6 namespace Upress\EzCache;
7
8 class RedisObjectCache {
9 const HOST = '127.0.0.1';
10 const PORT = 6379;
11 const TIMEOUT = 1;
12
13 private static $dropin_path;
14 private static $template_path;
15
16 public static function init() {
17 self::$dropin_path = WP_CONTENT_DIR . '/object-cache.php';
18 self::$template_path = EZCACHE_DIR . '/includes/object-cache-dropin.php';
19 $settings = Settings::get_settings();
20 if ( ! empty( $settings->enable_redis_object_cache ) ) {
21 self::maybe_deploy_dropin();
22 }
23 }
24
25 public static function is_available() {
26 $fp = @fsockopen( self::HOST, self::PORT, $errno, $errstr, self::TIMEOUT );
27 if ( $fp ) { fclose( $fp ); return true; }
28 return false;
29 }
30
31 public static function maybe_deploy_dropin() {
32 if ( ! isset( self::$dropin_path ) ) {
33 self::$dropin_path = WP_CONTENT_DIR . '/object-cache.php';
34 self::$template_path = EZCACHE_DIR . '/includes/object-cache-dropin.php';
35 }
36 if ( file_exists( self::$dropin_path ) && ! self::is_our_dropin() ) {
37 return false;
38 }
39 if ( ! file_exists( self::$template_path ) ) {
40 self::write_dropin_file( self::$template_path );
41 }
42 return copy( self::$template_path, self::$dropin_path );
43 }
44
45 public static function remove_dropin() {
46 if ( ! isset( self::$dropin_path ) ) {
47 self::$dropin_path = WP_CONTENT_DIR . '/object-cache.php';
48 }
49 if ( file_exists( self::$dropin_path ) && self::is_our_dropin() ) {
50 return unlink( self::$dropin_path );
51 }
52 return false;
53 }
54
55 public static function is_our_dropin() {
56 if ( ! isset( self::$dropin_path ) ) {
57 self::$dropin_path = WP_CONTENT_DIR . '/object-cache.php';
58 }
59 if ( ! file_exists( self::$dropin_path ) ) { return false; }
60 $header = file_get_contents( self::$dropin_path, false, null, 0, 256 );
61 return strpos( $header, 'ezCache Redis Object Cache Drop-in' ) !== false;
62 }
63
64 public static function get_status() {
65 $settings = Settings::get_settings();
66 $enabled = ! empty( $settings->enable_redis_object_cache );
67 $available = self::is_available();
68 $connected = false;
69 $hit_rate = 0;
70 $memory = '--';
71 $keys = 0;
72 $dropin_active = file_exists( WP_CONTENT_DIR . '/object-cache.php' ) && self::is_our_dropin();
73
74 if ( $enabled && $available ) {
75 $connected = true;
76 $info = self::redis_info();
77 if ( $info ) {
78 $hits = isset( $info['keyspace_hits'] ) ? (int) $info['keyspace_hits'] : 0;
79 $misses = isset( $info['keyspace_misses'] ) ? (int) $info['keyspace_misses'] : 0;
80 $total = $hits + $misses;
81 $hit_rate = $total > 0 ? round( ( $hits / $total ) * 100, 1 ) : 0;
82 $memory = isset( $info['used_memory_human'] ) ? $info['used_memory_human'] : '--';
83 $keys = self::count_keys();
84 }
85 }
86
87 return [
88 'enabled' => $enabled,
89 'available' => $available,
90 'connected' => $connected,
91 'dropin_active' => $dropin_active,
92 'our_dropin' => self::is_our_dropin(),
93 'hit_rate' => $hit_rate,
94 'memory' => $memory,
95 'keys' => $keys,
96 ];
97 }
98
99 public static function flush() {
100 $redis = self::get_connection();
101 if ( ! $redis ) { return false; }
102 try {
103 self::delete_by_pattern( $redis, 'ezcache:*' );
104 return true;
105 } catch ( \Exception $e ) { return false; }
106 }
107
108 /**
109 * Delete every key matching a pattern without loading them all into memory.
110 *
111 * Iterates with a non-blocking SCAN cursor and removes keys in small batches
112 * via UNLINK (falling back to DEL when UNLINK is unavailable). This avoids the
113 * memory exhaustion and Redis-blocking behaviour of KEYS + bulk DEL, which can
114 * crash PHP with a fatal "memory size exhausted" error on sites that hold tens
115 * or hundreds of thousands of cache keys.
116 *
117 * @param \Redis|RedisFallbackSocket $redis
118 * @param string $pattern
119 * @return int Number of keys removed.
120 */
121 private static function delete_by_pattern( $redis, $pattern ) {
122 $removed = 0;
123 // UNLINK (non-blocking delete) is only used for the phpredis client; the
124 // raw-socket fallback sticks to DEL on small, already-batched key sets.
125 $use_unlink = ( $redis instanceof \Redis ) && method_exists( $redis, 'unlink' );
126
127 if ( $redis instanceof \Redis ) {
128 // SCAN_RETRY makes phpredis retry internally so scan() never returns an
129 // empty batch mid-iteration — otherwise an empty (falsy) batch could end
130 // the loop early and leave keys behind.
131 $redis->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
132 }
133
134 $iterator = null;
135 while ( ( $keys = $redis->scan( $iterator, $pattern, 500 ) ) !== false ) {
136 if ( ! empty( $keys ) ) {
137 if ( $use_unlink ) { $redis->unlink( $keys ); }
138 else { $redis->del( $keys ); }
139 $removed += count( $keys );
140 }
141 }
142
143 return $removed;
144 }
145
146 public static function get_page( $url ) {
147 $redis = self::get_connection();
148 if ( ! $redis ) { return false; }
149 try {
150 $data = $redis->get( 'ezcache:page:' . (defined('DB_NAME') ? DB_NAME . ':' : '') . md5( $url ) );
151 return $data !== false ? $data : false;
152 } catch ( \Exception $e ) { return false; }
153 }
154
155 public static function set_page( $url, $html, $ttl = 604800 ) {
156 $redis = self::get_connection();
157 if ( ! $redis ) { return false; }
158 try {
159 return (bool) $redis->setex( 'ezcache:page:' . (defined('DB_NAME') ? DB_NAME . ':' : '') . md5( $url ), $ttl, $html );
160 } catch ( \Exception $e ) { return false; }
161 }
162
163 public static function delete_page( $url ) {
164 $redis = self::get_connection();
165 if ( ! $redis ) { return false; }
166 try {
167 return (bool) $redis->del( [ 'ezcache:page:' . (defined('DB_NAME') ? DB_NAME . ':' : '') . md5( $url ) ] );
168 } catch ( \Exception $e ) { return false; }
169 }
170
171 private static function get_connection() {
172 static $conn = null;
173 if ( $conn !== null ) { return $conn; }
174 if ( class_exists( 'Redis' ) ) {
175 try {
176 $r = new \Redis();
177 $r->connect( self::HOST, self::PORT, self::TIMEOUT );
178 $conn = $r;
179 return $conn;
180 } catch ( \Exception $e ) { return false; }
181 }
182 $conn = new RedisFallbackSocket( self::HOST, self::PORT, self::TIMEOUT );
183 if ( ! $conn->connected() ) { $conn = false; }
184 return $conn;
185 }
186
187 private static function redis_info() {
188 $redis = self::get_connection();
189 if ( ! $redis ) { return false; }
190 try {
191 if ( class_exists( 'Redis' ) && $redis instanceof \Redis ) { return $redis->info(); }
192 return $redis->info();
193 } catch ( \Exception $e ) { return false; }
194 }
195
196 private static function count_keys() {
197 $redis = self::get_connection();
198 if ( ! $redis ) { return 0; }
199 try {
200 // Count via SCAN rather than KEYS so the dashboard status call never
201 // blocks Redis or builds a huge in-memory array on large sites.
202 if ( $redis instanceof \Redis ) {
203 $redis->setOption( \Redis::OPT_SCAN, \Redis::SCAN_RETRY );
204 }
205 $count = 0;
206 $iterator = null;
207 while ( ( $keys = $redis->scan( $iterator, 'ezcache:*', 500 ) ) !== false ) {
208 $count += count( $keys );
209 }
210 return $count;
211 } catch ( \Exception $e ) { return 0; }
212 }
213
214 private static function write_dropin_file( $path ) {
215 $content = self::dropin_source();
216 @file_put_contents( $path, $content );
217 }
218
219 private static function dropin_source() {
220 // Return the object-cache drop-in source (stored separately)
221 $src_path = EZCACHE_DIR . '/includes/object-cache-dropin.php';
222 if ( file_exists( $src_path ) ) {
223 return file_get_contents( $src_path );
224 }
225 return '';
226 }
227 }
228
229 /**
230 * Raw-socket Redis client for environments without the php-redis extension.
231 */
232 class RedisFallbackSocket {
233 private $socket = null;
234 private $host;
235 private $port;
236 private $timeout;
237
238 public function __construct( $host, $port, $timeout ) {
239 $this->host = $host;
240 $this->port = $port;
241 $this->timeout = $timeout;
242 $this->socket = @fsockopen( $host, $port, $errno, $errstr, $timeout );
243 if ( $this->socket ) {
244 stream_set_timeout( $this->socket, $timeout );
245 }
246 }
247
248 public function connected() { return (bool) $this->socket; }
249
250 private function send( ...$args ) {
251 if ( ! $this->socket ) { return false; }
252 $cmd = '*' . count( $args ) . "\r\n";
253 foreach ( $args as $a ) { $cmd .= '$' . strlen( $a ) . "\r\n" . $a . "\r\n"; }
254 fwrite( $this->socket, $cmd );
255 return $this->read_response();
256 }
257
258 private function read_response() {
259 $line = fgets( $this->socket );
260 if ( $line === false ) { return false; }
261 $type = $line[0];
262 $data = rtrim( substr( $line, 1 ) );
263 switch ( $type ) {
264 case '+': return $data;
265 case '-': return false;
266 case ':': return (int) $data;
267 case '$':
268 $len = (int) $data;
269 if ( $len === -1 ) { return false; }
270 $bulk = '';
271 while ( strlen( $bulk ) < $len + 2 ) { $bulk .= fread( $this->socket, $len + 2 - strlen( $bulk ) ); }
272 return rtrim( $bulk, "\r\n" );
273 case '*':
274 $count = (int) $data;
275 if ( $count === -1 ) { return []; }
276 $arr = [];
277 for ( $i = 0; $i < $count; $i++ ) { $arr[] = $this->read_response(); }
278 return $arr;
279 }
280 return false;
281 }
282
283 public function get( $key ) { return $this->send( 'GET', $key ); }
284 public function set( $key, $value ) { return $this->send( 'SET', $key, $value ); }
285 public function setex( $key, $ttl, $value ) { return $this->send( 'SETEX', $key, (string) $ttl, $value ); }
286 public function del( array $keys ) { return $this->send( ...array_merge( [ 'DEL' ], $keys ) ); }
287 public function keys( $pattern ) { $r = $this->send( 'KEYS', $pattern ); return is_array( $r ) ? $r : []; }
288
289 /**
290 * Cursor-based SCAN that mimics phpredis: the iterator is passed by reference,
291 * a batch (possibly empty) is returned each call, and false signals completion.
292 *
293 * @param int|null $iterator Pass null on the first call; 0 once iteration ends.
294 * @param string $pattern
295 * @param int $count
296 * @return array|false
297 */
298 public function scan( &$iterator, $pattern, $count = 500 ) {
299 // A 0 iterator (set after the final batch) means iteration is complete.
300 if ( $iterator === 0 ) { return false; }
301 $cursor = ( $iterator === null ) ? 0 : $iterator;
302 $r = $this->send( 'SCAN', (string) $cursor, 'MATCH', $pattern, 'COUNT', (string) $count );
303 if ( ! is_array( $r ) || count( $r ) < 2 ) { $iterator = 0; return false; }
304 $iterator = (int) $r[0];
305 return is_array( $r[1] ) ? $r[1] : [];
306 }
307 public function info() {
308 $raw = $this->send( 'INFO' );
309 if ( ! $raw ) { return false; }
310 $info = [];
311 foreach ( explode( "\r\n", $raw ) as $line ) {
312 if ( strpos( $line, ':' ) !== false ) {
313 list( $k, $v ) = explode( ':', $line, 2 );
314 $info[ trim( $k ) ] = trim( $v );
315 }
316 }
317 return $info;
318 }
319 }
320