diff --git a/composer.json b/composer.json index 455a68cf74f7..6922cdf0562f 100644 --- a/composer.json +++ b/composer.json @@ -83,6 +83,7 @@ "doctrine/doctrine-bundle": "~1.4", "monolog/monolog": "~1.11", "ocramius/proxy-manager": "~0.4|~1.0|~2.0", + "predis/predis": "~1.0", "egulias/email-validator": "~1.2", "symfony/polyfill-apcu": "~1.1", "symfony/security-acl": "~2.8|~3.0", diff --git a/src/Symfony/Component/Cache/Adapter/RedisAdapter.php b/src/Symfony/Component/Cache/Adapter/RedisAdapter.php index aed471ed56dc..ab0b86e434e0 100644 --- a/src/Symfony/Component/Cache/Adapter/RedisAdapter.php +++ b/src/Symfony/Component/Cache/Adapter/RedisAdapter.php @@ -11,15 +11,19 @@ namespace Symfony\Component\Cache\Adapter; +use Predis\Connection\Factory; +use Predis\Connection\Aggregate\PredisCluster; +use Predis\Connection\Aggregate\RedisCluster; use Symfony\Component\Cache\Exception\InvalidArgumentException; /** * @author Aurimas Niekis + * @author Nicolas Grekas */ class RedisAdapter extends AbstractAdapter { private static $defaultConnectionOptions = array( - 'class' => \Redis::class, + 'class' => null, 'persistent' => 0, 'timeout' => 0, 'read_timeout' => 0, @@ -27,13 +31,19 @@ class RedisAdapter extends AbstractAdapter ); private $redis; - public function __construct(\Redis $redisClient, $namespace = '', $defaultLifetime = 0) + /** + * @param \Redis|\RedisArray|\RedisCluster|\Predis\Client $redisClient + */ + public function __construct($redisClient, $namespace = '', $defaultLifetime = 0) { parent::__construct($namespace, $defaultLifetime); if (preg_match('#[^-+_.A-Za-z0-9]#', $namespace, $match)) { throw new InvalidArgumentException(sprintf('RedisAdapter namespace contains "%s" but only characters in [-+_.A-Za-z0-9] are allowed.', $match[0])); } + if (!$redisClient instanceof \Redis && !$redisClient instanceof \RedisArray && !$redisClient instanceof \RedisCluster && !$redisClient instanceof \Predis\Client) { + throw new InvalidArgumentException(sprintf('%s() expects parameter 1 to be Redis, RedisArray, RedisCluster or Predis\Client, %s given', __METHOD__, is_object($redisClient) ? get_class($redisClient) : gettype($redisClient))); + } $this->redis = $redisClient; } @@ -52,7 +62,7 @@ public function __construct(\Redis $redisClient, $namespace = '', $defaultLifeti * * @throws InvalidArgumentException When the DSN is invalid. * - * @return \Redis + * @return \Redis|\Predis\Client According to the "class" option. */ public static function createConnection($dsn, array $options = array()) { @@ -86,7 +96,7 @@ public static function createConnection($dsn, array $options = array()) $params += $query; } $params += $options + self::$defaultConnectionOptions; - $class = $params['class']; + $class = null === $params['class'] ? (extension_loaded('redis') ? \Redis::class : \Predis\Client::class) : $params['class']; if (is_a($class, \Redis::class, true)) { $connect = empty($params['persistent']) ? 'connect' : 'pconnect'; @@ -105,8 +115,13 @@ public static function createConnection($dsn, array $options = array()) $e = preg_replace('/^ERR /', '', $redis->getLastError()); throw new InvalidArgumentException(sprintf('Redis connection failed (%s): %s', $e, $dsn)); } + } elseif (is_a($class, \Predis\Client::class, true)) { + $params['scheme'] = isset($params['host']) ? 'tcp' : 'unix'; + $params['database'] = $params['dbindex'] ?: null; + $params['password'] = $auth; + $redis = new $class((new Factory())->create($params)); } elseif (class_exists($class, false)) { - throw new InvalidArgumentException(sprintf('"%s" is not a subclass of "Redis"', $class)); + throw new InvalidArgumentException(sprintf('"%s" is not a subclass of "Redis" or "Predis\Client"', $class)); } else { throw new InvalidArgumentException(sprintf('Class "%s" does not exist', $class)); } @@ -139,7 +154,7 @@ protected function doFetch(array $ids) */ protected function doHave($id) { - return $this->redis->exists($id); + return (bool) $this->redis->exists($id); } /** @@ -147,16 +162,41 @@ protected function doHave($id) */ protected function doClear($namespace) { - // As documented in Redis documentation (http://redis.io/commands/keys) using KEYS - // can hang your server when it is executed against large databases (millions of items). - // Whenever you hit this scale, it is advised to deploy one Redis database per cache pool - // instead of using namespaces, so that FLUSHDB is used instead. - $lua = "local keys=redis.call('KEYS',ARGV[1]..'*') for i=1,#keys,5000 do redis.call('DEL',unpack(keys,i,math.min(i+4999,#keys))) end"; - - if (!isset($namespace[0])) { - $this->redis->flushDb(); - } else { - $this->redis->eval($lua, array($namespace), 0); + // When using a native Redis cluster, clearing the cache cannot work and always returns false. + // Clearing the cache should then be done by any other means (e.g. by restarting the cluster). + + $hosts = array($this->redis); + $evalArgs = array(array($namespace), 0); + + if ($this->redis instanceof \Predis\Client) { + $evalArgs = array(0, $namespace); + + $connection = $this->redis->getConnection(); + if ($connection instanceof PredisCluster) { + $hosts = array(); + foreach ($connection as $c) { + $hosts[] = new \Predis\Client($c); + } + } elseif ($connection instanceof RedisCluster) { + return false; + } + } elseif ($this->redis instanceof \RedisArray) { + foreach ($this->redis->_hosts() as $host) { + $hosts[] = $this->redis->_instance($host); + } + } elseif ($this->redis instanceof \RedisCluster) { + return false; + } + foreach ($hosts as $host) { + if (!isset($namespace[0])) { + $host->flushDb(); + } else { + // As documented in Redis documentation (http://redis.io/commands/keys) using KEYS + // can hang your server when it is executed against large databases (millions of items). + // Whenever you hit this scale, it is advised to deploy one Redis database per cache pool + // instead of using namespaces, so that FLUSHDB is used instead. + $host->eval("local keys=redis.call('KEYS',ARGV[1]..'*') for i=1,#keys,5000 do redis.call('DEL',unpack(keys,i,math.min(i+4999,#keys))) end", $evalArgs[0], $evalArgs[1]); + } } return true; @@ -194,12 +234,26 @@ protected function doSave(array $values, $lifetime) return $failed; } if ($lifetime > 0) { - $this->redis->multi(\Redis::PIPELINE); - foreach ($serialized as $id => $value) { - $this->redis->setEx($id, $lifetime, $value); - } - if (!$this->redis->exec()) { - return false; + if ($this->redis instanceof \RedisArray) { + $redis = array(); + foreach ($serialized as $id => $value) { + if (!isset($redis[$h = $this->redis->_target($id)])) { + $redis[$h] = $this->redis->_instance($h); + $redis[$h]->multi(\Redis::PIPELINE); + } + $redis[$h]->setEx($id, $lifetime, $value); + } + foreach ($redis as $h) { + if (!$h->exec()) { + $failed = false; + } + } + } else { + $this->pipeline(function ($pipe) use ($serialized, $lifetime) { + foreach ($serialized as $id => $value) { + $pipe->setEx($id, $lifetime, $value); + } + }); } } elseif (!$this->redis->mSet($serialized)) { return false; @@ -207,4 +261,23 @@ protected function doSave(array $values, $lifetime) return $failed; } + + private function pipeline(\Closure $callback) + { + if ($this->redis instanceof \Predis\Client) { + return $this->redis->pipeline($callback); + } + $pipe = $this->redis instanceof \Redis && $this->redis->multi(\Redis::PIPELINE); + try { + $e = null; + $callback($this->redis); + } catch (\Exception $e) { + } + if ($pipe) { + $this->redis->exec(); + } + if (null !== $e) { + throw $e; + } + } } diff --git a/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTest.php new file mode 100644 index 000000000000..8f1ebf655b35 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/AbstractRedisAdapterTest.php @@ -0,0 +1,46 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Cache\IntegrationTests\CachePoolTest; +use Symfony\Component\Cache\Adapter\RedisAdapter; + +abstract class AbstractRedisAdapterTest extends CachePoolTest +{ + protected static $redis; + + public function createCachePool() + { + if (defined('HHVM_VERSION')) { + $this->skippedTests['testDeferredSaveWithoutCommit'] = 'Fails on HHVM'; + } + + return new RedisAdapter(self::$redis, str_replace('\\', '.', __CLASS__)); + } + + public static function setupBeforeClass() + { + if (!extension_loaded('redis')) { + self::markTestSkipped('Extension redis required.'); + } + if (!@((new \Redis())->connect('127.0.0.1'))) { + $e = error_get_last(); + self::markTestSkipped($e['message']); + } + } + + public static function tearDownAfterClass() + { + self::$redis->flushDB(); + self::$redis = null; + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/PredisAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/PredisAdapterTest.php new file mode 100644 index 000000000000..87a6db0736e0 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/PredisAdapterTest.php @@ -0,0 +1,49 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +use Predis\Connection\StreamConnection; +use Symfony\Component\Cache\Adapter\RedisAdapter; + +class PredisAdapterTest extends AbstractRedisAdapterTest +{ + public static function setupBeforeClass() + { + parent::setupBeforeClass(); + self::$redis = new \Predis\Client(); + } + + public function testCreateConnection() + { + $redis = RedisAdapter::createConnection('redis://localhost/1', array('class' => \Predis\Client::class, 'timeout' => 3)); + $this->assertInstanceOf(\Predis\Client::class, $redis); + + $connection = $redis->getConnection(); + $this->assertInstanceOf(StreamConnection::class, $connection); + + $params = array( + 'scheme' => 'tcp', + 'host' => 'localhost', + 'path' => '', + 'dbindex' => '1', + 'port' => 6379, + 'class' => 'Predis\Client', + 'timeout' => 3, + 'persistent' => 0, + 'read_timeout' => 0, + 'retry_interval' => 0, + 'database' => '1', + 'password' => null, + ); + $this->assertSame($params, $connection->getParameters()->toArray()); + } +} diff --git a/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterTest.php index ef8efd26306d..2716f7b5abc6 100644 --- a/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterTest.php +++ b/src/Symfony/Component/Cache/Tests/Adapter/RedisAdapterTest.php @@ -11,38 +11,15 @@ namespace Symfony\Component\Cache\Tests\Adapter; -use Cache\IntegrationTests\CachePoolTest; use Symfony\Component\Cache\Adapter\RedisAdapter; -/** - * @requires extension redis - */ -class RedisAdapterTest extends CachePoolTest +class RedisAdapterTest extends AbstractRedisAdapterTest { - private static $redis; - - public function createCachePool() - { - if (defined('HHVM_VERSION')) { - $this->skippedTests['testDeferredSaveWithoutCommit'] = 'Fails on HHVM'; - } - - return new RedisAdapter(self::$redis, str_replace('\\', '.', __CLASS__)); - } - public static function setupBeforeClass() { + parent::setupBeforeClass(); self::$redis = new \Redis(); - if (!@self::$redis->connect('127.0.0.1')) { - $e = error_get_last(); - self::markTestSkipped($e['message']); - } - } - - public static function tearDownAfterClass() - { - self::$redis->flushDB(); - self::$redis->close(); + self::$redis->connect('127.0.0.1'); } public function testCreateConnection() diff --git a/src/Symfony/Component/Cache/Tests/Adapter/RedisArrayAdapterTest.php b/src/Symfony/Component/Cache/Tests/Adapter/RedisArrayAdapterTest.php new file mode 100644 index 000000000000..d2968aea47c2 --- /dev/null +++ b/src/Symfony/Component/Cache/Tests/Adapter/RedisArrayAdapterTest.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Cache\Tests\Adapter; + +class RedisArrayAdapterTest extends AbstractRedisAdapterTest +{ + public static function setupBeforeClass() + { + parent::setupBeforeClass(); + if (!class_exists('RedisArray')) { + self::markTestSkipped('The RedisArray class is required.'); + } + self::$redis = new \RedisArray(array('localhost'), array('lazy_connect' => true)); + } +} diff --git a/src/Symfony/Component/Cache/composer.json b/src/Symfony/Component/Cache/composer.json index ee8dc226bfdf..8e937ba29f97 100644 --- a/src/Symfony/Component/Cache/composer.json +++ b/src/Symfony/Component/Cache/composer.json @@ -25,7 +25,8 @@ }, "require-dev": { "cache/integration-tests": "dev-master", - "doctrine/cache": "~1.6" + "doctrine/cache": "~1.6", + "predis/predis": "~1.0" }, "suggest": { "symfony/polyfill-apcu": "For using ApcuAdapter on HHVM"