Added feature #16410: [Caching framework] Implement redis as cache backend
authorChristian Kuhn <lolli@schwarzbu.ch>
Mon, 15 Nov 2010 22:38:19 +0000 (22:38 +0000)
committerChristian Kuhn <lolli@schwarzbu.ch>
Mon, 15 Nov 2010 22:38:19 +0000 (22:38 +0000)
git-svn-id: https://svn.typo3.org/TYPO3v4/Core/trunk@9390 709f56b5-9817-0410-a4d7-c38de5d9e867

ChangeLog
t3lib/cache/backend/class.t3lib_cache_backend_redisbackend.php [new file with mode: 0644]
t3lib/config_default.php
t3lib/core_autoload.php
tests/t3lib/cache/backend/t3lib_cache_backend_redisbackendTest.php [new file with mode: 0644]

index 62b520a..544d6fb 100755 (executable)
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,7 @@
+2010-11-15  Christian Kuhn  <lolli@schwarzbu.ch>
+
+       * Added feature #16410: [Caching framework] Implement redis as cache backend
+
 2010-11-15  Steffen Gebert  <steffen@steffen-gebert.de>
 
        * Added feature #16403: Integrate modernizr into the TYPO3 Backend
diff --git a/t3lib/cache/backend/class.t3lib_cache_backend_redisbackend.php b/t3lib/cache/backend/class.t3lib_cache_backend_redisbackend.php
new file mode 100644 (file)
index 0000000..5d56b72
--- /dev/null
@@ -0,0 +1,702 @@
+<?php
+/***************************************************************
+*  Copyright notice
+*
+*  (c) 2010 Christian Kuhn <lolli@schwarzbu.ch>
+*  All rights reserved
+*
+*  This script is part of the TYPO3 project. The TYPO3 project is
+*  free software; you can redistribute it and/or modify
+*  it under the terms of the GNU General Public License as published by
+*  the Free Software Foundation; either version 2 of the License, or
+*  (at your option) any later version.
+*
+*  The GNU General Public License can be found at
+*  http://www.gnu.org/copyleft/gpl.html.
+*
+*  This script is distributed in the hope that it will be useful,
+*  but WITHOUT ANY WARRANTY; without even the implied warranty of
+*  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+*  GNU General Public License for more details.
+*
+*  This copyright notice MUST APPEAR in all copies of the script!
+***************************************************************/
+
+/**
+ * A caching backend which stores cache entries by using Redis with phpredis
+ * PHP module. Redis is a noSQL database with very good scaling characteristics
+ * in proportion to the amount of entries and data size.
+ *
+ * @see http://code.google.com/p/redis/
+ * @see http://github.com/owlient/phpredis
+ *
+ * Warning:
+ * Redis and phpredis are young projects with very high development speed.
+ * This implementation should be considered as experimental for now,
+ * internals might break or change while the dependent projects mature.
+ *
+ * Successfully tested with:
+ * - redis
+ *   version 2.0.0-rc2, version 1.2.0 does not work
+ *   git version 9fd01051bf8400babcca73a76a67dfc1847633ff from 2010-11-12
+ * - phpredis
+ *   git version 0abb9e5ec07b8a8c20b5 from 2010-07-18
+ *   git version 12769b03c8ec17b25573e0453003712011bba241 from 2010-11-08
+ *
+ * Implementation based on ext:rediscache by Christopher Hlubek - networkteam GmbH
+ *
+ * This backend uses the following types of redis keys:
+ * - identData:xxx, value type "string", volatile, expires after given lifetime
+ *   xxx is the given identifier name, value is the cache data
+ * - identTags:xxx, value type "set"
+ *   xxx is the given identifier name, value is a set of associated tags.
+ *   This is a "reverse" tag index. It provides quick access for all tags
+ *   associated with this identifier and is used when removing the identifier.
+ * - tagIdents:xxx, value type "set"
+ *   xxx is a tag name, value is a set of associated identifiers.
+ *   This is "forward" tag index. It is mainly used for flushing content by tag.
+ * - temp:xxx, value type "set"
+ *   xxx is a unique id, value is a set of identifiers. Used as temporary key
+ *   used in flushByTag() and flushByTags(), removed after usage again.
+ *
+ * Each cache using this backend should use an own redis database to
+ * avoid namespace problems. By default redis has 16 databases which are
+ * identified with numbers 0 .. 15. setDatabase() can be used to select one.
+ * The unit tests use and flush database numbers 0 and 1, production use should start from 2.
+ *
+ * @package TYPO3
+ * @subpackage t3lib_cache
+ * @api
+ * @scope prototype
+ */
+class t3lib_cache_backend_RedisBackend extends t3lib_cache_backend_AbstractBackend {
+
+       /**
+        * Faked unlimited lifetime = 31536000 (1 Year).
+        * In redis an entry does not have a lifetime by default (it's not "volatile").
+        * Entries can be made volatile either with EXPIRE after it has been SET,
+        * or with SETEX, which is a combined SET and EXPIRE command.
+        * But an entry can not be made "unvolatile" again. To set a volatile entry to
+        * not volatile again, it must be DELeted and SET without a following EXPIRE.
+        * To save these additional calls on every set(),
+        * we just make every entry volatile and treat a high number as "unlimited"
+        *
+        * @see http://code.google.com/p/redis/wiki/ExpireCommand
+        * @var integer Faked unlimited lifetime
+        */
+       const FAKED_UNLIMITED_LIFETIME = 31536000;
+
+       /**
+        * @var string Key prefix for identifier->data entries
+        */
+       const IDENTIFIER_DATA_PREFIX = 'identData:';
+
+       /**
+        * @var string Key prefix for identifier->tags sets
+        */
+       const IDENTIFIER_TAGS_PREFIX = 'identTags:';
+
+       /**
+        * @var string Key prefix for tag->identifiers sets
+        */
+       const TAG_IDENTIFIERS_PREFIX = 'tagIdents:';
+
+       /**
+        * @var Redis Instance of the PHP redis class
+        */
+       protected $redis;
+
+       /**
+        * @var boolean Indicates wether the server is connected
+        */
+       protected $connected = FALSE;
+
+       /**
+        * @var string Hostname / IP of the Redis server, defaults to 127.0.0.1.
+        */
+       protected $hostname = '127.0.0.1';
+
+       /**
+        * @var integer Port of the Redis server, defaults to 6379
+        */
+       protected $port = 6379;
+
+       /**
+        * @var integer Number of selected database, defaults to 0
+        */
+       protected $database = 0;
+
+       /**
+        * @var string Password for redis authentication
+        */
+       protected $password = '';
+
+       /**
+        * @var boolean Indicates wether data is compressed or not (requires php zlib)
+        */
+       protected $compression = FALSE;
+
+       /**
+        * @var integer -1 to 9, indicates zlib compression level: -1 = default level 6, 0 = no compression, 9 maximum compression
+        */
+       protected $compressionLevel = -1;
+
+       /**
+        * Construct this backend
+        *
+        * @param array Configuration options
+        * @throws t3lib_cache_Exception if php redis module is not loaded
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function __construct(array $options = array()) {
+               if (!extension_loaded('redis')) {
+                       throw new t3lib_cache_Exception(
+                               'The PHP extension "redis" must be installed and loaded in order to use the redis backend.',
+                               1279462933
+                       );
+               }
+
+               parent::__construct($options);
+
+               $this->initializeObject();
+       }
+
+       /**
+        * Initializes the redis backend
+        *
+        * @param array Configuration options
+        * @return void
+        * @throws t3lib_cache_Exception if access to redis with password is denied or if database selection fails
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       protected function initializeObject() {
+               $this->redis = new Redis();
+
+               try {
+                       $this->connected = $this->redis->connect($this->hostname, $this->port);
+               } catch (Exception $e) {
+                       t3lib_div::sysLog('Unable to connect to redis server.', 'core', 3);
+               }
+
+               if ($this->connected) {
+                       if (strlen($this->password)) {
+                               $success = $this->redis->auth($this->password);
+                               if (!$success) {
+                                       throw new t3lib_cache_Exception(
+                                               'The given password was not accepted by the redis server.',
+                                               1279765134
+                                       );
+                               }
+                       }
+
+                       if ($this->database > 0) {
+                               $success = $this->redis->select($this->database);
+                               if (!$success) {
+                                       throw new t3lib_cache_Exception(
+                                               'The given database "' . $this->database . '" could not be selected.',
+                                               1279765144
+                                       );
+                               }
+                       }
+               }
+       }
+
+       /**
+        * Setter for server hostname
+        *
+        * @param string Hostname
+        * @return void
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function setHostname($hostname) {
+               $this->hostname = $hostname;
+       }
+
+       /**
+        * Setter for server port
+        *
+        * @param integer Port
+        * @return void
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function setPort($port) {
+               $this->port = $port;
+       }
+
+       /**
+        * Setter for database number
+        *
+        * @param integer Database
+        * @return void
+        * @throws InvalidArgumentException if database number is not valid
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function setDatabase($database) {
+               if (!is_integer($database)) {
+                       throw new InvalidArgumentException(
+                               'The specified database number is of type "' . gettype($database) . '" but an integer is expected.',
+                               1279763057
+                       );
+               }
+               if ($database < 0) {
+                       throw new InvalidArgumentException(
+                               'The specified database "' . $database. '" must be greater or equal than zero.',
+                               1279763534
+                       );
+               }
+
+               $this->database = $database;
+       }
+
+       /**
+        * Setter for authentication password
+        *
+        * @param string Password
+        * @return void
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function setPassword($password) {
+               $this->password = $password;
+       }
+
+       /**
+        * Enable data compression
+        *
+        * @param boolean TRUE to enable compression
+        * @return void
+        * @throws InvalidArgumentException if compression parameter is not of type boolean
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function setCompression($compression) {
+               if (!is_bool($compression)) {
+                       throw new InvalidArgumentException(
+                               'The specified compression of type "' . gettype($compression) . '" but a boolean is expected.',
+                               1289679153
+                       );
+               }
+
+               $this->compression = $compression;
+       }
+
+       /**
+        * Set data compression level.
+        * If compression is enabled and this is not set,
+        * gzcompress default level will be used.
+        *
+        * @param integer -1 to 9: Compression level
+        * @return void
+        * @throws InvalidArgumentException if compressionLevel parameter is not within allowed bounds
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function setCompressionLevel($compressionLevel) {
+               if (!is_integer($compressionLevel)) {
+                       throw new InvalidArgumentException(
+                               'The specified compression of type "' . gettype($compressionLevel) . '" but an integer is expected.',
+                               1289679154
+                       );
+               }
+
+               if ($compressionLevel >= -1 && $compressionLevel <= 9) {
+                       $this->compressionLevel = $compressionLevel;
+               } else {
+                       throw new InvalidArgumentException(
+                               'The specified compression level must be an integer between -1 and 9.',
+                               1289679155
+                       );
+               }
+       }
+
+       /**
+        * Save data in the cache
+        *
+        * Scales O(1) with number of cache entries
+        * Scales O(n) with number of tags
+        *
+        * @param string Identifier for this specific cache entry
+        * @param string Data to be stored
+        * @param array Tags to associate with this cache entry
+        * @param integer Lifetime of this cache entry in seconds. If NULL is specified, default lifetime is used. "0" means unlimited lifetime.
+        * @return void
+        * @throws InvalidArgumentException if identifier is not valid
+        * @throws t3lib_cache_Exception_InvalidData if data is not a string
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function set($entryIdentifier, $data, array $tags = array(), $lifetime = NULL) {
+               if (!is_string($entryIdentifier)) {
+                       throw new InvalidArgumentException(
+                               'The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.',
+                               1279470252
+                       );
+               }
+               if (!is_string($data)) {
+                       throw new t3lib_cache_Exception_InvalidData(
+                               'The specified data is of type "' . gettype($data) . '" but a string is expected.',
+                               1279469941
+                       );
+               }
+
+               $lifetimeIsNull = is_null($lifetime);
+               $lifetimeIsInteger = is_integer($lifetime);
+
+               if (!$lifetimeIsNull && !$lifetimeIsInteger) {
+                       throw new InvalidArgumentException(
+                               'The specified lifetime is of type "' . gettype($lifetime) . '" but a string or NULL is expected.',
+                               1279488008
+                       );
+               }
+               if ($lifetimeIsInteger && $lifetime < 0) {
+                       throw new InvalidArgumentException(
+                               'The specified lifetime "' . $lifetime . '" must be greater or equal than zero.',
+                               1279487573
+                       );
+               }
+
+               if ($this->connected) {
+                       $expiration = $lifetimeIsNull ? $this->defaultLifetime : $lifetime;
+                       $expiration = $expiration === 0 ? self::FAKED_UNLIMITED_LIFETIME : $expiration;
+
+                       if ($this->compression) {
+                               $data = gzcompress($data, $this->compressionLevel);
+                       }
+
+                       $this->redis->setex(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, $expiration, $data);
+
+                       $addTags = $tags;
+                       $removeTags = array();
+                       $existingTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
+                       if (!empty($existingTags)) {
+                               $addTags = array_diff($tags, $existingTags);
+                               $removeTags = array_diff($existingTags, $tags);
+                       }
+
+                       if (count($removeTags) > 0 || count($addTags) > 0) {
+                               $queue = $this->redis->multi(Redis::PIPELINE);
+                               foreach ($removeTags as $tag) {
+                                       $queue->sRemove(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
+                                       $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
+                               }
+
+                               foreach ($addTags as $tag) {
+                                       $queue->sAdd(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier, $tag);
+                                       $queue->sAdd(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
+                               }
+                               $queue->exec();
+                       }
+               }
+       }
+
+       /**
+        * Loads data from the cache.
+        *
+        * Scales O(1) with number of cache entries
+        *
+        * @param string An identifier which describes the cache entry to load
+        * @return mixed The cache entry's content as a string or FALSE if the cache entry could not be loaded
+        * @throws InvalidArgumentException if identifier is not a string
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function get($entryIdentifier) {
+               if (!is_string($entryIdentifier)) {
+                       throw new InvalidArgumentException(
+                               'The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.',
+                               1279470253
+                       );
+               }
+
+               $storedEntry = FALSE;
+               if ($this->connected) {
+                       $storedEntry = $this->redis->get(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
+               }
+
+               if ($this->compression && strlen($storedEntry) > 0) {
+                       $storedEntry = gzuncompress($storedEntry);
+               }
+
+               return $storedEntry;
+       }
+
+       /**
+        * Checks if a cache entry with the specified identifier exists.
+        *
+        * Scales O(1) with number of cache entries
+        *
+        * @param string Identifier specifying the cache entry
+        * @return boolean TRUE if such an entry exists, FALSE if not
+        * @throws InvalidArgumentException if identifier is not a string
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function has($entryIdentifier) {
+               if (!is_string($entryIdentifier)) {
+                       throw new InvalidArgumentException(
+                               'The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.',
+                               1279470254
+                       );
+               }
+               return $this->connected && $this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier);
+       }
+
+       /**
+        * Removes all cache entries matching the specified identifier.
+        *
+        * Scales O(1) with number of cache entries
+        * Scales O(n) with number of tags
+        *
+        * @param string Specifies the cache entry to remove
+        * @return boolean TRUE if (at least) an entry could be removed or FALSE if no entry was found
+        * @throws InvalidArgumentException if identifier is not a string
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function remove($entryIdentifier) {
+               if (!is_string($entryIdentifier)) {
+                       throw new InvalidArgumentException(
+                               'The specified identifier is of type "' . gettype($entryIdentifier) . '" but a string is expected.',
+                               1279470255
+                       );
+               }
+
+               $elementsDeleted = FALSE;
+               if ($this->connected) {
+                       if ($this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier)) {
+                               $assignedTags = $this->redis->sMembers(self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
+
+                               $queue = $this->redis->multi(Redis::PIPELINE);
+                               foreach ($assignedTags as $tag) {
+                                       $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $entryIdentifier);
+                               }
+                               $queue->delete(self::IDENTIFIER_DATA_PREFIX . $entryIdentifier, self::IDENTIFIER_TAGS_PREFIX . $entryIdentifier);
+                               $queue->exec();
+                               $elementsDeleted = TRUE;
+                       }
+               }
+
+               return $elementsDeleted;
+       }
+
+       /**
+        * Finds and returns all cache entry identifiers which are tagged by the
+        * specified tag.
+        *
+        * Scales O(1) with number of cache entries
+        * Scales O(n) with number of tag entries
+        *
+        * @param string The tag to search for
+        * @return array An array of entries with all matching entries. An empty array if no entries matched
+        * @throws InvalidArgumentException if tag is not a string
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function findIdentifiersByTag($tag) {
+               if (!is_string($tag)) {
+                       throw new InvalidArgumentException(
+                               'The specified tag is of type "' . gettype($tag) . '" but a string is expected.',
+                               1279569759
+                       );
+               }
+
+               $foundIdentifiers = array();
+               if ($this->connected) {
+                       $foundIdentifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
+               }
+
+               return $foundIdentifiers;
+       }
+
+       /**
+        * Finds and returns all cache entry identifiers which are tagged
+        * with all of the specified tags.
+        *
+        * Scales O(n) with number of tags
+        *
+        * @param array Array of tags to search for
+        * @return array An array with identifiers of all matching entries. An empty array if no entries matched
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function findIdentifiersByTags(array $tags) {
+               $foundIdentifiers = array();
+
+               if ($this->connected) {
+                       $tagsWithPrefix = array();
+                       foreach ($tags as $tag) {
+                               $tagsWithPrefix[] = self::TAG_IDENTIFIERS_PREFIX . $tag;
+                       }
+                       $foundIdentifiers = $this->redis->sInter($tagsWithPrefix);
+               }
+
+               return $foundIdentifiers;
+       }
+
+       /**
+        * Removes all cache entries of this cache.
+        *
+        * Scales O(1) with number of cache entries
+        *
+        * @return void
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function flush() {
+               if ($this->connected) {
+                       $this->redis->flushdb();
+               }
+       }
+
+       /**
+        * Removes all cache entries of this cache which are tagged with the specified tag.
+        *
+        * Scales O(1) with number of cache entries
+        * Scales O(n^2) with number of tag entries
+        *
+        * @param string Tag the entries must have
+        * @return void
+        * @throws InvalidArgumentException if identifier is not a string
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function flushByTag($tag) {
+               if (!is_string($tag)) {
+                       throw new InvalidArgumentException(
+                               'The specified tag is of type "' . gettype($tag) . '" but a string is expected.',
+                               1279578078
+                       );
+               }
+
+               if ($this->connected) {
+                       $identifiers = $this->redis->sMembers(self::TAG_IDENTIFIERS_PREFIX . $tag);
+
+                       if (count($identifiers) > 0) {
+                               $this->removeIdentifierEntriesAndRelations($identifiers, array($tag));
+                       }
+               }
+       }
+
+       /**
+        * Removes all cache entries of this cache which are tagged with one of the specified tags.
+        *
+        * Scales O(1) with number of cache entries
+        * Scales O(n^2) with number of tags
+        *
+        * @param array Tags the entries must have
+        * @return void
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @api
+        */
+       public function flushByTags(array $tags) {
+               if ($this->connected) {
+                       $prefixedKeysToDelete = array();
+                       foreach ($tags as $tag) {
+                               $prefixedKeysToDelete[] = self::TAG_IDENTIFIERS_PREFIX . $tag;
+                       }
+
+                               // Get all identifiers tagged with at least one of the given tags
+                       $identifiers = $this->redis->sUnion($prefixedKeysToDelete);
+
+                       if (count($identifiers)) {
+                               $this->removeIdentifierEntriesAndRelations($identifiers, $tags, $prefixedKeysToDelete);
+                       }
+               }
+       }
+
+       /**
+        * With the current internal structure, only the identifier to data entries
+        * have a redis internal lifetime. If an entry expires, attached
+        * identifier to tags and tag to identifiers entries will be left over.
+        * This methods finds those entries and cleans them up.
+        *
+        * Scales O(n*m) with number of cache entries (n) and number of tags (m)
+        *
+        * @return void
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        * @api
+        */
+       public function collectGarbage() {
+               $identifierToTagsKeys = $this->redis->getKeys(self::IDENTIFIER_TAGS_PREFIX . '*');
+               foreach ($identifierToTagsKeys as $identifierToTagsKey) {
+                       list(,$identifier) = explode(':', $identifierToTagsKey);
+                               // Check if the data entry still exists
+                       if (!$this->redis->exists(self::IDENTIFIER_DATA_PREFIX . $identifier)) {
+                               $tagsToRemoveIdentifierFrom = $this->redis->sMembers($identifierToTagsKey);
+                               $queue = $this->redis->multi(Redis::PIPELINE);
+                               $queue->delete($identifierToTagsKey);
+                               foreach ($tagsToRemoveIdentifierFrom as $tag) {
+                                       $queue->sRemove(self::TAG_IDENTIFIERS_PREFIX . $tag, $identifier);
+                               }
+                               $queue->exec();
+                       }
+               }
+       }
+
+       /**
+        * Helper method for flushByTag() and flushByTags()
+        * Gets list of identifiers and tags and removes all relations of those tags
+        *
+        * Scales O(1) with number of cache entries
+        * Scales O(n^2) with number of tags
+        *
+        * @param array List of identifiers to remove
+        * @param array List of tags to be handled
+        * @return void
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @author Christopher Hlubek <hlubek@networkteam.com>
+        */
+       protected function removeIdentifierEntriesAndRelations(array $identifiers, array $tags) {
+                       // Set a temporary entry which holds all identifiers that need to be removed from
+                       // the tag to identifiers sets
+               $uniqueTempKey = 'temp:' . uniqId();
+               $prefixedKeysToDelete = array($uniqueTempKey);
+
+               $prefixedIdentifierToTagsKeysToDelete = array();
+               foreach ($identifiers as $identifier) {
+                       $prefixedKeysToDelete[] = self::IDENTIFIER_DATA_PREFIX . $identifier;
+                       $prefixedIdentifierToTagsKeysToDelete[] = self::IDENTIFIER_TAGS_PREFIX . $identifier;
+               }
+               foreach ($tags as $tag) {
+                       $prefixedKeysToDelete[] = self::TAG_IDENTIFIERS_PREFIX . $tag;
+               }
+
+               $tagToIdentifiersSetsToRemoveIdentifiersFrom = $this->redis->sUnion($prefixedIdentifierToTagsKeysToDelete);
+
+                       // Remove the tag to identifier set of the given tags, they will be removed anyway
+               $tagToIdentifiersSetsToRemoveIdentifiersFrom = array_diff($tagToIdentifiersSetsToRemoveIdentifiersFrom, $tags);
+
+                       // Diff all identifiers that must be removed from tag to identifiers sets off from a
+                       // tag to identifiers set and store result in same tag to identifiers set again
+               $queue = $this->redis->multi(Redis::PIPELINE);
+               foreach ($identifiers as $identifier) {
+                       $queue->sAdd($uniqueTempKey, $identifier);
+               }
+               foreach ($tagToIdentifiersSetsToRemoveIdentifiersFrom as $tagToIdentifiersSet) {
+                       $queue->sDiffStore(
+                               self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet,
+                               self::TAG_IDENTIFIERS_PREFIX . $tagToIdentifiersSet,
+                               $uniqueTempKey
+                       );
+               }
+
+               $queue->delete(array_merge($prefixedKeysToDelete, $prefixedIdentifierToTagsKeysToDelete));
+               $queue->exec();
+       }
+}
+?>
index af18dcf..16169ed 100644 (file)
@@ -118,6 +118,7 @@ $TYPO3_CONF_VARS = array(
                                't3lib_cache_backend_GlobalsBackend'         => 't3lib_cache_backend_GlobalsBackend',
                                't3lib_cache_backend_MemcachedBackend'       => 't3lib_cache_backend_MemcachedBackend',
                                't3lib_cache_backend_PdoBackend'             => 't3lib_cache_backend_PdoBackend',
+                               't3lib_cache_backend_RedisBackend'           => 't3lib_cache_backend_RedisBackend',
                                't3lib_cache_backend_ApcBackend'             => 't3lib_cache_backend_ApcBackend',
                                't3lib_cache_backend_NullBackend'            => 't3lib_cache_backend_NullBackend',
                                't3lib_cache_backend_TransientMemoryBackend' => 't3lib_cache_backend_TransientMemoryBackend',
index e27ab58..f6bb09e 100644 (file)
@@ -95,6 +95,7 @@ $t3libClasses = array(
        't3lib_cache_backend_memcachedbackend' => PATH_t3lib . 'cache/backend/class.t3lib_cache_backend_memcachedbackend.php',
        't3lib_cache_backend_nullbackend' => PATH_t3lib . 'cache/backend/class.t3lib_cache_backend_nullbackend.php',
        't3lib_cache_backend_pdobackend' => PATH_t3lib . 'cache/backend/class.t3lib_cache_backend_pdobackend.php',
+       't3lib_cache_backend_redisbackend' => PATH_t3lib . 'cache/backend/class.t3lib_cache_backend_redisbackend.php',
        't3lib_cache_backend_transientmemorybackend' => PATH_t3lib . 'cache/backend/class.t3lib_cache_backend_transientmemorybackend.php',
        't3lib_cache_backend_backend' => PATH_t3lib . 'cache/backend/interfaces/interface.t3lib_cache_backend_backend.php',
        't3lib_cache_backend_phpcapablebackend' => PATH_t3lib . 'cache/backend/interfaces/interface.t3lib_cache_backend_phpcapablebackend.php',
diff --git a/tests/t3lib/cache/backend/t3lib_cache_backend_redisbackendTest.php b/tests/t3lib/cache/backend/t3lib_cache_backend_redisbackendTest.php
new file mode 100644 (file)
index 0000000..ae7af98
--- /dev/null
@@ -0,0 +1,1137 @@
+<?php
+/***************************************************************
+*  Copyright notice
+*
+*  (c) 2010 Christian Kuhn <lolli@schwarzbu.ch>
+*  All rights reserved
+*
+*  This script is part of the TYPO3 project. The TYPO3 project is
+*  free software; you can redistribute it and/or modify
+*  it under the terms of the GNU General Public License as published by
+*  the Free Software Foundation; either version 2 of the License, or
+*  (at your option) any later version.
+*
+*  The GNU General Public License can be found at
+*  http://www.gnu.org/copyleft/gpl.html.
+*
+*  This script is distributed in the hope that it will be useful,
+*  but WITHOUT ANY WARRANTY; without even the implied warranty of
+*  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+*  GNU General Public License for more details.
+*
+*  This copyright notice MUST APPEAR in all copies of the script!
+***************************************************************/
+
+/**
+ * Testcase for the cache to redis backend
+ *
+ * This class has functional tests as well as implementation tests:
+ * - The functional tests make API calls to the backend and check expected behaviour
+ * - The implementation tests make additional calls with an own redis instance to
+ *   check stored data structures in the redis server, which can not be checked
+ *   by functional tests alone. Those tests will fail if any changes
+ *   to the internal data structure are done.
+ *
+ * Warning:
+ * The unit tests use and flush redis database numbers 0 and 1!
+ *
+ * @author Christian Kuhn <lolli@schwarzbu.ch>
+ * @package TYPO3
+ * @subpackage tests
+ */
+class t3lib_cache_backend_RedisBackendTest extends tx_phpunit_testcase {
+
+       /**
+        * @var boolean
+        */
+       protected $backupGlobals = TRUE;
+
+       /**
+        * If set, the tearDown() method will flush the cache used by this unit test.
+        *
+        * @var t3lib_cache_backend_RedisBackend
+        */
+       protected $backend = NULL;
+
+       /**
+        * Own redis instance used in implementation tests
+        *
+        * @var Redis
+        */
+       protected $redis = NULL;
+
+       /**
+        * Set up this testcase
+        *
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setUp() {
+               if (!extension_loaded('redis')) {
+                       $this->markTestSkipped('redis extension was not available');
+               }
+
+               try {
+                       if (!fsockopen('127.0.0.1', 6379)) {
+                               $this->markTestSkipped('redis server not reachable');
+                       }
+               } catch (Exception $e) {
+                       $this->markTestSkipped('redis server not reachable');
+               }
+
+               $this->backupGlobals = TRUE;
+       }
+
+       /**
+        * Sets up the redis backend used for testing
+        *
+        * @param array Options for the redis backend
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       protected function setUpBackend(array $backendOptions = array()) {
+               $mockCache = $this->getMock('t3lib_cache_frontend_Frontend', array(), array(), '', FALSE);
+               $mockCache->expects($this->any())->method('getIdentifier')->will($this->returnValue('TestCache'));
+
+               $this->backend = new t3lib_cache_backend_RedisBackend($backendOptions);
+               $this->backend->setCache($mockCache);
+       }
+
+       /**
+        * Sets up an own redis instance for implementation tests
+        *
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       protected function setUpRedis() {
+               $this->redis = new Redis();
+               $this->redis->connect('127.0.0.1', 6379);
+       }
+
+       /**
+        * Tear down this testcase
+        *
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function tearDown() {
+               if ($this->backend instanceof t3lib_cache_backend_RedisBackend) {
+                       $this->backend->flush();
+               }
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function constructorThrowsNoExceptionIfPasswordOptionIsSet() {
+               try {
+                       $this->setUpBackend(array('password' => 'foo'));
+               } catch (Exception $e) {
+                       $this->assertTrue();
+               }
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function constructorThrowsNoExceptionIfGivenDatabaseWasSuccessfullySelected() {
+               try {
+                       $this->setUpBackend(array('database' => 1));
+               } catch (Exception $e) {
+                       $this->assertTrue();
+               }
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setDatabaseThrowsExceptionIfGivenDatabaseNumberIsNotAnInteger() {
+               $this->setUpBackend(array('database' => 'foo'));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setDatabaseThrowsExceptionIfGivenDatabaseNumberIsNegative() {
+               $this->setUpBackend(array('database' => -1));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setCompressionThrowsExceptionIfCompressionParameterIsNotOfTypeBoolean() {
+               $this->setUpBackend(array('compression' => 'foo'));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setCompressionLevelThrowsExceptionIfCompressionLevelIsNotInteger() {
+               $this->setUpBackend(array('compressionLevel' => 'foo'));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setCompressionLevelThrowsExceptionIfCompressionLevelIsNotBetweenMinusOneAndNine() {
+               $this->setUpBackend(array('compressionLevel' => 11));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setThrowsExceptionIfIdentifierIsNotAString() {
+               $this->setUpBackend();
+               $this->backend->set(array(), 'data');
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException t3lib_cache_Exception_InvalidData
+        */
+       public function setThrowsExceptionIfDataIsNotAString() {
+               $this->setUpBackend();
+               $this->backend->set('identifier' . uniqid(), array());
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setThrowsExceptionIfLifetimeIsNegative() {
+               $this->setUpBackend();
+               $this->backend->set('identifier' . uniqid(), 'data', array(), -42);
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function setThrowsExceptionIfLifetimeIsNotNullOrAnInteger() {
+               $this->setUpBackend();
+               $this->backend->set('identifier' . uniqid(), 'data', array(), array());
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setStoresEntriesInSelectedDatabase() {
+               $this->setUpRedis();
+               $this->redis->select(1);
+
+               $this->setUpBackend(array('database' => 1));
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data');
+
+               $this->assertTrue($this->redis->exists('identData:' . $identifier));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesStringDataTypeForIdentifierToDataEntry() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data');
+
+               $this->assertSame(Redis::REDIS_STRING, $this->redis->type('identData:' . $identifier));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesEntryWithDefaultLifeTime() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $defaultLifetime = 42;
+               $this->backend->setDefaultLifetime($defaultLifetime);
+               $this->backend->set($identifier, 'data');
+
+               $lifetimeRegisteredInBackend = $this->redis->ttl('identData:' . $identifier);
+               $this->assertSame($defaultLifetime, $lifetimeRegisteredInBackend);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesEntryWithSpecifiedLifeTime() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $lifetime = 43;
+               $this->backend->set($identifier, 'data', array(), $lifetime);
+
+               $lifetimeRegisteredInBackend = $this->redis->ttl('identData:' . $identifier);
+               $this->assertSame($lifetime, $lifetimeRegisteredInBackend);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesEntryWithUnlimitedLifeTime() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data', array(), 0);
+
+               $lifetimeRegisteredInBackend = $this->redis->ttl('identData:' . $identifier);
+               $this->assertSame(31536000, $lifetimeRegisteredInBackend);
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Jul Jensen <julle@typo3.org>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setOverwritesExistingEntryWithNewData() {
+               $this->setUpBackend();
+               $data = 'data 1';
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, $data);
+               $otherData = 'data 2';
+               $this->backend->set($identifier, $otherData);
+               $fetchedData = $this->backend->get($identifier);
+               $this->assertSame($otherData, $fetchedData);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setOverwritesExistingEntryWithSpecifiedLifetime() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $data = 'data';
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, $data);
+               $lifetime = 42;
+               $this->backend->set($identifier, $data, array(), $lifetime);
+
+               $lifetimeRegisteredInBackend = $this->redis->ttl('identData:' . $identifier);
+               $this->assertSame($lifetime, $lifetimeRegisteredInBackend);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setOverwritesExistingEntryWithNewDefaultLifetime() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $data = 'data';
+               $identifier = 'identifier' . uniqid();
+               $lifetime = 42;
+               $this->backend->set($identifier, $data, array(), $lifetime);
+               $newDefaultLifetime = 43;
+               $this->backend->setDefaultLifetime($newDefaultLifetime);
+               $this->backend->set($identifier, $data, array(), $newDefaultLifetime);
+
+               $lifetimeRegisteredInBackend = $this->redis->ttl('identData:' . $identifier);
+               $this->assertSame($newDefaultLifetime, $lifetimeRegisteredInBackend);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setOverwritesExistingEntryWithNewUnlimitedLifetime() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $data = 'data';
+               $identifier = 'identifier' . uniqid();
+               $lifetime = 42;
+               $this->backend->set($identifier, $data, array(), $lifetime);
+               $this->backend->set($identifier, $data, array(), 0);
+
+               $lifetimeRegisteredInBackend = $this->redis->ttl('identData:' . $identifier);
+               $this->assertSame(31536000, $lifetimeRegisteredInBackend);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesSetDataTypeForIdentifierToTagsSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data', array('tag'));
+
+               $this->assertSame(Redis::REDIS_SET, $this->redis->type('identTags:' . $identifier));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesSpecifiedTagsInIdentifierToTagsSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tags = array('thatTag', 'thisTag');
+               $this->backend->set($identifier, 'data', $tags);
+
+               $savedTags = $this->redis->sMembers('identTags:' . $identifier);
+               sort($savedTags);
+               $this->assertSame($tags, $savedTags);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setRemovesAllPreviouslySetTagsFromIdentifierToTagsSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tags = array('fooTag', 'barTag');
+               $this->backend->set($identifier, 'data', $tags);
+               $this->backend->set($identifier, 'data', array());
+
+               $this->assertSame(array(), $this->redis->sMembers('identTags:' . $identifier));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setRemovesMultiplePreviouslySetTagsFromIdentifierToTagsSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $firstTagSet = array('tag1', 'tag2', 'tag3', 'tag4');
+               $this->backend->set($identifier, 'data', $firstTagSet);
+               $secondTagSet = array('tag1', 'tag3');
+               $this->backend->set($identifier, 'data', $secondTagSet);
+
+               $actualTagSet = $this->redis->sMembers('identTags:' . $identifier);
+               sort($actualTagSet);
+               $this->assertSame($secondTagSet, $actualTagSet);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesSetDataTypeForTagToIdentifiersSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tag = 'tag';
+               $this->backend->set($identifier, 'data', array($tag));
+
+               $this->assertSame(Redis::REDIS_SET, $this->redis->type('tagIdents:' . $tag));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesIdentifierInTagToIdentifiersSetOfSpecifiedTag() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tag = 'thisTag';
+               $this->backend->set($identifier, 'data', array($tag));
+
+               $savedTagToIdentifiersMemberArray = $this->redis->sMembers('tagIdents:' . $tag);
+               $this->assertSame(array($identifier), $savedTagToIdentifiersMemberArray);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setAppendsSecondIdentifierInTagToIdentifiersEntry() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $firstIdentifier = 'identifier' . uniqid();
+               $tag = 'thisTag';
+               $this->backend->set($firstIdentifier, 'data', array($tag));
+               $secondIdentifier = 'identifier' . uniqid();
+               $this->backend->set($secondIdentifier, 'data', array($tag));
+
+               $savedTagToIdentifiersMemberArray = $this->redis->sMembers('tagIdents:' . $tag);
+               sort($savedTagToIdentifiersMemberArray);
+               $identifierArray = array($firstIdentifier, $secondIdentifier);
+               sort($identifierArray);
+               $this->assertSame(array($firstIdentifier, $secondIdentifier), $savedTagToIdentifiersMemberArray);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setRemovesIdentifierFromTagToIdentifiersEntryIfTagIsOmittedOnConsecutiveSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tag = 'thisTag';
+               $this->backend->set($identifier, 'data', array($tag));
+               $this->backend->set($identifier, 'data', array());
+
+               $savedTagToIdentifiersMemberArray = $this->redis->sMembers('tagIdents:' . $tag);
+               $this->assertSame(array(), $savedTagToIdentifiersMemberArray);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setAddsIdentifierInTagToIdentifiersEntryIfTagIsAddedOnConsecutiveSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data');
+               $tag = 'thisTag';
+               $this->backend->set($identifier, 'data', array($tag));
+
+               $savedTagToIdentifiersMemberArray = $this->redis->sMembers('tagIdents:' . $tag);
+               $this->assertSame(array($identifier), $savedTagToIdentifiersMemberArray);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesCompressedDataWithEnabledCompression() {
+               $this->setUpBackend(
+                       array(
+                               'compression' => TRUE,
+                       )
+               );
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $data = 'some data ' . microtime();
+               $this->backend->set($identifier, $data);
+
+               $uncompresedStoredData = '';
+               try {
+                       $uncompresedStoredData = @gzuncompress($this->redis->get('identData:' . $identifier));
+               } catch (Exception $e) {
+               }
+
+               $this->assertEquals($data, $uncompresedStoredData, 'Original and compressed data don\'t match');
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function setSavesPlaintextDataWithEnabledCompressionAndCompressionLevel0() {
+               $this->setUpBackend(
+                       array(
+                               'compression' => TRUE,
+                               'compressionLevel' => 0,
+                       )
+               );
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $data = 'some data ' . microtime();
+               $this->backend->set($identifier, $data);
+
+               $this->assertGreaterThan(0, substr_count($this->redis->get('identData:' . $identifier), $data), 'Plaintext data not found');
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function hasThrowsExceptionIfIdentifierIsNotAString() {
+               $this->setUpBackend();
+               $this->backend->has(array());
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function hasReturnsFalseForNotExistingEntry() {
+               $this->setUpBackend();
+               $identifier = 'identifier' . uniqid();
+               $this->assertFalse($this->backend->has($identifier));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function hasReturnsTrueForPreviouslySetEntry() {
+               $this->setUpBackend();
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data');
+               $this->assertTrue($this->backend->has($identifier));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function getThrowsExceptionIfIdentifierIsNotAString() {
+               $this->setUpBackend();
+               $this->backend->get(array());
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function getReturnsPreviouslyCompressedSetEntry() {
+               $this->setUpBackend(
+                       array(
+                               'compression' => TRUE,
+                       )
+               );
+               $data = 'data';
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, $data);
+               $fetchedData = $this->backend->get($identifier);
+               $this->assertSame($data, $fetchedData);
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function getReturnsPreviouslySetEntry() {
+               $this->setUpBackend();
+               $data = 'data';
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, $data);
+               $fetchedData = $this->backend->get($identifier);
+               $this->assertSame($data, $fetchedData);
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function removeThrowsExceptionIfIdentifierIsNotAString() {
+               $this->setUpBackend();
+               $this->backend->remove(array());
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function removeReturnsFalseIfNoEntryWasDeleted() {
+               $this->setUpBackend();
+               $this->assertFalse($this->backend->remove('identifier' . uniqid()));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function removeReturnsTrueIfAnEntryWasDeleted() {
+               $this->setUpBackend();
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data');
+               $this->assertTrue($this->backend->remove($identifier));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Jul Jensen <julle@typo3.org>
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function removeDeletesEntryFromCache() {
+               $this->setUpBackend();
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data');
+               $this->backend->remove($identifier);
+               $this->assertFalse($this->backend->has($identifier));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function removeDeletesIdentifierToTagEntry() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tag = 'thisTag';
+               $this->backend->set($identifier, 'data', array($tag));
+               $this->backend->remove($identifier);
+
+               $this->assertFalse($this->redis->exists('identTags:' . $identifier));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function removeDeletesIdentifierFromTagToIdentifiersSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tag = 'thisTag';
+               $this->backend->set($identifier, 'data', array($tag));
+               $this->backend->remove($identifier);
+
+               $tagToIdentifiersMemberArray = $this->redis->sMembers('tagIdents:' . $tag);
+               $this->assertSame(array(), $tagToIdentifiersMemberArray);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function removeDeletesIdentifierFromTagToIdentifiersSetWithMultipleEntries() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $firstIdentifier = 'identifier' . uniqid();
+               $secondIdentifier = 'identifier' . uniqid();
+               $tag = 'thisTag';
+               $this->backend->set($firstIdentifier, 'data', array($tag));
+               $this->backend->set($secondIdentifier, 'data', array($tag));
+               $this->backend->remove($firstIdentifier);
+
+               $tagToIdentifiersMemberArray = $this->redis->sMembers('tagIdents:' . $tag);
+               $this->assertSame(array($secondIdentifier), $tagToIdentifiersMemberArray);
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function findIdentifiersByTagThrowsExceptionIfTagIsNotAString() {
+               $this->setUpBackend();
+               $this->backend->findIdentifiersByTag(array());
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function findIdentifiersByTagReturnsEmptyArrayForNotExistingTag() {
+               $this->setUpBackend();
+               $this->assertSame(array(), $this->backend->findIdentifiersByTag('thisTag'));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function findIdentifiersByTagReturnsAllIdentifiersTagedWithSpecifiedTag() {
+               $this->setUpBackend();
+
+               $firstIdentifier = 'identifier' . uniqid();
+               $secondIdentifier = 'identifier' . uniqid();
+               $thirdIdentifier = 'identifier' . uniqid();
+               $tagsForFirstIdentifier = array('thisTag');
+               $tagsForSecondIdentifier = array('thatTag');
+               $tagsForThirdIdentifier = array('thisTag', 'thatTag');
+
+               $this->backend->set($firstIdentifier, 'data', $tagsForFirstIdentifier);
+               $this->backend->set($secondIdentifier, 'data', $tagsForSecondIdentifier);
+               $this->backend->set($thirdIdentifier, 'data', $tagsForThirdIdentifier);
+
+               $expectedResult = array($firstIdentifier, $thirdIdentifier);
+               $actualResult = $this->backend->findIdentifiersByTag('thisTag');
+               sort($actualResult);
+
+               $this->assertSame($expectedResult, $actualResult);
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function findIdentifiersByTagsReturnEmptyArrayForNotExistingTag() {
+               $this->setUpBackend();
+               $this->assertEquals(array(), $this->backend->findIdentifiersByTags(array('thisTags')));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function findIdentifiersByTagsReturnsIdentifiersTaggedWithAllSpecifiedTags() {
+               $this->setUpBackend();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1'));
+               $this->backend->set($identifier . 'B', 'data', array('tag2'));
+               $this->backend->set($identifier . 'C', 'data', array('tag1', 'tag2'));
+               $this->backend->set($identifier . 'D', 'data', array('tag1', 'tag2', 'tag3'));
+
+               $expectedResult = array($identifier . 'C', $identifier . 'D');
+               $actualResult = $this->backend->findIdentifiersByTags(array('tag1', 'tag2'));
+               sort($actualResult);
+
+               $this->assertEquals($expectedResult, $actualResult);
+       }
+
+
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushRemovesAllEntriesFromCache() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier, 'data');
+               $this->backend->flush();
+
+               $this->assertSame(array(), $this->redis->getKeys('*'));
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        * @expectedException InvalidArgumentException
+        */
+       public function flushByTagThrowsExceptionIfTagIsNotAString() {
+               $this->setUpBackend();
+               $this->backend->flushByTag(array());
+       }
+
+       /**
+        * @test Functional
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagRemovesEntriesTaggedWithSpecifiedTag() {
+               $this->setUpBackend();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1'));
+               $this->backend->set($identifier . 'B', 'data', array('tag2'));
+               $this->backend->set($identifier . 'C', 'data', array('tag1', 'tag2'));
+               $this->backend->flushByTag('tag1');
+
+               $expectedResult = array(FALSE, TRUE, FALSE);
+               $actualResult = array(
+                       $this->backend->has($identifier . 'A'),
+                       $this->backend->has($identifier . 'B'),
+                       $this->backend->has($identifier . 'C'),
+               );
+               $this->assertSame($expectedResult, $actualResult);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagRemovesTemporarySet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1'));
+               $this->backend->set($identifier . 'C', 'data', array('tag1', 'tag2'));
+               $this->backend->flushByTag('tag1');
+
+               $this->assertSame(array(), $this->redis->getKeys('temp*'));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagRemovesIdentifierToTagsSetOfEntryTaggedWithGivenTag() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tag = 'tag1';
+               $this->backend->set($identifier, 'data', array($tag));
+               $this->backend->flushByTag($tag);
+
+               $this->assertFalse($this->redis->exists('identTags:' . $identifier));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagDoesNotRemoveIdentifierToTagsSetOfUnrelatedEntry() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifierToBeRemoved = 'identifier' . uniqid();
+               $tagToRemove = 'tag1';
+               $this->backend->set($identifierToBeRemoved, 'data', array($tagToRemove));
+
+               $identifierNotToBeRemoved = 'identifier' . uniqid();
+               $tagNotToRemove = 'tag2';
+               $this->backend->set($identifierNotToBeRemoved, 'data', array($tagNotToRemove));
+
+               $this->backend->flushByTag($tagToRemove);
+
+               $this->assertSame(array($tagNotToRemove), $this->redis->sMembers('identTags:' . $identifierNotToBeRemoved));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagRemovesTagToIdentifiersSetOfGivenTag() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $tag = 'tag1';
+               $this->backend->set($identifier, 'data', array($tag));
+               $this->backend->flushByTag($tag);
+
+               $this->assertFalse($this->redis->exists('tagIdents:' . $tag));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagRemovesIdentifiersTaggedWithGivenTagFromTagToIdentifiersSets() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1', 'tag2'));
+               $this->backend->set($identifier . 'B', 'data', array('tag1', 'tag2'));
+               $this->backend->set($identifier . 'C', 'data', array('tag2'));
+
+               $this->backend->flushByTag('tag1');
+
+               $this->assertSame(array($identifier . 'C'), $this->redis->sMembers('tagIdents:tag2'));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagsRemovesEntriesTaggedWithSpecifiedTags() {
+               $this->setUpBackend();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1'));
+               $this->backend->set($identifier . 'B', 'data', array('tag2'));
+               $this->backend->set($identifier . 'C', 'data', array('tag3'));
+               $this->backend->set($identifier . 'D', 'data', array('tag1', 'tag2'));
+               $this->backend->set($identifier . 'E', 'data', array('tag1', 'tag3'));
+               $this->backend->flushByTags(array('tag1', 'tag2'));
+
+               $expectedResult = array(FALSE, FALSE, TRUE, FALSE, FALSE);
+               $actualResult = array(
+                       $this->backend->has($identifier . 'A'),
+                       $this->backend->has($identifier . 'B'),
+                       $this->backend->has($identifier . 'C'),
+                       $this->backend->has($identifier . 'D'),
+                       $this->backend->has($identifier . 'E'),
+               );
+
+               $this->assertEquals($expectedResult, $actualResult);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagsRemovesIdentifierToTagsSetOfEntriesTaggedWithGivenTags() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1', 'tag2'));
+               $this->backend->set($identifier . 'B', 'data', array('tag1', 'tag3'));
+               $this->backend->set($identifier . 'C', 'data', array('tag2', 'tag4'));
+               $this->backend->set($identifier . 'D', 'data', array('tag3', 'tag4'));
+
+               $this->backend->flushByTags(array('tag1', 'tag2'));
+
+               $expectedResult = array(FALSE, FALSE, FALSE, TRUE);
+               $actualResult = array(
+                       $this->redis->exists('identTags:' . $identifier . 'A'),
+                       $this->redis->exists('identTags:' . $identifier . 'B'),
+                       $this->redis->exists('identTags:' . $identifier . 'C'),
+                       $this->redis->exists('identTags:' . $identifier . 'D'),
+               );
+
+               $this->assertEquals($expectedResult, $actualResult);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagsRemovesTagToIdentifiersSetsOfGivenTags() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1', 'tag2'));
+               $this->backend->set($identifier . 'B', 'data', array('tag1', 'tag3'));
+               $this->backend->set($identifier . 'C', 'data', array('tag2', 'tag4'));
+               $this->backend->set($identifier . 'D', 'data', array('tag3', 'tag4'));
+
+               $this->backend->flushByTags(array('tag1', 'tag2'));
+
+               $expectedResult = array(FALSE, FALSE, TRUE, TRUE);
+               $actualResult = array(
+                       $this->redis->exists('tagIdents:tag1'),
+                       $this->redis->exists('tagIdents:tag2'),
+                       $this->redis->exists('tagIdents:tag3'),
+                       $this->redis->exists('tagIdents:tag4'),
+               );
+
+               $this->assertEquals($expectedResult, $actualResult);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function flushByTagsRemovesIdentifiersTaggedWithGivenTagsFromTagToIdentifiersSets() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set('A' . $identifier, 'data', array('tag1', 'tag2'));
+               $this->backend->set('B' . $identifier, 'data', array('tag1', 'tag3'));
+               $this->backend->set('C' . $identifier, 'data', array('tag3', 'tag4'));
+               $this->backend->set('D' . $identifier, 'data', array('tag3', 'tag4', 'tag5'));
+
+               $this->backend->flushByTags(array('tag1', 'tag2'));
+
+               $expectedResult = array(
+                       array('C' . $identifier, 'D' . $identifier),
+                       array('C' . $identifier, 'D' . $identifier),
+                       array('D' . $identifier),
+               );
+
+               $tag3Identifiers = $this->redis->sMembers('tagIdents:tag3');
+               $tag4Identifiers = $this->redis->sMembers('tagIdents:tag4');
+               $tag5Identifiers = $this->redis->sMembers('tagIdents:tag5');
+               sort($tag3Identifiers);
+               sort($tag4Identifiers);
+
+               $actualResult = array(
+                       $tag3Identifiers,
+                       $tag4Identifiers,
+                       $tag5Identifiers,
+               );
+
+               $this->assertEquals($expectedResult, $actualResult);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function collectGarbageDoesNotRemoveNotExpiredIdentifierToDataEntry() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag'));
+               $this->backend->set($identifier . 'B', 'data', array('tag'));
+
+               $this->redis->delete('identData:' . $identifier . 'A');
+
+               $this->backend->collectGarbage();
+
+               $this->assertTrue($this->redis->exists('identData:' . $identifier . 'B'));
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function collectGarbageRemovesLeftOverIdentifierToTagsSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag'));
+               $this->backend->set($identifier . 'B', 'data', array('tag'));
+
+               $this->redis->delete('identData:' . $identifier . 'A');
+
+               $this->backend->collectGarbage();
+
+               $expectedResult = array(FALSE, TRUE);
+               $actualResult = array(
+                       $this->redis->exists('identTags:' . $identifier . 'A'),
+                       $this->redis->exists('identTags:' . $identifier . 'B'),
+               );
+               $this->assertSame($expectedResult, $actualResult);
+       }
+
+       /**
+        * @test Implementation
+        * @author Christian Kuhn <lolli@schwarzbu.ch>
+        */
+       public function collectGarbageRemovesExpiredIdentifierFromTagsToIdentifierSet() {
+               $this->setUpBackend();
+               $this->setUpRedis();
+
+               $identifier = 'identifier' . uniqid();
+               $this->backend->set($identifier . 'A', 'data', array('tag1', 'tag2'));
+               $this->backend->set($identifier . 'B', 'data', array('tag2'));
+
+               $this->redis->delete('identData:' . $identifier . 'A');
+
+               $this->backend->collectGarbage();
+
+               $expectedResult = array(
+                       array(),
+                       array($identifier . 'B')
+               );
+               $actualResult = array(
+                       $this->redis->sMembers('tagIdents:tag1'),
+                       $this->redis->sMembers('tagIdents:tag2'),
+               );
+               $this->assertSame($expectedResult, $actualResult);
+       }
+}
+?>