[BUGFIX] Implement connection timeout option for Redis backend
[Packages/TYPO3.CMS.git] / typo3 / sysext / core / Classes / Cache / Backend / RedisBackend.php
1 <?php
2 namespace TYPO3\CMS\Core\Cache\Backend;
3
4 /*
5 * This file is part of the TYPO3 CMS project.
6 *
7 * It is free software; you can redistribute it and/or modify it under
8 * the terms of the GNU General Public License, either version 2
9 * of the License, or any later version.
10 *
11 * For the full copyright and license information, please read the
12 * LICENSE.txt file that was distributed with this source code.
13 *
14 * The TYPO3 project - inspiring people to share!
15 */
16
17 use TYPO3\CMS\Core\Utility\StringUtility;
18
19 /**
20 * A caching backend which stores cache entries by using Redis with phpredis
21 * PHP module. Redis is a noSQL database with very good scaling characteristics
22 * in proportion to the amount of entries and data size.
23 *
24 * @see http://code.google.com/p/redis/
25 * @see http://github.com/owlient/phpredis
26 * @api
27 */
28 class RedisBackend extends AbstractBackend implements TaggableBackendInterface
29 {
30 /**
31 * Faked unlimited lifetime = 31536000 (1 Year).
32 * In redis an entry does not have a lifetime by default (it's not "volatile").
33 * Entries can be made volatile either with EXPIRE after it has been SET,
34 * or with SETEX, which is a combined SET and EXPIRE command.
35 * But an entry can not be made "unvolatile" again. To set a volatile entry to
36 * not volatile again, it must be DELeted and SET without a following EXPIRE.
37 * To save these additional calls on every set(),
38 * we just make every entry volatile and treat a high number as "unlimited"
39 *
40 * @see http://code.google.com/p/redis/wiki/ExpireCommand
41 * @var int Faked unlimited lifetime
42 */
43 const FAKED_UNLIMITED_LIFETIME = 31536000;
44 /**
45 * Key prefix for identifier->data entries
46 *
47 * @var string
48 */
49 const IDENTIFIER_DATA_PREFIX = 'identData:';
50 /**
51 * Key prefix for identifier->tags sets
52 *
53 * @var string
54 */
55 const IDENTIFIER_TAGS_PREFIX = 'identTags:';
56 /**
57 * Key prefix for tag->identifiers sets
58 *
59 * @var string
60 */
61 const TAG_IDENTIFIERS_PREFIX = 'tagIdents:';
62 /**
63 * Instance of the PHP redis class
64 *
65 * @var \Redis
66 */
67 protected $redis;
68
69 /**
70 * Indicates whether the server is connected
71 *
72 * @var bool
73 */
74 protected $connected = false;
75
76 /**
77 * Persistent connection
78 *
79 * @var bool
80 */
81 protected $persistentConnection = false;
82
83 /**
84 * Hostname / IP of the Redis server, defaults to 127.0.0.1.
85 *
86 * @var string
87 */
88 protected $hostname = '127.0.0.1';
89
90 /**
91 * Port of the Redis server, defaults to 6379
92 *
93 * @var int
94 */
95 protected $port = 6379;
96
97 /**
98 * Number of selected database, defaults to 0
99 *
100 * @var int
101 */
102 protected $database = 0;
103
104 /**
105 * Password for redis authentication
106 *
107 * @var string
108 */
109 protected $password = '';
110
111 /**
112 * Indicates whether data is compressed or not (requires php zlib)
113 *
114 * @var bool
115 */
116 protected $compression = false;
117
118 /**
119 * -1 to 9, indicates zlib compression level: -1 = default level 6, 0 = no compression, 9 maximum compression
120 *
121 * @var int
122 */
123 protected $compressionLevel = -1;
124
125 /**
126 * limit in seconds (default is 0 meaning unlimited)
127 *
128 * @var int
129 */
130 protected $connectionTimeout = 0;
131
132 /**
133 * Construct this backend
134 *
135 * @param string $context FLOW3's application context
136 * @param array $options Configuration options
137 * @throws \TYPO3\CMS\Core\Cache\Exception if php redis module is not loaded
138 */
139 public function __construct($context, array $options = [])
140 {
141 if (!extension_loaded('redis')) {
142 throw new \TYPO3\CMS\Core\Cache\Exception('The PHP extension "redis" must be installed and loaded in order to use the redis backend.', 1279462933);
143 }
144 parent::__construct($context, $options);
145 }
146
147 /**
148 * Initializes the redis backend
149 *
150 * @return void
151 * @throws \TYPO3\CMS\Core\Cache\Exception if access to redis with password is denied or if database selection fails
152 */
153 public function initializeObject()
154 {
155 $this->redis = new \Redis();
156 try {
157 if ($this->persistentConnection) {
158 $this->connected = $this->redis->pconnect($this->hostname, $this->port, $this->connectionTimeout);
159 } else {
160 $this->connected = $this->redis->connect($this->hostname, $this->port, $this->connectionTimeout);
161 }
162 } catch (\Exception $e) {
163 \TYPO3\CMS\Core\Utility\GeneralUtility::sysLog('Could not connect to redis server.', 'core', \TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_ERROR);
164 }
165 if ($this->connected) {
166 if ($this->password !== '') {
167 $success = $this->redis->auth($this->password);
168 if (!$success) {
169 throw new \TYPO3\CMS\Core\Cache\Exception('The given password was not accepted by the redis server.', 1279765134);
170 }
171 }
172 if ($this->database > 0) {
173 $success = $this->redis->select($this->database);
174 if (!$success) {
175 throw new \TYPO3\CMS\Core\Cache\Exception('The given database "' . $this->database . '" could not be selected.', 1279765144);
176 }
177 }
178 }
179 }
180
181 /**
182 * Setter for persistent connection
183 *
184 * @param bool $persistentConnection
185 * @return void
186 * @api
187 */
188 public function setPersistentConnection($persistentConnection)
189 {
190 $this->persistentConnection = $persistentConnection;
191 }
192
193 /**
194 * Setter for server hostname
195 *
196 * @param string $hostname Hostname
197 * @return void
198 * @api
199 */
200 public function setHostname($hostname)
201 {
202 $this->hostname = $hostname;
203 }
204
205 /**
206 * Setter for server port
207 *
208 * @param int $port Port
209 * @return void
210 * @api
211 */
212 public function setPort($port)
213 {
214 $this->port = $port;
215 }
216
217 /**
218 * Setter for database number
219 *
220 * @param int $database Database
221 * @return void
222 * @throws \InvalidArgumentException if database number is not valid
223 * @api
224 */
225 public function setDatabase($database)
226 {
227 if (!is_int($database)) {
228 throw new \InvalidArgumentException('The specified database number is of type "' . gettype($database) . '" but an integer is expected.', 1279763057);
229 }
230 if ($database < 0) {
231 throw new \InvalidArgumentException('The specified database "' . $database . '" must be greater or equal than zero.', 1279763534);
232 }
233 $this->database = $database;
234 }
235
236 /**
237 * Setter for authentication password
238 *
239 * @param string $password Password
240 * @return void
241 * @api
242 */
243 public function setPassword($password)
244 {
245 $this->password = $password;
246 }
247
248 /**
249 * Enable data compression
250 *
251 * @param bool $compression TRUE to enable compression
252 * @return void
253 * @throws \InvalidArgumentException if compression parameter is not of type boolean
254 * @api
255 */
256 public function setCompression($compression)
257 {
258 if (!is_bool($compression)) {
259 throw new \InvalidArgumentException('The specified compression of type "' . gettype($compression) . '" but a boolean is expected.', 1289679153);
260 }
261 $this->compression = $compression;
262 }
263
264 /**
265 * Set data compression level.
266 * If compression is enabled and this is not set,
267 * gzcompress default level will be used.
268 *
269 * @param int $compressionLevel -1 to 9: Compression level
270 * @return void
271 * @throws \InvalidArgumentException if compressionLevel parameter is not within allowed bounds
272 * @api
273 */
274 public function setCompressionLevel($compressionLevel)
275 {
276 if (!is_int($compressionLevel)) {
277 throw new \InvalidArgumentException('The specified compression of type "' . gettype($compressionLevel) . '" but an integer is expected.', 1289679154);
278 }
279 if ($compressionLevel >= -1 && $compressionLevel <= 9) {
280 $this->compressionLevel = $compressionLevel;
281 } else {
282 throw new \InvalidArgumentException('The specified compression level must be an integer between -1 and 9.', 1289679155);
283 }
284 }
285
286 /**
287 * Set connection timeout.
288 * This value in seconds is used as a maximum number
289 * of seconds to wait if a connection can be established.
290 *
291 * @param int $connectionTimeout limit in seconds, a value greater or equal than 0
292 * @return void
293 * @throws \InvalidArgumentException if compressionLevel parameter is not within allowed bounds
294 * @api
295 */
296 public function setConnectionTimeout($connectionTimeout)
297 {
298 if (!is_int($connectionTimeout)) {
299 throw new \InvalidArgumentException('The specified connection timeout is of type "' . gettype($connectionTimeout) . '" but an integer is expected.', 1487849315);
300 }
301
302 if ($connectionTimeout < 0) {
303 throw new \InvalidArgumentException('The specified connection timeout "' . $connectionTimeout . '" must be greater or equal than zero.', 1487849326);
304 }
305
306 $this->connectionTimeout = $connectionTimeout;
307 }
308
309 /**
310 * Save data in the cache
311 *
312 * Scales O(1) with number of cache entries
313 * Scales O(n) with number of tags
314 *
315 * @param string $entryIdentifier Identifier for this specific cache entry
316 * @param string $data Data to be stored
317 * @param array $tags Tags to associate with this cache entry
318 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, default lifetime is used. "0" means unlimited lifetime.
319 * @return void
320 * @throws \InvalidArgumentException if identifier is not valid
321 * @throws \TYPO3\CMS\Core\Cache\Exception\InvalidDataException if data is not a string
322 * @api
323 */
324 public function set($entryIdentifier, $data, array $tags = [], $lifetime = null)
325 {
326 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
327 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006651);
328 }
329 if (!is_string($data)) {
330 throw new \TYPO3\CMS\Core\Cache\Exception\InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1279469941);
331 }
332 $lifetime = $lifetime === null ? $this->defaultLifetime : $lifetime;
333 if (!is_int($lifetime)) {
334 throw new \InvalidArgumentException('The specified lifetime is of type "' . gettype($lifetime) . '" but an integer or NULL is expected.', 1279488008);
335 }
336 if ($lifetime < 0) {
337 throw new \InvalidArgumentException('The specified lifetime "' . $lifetime . '" must be greater or equal than zero.', 1279487573);
338 }
339 if ($this->connected) {
340 $expiration = $lifetime === 0 ? self::FAKED_UNLIMITED_LIFETIME : $lifetime;
341 if ($this->compression) {
342 $data = gzcompress($data, $this->compressionLevel);
343 }
344 $this->redis->setex(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, $expiration, $data);
345 $addTags = $tags;
346 $removeTags = [];
347 $existingTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
348 if (!empty($existingTags)) {
349 $addTags = array_diff($tags, $existingTags);
350 $removeTags = array_diff($existingTags, $tags);
351 }
352 if (!empty($removeTags) || !empty($addTags)) {
353 $queue = $this->redis->multi(\Redis::PIPELINE);
354 foreach ($removeTags as $tag) {
355 $queue->sRemove(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
356 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
357 }
358 foreach ($addTags as $tag) {
359 $queue->sAdd(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
360 $queue->sAdd(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
361 }
362 $queue->exec();
363 }
364 }
365 }
366
367 /**
368 * Loads data from the cache.
369 *
370 * Scales O(1) with number of cache entries
371 *
372 * @param string $entryIdentifier An identifier which describes the cache entry to load
373 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
374 * @throws \InvalidArgumentException if identifier is not a string
375 * @api
376 */
377 public function get($entryIdentifier)
378 {
379 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
380 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006652);
381 }
382 $storedEntry = false;
383 if ($this->connected) {
384 $storedEntry = $this->redis->get(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
385 }
386 if ($this->compression && (string)$storedEntry !== '') {
387 $storedEntry = gzuncompress($storedEntry);
388 }
389 return $storedEntry;
390 }
391
392 /**
393 * Checks if a cache entry with the specified identifier exists.
394 *
395 * Scales O(1) with number of cache entries
396 *
397 * @param string $entryIdentifier Identifier specifying the cache entry
398 * @return bool TRUE if such an entry exists, FALSE if not
399 * @throws \InvalidArgumentException if identifier is not a string
400 * @api
401 */
402 public function has($entryIdentifier)
403 {
404 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
405 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006653);
406 }
407 return $this->connected && $this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
408 }
409
410 /**
411 * Removes all cache entries matching the specified identifier.
412 *
413 * Scales O(1) with number of cache entries
414 * Scales O(n) with number of tags
415 *
416 * @param string $entryIdentifier Specifies the cache entry to remove
417 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
418 * @throws \InvalidArgumentException if identifier is not a string
419 * @api
420 */
421 public function remove($entryIdentifier)
422 {
423 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
424 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006654);
425 }
426 $elementsDeleted = false;
427 if ($this->connected) {
428 if ($this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier)) {
429 $assignedTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
430 $queue = $this->redis->multi(\Redis::PIPELINE);
431 foreach ($assignedTags as $tag) {
432 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
433 }
434 $queue->delete(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
435 $queue->exec();
436 $elementsDeleted = true;
437 }
438 }
439 return $elementsDeleted;
440 }
441
442 /**
443 * Finds and returns all cache entry identifiers which are tagged by the
444 * specified tag.
445 *
446 * Scales O(1) with number of cache entries
447 * Scales O(n) with number of tag entries
448 *
449 * @param string $tag The tag to search for
450 * @return array An array of entries with all matching entries. An empty array if no entries matched
451 * @throws \InvalidArgumentException if tag is not a string
452 * @api
453 */
454 public function findIdentifiersByTag($tag)
455 {
456 if (!$this->canBeUsedInStringContext($tag)) {
457 throw new \InvalidArgumentException('The specified tag is of type "' . gettype($tag) . '" which can\'t be converted to string.', 1377006655);
458 }
459 $foundIdentifiers = [];
460 if ($this->connected) {
461 $foundIdentifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
462 }
463 return $foundIdentifiers;
464 }
465
466 /**
467 * Removes all cache entries of this cache.
468 *
469 * Scales O(1) with number of cache entries
470 *
471 * @return void
472 * @api
473 */
474 public function flush()
475 {
476 if ($this->connected) {
477 $this->redis->flushDB();
478 }
479 }
480
481 /**
482 * Removes all cache entries of this cache which are tagged with the specified tag.
483 *
484 * Scales O(1) with number of cache entries
485 * Scales O(n^2) with number of tag entries
486 *
487 * @param string $tag Tag the entries must have
488 * @return void
489 * @throws \InvalidArgumentException if identifier is not a string
490 * @api
491 */
492 public function flushByTag($tag)
493 {
494 if (!$this->canBeUsedInStringContext($tag)) {
495 throw new \InvalidArgumentException('The specified tag is of type "' . gettype($tag) . '" which can\'t be converted to string.', 1377006656);
496 }
497 if ($this->connected) {
498 $identifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
499 if (!empty($identifiers)) {
500 $this->removeIdentifierEntriesAndRelations($identifiers, [$tag]);
501 }
502 }
503 }
504
505 /**
506 * With the current internal structure, only the identifier to data entries
507 * have a redis internal lifetime. If an entry expires, attached
508 * identifier to tags and tag to identifiers entries will be left over.
509 * This methods finds those entries and cleans them up.
510 *
511 * Scales O(n*m) with number of cache entries (n) and number of tags (m)
512 *
513 * @return void
514 * @api
515 */
516 public function collectGarbage()
517 {
518 $identifierToTagsKeys = $this->redis->getKeys(self::IDENTIFIER_TAGS_PREFIX . '*');
519 foreach ($identifierToTagsKeys as $identifierToTagsKey) {
520 list(, $identifier) = explode(':', $identifierToTagsKey);
521 // Check if the data entry still exists
522 if (!$this->redis->exists((self::IDENTIFIER_DATA_PREFIX . $identifier))) {
523 $tagsToRemoveIdentifierFrom = $this->redis->sMembers($identifierToTagsKey);
524 $queue = $this->redis->multi(\Redis::PIPELINE);
525 $queue->delete($identifierToTagsKey);
526 foreach ($tagsToRemoveIdentifierFrom as $tag) {
527 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $identifier);
528 }
529 $queue->exec();
530 }
531 }
532 }
533
534 /**
535 * Helper method for flushByTag()
536 * Gets list of identifiers and tags and removes all relations of those tags
537 *
538 * Scales O(1) with number of cache entries
539 * Scales O(n^2) with number of tags
540 *
541 * @param array $identifiers List of identifiers to remove
542 * @param array $tags List of tags to be handled
543 * @return void
544 */
545 protected function removeIdentifierEntriesAndRelations(array $identifiers, array $tags)
546 {
547 // Set a temporary entry which holds all identifiers that need to be removed from
548 // the tag to identifiers sets
549 $uniqueTempKey = 'temp:' . StringUtility::getUniqueId();
550 $prefixedKeysToDelete = [$uniqueTempKey];
551 $prefixedIdentifierToTagsKeysToDelete = [];
552 foreach ($identifiers as $identifier) {
553 $prefixedKeysToDelete[] = self::IDENTIFIER_DATA_PREFIX . $identifier;
554 $prefixedIdentifierToTagsKeysToDelete[] = self::IDENTIFIER_TAGS_PREFIX . $identifier;
555 }
556 foreach ($tags as $tag) {
557 $prefixedKeysToDelete[] = self::TAG_IDENTIFIERS_PREFIX . $tag;
558 }
559 $tagToIdentifiersSetsToRemoveIdentifiersFrom = $this->redis->sUnion($prefixedIdentifierToTagsKeysToDelete);
560 // Remove the tag to identifier set of the given tags, they will be removed anyway
561 $tagToIdentifiersSetsToRemoveIdentifiersFrom = array_diff($tagToIdentifiersSetsToRemoveIdentifiersFrom, $tags);
562 // Diff all identifiers that must be removed from tag to identifiers sets off from a
563 // tag to identifiers set and store result in same tag to identifiers set again
564 $queue = $this->redis->multi(\Redis::PIPELINE);
565 foreach ($identifiers as $identifier) {
566 $queue->sAdd($uniqueTempKey, $identifier);
567 }
568 foreach ($tagToIdentifiersSetsToRemoveIdentifiersFrom as $tagToIdentifiersSet) {
569 $queue->sDiffStore(self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet, self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet, $uniqueTempKey);
570 }
571 $queue->delete(array_merge($prefixedKeysToDelete, $prefixedIdentifierToTagsKeysToDelete));
572 $queue->exec();
573 }
574
575 /**
576 * Helper method to catch invalid identifiers and tags
577 *
578 * @param mixed $variable Variable to be checked
579 * @return bool
580 */
581 protected function canBeUsedInStringContext($variable)
582 {
583 return is_scalar($variable) || (is_object($variable) && method_exists($variable, '__toString'));
584 }
585 }