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