[CLEANUP] Replace strlen() with === for zero length check
[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 /**
18 * A caching backend which stores cache entries by using Redis with phpredis
19 * PHP module. Redis is a noSQL database with very good scaling characteristics
20 * in proportion to the amount of entries and data size.
21 *
22 * @see http://code.google.com/p/redis/
23 * @see http://github.com/owlient/phpredis
24 * @author Christopher Hlubek <hlubek@networkteam.com>
25 * @author Christian Kuhn <lolli@schwarzbu.ch>
26 * @api
27 */
28 class RedisBackend extends \TYPO3\CMS\Core\Cache\Backend\AbstractBackend implements \TYPO3\CMS\Core\Cache\Backend\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 * Hostname / IP of the Redis server, defaults to 127.0.0.1.
78 *
79 * @var string
80 */
81 protected $hostname = '127.0.0.1';
82
83 /**
84 * Port of the Redis server, defaults to 6379
85 *
86 * @var int
87 */
88 protected $port = 6379;
89
90 /**
91 * Number of selected database, defaults to 0
92 *
93 * @var int
94 */
95 protected $database = 0;
96
97 /**
98 * Password for redis authentication
99 *
100 * @var string
101 */
102 protected $password = '';
103
104 /**
105 * Indicates whether data is compressed or not (requires php zlib)
106 *
107 * @var bool
108 */
109 protected $compression = FALSE;
110
111 /**
112 * -1 to 9, indicates zlib compression level: -1 = default level 6, 0 = no compression, 9 maximum compression
113 *
114 * @var int
115 */
116 protected $compressionLevel = -1;
117
118 /**
119 * Construct this backend
120 *
121 * @param string $context FLOW3's application context
122 * @param array $options Configuration options
123 * @throws \TYPO3\CMS\Core\Cache\Exception if php redis module is not loaded
124 */
125 public function __construct($context, array $options = array()) {
126 if (!extension_loaded('redis')) {
127 throw new \TYPO3\CMS\Core\Cache\Exception('The PHP extension "redis" must be installed and loaded in order to use the redis backend.', 1279462933);
128 }
129 parent::__construct($context, $options);
130 }
131
132 /**
133 * Initializes the redis backend
134 *
135 * @return void
136 * @throws \TYPO3\CMS\Core\Cache\Exception if access to redis with password is denied or if database selection fails
137 */
138 public function initializeObject() {
139 $this->redis = new \Redis();
140 try {
141 $this->connected = $this->redis->connect($this->hostname, $this->port);
142 } catch (\Exception $e) {
143 \TYPO3\CMS\Core\Utility\GeneralUtility::sysLog('Could not connect to redis server.', 'core', \TYPO3\CMS\Core\Utility\GeneralUtility::SYSLOG_SEVERITY_ERROR);
144 }
145 if ($this->connected) {
146 if ($this->password !== '') {
147 $success = $this->redis->auth($this->password);
148 if (!$success) {
149 throw new \TYPO3\CMS\Core\Cache\Exception('The given password was not accepted by the redis server.', 1279765134);
150 }
151 }
152 if ($this->database > 0) {
153 $success = $this->redis->select($this->database);
154 if (!$success) {
155 throw new \TYPO3\CMS\Core\Cache\Exception('The given database "' . $this->database . '" could not be selected.', 1279765144);
156 }
157 }
158 }
159 }
160
161 /**
162 * Setter for server hostname
163 *
164 * @param string $hostname Hostname
165 * @return void
166 * @api
167 */
168 public function setHostname($hostname) {
169 $this->hostname = $hostname;
170 }
171
172 /**
173 * Setter for server port
174 *
175 * @param int $port Port
176 * @return void
177 * @api
178 */
179 public function setPort($port) {
180 $this->port = $port;
181 }
182
183 /**
184 * Setter for database number
185 *
186 * @param int $database Database
187 * @return void
188 * @throws \InvalidArgumentException if database number is not valid
189 * @api
190 */
191 public function setDatabase($database) {
192 if (!is_integer($database)) {
193 throw new \InvalidArgumentException('The specified database number is of type "' . gettype($database) . '" but an integer is expected.', 1279763057);
194 }
195 if ($database < 0) {
196 throw new \InvalidArgumentException('The specified database "' . $database . '" must be greater or equal than zero.', 1279763534);
197 }
198 $this->database = $database;
199 }
200
201 /**
202 * Setter for authentication password
203 *
204 * @param string $password Password
205 * @return void
206 * @api
207 */
208 public function setPassword($password) {
209 $this->password = $password;
210 }
211
212 /**
213 * Enable data compression
214 *
215 * @param bool $compression TRUE to enable compression
216 * @return void
217 * @throws \InvalidArgumentException if compression parameter is not of type boolean
218 * @api
219 */
220 public function setCompression($compression) {
221 if (!is_bool($compression)) {
222 throw new \InvalidArgumentException('The specified compression of type "' . gettype($compression) . '" but a boolean is expected.', 1289679153);
223 }
224 $this->compression = $compression;
225 }
226
227 /**
228 * Set data compression level.
229 * If compression is enabled and this is not set,
230 * gzcompress default level will be used.
231 *
232 * @param int $compressionLevel -1 to 9: Compression level
233 * @return void
234 * @throws \InvalidArgumentException if compressionLevel parameter is not within allowed bounds
235 * @api
236 */
237 public function setCompressionLevel($compressionLevel) {
238 if (!is_integer($compressionLevel)) {
239 throw new \InvalidArgumentException('The specified compression of type "' . gettype($compressionLevel) . '" but an integer is expected.', 1289679154);
240 }
241 if ($compressionLevel >= -1 && $compressionLevel <= 9) {
242 $this->compressionLevel = $compressionLevel;
243 } else {
244 throw new \InvalidArgumentException('The specified compression level must be an integer between -1 and 9.', 1289679155);
245 }
246 }
247
248 /**
249 * Save data in the cache
250 *
251 * Scales O(1) with number of cache entries
252 * Scales O(n) with number of tags
253 *
254 * @param string $entryIdentifier Identifier for this specific cache entry
255 * @param string $data Data to be stored
256 * @param array $tags Tags to associate with this cache entry
257 * @param int $lifetime Lifetime of this cache entry in seconds. If NULL is specified, default lifetime is used. "0" means unlimited lifetime.
258 * @return void
259 * @throws \InvalidArgumentException if identifier is not valid
260 * @throws \TYPO3\CMS\Core\Cache\Exception\InvalidDataException if data is not a string
261 * @api
262 */
263 public function set($entryIdentifier, $data, array $tags = array(), $lifetime = NULL) {
264 if (!is_string($entryIdentifier)) {
265 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.', 1279470252);
266 }
267 if (!is_string($data)) {
268 throw new \TYPO3\CMS\Core\Cache\Exception\InvalidDataException('The specified data is of type "' . gettype($data) . '" but a string is expected.', 1279469941);
269 }
270 $lifetime = $lifetime === NULL ? $this->defaultLifetime : $lifetime;
271 if (!is_integer($lifetime)) {
272 throw new \InvalidArgumentException('The specified lifetime is of type "' . gettype($lifetime) . '" but an integer or NULL is expected.', 1279488008);
273 }
274 if ($lifetime < 0) {
275 throw new \InvalidArgumentException('The specified lifetime "' . $lifetime . '" must be greater or equal than zero.', 1279487573);
276 }
277 if ($this->connected) {
278 $expiration = $lifetime === 0 ? self::FAKED_UNLIMITED_LIFETIME : $lifetime;
279 if ($this->compression) {
280 $data = gzcompress($data, $this->compressionLevel);
281 }
282 $this->redis->setex(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, $expiration, $data);
283 $addTags = $tags;
284 $removeTags = array();
285 $existingTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
286 if (!empty($existingTags)) {
287 $addTags = array_diff($tags, $existingTags);
288 $removeTags = array_diff($existingTags, $tags);
289 }
290 if (count($removeTags) > 0 || count($addTags) > 0) {
291 $queue = $this->redis->multi(\Redis::PIPELINE);
292 foreach ($removeTags as $tag) {
293 $queue->sRemove(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
294 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
295 }
296 foreach ($addTags as $tag) {
297 $queue->sAdd(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
298 $queue->sAdd(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
299 }
300 $queue->exec();
301 }
302 }
303 }
304
305 /**
306 * Loads data from the cache.
307 *
308 * Scales O(1) with number of cache entries
309 *
310 * @param string $entryIdentifier An identifier which describes the cache entry to load
311 * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
312 * @throws \InvalidArgumentException if identifier is not a string
313 * @api
314 */
315 public function get($entryIdentifier) {
316 if (!is_string($entryIdentifier)) {
317 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.', 1279470253);
318 }
319 $storedEntry = FALSE;
320 if ($this->connected) {
321 $storedEntry = $this->redis->get(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
322 }
323 if ($this->compression && (string)$storedEntry !== '') {
324 $storedEntry = gzuncompress($storedEntry);
325 }
326 return $storedEntry;
327 }
328
329 /**
330 * Checks if a cache entry with the specified identifier exists.
331 *
332 * Scales O(1) with number of cache entries
333 *
334 * @param string $entryIdentifier Identifier specifying the cache entry
335 * @return bool TRUE if such an entry exists, FALSE if not
336 * @throws \InvalidArgumentException if identifier is not a string
337 * @api
338 */
339 public function has($entryIdentifier) {
340 if (!is_string($entryIdentifier)) {
341 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.', 1279470254);
342 }
343 return $this->connected && $this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
344 }
345
346 /**
347 * Removes all cache entries matching the specified identifier.
348 *
349 * Scales O(1) with number of cache entries
350 * Scales O(n) with number of tags
351 *
352 * @param string $entryIdentifier Specifies the cache entry to remove
353 * @return bool TRUE if (at least) an entry could be removed or FALSE if no entry was found
354 * @throws \InvalidArgumentException if identifier is not a string
355 * @api
356 */
357 public function remove($entryIdentifier) {
358 if (!is_string($entryIdentifier)) {
359 throw new \InvalidArgumentException('The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.', 1279470255);
360 }
361 $elementsDeleted = FALSE;
362 if ($this->connected) {
363 if ($this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier)) {
364 $assignedTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
365 $queue = $this->redis->multi(\Redis::PIPELINE);
366 foreach ($assignedTags as $tag) {
367 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
368 }
369 $queue->delete(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
370 $queue->exec();
371 $elementsDeleted = TRUE;
372 }
373 }
374 return $elementsDeleted;
375 }
376
377 /**
378 * Finds and returns all cache entry identifiers which are tagged by the
379 * specified tag.
380 *
381 * Scales O(1) with number of cache entries
382 * Scales O(n) with number of tag entries
383 *
384 * @param string $tag The tag to search for
385 * @return array An array of entries with all matching entries. An empty array if no entries matched
386 * @throws \InvalidArgumentException if tag is not a string
387 * @api
388 */
389 public function findIdentifiersByTag($tag) {
390 if (!is_string($tag)) {
391 throw new \InvalidArgumentException('The specified tag is of type "' . gettype($tag) . '" but a string is expected.', 1279569759);
392 }
393 $foundIdentifiers = array();
394 if ($this->connected) {
395 $foundIdentifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
396 }
397 return $foundIdentifiers;
398 }
399
400 /**
401 * Removes all cache entries of this cache.
402 *
403 * Scales O(1) with number of cache entries
404 *
405 * @return void
406 * @api
407 */
408 public function flush() {
409 if ($this->connected) {
410 $this->redis->flushdb();
411 }
412 }
413
414 /**
415 * Removes all cache entries of this cache which are tagged with the specified tag.
416 *
417 * Scales O(1) with number of cache entries
418 * Scales O(n^2) with number of tag entries
419 *
420 * @param string $tag Tag the entries must have
421 * @return void
422 * @throws \InvalidArgumentException if identifier is not a string
423 * @api
424 */
425 public function flushByTag($tag) {
426 if (!is_string($tag)) {
427 throw new \InvalidArgumentException('The specified tag is of type "' . gettype($tag) . '" but a string is expected.', 1279578078);
428 }
429 if ($this->connected) {
430 $identifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
431 if (count($identifiers) > 0) {
432 $this->removeIdentifierEntriesAndRelations($identifiers, array($tag));
433 }
434 }
435 }
436
437 /**
438 * With the current internal structure, only the identifier to data entries
439 * have a redis internal lifetime. If an entry expires, attached
440 * identifier to tags and tag to identifiers entries will be left over.
441 * This methods finds those entries and cleans them up.
442 *
443 * Scales O(n*m) with number of cache entries (n) and number of tags (m)
444 *
445 * @return void
446 * @api
447 */
448 public function collectGarbage() {
449 $identifierToTagsKeys = $this->redis->getKeys(self::IDENTIFIER_TAGS_PREFIX . '*');
450 foreach ($identifierToTagsKeys as $identifierToTagsKey) {
451 list(, $identifier) = explode(':', $identifierToTagsKey);
452 // Check if the data entry still exists
453 if (!$this->redis->exists((self::IDENTIFIER_DATA_PREFIX . $identifier))) {
454 $tagsToRemoveIdentifierFrom = $this->redis->sMembers($identifierToTagsKey);
455 $queue = $this->redis->multi(\Redis::PIPELINE);
456 $queue->delete($identifierToTagsKey);
457 foreach ($tagsToRemoveIdentifierFrom as $tag) {
458 $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $identifier);
459 }
460 $queue->exec();
461 }
462 }
463 }
464
465 /**
466 * Helper method for flushByTag()
467 * Gets list of identifiers and tags and removes all relations of those tags
468 *
469 * Scales O(1) with number of cache entries
470 * Scales O(n^2) with number of tags
471 *
472 * @param array $identifiers List of identifiers to remove
473 * @param array $tags List of tags to be handled
474 * @return void
475 */
476 protected function removeIdentifierEntriesAndRelations(array $identifiers, array $tags) {
477 // Set a temporary entry which holds all identifiers that need to be removed from
478 // the tag to identifiers sets
479 $uniqueTempKey = 'temp:' . uniqid('', TRUE);
480 $prefixedKeysToDelete = array($uniqueTempKey);
481 $prefixedIdentifierToTagsKeysToDelete = array();
482 foreach ($identifiers as $identifier) {
483 $prefixedKeysToDelete[] = self::IDENTIFIER_DATA_PREFIX . $identifier;
484 $prefixedIdentifierToTagsKeysToDelete[] = self::IDENTIFIER_TAGS_PREFIX . $identifier;
485 }
486 foreach ($tags as $tag) {
487 $prefixedKeysToDelete[] = self::TAG_IDENTIFIERS_PREFIX . $tag;
488 }
489 $tagToIdentifiersSetsToRemoveIdentifiersFrom = $this->redis->sUnion($prefixedIdentifierToTagsKeysToDelete);
490 // Remove the tag to identifier set of the given tags, they will be removed anyway
491 $tagToIdentifiersSetsToRemoveIdentifiersFrom = array_diff($tagToIdentifiersSetsToRemoveIdentifiersFrom, $tags);
492 // Diff all identifiers that must be removed from tag to identifiers sets off from a
493 // tag to identifiers set and store result in same tag to identifiers set again
494 $queue = $this->redis->multi(\Redis::PIPELINE);
495 foreach ($identifiers as $identifier) {
496 $queue->sAdd($uniqueTempKey, $identifier);
497 }
498 foreach ($tagToIdentifiersSetsToRemoveIdentifiersFrom as $tagToIdentifiersSet) {
499 $queue->sDiffStore(self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet, self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet, $uniqueTempKey);
500 }
501 $queue->delete(array_merge($prefixedKeysToDelete, $prefixedIdentifierToTagsKeysToDelete));
502 $queue->exec();
503 }
504
505 }