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