diff --git a/lib/Predis/Command/Hash/RedisClusterHashStrategy.php b/lib/Predis/Command/Hash/RedisClusterHashStrategy.php new file mode 100644 index 00000000..af30b727 --- /dev/null +++ b/lib/Predis/Command/Hash/RedisClusterHashStrategy.php @@ -0,0 +1,254 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Predis\Command\Hash; + +use Predis\Command\CommandInterface; +use Predis\Distribution\HashGeneratorInterface; + +/** + * Default class used by Predis to calculate hashes out of keys of + * commands supported by redis-cluster. + * + * @author Daniele Alessandri + */ +class RedisClusterHashStrategy implements CommandHashStrategyInterface +{ + private $commands; + + /** + * + */ + public function __construct() + { + $this->commands = $this->getDefaultCommands(); + } + + /** + * Returns the default map of supported commands with their handlers. + * + * @return array + */ + protected function getDefaultCommands() + { + $keyIsFirstArgument = array($this, 'getKeyFromFirstArgument'); + + return array( + /* commands operating on the key space */ + 'EXISTS' => $keyIsFirstArgument, + 'DEL' => array($this, 'getKeyFromAllArguments'), + 'TYPE' => $keyIsFirstArgument, + 'EXPIRE' => $keyIsFirstArgument, + 'EXPIREAT' => $keyIsFirstArgument, + 'PERSIST' => $keyIsFirstArgument, + 'PEXPIRE' => $keyIsFirstArgument, + 'PEXPIREAT' => $keyIsFirstArgument, + 'TTL' => $keyIsFirstArgument, + 'PTTL' => $keyIsFirstArgument, + 'SORT' => $keyIsFirstArgument, // TODO + + /* commands operating on string values */ + 'APPEND' => $keyIsFirstArgument, + 'DECR' => $keyIsFirstArgument, + 'DECRBY' => $keyIsFirstArgument, + 'GET' => $keyIsFirstArgument, + 'GETBIT' => $keyIsFirstArgument, + 'MGET' => array($this, 'getKeyFromAllArguments'), + 'SET' => $keyIsFirstArgument, + 'GETRANGE' => $keyIsFirstArgument, + 'GETSET' => $keyIsFirstArgument, + 'INCR' => $keyIsFirstArgument, + 'INCRBY' => $keyIsFirstArgument, + 'SETBIT' => $keyIsFirstArgument, + 'SETEX' => $keyIsFirstArgument, + 'MSET' => array($this, 'getKeyFromInterleavedArguments'), + 'MSETNX' => array($this, 'getKeyFromInterleavedArguments'), + 'SETNX' => $keyIsFirstArgument, + 'SETRANGE' => $keyIsFirstArgument, + 'STRLEN' => $keyIsFirstArgument, + 'SUBSTR' => $keyIsFirstArgument, + 'BITCOUNT' => $keyIsFirstArgument, + + /* commands operating on lists */ + 'LINSERT' => $keyIsFirstArgument, + 'LINDEX' => $keyIsFirstArgument, + 'LLEN' => $keyIsFirstArgument, + 'LPOP' => $keyIsFirstArgument, + 'RPOP' => $keyIsFirstArgument, + 'BLPOP' => array($this, 'getKeyFromBlockingListCommands'), + 'BRPOP' => array($this, 'getKeyFromBlockingListCommands'), + 'LPUSH' => $keyIsFirstArgument, + 'LPUSHX' => $keyIsFirstArgument, + 'RPUSH' => $keyIsFirstArgument, + 'RPUSHX' => $keyIsFirstArgument, + 'LRANGE' => $keyIsFirstArgument, + 'LREM' => $keyIsFirstArgument, + 'LSET' => $keyIsFirstArgument, + 'LTRIM' => $keyIsFirstArgument, + + /* commands operating on sets */ + 'SADD' => $keyIsFirstArgument, + 'SCARD' => $keyIsFirstArgument, + 'SISMEMBER' => $keyIsFirstArgument, + 'SMEMBERS' => $keyIsFirstArgument, + 'SPOP' => $keyIsFirstArgument, + 'SRANDMEMBER' => $keyIsFirstArgument, + 'SREM' => $keyIsFirstArgument, + + /* commands operating on sorted sets */ + 'ZADD' => $keyIsFirstArgument, + 'ZCARD' => $keyIsFirstArgument, + 'ZCOUNT' => $keyIsFirstArgument, + 'ZINCRBY' => $keyIsFirstArgument, + 'ZRANGE' => $keyIsFirstArgument, + 'ZRANGEBYSCORE' => $keyIsFirstArgument, + 'ZRANK' => $keyIsFirstArgument, + 'ZREM' => $keyIsFirstArgument, + 'ZREMRANGEBYRANK' => $keyIsFirstArgument, + 'ZREMRANGEBYSCORE' => $keyIsFirstArgument, + 'ZREVRANGE' => $keyIsFirstArgument, + 'ZREVRANGEBYSCORE' => $keyIsFirstArgument, + 'ZREVRANK' => $keyIsFirstArgument, + 'ZSCORE' => $keyIsFirstArgument, + + /* commands operating on hashes */ + 'HDEL' => $keyIsFirstArgument, + 'HEXISTS' => $keyIsFirstArgument, + 'HGET' => $keyIsFirstArgument, + 'HGETALL' => $keyIsFirstArgument, + 'HMGET' => $keyIsFirstArgument, + 'HINCRBY' => $keyIsFirstArgument, + 'HINCRBYFLOAT' => $keyIsFirstArgument, + 'HKEYS' => $keyIsFirstArgument, + 'HLEN' => $keyIsFirstArgument, + 'HSET' => $keyIsFirstArgument, + 'HSETNX' => $keyIsFirstArgument, + 'HVALS' => $keyIsFirstArgument, + ); + } + + /** + * Returns the list of IDs for the supported commands. + * + * @return array + */ + public function getSupportedCommands() + { + return array_keys($this->commands); + } + + /** + * Sets an handler for the specified command ID. + * + * The signature of the callback must have a single parameter + * of type Predis\Command\CommandInterface. + * + * When the callback argument is omitted or NULL, the previously + * associated handler for the specified command ID is removed. + * + * @param string $commandId The ID of the command to be handled. + * @param mixed $callback A valid callable object or NULL. + */ + public function setCommandHandler($commandId, $callback = null) + { + $commandId = strtoupper($commandId); + + if (!isset($callback)) { + unset($this->commands[$commandId]); + return; + } + + if (!is_callable($callback)) { + throw new \InvalidArgumentException("Callback must be a valid callable object or NULL"); + } + + $this->commands[$commandId] = $callback; + } + + /** + * Extracts the key from the first argument of a command instance. + * + * @param CommandInterface $command Command instance. + * @return string + */ + protected function getKeyFromFirstArgument(CommandInterface $command) + { + return $command->getArgument(0); + } + + /** + * Extracts the key from a command that can accept multiple keys ensuring + * that only one key is actually specified to comply with redis-cluster. + * + * @param CommandInterface $command Command instance. + * @return string + */ + protected function getKeyFromAllArguments(CommandInterface $command) + { + $arguments = $command->getArguments(); + + if (count($arguments) === 1) { + return $arguments[0]; + } + } + + /** + * Extracts the key from a command that can accept multiple keys ensuring + * that only one key is actually specified to comply with redis-cluster. + * + * @param CommandInterface $command Command instance. + * @return string + */ + protected function getKeyFromInterleavedArguments(CommandInterface $command) + { + $arguments = $command->getArguments(); + + if (count($arguments) === 2) { + return $arguments[0]; + } + } + + /** + * Extracts the key from BLPOP and BRPOP commands ensuring that only one key + * is actually specified to comply with redis-cluster. + * + * @param CommandInterface $command Command instance. + * @return string + */ + protected function getKeyFromBlockingListCommands(CommandInterface $command) + { + $arguments = $command->getArguments(); + + if (count($arguments) === 2) { + return $arguments[0]; + } + } + + /** + * {@inheritdoc} + */ + public function getHash(HashGeneratorInterface $hasher, CommandInterface $command) + { + if (isset($this->commands[$cmdID = $command->getId()])) { + if ($key = call_user_func($this->commands[$cmdID], $command)) { + return $this->getKeyHash($hasher, $key); + } + } + } + + /** + * {@inheritdoc} + */ + public function getKeyHash(HashGeneratorInterface $hasher, $key) + { + return $hasher->hash($key); + } +} diff --git a/lib/Predis/Connection/RedisCluster.php b/lib/Predis/Connection/RedisCluster.php index 3a0b426c..64c53f58 100644 --- a/lib/Predis/Connection/RedisCluster.php +++ b/lib/Predis/Connection/RedisCluster.php @@ -15,6 +15,9 @@ use Predis\ClientException; use Predis\NotSupportedException; use Predis\ResponseErrorInterface; use Predis\Command\CommandInterface; +use Predis\Command\Hash\RedisClusterHashStrategy; +use Predis\Connection\ConnectionFactory; +use Predis\Connection\ConnectionFactoryInterface; use Predis\Distribution\CRC16HashGenerator; /** @@ -27,17 +30,19 @@ class RedisCluster implements ClusterConnectionInterface, \IteratorAggregate, \C private $pool; private $slots; private $connections; - private $hashgenerator; + private $distributor; + private $cmdHasher; /** - * @param FactoryInterface $connections Connection factory object. + * @param ConnectionFactoryInterface $connections Connection factory object. */ - public function __construct(FactoryInterface $connections = null) + public function __construct(ConnectionFactoryInterface $connections = null) { $this->pool = array(); $this->slots = array(); - $this->connections = $connections; - $this->hashgenerator = new CRC16HashGenerator(); + $this->connections = $connections ?: new ConnectionFactory(); + $this->distributor = new CRC16HashGenerator(); + $this->cmdHasher = new RedisClusterHashStrategy(); } /** @@ -119,12 +124,16 @@ class RedisCluster implements ClusterConnectionInterface, \IteratorAggregate, \C */ public function getConnection(CommandInterface $command) { - if ($hash = $command->getHash() === null) { - $hash = $this->hashgenerator->hash($command->getArgument(0)); + $hash = $command->getHash(); + + if (!isset($hash)) { + $hash = $this->cmdHasher->getHash($this->distributor, $command); if (!isset($hash)) { throw new NotSupportedException("Cannot send {$command->getId()} commands to redis-cluster"); } + + $command->setHash($hash); } $slot = $hash & 4095; // 0x0FFF @@ -133,8 +142,7 @@ class RedisCluster implements ClusterConnectionInterface, \IteratorAggregate, \C return $this->slots[$slot]; } - $connection = $this->pool[array_rand($this->pool)]; - $this->slots[$slot] = $connection; + $this->slots[$slot] = $connection = $this->pool[array_rand($this->pool)]; return $connection; } @@ -174,7 +182,7 @@ class RedisCluster implements ClusterConnectionInterface, \IteratorAggregate, \C switch ($request) { case 'MOVED': $this->add($connection); - $this->slots[$slot] = $connection; + $this->slots[(int) $slot] = $connection; return $this->executeCommand($command); case 'ASK': @@ -185,6 +193,17 @@ class RedisCluster implements ClusterConnectionInterface, \IteratorAggregate, \C } } + /** + * Returns the underlying command hash strategy used to hash + * commands by their keys. + * + * @return CommandHashStrategy + */ + public function getCommandHashStrategy() + { + return $this->cmdHasher; + } + /** * {@inheritdoc} */ diff --git a/tests/Predis/Command/Hash/RedisClusterHashStrategyTest.php b/tests/Predis/Command/Hash/RedisClusterHashStrategyTest.php new file mode 100644 index 00000000..ba0ae746 --- /dev/null +++ b/tests/Predis/Command/Hash/RedisClusterHashStrategyTest.php @@ -0,0 +1,325 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Predis\Command\Hash; + +use \PHPUnit_Framework_TestCase as StandardTestCase; + +use Predis\Distribution\CRC16HashGenerator; +use Predis\Profile\ServerProfile; + +/** + * + */ +class RedisClusterHashStrategyTest extends StandardTestCase +{ + /** + * @group disconnected + */ + public function testDoesNotSupportKeyTags() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + + $this->assertSame(35910, $hashstrategy->getKeyHash($distribution, '{foo}')); + $this->assertSame(60032, $hashstrategy->getKeyHash($distribution, '{foo}:bar')); + $this->assertSame(27528, $hashstrategy->getKeyHash($distribution, '{foo}:baz')); + $this->assertSame(34064, $hashstrategy->getKeyHash($distribution, 'bar:{foo}:bar')); + } + + /** + * @group disconnected + */ + public function testSupportedCommands() + { + $hashstrategy = new RedisClusterHashStrategy(); + + $this->assertSame($this->getExpectedCommands(), $hashstrategy->getSupportedCommands()); + } + + /** + * @group disconnected + */ + public function testReturnsNullOnUnsupportedCommand() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $command = ServerProfile::getDevelopment()->createCommand('ping'); + + $this->assertNull($hashstrategy->getHash($distribution, $command)); + } + + /** + * @group disconnected + */ + public function testFirstKeyCommands() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + $arguments = array('key'); + + foreach ($this->getExpectedCommands('keys-first') as $commandID) { + $command = $profile->createCommand($commandID, $arguments); + $this->assertNotNull($hashstrategy->getHash($distribution, $command), $commandID); + } + } + + /** + * @group disconnected + */ + public function testAllKeysCommandsWithOneKey() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + $arguments = array('key'); + + foreach ($this->getExpectedCommands('keys-all') as $commandID) { + $command = $profile->createCommand($commandID, $arguments); + $this->assertNotNull($hashstrategy->getHash($distribution, $command), $commandID); + } + } + + /** + * @group disconnected + */ + public function testAllKeysCommandsWithMoreKeys() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + $arguments = array('key1', 'key2'); + + foreach ($this->getExpectedCommands('keys-all') as $commandID) { + $command = $profile->createCommand($commandID, $arguments); + $this->assertNull($hashstrategy->getHash($distribution, $command), $commandID); + } + } + + /** + * @group disconnected + */ + public function testInterleavedKeysCommandsWithOneKey() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + $arguments = array('key:1', 'value1'); + + foreach ($this->getExpectedCommands('keys-interleaved') as $commandID) { + $command = $profile->createCommand($commandID, $arguments); + $this->assertNotNull($hashstrategy->getHash($distribution, $command), $commandID); + } + } + + /** + * @group disconnected + */ + public function testInterleavedKeysCommandsWithMoreKeys() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + $arguments = array('key:1', 'value1', 'key:2', 'value2'); + + foreach ($this->getExpectedCommands('keys-interleaved') as $commandID) { + $command = $profile->createCommand($commandID, $arguments); + $this->assertNull($hashstrategy->getHash($distribution, $command), $commandID); + } + } + + /** + * @group disconnected + */ + public function testKeysForBlockingListCommandsWithOneKey() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + $arguments = array('key:1', 10); + + foreach ($this->getExpectedCommands('keys-blockinglist') as $commandID) { + $command = $profile->createCommand($commandID, $arguments); + $this->assertNotNull($hashstrategy->getHash($distribution, $command), $commandID); + } + } + + /** + * @group disconnected + */ + public function testKeysForBlockingListCommandsWithMoreKeys() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + $arguments = array('key:1', 'key:2', 10); + + foreach ($this->getExpectedCommands('keys-blockinglist') as $commandID) { + $command = $profile->createCommand($commandID, $arguments); + $this->assertNull($hashstrategy->getHash($distribution, $command), $commandID); + } + } + + /** + * @group disconnected + */ + public function testUnsettingCommandHandler() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + + $hashstrategy->setCommandHandler('set'); + $hashstrategy->setCommandHandler('get', null); + + $command = $profile->createCommand('set', array('key', 'value')); + $this->assertNull($hashstrategy->getHash($distribution, $command)); + + $command = $profile->createCommand('get', array('key')); + $this->assertNull($hashstrategy->getHash($distribution, $command)); + } + + /** + * @group disconnected + */ + public function testSettingCustomCommandHandler() + { + $distribution = new CRC16HashGenerator(); + $hashstrategy = new RedisClusterHashStrategy(); + $profile = ServerProfile::getDevelopment(); + + $callable = $this->getMock('stdClass', array('__invoke')); + $callable->expects($this->once()) + ->method('__invoke') + ->with($this->isInstanceOf('Predis\Command\CommandInterface')) + ->will($this->returnValue('key')); + + $hashstrategy->setCommandHandler('get', $callable); + + $command = $profile->createCommand('get', array('key')); + $this->assertNotNull($hashstrategy->getHash($distribution, $command)); + } + + // ******************************************************************** // + // ---- HELPER METHODS ------------------------------------------------ // + // ******************************************************************** // + + /** + * Returns the list of expected supported commands. + * + * @param string $type Optional type of command (based on its keys) + * @return array + */ + protected function getExpectedCommands($type = null) + { + $commands = array( + /* commands operating on the key space */ + 'EXISTS' => 'keys-first', + 'DEL' => 'keys-all', + 'TYPE' => 'keys-first', + 'EXPIRE' => 'keys-first', + 'EXPIREAT' => 'keys-first', + 'PERSIST' => 'keys-first', + 'PEXPIRE' => 'keys-first', + 'PEXPIREAT' => 'keys-first', + 'TTL' => 'keys-first', + 'PTTL' => 'keys-first', + 'SORT' => 'keys-first', // TODO + + /* commands operating on string values */ + 'APPEND' => 'keys-first', + 'DECR' => 'keys-first', + 'DECRBY' => 'keys-first', + 'GET' => 'keys-first', + 'GETBIT' => 'keys-first', + 'MGET' => 'keys-all', + 'SET' => 'keys-first', + 'GETRANGE' => 'keys-first', + 'GETSET' => 'keys-first', + 'INCR' => 'keys-first', + 'INCRBY' => 'keys-first', + 'SETBIT' => 'keys-first', + 'SETEX' => 'keys-first', + 'MSET' => 'keys-interleaved', + 'MSETNX' => 'keys-interleaved', + 'SETNX' => 'keys-first', + 'SETRANGE' => 'keys-first', + 'STRLEN' => 'keys-first', + 'SUBSTR' => 'keys-first', + 'BITCOUNT' => 'keys-first', + + /* commands operating on lists */ + 'LINSERT' => 'keys-first', + 'LINDEX' => 'keys-first', + 'LLEN' => 'keys-first', + 'LPOP' => 'keys-first', + 'RPOP' => 'keys-first', + 'BLPOP' => 'keys-blockinglist', + 'BRPOP' => 'keys-blockinglist', + 'LPUSH' => 'keys-first', + 'LPUSHX' => 'keys-first', + 'RPUSH' => 'keys-first', + 'RPUSHX' => 'keys-first', + 'LRANGE' => 'keys-first', + 'LREM' => 'keys-first', + 'LSET' => 'keys-first', + 'LTRIM' => 'keys-first', + + /* commands operating on sets */ + 'SADD' => 'keys-first', + 'SCARD' => 'keys-first', + 'SISMEMBER' => 'keys-first', + 'SMEMBERS' => 'keys-first', + 'SPOP' => 'keys-first', + 'SRANDMEMBER' => 'keys-first', + 'SREM' => 'keys-first', + + /* commands operating on sorted sets */ + 'ZADD' => 'keys-first', + 'ZCARD' => 'keys-first', + 'ZCOUNT' => 'keys-first', + 'ZINCRBY' => 'keys-first', + 'ZRANGE' => 'keys-first', + 'ZRANGEBYSCORE' => 'keys-first', + 'ZRANK' => 'keys-first', + 'ZREM' => 'keys-first', + 'ZREMRANGEBYRANK' => 'keys-first', + 'ZREMRANGEBYSCORE' => 'keys-first', + 'ZREVRANGE' => 'keys-first', + 'ZREVRANGEBYSCORE' => 'keys-first', + 'ZREVRANK' => 'keys-first', + 'ZSCORE' => 'keys-first', + + /* commands operating on hashes */ + 'HDEL' => 'keys-first', + 'HEXISTS' => 'keys-first', + 'HGET' => 'keys-first', + 'HGETALL' => 'keys-first', + 'HMGET' => 'keys-first', + 'HINCRBY' => 'keys-first', + 'HINCRBYFLOAT' => 'keys-first', + 'HKEYS' => 'keys-first', + 'HLEN' => 'keys-first', + 'HSET' => 'keys-first', + 'HSETNX' => 'keys-first', + 'HVALS' => 'keys-first', + ); + + if (isset($type)) { + $commands = array_filter($commands, function ($expectedType) use ($type) { + return $expectedType === $type; + }); + } + + return array_keys($commands); + } +}