1<?php 2 3/** 4 +-----------------------------------------------------------------------+ 5 | This file is part of the Roundcube Webmail client | 6 | | 7 | Copyright (C) The Roundcube Dev Team | 8 | Copyright (C) Kolab Systems AG | 9 | | 10 | Licensed under the GNU General Public License version 3 or | 11 | any later version with exceptions for skins & plugins. | 12 | See the README file for a full license statement. | 13 | | 14 | PURPOSE: | 15 | Caching engine | 16 +-----------------------------------------------------------------------+ 17 | Author: Thomas Bruederli <roundcube@gmail.com> | 18 | Author: Aleksander Machniak <alec@alec.pl> | 19 +-----------------------------------------------------------------------+ 20*/ 21 22/** 23 * Interface class for accessing Roundcube cache 24 * 25 * @package Framework 26 * @subpackage Cache 27 */ 28class rcube_cache 29{ 30 protected $type; 31 protected $userid; 32 protected $prefix; 33 protected $ttl; 34 protected $packed; 35 protected $indexed; 36 protected $index; 37 protected $index_update; 38 protected $cache = []; 39 protected $updates = []; 40 protected $exp_records = []; 41 protected $refresh_time = 0.5; // how often to refresh/save the index and cache entries 42 protected $debug = false; 43 protected $max_packet = -1; 44 45 const MAX_EXP_LEVEL = 2; 46 const DATE_FORMAT = 'Y-m-d H:i:s.u'; 47 const DATE_FORMAT_REGEX = '[0-9]{4}-[0-9]{2}-[0-9]{2} [0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{1,6}'; 48 49 50 /** 51 * Object factory 52 * 53 * @param string $type Engine type ('db', 'memcache', 'apc', 'redis') 54 * @param int $userid User identifier 55 * @param string $prefix Key name prefix 56 * @param string $ttl Expiration time of memcache/apc items 57 * @param bool $packed Enables/disabled data serialization. 58 * It's possible to disable data serialization if you're sure 59 * stored data will be always a safe string 60 * @param bool $indexed Use indexed cache. Indexed cache is more appropriate for 61 * storing big data with possibility to remove it by a key prefix. 62 * Non-indexed cache does not remove data, but flags it for expiration, 63 * also stores it in memory until close() method is called. 64 * 65 * @param rcube_cache Cache object 66 */ 67 public static function factory($type, $userid, $prefix = '', $ttl = 0, $packed = true, $indexed = false) 68 { 69 $driver = strtolower($type) ?: 'db'; 70 $class = "rcube_cache_$driver"; 71 72 if (!$driver || !class_exists($class)) { 73 rcube::raise_error([ 74 'code' => 600, 'type' => 'db', 75 'line' => __LINE__, 'file' => __FILE__, 76 'message' => "Configuration error. Unsupported cache driver: $driver" 77 ], 78 true, true 79 ); 80 } 81 82 return new $class($userid, $prefix, $ttl, $packed, $indexed); 83 } 84 85 /** 86 * Object constructor. 87 * 88 * @param int $userid User identifier 89 * @param string $prefix Key name prefix 90 * @param string $ttl Expiration time of memcache/apc items 91 * @param bool $packed Enables/disabled data serialization. 92 * It's possible to disable data serialization if you're sure 93 * stored data will be always a safe string 94 * @param bool $indexed Use indexed cache. Indexed cache is more appropriate for 95 * storing big data with possibility to remove it by key prefix. 96 * Non-indexed cache does not remove data, but flags it for expiration, 97 * also stores it in memory until close() method is called. 98 */ 99 public function __construct($userid, $prefix = '', $ttl = 0, $packed = true, $indexed = false) 100 { 101 $this->userid = (int) $userid; 102 $this->ttl = min(get_offset_sec($ttl), 2592000); 103 $this->prefix = $prefix; 104 $this->packed = $packed; 105 $this->indexed = $indexed; 106 } 107 108 /** 109 * Returns cached value. 110 * 111 * @param string $key Cache key name 112 * 113 * @return mixed Cached value 114 */ 115 public function get($key) 116 { 117 if (array_key_exists($key, $this->cache)) { 118 return $this->cache[$key]; 119 } 120 121 return $this->read_record($key); 122 } 123 124 /** 125 * Sets (add/update) value in cache. 126 * 127 * @param string $key Cache key name 128 * @param mixed $data Cache data 129 * 130 * @return bool True on success, False on failure 131 */ 132 public function set($key, $data) 133 { 134 return $this->write_record($key, $data); 135 } 136 137 /** 138 * @deprecated Use self::get() 139 */ 140 public function read($key) 141 { 142 return $this->get($key); 143 } 144 145 /** 146 * @deprecated Use self::set() 147 */ 148 public function write($key, $data) 149 { 150 return $this->set($key, $data); 151 } 152 153 /** 154 * Clears the cache. 155 * 156 * @param string $key Cache key name or pattern 157 * @param bool $prefix_mode Enable it to clear all keys starting 158 * with prefix specified in $key 159 */ 160 public function remove($key = null, $prefix_mode = false) 161 { 162 // Remove record(s) from the backend 163 $this->remove_record($key, $prefix_mode); 164 } 165 166 /** 167 * Remove cache records older than ttl 168 */ 169 public function expunge() 170 { 171 // to be overwritten by engine class 172 } 173 174 /** 175 * Remove expired records of all caches 176 */ 177 public static function gc() 178 { 179 // Only DB cache requires an action to remove expired entries 180 rcube_cache_db::gc(); 181 } 182 183 /** 184 * Writes the cache back to the DB. 185 */ 186 public function close() 187 { 188 $this->write_index(true); 189 $this->index = null; 190 $this->cache = []; 191 $this->updates = []; 192 } 193 194 /** 195 * A helper to build cache key for specified parameters. 196 * 197 * @param string $prefix Key prefix (Max. length 64 characters) 198 * @param array $params Additional parameters 199 * 200 * @return string Key name 201 */ 202 public static function key_name($prefix, $params = []) 203 { 204 $cache_key = $prefix; 205 206 if (!empty($params)) { 207 $func = function($v) { 208 if (is_array($v)) { 209 sort($v); 210 } 211 return is_string($v) ? $v : serialize($v); 212 }; 213 214 $params = array_map($func, $params); 215 $cache_key .= '.' . md5(implode(':', $params)); 216 } 217 218 return $cache_key; 219 } 220 221 /** 222 * Reads cache entry. 223 * 224 * @param string $key Cache key name 225 * 226 * @return mixed Cached value 227 */ 228 protected function read_record($key) 229 { 230 $this->load_index(); 231 232 // Consistency check (#1490390) 233 if (is_array($this->index) && !in_array($key, $this->index)) { 234 // we always check if the key exist in the index 235 // to have data in consistent state. Keeping the index consistent 236 // is needed for keys delete operation when we delete all keys or by prefix. 237 return; 238 } 239 240 $ckey = $this->ckey($key); 241 $data = $this->get_item($ckey); 242 243 if ($this->indexed) { 244 return $data !== false ? $this->unserialize($data) : null; 245 } 246 247 if ($data !== false) { 248 $timestamp = 0; 249 $utc = new DateTimeZone('UTC'); 250 251 // Extract timestamp from the data entry 252 if (preg_match('/^(' . self::DATE_FORMAT_REGEX . '):/', $data, $matches)) { 253 try { 254 $timestamp = new DateTime($matches[1], $utc); 255 $data = substr($data, strlen($matches[1]) + 1); 256 } 257 catch (Exception $e) { 258 // invalid date = no timestamp 259 } 260 } 261 262 // Check if the entry is still valid by comparing with EXP timestamps 263 // For example for key 'mailboxes.123456789' we check entries: 264 // 'EXP:*', 'EXP:mailboxes' and 'EXP:mailboxes.123456789'. 265 if ($timestamp) { 266 $path = explode('.', "*.$key"); 267 $path_len = min(self::MAX_EXP_LEVEL + 1, count($path)); 268 269 for ($x = 1; $x <= $path_len; $x++) { 270 $prefix = implode('.', array_slice($path, 0, $x)); 271 if ($x > 1) { 272 $prefix = substr($prefix, 2); // remove "*." prefix 273 } 274 275 if (($ts = $this->get_exp_timestamp($prefix)) && $ts > $timestamp) { 276 $timestamp = 0; 277 break; 278 } 279 } 280 } 281 282 $data = $timestamp ? $this->unserialize($data) : null; 283 } 284 else { 285 $data = null; 286 } 287 288 return $this->cache[$key] = $data; 289 } 290 291 /** 292 * Writes single cache record into DB. 293 * 294 * @param string $key Cache key name 295 * @param mixed $data Serialized cache data 296 * 297 * @return bool True on success, False on failure 298 */ 299 protected function write_record($key, $data) 300 { 301 if ($this->indexed) { 302 $result = $this->store_record($key, $data); 303 304 if ($result) { 305 $this->load_index(); 306 $this->index[] = $key; 307 308 if (!$this->index_update) { 309 $this->index_update = time(); 310 } 311 } 312 } 313 else { 314 // In this mode we do not save the entry to the database immediately 315 // It's because we have cases where the same entry is updated 316 // multiple times in one request (e.g. 'messagecount' entry rcube_imap). 317 $this->updates[$key] = new DateTime('now', new DateTimeZone('UTC')); 318 $this->cache[$key] = $data; 319 $result = true; 320 } 321 322 $this->write_index(); 323 324 return $result; 325 } 326 327 /** 328 * Deletes the cache record(s). 329 * 330 * @param string $key Cache key name or pattern 331 * @param boolean $prefix_mode Enable it to clear all keys starting 332 * with prefix specified in $key 333 */ 334 protected function remove_record($key = null, $prefix_mode = false) 335 { 336 if ($this->indexed) { 337 return $this->remove_record_indexed($key, $prefix_mode); 338 } 339 340 // "Remove" all keys 341 if ($key === null) { 342 $ts = new DateTime('now', new DateTimeZone('UTC')); 343 $this->add_item($this->ekey('*'), $ts->format(self::DATE_FORMAT)); 344 $this->cache = []; 345 } 346 // "Remove" keys by name prefix 347 else if ($prefix_mode) { 348 $ts = new DateTime('now', new DateTimeZone('UTC')); 349 $prefix = implode('.', array_slice(explode('.', trim($key, '. ')), 0, self::MAX_EXP_LEVEL)); 350 351 $this->add_item($this->ekey($prefix), $ts->format(self::DATE_FORMAT)); 352 353 foreach (array_keys($this->cache) as $k) { 354 if (strpos($k, $key) === 0) { 355 $this->cache[$k] = null; 356 } 357 } 358 } 359 // Remove one key by name 360 else { 361 $this->delete_item($this->ckey($key)); 362 $this->cache[$key] = null; 363 } 364 } 365 366 /** 367 * @see self::remove_record() 368 */ 369 protected function remove_record_indexed($key = null, $prefix_mode = false) 370 { 371 $this->load_index(); 372 373 // Remove all keys 374 if ($key === null) { 375 foreach ($this->index as $key) { 376 $this->delete_item($this->ckey($key)); 377 if (!$this->index_update) { 378 $this->index_update = time(); 379 } 380 } 381 382 $this->index = []; 383 } 384 // Remove keys by name prefix 385 else if ($prefix_mode) { 386 foreach ($this->index as $idx => $k) { 387 if (strpos($k, $key) === 0) { 388 $this->delete_item($this->ckey($k)); 389 unset($this->index[$idx]); 390 if (!$this->index_update) { 391 $this->index_update = time(); 392 } 393 } 394 } 395 } 396 // Remove one key by name 397 else { 398 $this->delete_item($this->ckey($key)); 399 if (($idx = array_search($key, $this->index)) !== false) { 400 unset($this->index[$idx]); 401 if (!$this->index_update) { 402 $this->index_update = time(); 403 } 404 } 405 } 406 407 $this->write_index(); 408 } 409 410 /** 411 * Writes the index entry as well as updated entries into memcache/apc/redis DB. 412 */ 413 protected function write_index($force = null) 414 { 415 // Write updated/new entries when needed 416 if (!$this->indexed) { 417 $need_update = $force === true; 418 419 if (!$need_update && !empty($this->updates)) { 420 $now = new DateTime('now', new DateTimeZone('UTC')); 421 $need_update = floatval(min($this->updates)->format('U.u')) < floatval($now->format('U.u')) - $this->refresh_time; 422 } 423 424 if ($need_update) { 425 foreach ($this->updates as $key => $ts) { 426 if (isset($this->cache[$key])) { 427 $this->store_record($key, $this->cache[$key], $ts); 428 } 429 } 430 431 $this->updates = []; 432 } 433 } 434 // Write index entry when needed 435 else { 436 $need_update = $this->index_update && $this->index !== null 437 && ($force === true || $this->index_update > time() - $this->refresh_time); 438 439 if ($need_update) { 440 $index = serialize(array_values(array_unique($this->index))); 441 442 $this->add_item($this->ikey(), $index); 443 $this->index_update = null; 444 $this->index = null; 445 } 446 } 447 } 448 449 /** 450 * Gets the index entry from memcache/apc/redis DB. 451 */ 452 protected function load_index() 453 { 454 if (!$this->indexed) { 455 return; 456 } 457 458 if ($this->index !== null) { 459 return; 460 } 461 462 $data = $this->get_item($this->ikey()); 463 $this->index = $data ? unserialize($data) : []; 464 } 465 466 /** 467 * Write data entry into cache 468 */ 469 protected function store_record($key, $data, $ts = null) 470 { 471 $value = $this->serialize($data); 472 473 if (!$this->indexed) { 474 if (!$ts) { 475 $ts = new DateTime('now', new DateTimeZone('UTC')); 476 } 477 478 $value = $ts->format(self::DATE_FORMAT) . ':' . $value; 479 } 480 481 $size = strlen($value); 482 483 // don't attempt to write too big data sets 484 if ($size > $this->max_packet_size()) { 485 trigger_error("rcube_cache: max_packet_size ($this->max_packet) exceeded for key $key. Tried to write $size bytes", E_USER_WARNING); 486 return false; 487 } 488 489 return $this->add_item($this->ckey($key), $value); 490 } 491 492 /** 493 * Fetches cache entry. 494 * 495 * @param string $key Cache internal key name 496 * 497 * @return mixed Cached value 498 */ 499 protected function get_item($key) 500 { 501 // to be overwritten by engine class 502 } 503 504 /** 505 * Adds entry into memcache/apc/redis DB. 506 * 507 * @param string $key Cache internal key name 508 * @param mixed $data Serialized cache data 509 * 510 * @param bool True on success, False on failure 511 */ 512 protected function add_item($key, $data) 513 { 514 // to be overwritten by engine class 515 } 516 517 /** 518 * Deletes entry from memcache/apc/redis DB. 519 * 520 * @param string $key Cache internal key name 521 * 522 * @param bool True on success, False on failure 523 */ 524 protected function delete_item($key) 525 { 526 // to be overwritten by engine class 527 } 528 529 /** 530 * Get EXP:<key> record value from cache 531 */ 532 protected function get_exp_timestamp($key) 533 { 534 if (!array_key_exists($key, $this->exp_records)) { 535 $data = $this->get_item($this->ekey($key)); 536 537 $this->exp_records[$key] = $data ? new DateTime($data, new DateTimeZone('UTC')) : null; 538 } 539 540 return $this->exp_records[$key]; 541 } 542 543 /** 544 * Creates per-user index cache key name (for memcache, apc, redis) 545 * 546 * @return string Cache key 547 */ 548 protected function ikey() 549 { 550 $key = $this->prefix . 'INDEX'; 551 552 if ($this->userid) { 553 $key = $this->userid . ':' . $key; 554 } 555 556 return $key; 557 } 558 559 /** 560 * Creates per-user cache key name (for memcache, apc, redis) 561 * 562 * @param string $key Cache key name 563 * 564 * @return string Cache key 565 */ 566 protected function ckey($key) 567 { 568 $key = $this->prefix . ':' . $key; 569 570 if ($this->userid) { 571 $key = $this->userid . ':' . $key; 572 } 573 574 return $key; 575 } 576 577 /** 578 * Creates per-user cache key name for expiration time entry 579 * 580 * @param string $key Cache key name 581 * 582 * @return string Cache key 583 */ 584 protected function ekey($key, $prefix = null) 585 { 586 $key = $this->prefix . 'EXP:' . $key; 587 588 if ($this->userid) { 589 $key = $this->userid . ':' . $key; 590 } 591 592 return $key; 593 } 594 595 /** 596 * Serializes data for storing 597 */ 598 protected function serialize($data) 599 { 600 return $this->packed ? serialize($data) : $data; 601 } 602 603 /** 604 * Unserializes serialized data 605 */ 606 protected function unserialize($data) 607 { 608 return $this->packed ? @unserialize($data) : $data; 609 } 610 611 /** 612 * Determine the maximum size for cache data to be written 613 */ 614 protected function max_packet_size() 615 { 616 if ($this->max_packet < 0) { 617 $config = rcube::get_instance()->config; 618 $max_packet = $config->get($this->type . '_max_allowed_packet'); 619 $this->max_packet = parse_bytes($max_packet) ?: 2097152; // default/max is 2 MB 620 } 621 622 return $this->max_packet; 623 } 624 625 /** 626 * Write memcache/apc/redis debug info to the log 627 */ 628 protected function debug($type, $key, $data = null, $result = null) 629 { 630 $line = strtoupper($type) . ' ' . $key; 631 632 if ($data !== null) { 633 $line .= ' ' . ($this->packed ? $data : serialize($data)); 634 } 635 636 rcube::debug($this->type, $line, $result); 637 } 638} 639