Aleksander Machniak
2016-01-19 bffca14d964091b3256868bc42bcb9417a72629b
commit | author | age
50abd5 1 <?php
AM 2
a95874 3 /**
50abd5 4  +-----------------------------------------------------------------------+
AM 5  | This file is part of the Roundcube Webmail client                     |
6  | Copyright (C) 2011-2013, The Roundcube Dev Team                       |
7  | Copyright (C) 2011-2013, Kolab Systems AG                             |
8  |                                                                       |
9  | Licensed under the GNU General Public License version 3 or            |
10  | any later version with exceptions for skins & plugins.                |
11  | See the README file for a full license statement.                     |
12  |                                                                       |
13  | PURPOSE:                                                              |
14  |   Shared (cross-user) caching engine                                  |
15  +-----------------------------------------------------------------------+
16  | Author: Thomas Bruederli <roundcube@gmail.com>                        |
17  | Author: Aleksander Machniak <alec@alec.pl>                            |
18  +-----------------------------------------------------------------------+
19 */
20
21 /**
22  * Interface class for accessing Roundcube shared cache
23  *
24  * @package    Framework
25  * @subpackage Cache
26  * @author     Thomas Bruederli <roundcube@gmail.com>
27  * @author     Aleksander Machniak <alec@alec.pl>
28  */
b3e342 29 class rcube_cache_shared
50abd5 30 {
AM 31     /**
32      * Instance of database handler
33      *
34      * @var rcube_db|Memcache|bool
35      */
36     private $db;
37     private $type;
38     private $prefix;
39     private $ttl;
40     private $packed;
41     private $index;
42     private $table;
44708e 43     private $debug;
b1e35a 44     private $index_changed = false;
50abd5 45     private $cache         = array();
AM 46     private $cache_changes = array();
47     private $cache_sums    = array();
81d4ff 48     private $max_packet    = -1;
44708e 49
50abd5 50
AM 51     /**
52      * Object constructor.
53      *
54      * @param string $type   Engine type ('db' or 'memcache' or 'apc')
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      */
61     function __construct($type, $prefix='', $ttl=0, $packed=true)
62     {
63         $rcube = rcube::get_instance();
64         $type  = strtolower($type);
65
66         if ($type == 'memcache') {
44708e 67             $this->type  = 'memcache';
AM 68             $this->db    = $rcube->get_memcache();
69             $this->debug = $rcube->config->get('memcache_debug');
50abd5 70         }
AM 71         else if ($type == 'apc') {
44708e 72             $this->type  = 'apc';
AM 73             $this->db    = function_exists('apc_exists'); // APC 3.1.4 required
74             $this->debug = $rcube->config->get('apc_debug');
50abd5 75         }
AM 76         else {
77             $this->type  = 'db';
78             $this->db    = $rcube->get_dbh();
34a090 79             $this->table = $this->db->table_name('cache_shared', true);
50abd5 80         }
AM 81
82         // convert ttl string to seconds
83         $ttl = get_offset_sec($ttl);
84         if ($ttl > 2592000) $ttl = 2592000;
85
86         $this->ttl       = $ttl;
87         $this->packed    = $packed;
88         $this->prefix    = $prefix;
89     }
90
91     /**
92      * Returns cached value.
93      *
94      * @param string $key Cache key name
95      *
96      * @return mixed Cached value
97      */
98     function get($key)
99     {
100         if (!array_key_exists($key, $this->cache)) {
101             return $this->read_record($key);
102         }
103
104         return $this->cache[$key];
105     }
106
107     /**
108      * Sets (add/update) value in cache.
109      *
110      * @param string $key  Cache key name
111      * @param mixed  $data Cache data
112      */
113     function set($key, $data)
114     {
115         $this->cache[$key]         = $data;
116         $this->cache_changes[$key] = true;
117     }
118
119     /**
120      * Returns cached value without storing it in internal memory.
121      *
122      * @param string $key Cache key name
123      *
124      * @return mixed Cached value
125      */
126     function read($key)
127     {
128         if (array_key_exists($key, $this->cache)) {
129             return $this->cache[$key];
130         }
131
132         return $this->read_record($key, true);
133     }
134
135     /**
136      * Sets (add/update) value in cache and immediately saves
137      * it in the backend, no internal memory will be used.
138      *
139      * @param string $key  Cache key name
140      * @param mixed  $data Cache data
141      *
142      * @param boolean True on success, False on failure
143      */
144     function write($key, $data)
145     {
a6b0ca 146         return $this->write_record($key, $this->serialize($data));
50abd5 147     }
AM 148
149     /**
150      * Clears the cache.
151      *
152      * @param string  $key         Cache key name or pattern
153      * @param boolean $prefix_mode Enable it to clear all keys starting
154      *                             with prefix specified in $key
155      */
156     function remove($key=null, $prefix_mode=false)
157     {
158         // Remove all keys
159         if ($key === null) {
160             $this->cache         = array();
161             $this->cache_changes = array();
162             $this->cache_sums    = array();
163         }
164         // Remove keys by name prefix
165         else if ($prefix_mode) {
166             foreach (array_keys($this->cache) as $k) {
167                 if (strpos($k, $key) === 0) {
168                     $this->cache[$k] = null;
169                     $this->cache_changes[$k] = false;
170                     unset($this->cache_sums[$k]);
171                 }
172             }
173         }
174         // Remove one key by name
175         else {
176             $this->cache[$key] = null;
177             $this->cache_changes[$key] = false;
178             unset($this->cache_sums[$key]);
179         }
180
181         // Remove record(s) from the backend
182         $this->remove_record($key, $prefix_mode);
183     }
184
185     /**
186      * Remove cache records older than ttl
187      */
188     function expunge()
189     {
00cb22 190         if ($this->type == 'db' && $this->db && $this->ttl) {
50abd5 191             $this->db->query(
34a090 192                 "DELETE FROM {$this->table}"
AM 193                 . " WHERE `cache_key` LIKE ?"
194                 . " AND `expires` < " . $this->db->now(),
60b6d7 195                 $this->prefix . '.%');
50abd5 196         }
60b6d7 197     }
AM 198
199     /**
200      * Remove expired records of all caches
201      */
202     static function gc()
203     {
204         $rcube = rcube::get_instance();
205         $db    = $rcube->get_dbh();
206
34a090 207         $db->query("DELETE FROM " . $db->table_name('cache_shared', true) . " WHERE `expires` < " . $db->now());
50abd5 208     }
AM 209
210     /**
211      * Writes the cache back to the DB.
212      */
213     function close()
214     {
215         foreach ($this->cache as $key => $data) {
216             // The key has been used
217             if ($this->cache_changes[$key]) {
218                 // Make sure we're not going to write unchanged data
219                 // by comparing current md5 sum with the sum calculated on DB read
a6b0ca 220                 $data = $this->serialize($data);
50abd5 221
AM 222                 if (!$this->cache_sums[$key] || $this->cache_sums[$key] != md5($data)) {
223                     $this->write_record($key, $data);
224                 }
225             }
226         }
227
b1e35a 228         if ($this->index_changed) {
AM 229             $this->write_index();
230         }
9335f9 231
AM 232         // reset internal cache index, thanks to this we can force index reload
233         $this->index = null;
50abd5 234     }
AM 235
236     /**
237      * Reads cache entry.
238      *
239      * @param string  $key     Cache key name
240      * @param boolean $nostore Enable to skip in-memory store
241      *
242      * @return mixed Cached value
243      */
244     private function read_record($key, $nostore=false)
245     {
246         if (!$this->db) {
247             return null;
248         }
249
250         if ($this->type != 'db') {
fff8e0 251             $this->load_index();
AM 252
253             // Consistency check (#1490390)
254             if (!in_array($key, $this->index)) {
255                 // we always check if the key exist in the index
256                 // to have data in consistent state. Keeping the index consistent
257                 // is needed for keys delete operation when we delete all keys or by prefix.
258             }
044c1a 259             else {
44708e 260                 $ckey = $this->ckey($key);
044c1a 261
AM 262                 if ($this->type == 'memcache') {
263                     $data = $this->db->get($ckey);
264                 }
265                 else if ($this->type == 'apc') {
266                     $data = apc_fetch($ckey);
267                 }
44708e 268
AM 269                 if ($this->debug) {
270                     $this->debug('get', $ckey, $data);
271                 }
50abd5 272             }
AM 273
274             if ($data) {
275                 $md5sum = md5($data);
a6b0ca 276                 $data   = $this->unserialize($data);
50abd5 277
AM 278                 if ($nostore) {
279                     return $data;
280                 }
281
282                 $this->cache_sums[$key] = $md5sum;
283                 $this->cache[$key]      = $data;
284             }
285             else {
286                 $this->cache[$key] = null;
287             }
288         }
289         else {
290             $sql_result = $this->db->limitquery(
34a090 291                 "SELECT `data`, `cache_key`".
AM 292                 " FROM {$this->table}" .
293                 " WHERE `cache_key` = ?".
50abd5 294                 // for better performance we allow more records for one key
AM 295                 // get the newer one
34a090 296                 " ORDER BY `created` DESC",
50abd5 297                 0, 1, $this->prefix . '.' . $key);
AM 298
299             if ($sql_arr = $this->db->fetch_assoc($sql_result)) {
300                 $md5sum = $sql_arr['data'] ? md5($sql_arr['data']) : null;
301                 if ($sql_arr['data']) {
a6b0ca 302                     $data = $this->unserialize($sql_arr['data']);
50abd5 303                 }
AM 304
305                 if ($nostore) {
306                     return $data;
307                 }
308
309                 $this->cache[$key]      = $data;
310                 $this->cache_sums[$key] = $md5sum;
311             }
312             else {
313                 $this->cache[$key] = null;
314             }
315         }
316
317         return $this->cache[$key];
318     }
319
320     /**
321      * Writes single cache record into DB.
322      *
323      * @param string $key  Cache key name
81d4ff 324      * @param mixed  $data Serialized cache data
50abd5 325      *
AM 326      * @param boolean True on success, False on failure
327      */
328     private function write_record($key, $data)
329     {
330         if (!$this->db) {
81d4ff 331             return false;
AM 332         }
333
334         // don't attempt to write too big data sets
335         if (strlen($data) > $this->max_packet_size()) {
336             trigger_error("rcube_cache: max_packet_size ($this->max_packet) exceeded for key $key. Tried to write " . strlen($data) . " bytes", E_USER_WARNING);
50abd5 337             return false;
AM 338         }
339
340         if ($this->type == 'memcache' || $this->type == 'apc') {
652e11 341             $result = $this->add_record($this->ckey($key), $data);
AM 342
343             // make sure index will be updated
b120d4 344             if ($result) {
AM 345                 if (!array_key_exists($key, $this->cache_sums)) {
346                     $this->cache_sums[$key] = true;
347                 }
348
349                 $this->load_index();
350
351                 if (!$this->index_changed && !in_array($key, $this->index)) {
352                     $this->index_changed = true;
353                 }
652e11 354             }
AM 355
356             return $result;
50abd5 357         }
AM 358
359         $key_exists = array_key_exists($key, $this->cache_sums);
360         $key        = $this->prefix . '.' . $key;
361
362         // Remove NULL rows (here we don't need to check if the record exist)
363         if ($data == 'N;') {
34a090 364             $this->db->query("DELETE FROM {$this->table} WHERE `cache_key` = ?", $key);
50abd5 365             return true;
AM 366         }
367
368         // update existing cache record
369         if ($key_exists) {
370             $result = $this->db->query(
34a090 371                 "UPDATE {$this->table}" .
AM 372                 " SET `created` = " . $this->db->now() .
373                     ", `expires` = " . ($this->ttl ? $this->db->now($this->ttl) : 'NULL') .
374                     ", `data` = ?".
375                 " WHERE `cache_key` = ?",
50abd5 376                 $data, $key);
AM 377         }
378         // add new cache record
379         else {
380             // for better performance we allow more records for one key
381             // so, no need to check if record exist (see rcube_cache::read_record())
382             $result = $this->db->query(
34a090 383                 "INSERT INTO {$this->table}".
AM 384                 " (`created`, `expires`, `cache_key`, `data`)".
60b6d7 385                 " VALUES (".$this->db->now().", " . ($this->ttl ? $this->db->now($this->ttl) : 'NULL') . ", ?, ?)",
50abd5 386                 $key, $data);
AM 387         }
388
389         return $this->db->affected_rows($result);
390     }
391
392     /**
393      * Deletes the cache record(s).
394      *
395      * @param string  $key         Cache key name or pattern
396      * @param boolean $prefix_mode Enable it to clear all keys starting
397      *                             with prefix specified in $key
398      */
399     private function remove_record($key=null, $prefix_mode=false)
400     {
401         if (!$this->db) {
402             return;
403         }
404
405         if ($this->type != 'db') {
406             $this->load_index();
407
408             // Remove all keys
409             if ($key === null) {
410                 foreach ($this->index as $key) {
b120d4 411                     $this->delete_record($this->ckey($key));
50abd5 412                 }
b120d4 413
50abd5 414                 $this->index = array();
AM 415             }
416             // Remove keys by name prefix
417             else if ($prefix_mode) {
b120d4 418                 foreach ($this->index as $idx => $k) {
50abd5 419                     if (strpos($k, $key) === 0) {
b120d4 420                         $this->delete_record($this->ckey($k));
AM 421                         unset($this->index[$idx]);
50abd5 422                     }
AM 423                 }
424             }
425             // Remove one key by name
426             else {
b120d4 427                 $this->delete_record($this->ckey($key));
AM 428                 if (($idx = array_search($key, $this->index)) !== false) {
429                     unset($this->index[$idx]);
430                 }
50abd5 431             }
b120d4 432
AM 433             $this->index_changed = true;
50abd5 434
AM 435             return;
436         }
437
438         // Remove all keys (in specified cache)
439         if ($key === null) {
34a090 440             $where = " WHERE `cache_key` LIKE " . $this->db->quote($this->prefix.'.%');
50abd5 441         }
AM 442         // Remove keys by name prefix
443         else if ($prefix_mode) {
34a090 444             $where = " WHERE `cache_key` LIKE " . $this->db->quote($this->prefix.'.'.$key.'%');
50abd5 445         }
AM 446         // Remove one key by name
447         else {
34a090 448             $where = " WHERE `cache_key` = " . $this->db->quote($this->prefix.'.'.$key);
50abd5 449         }
AM 450
451         $this->db->query("DELETE FROM " . $this->table . $where);
452     }
453
454     /**
455      * Adds entry into memcache/apc DB.
456      *
a84f0b 457      * @param string $key  Cache internal key name
fff8e0 458      * @param mixed  $data Serialized cache data
50abd5 459      *
AM 460      * @param boolean True on success, False on failure
461      */
fff8e0 462     private function add_record($key, $data)
50abd5 463     {
AM 464         if ($this->type == 'memcache') {
465             $result = $this->db->replace($key, $data, MEMCACHE_COMPRESSED, $this->ttl);
044c1a 466
50abd5 467             if (!$result) {
AM 468                 $result = $this->db->set($key, $data, MEMCACHE_COMPRESSED, $this->ttl);
44708e 469             }
50abd5 470         }
AM 471         else if ($this->type == 'apc') {
472             if (apc_exists($key)) {
473                 apc_delete($key);
474             }
44708e 475
044c1a 476             $result = apc_store($key, $data, $this->ttl);
AM 477         }
478
479         if ($this->debug) {
480             $this->debug('set', $key, $data, $result);
50abd5 481         }
AM 482
483         return $result;
484     }
485
486     /**
487      * Deletes entry from memcache/apc DB.
b120d4 488      *
a84f0b 489      * @param string $key Cache internal key name
b120d4 490      *
AM 491      * @param boolean True on success, False on failure
50abd5 492      */
b120d4 493     private function delete_record($key)
50abd5 494     {
AM 495         if ($this->type == 'memcache') {
496             // #1488592: use 2nd argument
a84f0b 497             $result = $this->db->delete($key, 0);
50abd5 498         }
AM 499         else {
a84f0b 500             $result = apc_delete($key);
044c1a 501         }
44708e 502
044c1a 503         if ($this->debug) {
a84f0b 504             $this->debug('delete', $key, null, $result);
50abd5 505         }
AM 506
b120d4 507         return $result;
50abd5 508     }
AM 509
510     /**
511      * Writes the index entry into memcache/apc DB.
512      */
513     private function write_index()
514     {
044c1a 515         if (!$this->db || $this->type == 'db') {
50abd5 516             return;
AM 517         }
518
519         $this->load_index();
520
521         // Make sure index contains new keys
522         foreach ($this->cache as $key => $value) {
652e11 523             if ($value !== null && !in_array($key, $this->index)) {
AM 524                 $this->index[] = $key;
525             }
526         }
527
528         // new keys added using self::write()
529         foreach ($this->cache_sums as $key => $value) {
530             if ($value === true && !in_array($key, $this->index)) {
531                 $this->index[] = $key;
50abd5 532             }
AM 533         }
534
535         $data = serialize($this->index);
536         $this->add_record($this->ikey(), $data);
537     }
538
539     /**
540      * Gets the index entry from memcache/apc DB.
541      */
542     private function load_index()
543     {
044c1a 544         if (!$this->db || $this->type == 'db') {
50abd5 545             return;
AM 546         }
547
548         if ($this->index !== null) {
549             return;
550         }
551
552         $index_key = $this->ikey();
553
554         if ($this->type == 'memcache') {
555             $data = $this->db->get($index_key);
556         }
557         else if ($this->type == 'apc') {
558             $data = apc_fetch($index_key);
044c1a 559         }
44708e 560
044c1a 561         if ($this->debug) {
AM 562             $this->debug('get', $index_key, $data);
50abd5 563         }
AM 564
565         $this->index = $data ? unserialize($data) : array();
566     }
567
568     /**
569      * Creates cache key name (for memcache and apc)
570      *
571      * @param string $key Cache key name
572      *
573      * @return string Cache key
574      */
575     private function ckey($key)
576     {
577         return $this->prefix . ':' . $key;
578     }
579
580     /**
581      * Creates index cache key name (for memcache and apc)
582      *
583      * @return string Cache key
584      */
585     private function ikey()
586     {
587         // This way each cache will have its own index
588         return $this->prefix . 'INDEX';
589     }
a6b0ca 590
AM 591     /**
592      * Serializes data for storing
593      */
594     private function serialize($data)
595     {
596         if ($this->type == 'db') {
597             return $this->db->encode($data, $this->packed);
598         }
599
600         return $this->packed ? serialize($data) : $data;
601     }
602
603     /**
604      * Unserializes serialized data
605      */
606     private function unserialize($data)
607     {
608         if ($this->type == 'db') {
609             return $this->db->decode($data, $this->packed);
610         }
611
612         return $this->packed ? @unserialize($data) : $data;
613     }
81d4ff 614
AM 615     /**
616      * Determine the maximum size for cache data to be written
617      */
618     private function max_packet_size()
619     {
620         if ($this->max_packet < 0) {
621             $this->max_packet = 2097152; // default/max is 2 MB
622
623             if ($this->type == 'db') {
2a31f6 624                 if ($value = $this->db->get_variable('max_allowed_packet', $this->max_packet)) {
AM 625                     $this->max_packet = $value;
626                 }
627                 $this->max_packet -= 2000;
81d4ff 628             }
AM 629             else if ($this->type == 'memcache') {
630                 $stats = $this->db->getStats();
631                 $remaining = $stats['limit_maxbytes'] - $stats['bytes'];
632                 $this->max_packet = min($remaining / 5, $this->max_packet);
633             }
634             else if ($this->type == 'apc' && function_exists('apc_sma_info')) {
635                 $stats = apc_sma_info();
636                 $this->max_packet = min($stats['avail_mem'] / 5, $this->max_packet);
637             }
638         }
639
640         return $this->max_packet;
641     }
44708e 642
AM 643     /**
644      * Write memcache/apc debug info to the log
645      */
646     private function debug($type, $key, $data = null, $result = null)
647     {
11d5e7 648         $line = strtoupper($type) . ' ' . $key;
44708e 649
AM 650         if ($data !== null) {
651             $line .= ' ' . ($this->packed ? $data : serialize($data));
652         }
653
11d5e7 654         rcube::debug($this->type, $line, $result);
44708e 655     }
50abd5 656 }