c035d7f627156d643b4cc24db88fb0b0ec969b07
[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 * Construct this backend
127 *
128 * @param string $context FLOW3's application context
129 * @param array $options Configuration options
130 * @throws \TYPO3\CMS\Core\Cache\Exception if php redis module is not loaded
131 */
132 public function __construct($context, array $options = [])
133 {
134 if (!extension_loaded('redis')) {
135 throw new \TYPO3\CMS\Core\Cache\Exception('The PHP extension "redis" must be installed and loaded in order to use the redis backend.', 1279462933);
136 }
137 parent::__construct($context, $options);
138 }
139
140 /**
141 * Initializes the redis backend
142 *
143 * @return void
144 * @throws \TYPO3\CMS\Core\Cache\Exception if access to redis with password is denied or if database selection fails
145 */
146 public function initializeObject()
147 {
148 $this->redis = new \Redis();
149 try {
150 if ($this->persistentConnection) {
151 $this->connected = $this->redis->pconnect($this->hostname, $this->port);
152 } else {
153 $this->connected = $this->redis->connect($this->hostname, $this->port);
154 }
155 } catch (\Exception $e) {
156 \TYPO3\CMS\Core\Utility\GeneralUtility::sysLog('Could not connect to redis server.', 'core', \TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_ERROR);
157 }
158 if ($this->connected) {
159 if ($this->password !== '') {
160 $success = $this->redis->auth($this->password);
161 if (!$success) {
162 throw new \TYPO3\CMS\Core\Cache\Exception('The given password was not accepted by the redis server.', 1279765134);
163 }
164 }
165 if ($this->database > 0) {
166 $success = $this->redis->select($this->database);
167 if (!$success) {
168 throw new \TYPO3\CMS\Core\Cache\Exception('The given database "' . $this->database . '" could not be selected.', 1279765144);
169 }
170 }
171 }
172 }
173
174 /**
175 * Setter for persistent connection
176 *
177 * @param bool $persistentConnection
178 * @return void
179 * @api
180 */
181 public function setPersistentConnection($persistentConnection)
182 {
183 $this->persistentConnection = $persistentConnection;
184 }
185
186 /**
187 * Setter for server hostname
188 *
189 * @param string $hostname Hostname
190 * @return void
191 * @api
192 */
193 public function setHostname($hostname)
194 {
195 $this->hostname = $hostname;
196 }
197
198 /**
199 * Setter for server port
200 *
201 * @param int $port Port
202 * @return void
203 * @api
204 */
205 public function setPort($port)
206 {
207 $this->port = $port;
208 }
209
210 /**
211 * Setter for database number
212 *
213 * @param int $database Database
214 * @return void
215 * @throws \InvalidArgumentException if database number is not valid
216 * @api
217 */
218 public function setDatabase($database)
219 {
220 if (!is_int($database)) {
221 throw new \InvalidArgumentException('The specified database number is of type "' . gettype($database) . '" but an integer is expected.', 1279763057);
222 }
223 if ($database < 0) {
224 throw new \InvalidArgumentException('The specified database "' . $database . '" must be greater or equal than zero.', 1279763534);
225 }
226 $this->database = $database;
227 }
228
229 /**
230 * Setter for authentication password
231 *
232 * @param string $password Password
233 * @return void
234 * @api
235 */
236 public function setPassword($password)
237 {
238 $this->password = $password;
239 }
240
241 /**
242 * Enable data compression
243 *
244 * @param bool $compression TRUE to enable compression
245 * @return void
246 * @throws \InvalidArgumentException if compression parameter is not of type boolean
247 * @api
248 */
249 public function setCompression($compression)
250 {
251 if (!is_bool($compression)) {
252 throw new \InvalidArgumentException('The specified compression of type "' . gettype($compression) . '" but a boolean is expected.', 1289679153);
253 }
254 $this->compression = $compression;
255 }
256
257 /**
258 * Set data compression level.
259 * If compression is enabled and this is not set,
260 * gzcompress default level will be used.
261 *
262 * @param int $compressionLevel -1 to 9: Compression level
263 * @return void
264 * @throws \InvalidArgumentException if compressionLevel parameter is not within allowed bounds
265 * @api
266 */
267 public function setCompressionLevel($compressionLevel)
268 {
269 if (!is_int($compressionLevel)) {
270 throw new \InvalidArgumentException('The specified compression of type "' . gettype($compressionLevel) . '" but an integer is expected.', 1289679154);
271 }
272 if ($compressionLevel >= -1 && $compressionLevel <= 9) {
273 $this->compressionLevel = $compressionLevel;
274 } else {
275 throw new \InvalidArgumentException('The specified compression level must be an integer between -1 and 9.', 1289679155);
276 }
277 }
278
279 /**
280 * Save data in the cache
281 *
282 * Scales O(1) with number of cache entries
283 * Scales O(n) with number of tags
284 *
285 * @param string $entryIdentifier Identifier for this specific cache entry
286 * @param string $data Data to be stored
287 * @param array $tags Tags to associate with this cache entry
288 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, default lifetime is used. "0" means unlimited lifetime.
289 * @return void
290 * @throws \InvalidArgumentException if identifier is not valid
291 * @throws \TYPO3\CMS\Core\Cache\Exception\InvalidDataException if data is not a string
292 * @api
293 */
294 public function set($entryIdentifier, $data, array $tags = [], $lifetime = null)
295 {
296 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
297 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006651);
298 }
299 if (!is_string($data)) {
300 throw new \TYPO3\CMS\Core\Cache\Exception\InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1279469941);
301 }
302 $lifetime = $lifetime === null ? $this->defaultLifetime : $lifetime;
303 if (!is_int($lifetime)) {
304 throw new \InvalidArgumentException('The specified lifetime is of type "' . gettype($lifetime) . '" but an integer or NULL is expected.', 1279488008);
305 }
306 if ($lifetime < 0) {
307 throw new \InvalidArgumentException('The specified lifetime "' . $lifetime . '" must be greater or equal than zero.', 1279487573);
308 }
309 if ($this->connected) {
310 $expiration = $lifetime === 0 ? self::FAKED_UNLIMITED_LIFETIME : $lifetime;
311 if ($this->compression) {
312 $data = gzcompress($data, $this->compressionLevel);
313 }
314 $this->redis->setex(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, $expiration, $data);
315 $addTags = $tags;
316 $removeTags = [];
317 $existingTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
318 if (!empty($existingTags)) {
319 $addTags = array_diff($tags, $existingTags);
320 $removeTags = array_diff($existingTags, $tags);
321 }
322 if (!empty($removeTags) || !empty($addTags)) {
323 $queue = $this->redis->multi(\Redis::PIPELINE);
324 foreach ($removeTags as $tag) {
325 $queue->sRemove(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
326 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
327 }
328 foreach ($addTags as $tag) {
329 $queue->sAdd(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
330 $queue->sAdd(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
331 }
332 $queue->exec();
333 }
334 }
335 }
336
337 /**
338 * Loads data from the cache.
339 *
340 * Scales O(1) with number of cache entries
341 *
342 * @param string $entryIdentifier An identifier which describes the cache entry to load
343 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
344 * @throws \InvalidArgumentException if identifier is not a string
345 * @api
346 */
347 public function get($entryIdentifier)
348 {
349 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
350 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006652);
351 }
352 $storedEntry = false;
353 if ($this->connected) {
354 $storedEntry = $this->redis->get(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
355 }
356 if ($this->compression && (string)$storedEntry !== '') {
357 $storedEntry = gzuncompress($storedEntry);
358 }
359 return $storedEntry;
360 }
361
362 /**
363 * Checks if a cache entry with the specified identifier exists.
364 *
365 * Scales O(1) with number of cache entries
366 *
367 * @param string $entryIdentifier Identifier specifying the cache entry
368 * @return bool TRUE if such an entry exists, FALSE if not
369 * @throws \InvalidArgumentException if identifier is not a string
370 * @api
371 */
372 public function has($entryIdentifier)
373 {
374 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
375 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006653);
376 }
377 return $this->connected && $this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
378 }
379
380 /**
381 * Removes all cache entries matching the specified identifier.
382 *
383 * Scales O(1) with number of cache entries
384 * Scales O(n) with number of tags
385 *
386 * @param string $entryIdentifier Specifies the cache entry to remove
387 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
388 * @throws \InvalidArgumentException if identifier is not a string
389 * @api
390 */
391 public function remove($entryIdentifier)
392 {
393 if (!$this->canBeUsedInStringContext($entryIdentifier)) {
394 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" which can\'t be converted to string.', 1377006654);
395 }
396 $elementsDeleted = false;
397 if ($this->connected) {
398 if ($this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier)) {
399 $assignedTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
400 $queue = $this->redis->multi(\Redis::PIPELINE);
401 foreach ($assignedTags as $tag) {
402 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
403 }
404 $queue->delete(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
405 $queue->exec();
406 $elementsDeleted = true;
407 }
408 }
409 return $elementsDeleted;
410 }
411
412 /**
413 * Finds and returns all cache entry identifiers which are tagged by the
414 * specified tag.
415 *
416 * Scales O(1) with number of cache entries
417 * Scales O(n) with number of tag entries
418 *
419 * @param string $tag The tag to search for
420 * @return array An array of entries with all matching entries. An empty array if no entries matched
421 * @throws \InvalidArgumentException if tag is not a string
422 * @api
423 */
424 public function findIdentifiersByTag($tag)
425 {
426 if (!$this->canBeUsedInStringContext($tag)) {
427 throw new \InvalidArgumentException('The specified tag is of type "' . gettype($tag) . '" which can\'t be converted to string.', 1377006655);
428 }
429 $foundIdentifiers = [];
430 if ($this->connected) {
431 $foundIdentifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
432 }
433 return $foundIdentifiers;
434 }
435
436 /**
437 * Removes all cache entries of this cache.
438 *
439 * Scales O(1) with number of cache entries
440 *
441 * @return void
442 * @api
443 */
444 public function flush()
445 {
446 if ($this->connected) {
447 $this->redis->flushDB();
448 }
449 }
450
451 /**
452 * Removes all cache entries of this cache which are tagged with the specified tag.
453 *
454 * Scales O(1) with number of cache entries
455 * Scales O(n^2) with number of tag entries
456 *
457 * @param string $tag Tag the entries must have
458 * @return void
459 * @throws \InvalidArgumentException if identifier is not a string
460 * @api
461 */
462 public function flushByTag($tag)
463 {
464 if (!$this->canBeUsedInStringContext($tag)) {
465 throw new \InvalidArgumentException('The specified tag is of type "' . gettype($tag) . '" which can\'t be converted to string.', 1377006656);
466 }
467 if ($this->connected) {
468 $identifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
469 if (!empty($identifiers)) {
470 $this->removeIdentifierEntriesAndRelations($identifiers, [$tag]);
471 }
472 }
473 }
474
475 /**
476 * With the current internal structure, only the identifier to data entries
477 * have a redis internal lifetime. If an entry expires, attached
478 * identifier to tags and tag to identifiers entries will be left over.
479 * This methods finds those entries and cleans them up.
480 *
481 * Scales O(n*m) with number of cache entries (n) and number of tags (m)
482 *
483 * @return void
484 * @api
485 */
486 public function collectGarbage()
487 {
488 $identifierToTagsKeys = $this->redis->getKeys(self::IDENTIFIER_TAGS_PREFIX . '*');
489 foreach ($identifierToTagsKeys as $identifierToTagsKey) {
490 list(, $identifier) = explode(':', $identifierToTagsKey);
491 // Check if the data entry still exists
492 if (!$this->redis->exists((self::IDENTIFIER_DATA_PREFIX . $identifier))) {
493 $tagsToRemoveIdentifierFrom = $this->redis->sMembers($identifierToTagsKey);
494 $queue = $this->redis->multi(\Redis::PIPELINE);
495 $queue->delete($identifierToTagsKey);
496 foreach ($tagsToRemoveIdentifierFrom as $tag) {
497 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $identifier);
498 }
499 $queue->exec();
500 }
501 }
502 }
503
504 /**
505 * Helper method for flushByTag()
506 * Gets list of identifiers and tags and removes all relations of those tags
507 *
508 * Scales O(1) with number of cache entries
509 * Scales O(n^2) with number of tags
510 *
511 * @param array $identifiers List of identifiers to remove
512 * @param array $tags List of tags to be handled
513 * @return void
514 */
515 protected function removeIdentifierEntriesAndRelations(array $identifiers, array $tags)
516 {
517 // Set a temporary entry which holds all identifiers that need to be removed from
518 // the tag to identifiers sets
519 $uniqueTempKey = 'temp:' . StringUtility::getUniqueId();
520 $prefixedKeysToDelete = [$uniqueTempKey];
521 $prefixedIdentifierToTagsKeysToDelete = [];
522 foreach ($identifiers as $identifier) {
523 $prefixedKeysToDelete[] = self::IDENTIFIER_DATA_PREFIX . $identifier;
524 $prefixedIdentifierToTagsKeysToDelete[] = self::IDENTIFIER_TAGS_PREFIX . $identifier;
525 }
526 foreach ($tags as $tag) {
527 $prefixedKeysToDelete[] = self::TAG_IDENTIFIERS_PREFIX . $tag;
528 }
529 $tagToIdentifiersSetsToRemoveIdentifiersFrom = $this->redis->sUnion($prefixedIdentifierToTagsKeysToDelete);
530 // Remove the tag to identifier set of the given tags, they will be removed anyway
531 $tagToIdentifiersSetsToRemoveIdentifiersFrom = array_diff($tagToIdentifiersSetsToRemoveIdentifiersFrom, $tags);
532 // Diff all identifiers that must be removed from tag to identifiers sets off from a
533 // tag to identifiers set and store result in same tag to identifiers set again
534 $queue = $this->redis->multi(\Redis::PIPELINE);
535 foreach ($identifiers as $identifier) {
536 $queue->sAdd($uniqueTempKey, $identifier);
537 }
538 foreach ($tagToIdentifiersSetsToRemoveIdentifiersFrom as $tagToIdentifiersSet) {
539 $queue->sDiffStore(self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet, self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet, $uniqueTempKey);
540 }
541 $queue->delete(array_merge($prefixedKeysToDelete, $prefixedIdentifierToTagsKeysToDelete));
542 $queue->exec();
543 }
544
545 /**
546 * Helper method to catch invalid identifiers and tags
547 *
548 * @param mixed $variable Variable to be checked
549 * @return bool
550 */
551 protected function canBeUsedInStringContext($variable)
552 {
553 return is_scalar($variable) || (is_object($variable) && method_exists($variable, '__toString'));
554 }
555 }