Explorar el Código

Merge branch 'v1.1-sentinel'

This merge resolves #131.
Daniele Alessandri hace 8 años
padre
commit
349a70a08a

+ 2 - 0
CHANGELOG.md

@@ -13,6 +13,8 @@ v1.1.0 (2016-0x-xx)
   is needed to prevent confusion with how `path` is used to select a database
   when using the `redis` scheme.
 
+- Implemented support for redis-sentinel in the context of replication.
+
 - Changed how Predis handles URI strings in the context of UNIX domain sockets:
   `unix:///path/to/socket` should be used now instead of `unix:/path/to/socket`
   (note the lack of a double slash after the scheme). The old format should be

+ 1 - 0
README.md

@@ -25,6 +25,7 @@ More details about this project can be found on the [frequently asked questions]
 - Clustering via client-side sharding using consistent hashing or custom distributors.
 - Smart support for [redis-cluster](http://redis.io/topics/cluster-tutorial) (Redis >= 3.0).
 - Support for master-slave replication (write operations on master, read operations on slaves).
+- Support for redis-sentinel to provide high-availability in replication scenarios.
 - Transparent key prefixing for all known Redis commands using a customizable prefixing strategy.
 - Command pipelining (works on both single nodes and aggregate connections).
 - Abstraction for Redis transactions (Redis >= 2.0) supporting CAS operations (Redis >= 2.2).

+ 60 - 0
examples/replication_sentinel.php

@@ -0,0 +1,60 @@
+<?php
+
+/*
+ * This file is part of the Predis package.
+ *
+ * (c) Daniele Alessandri <suppakilla@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+require __DIR__.'/shared.php';
+
+// Predis supports redis-sentinel to provide high availability in master / slave
+// scenarios. The only but relevant difference with a basic replication scenario
+// is that sentinel servers can manage the master server and its slaves based on
+// their state, which means that they are able to provide an authoritative and
+// updated configuration to clients thus avoiding static configurations for the
+// replication servers and their roles.
+
+use Predis\Connection\Aggregate\SentinelReplication;
+
+// Instead of connection parameters pointing to redis nodes, we provide a list
+// of instances of redis-sentinel. Users should always provide a timeout value
+// low enough to not hinder operations just in case a sentinel is unreachable
+// but Predis uses a default value of 100 milliseconds for sentinel parameters
+// without an explicit timeout value.
+//
+// NOTE: in real-world scenarios sentinels should be running on different hosts!
+$sentinels = array(
+    'tcp://127.0.0.1:5380?timeout=0.100',
+    'tcp://127.0.0.1:5381?timeout=0.100',
+    'tcp://127.0.0.1:5382?timeout=0.100',
+);
+
+$client = new Predis\Client($sentinels, array(
+    'replication' => 'sentinel',
+    'service' => 'mymaster',
+));
+
+// Read operation.
+$exists = $client->exists('foo') ? 'yes' : 'no';
+$current = $client->getConnection()->getCurrent()->getParameters();
+echo "Does 'foo' exist on {$current->alias}? $exists.", PHP_EOL;
+
+// Write operation.
+$client->set('foo', 'bar');
+$current = $client->getConnection()->getCurrent()->getParameters();
+echo "Now 'foo' has been set to 'bar' on {$current->alias}!", PHP_EOL;
+
+// Read operation.
+$bar = $client->get('foo');
+$current = $client->getConnection()->getCurrent()->getParameters();
+echo "We fetched 'foo' from {$current->alias} and its value is '$bar'.", PHP_EOL;
+
+/* OUTPUT:
+Does 'foo' exist on slave-127.0.0.1:6381? yes.
+Now 'foo' has been set to 'bar' on master!
+We fetched 'foo' from master and its value is 'bar'.
+*/

+ 9 - 4
src/Client.php

@@ -120,13 +120,18 @@ class Client implements ClientInterface
             if ($options->defined('aggregate')) {
                 $initializer = $this->getConnectionInitializerWrapper($options->aggregate);
                 $connection = $initializer($parameters, $options);
-            } else {
-                if ($options->defined('replication') && $replication = $options->replication) {
+            } elseif ($options->defined('replication')) {
+                $replication = $options->replication;
+
+                if ($replication instanceof AggregateConnectionInterface) {
                     $connection = $replication;
+                    $options->connections->aggregate($connection, $parameters);
                 } else {
-                    $connection = $options->cluster;
+                    $initializer = $this->getConnectionInitializerWrapper($replication);
+                    $connection = $initializer($parameters, $options);
                 }
-
+            } else {
+                $connection = $options->cluster;
                 $options->connections->aggregate($connection, $parameters);
             }
 

+ 7 - 0
src/Configuration/ReplicationOption.php

@@ -12,6 +12,7 @@
 namespace Predis\Configuration;
 
 use Predis\Connection\Aggregate\MasterSlaveReplication;
+use Predis\Connection\Aggregate\SentinelReplication;
 use Predis\Connection\Aggregate\ReplicationInterface;
 
 /**
@@ -39,6 +40,12 @@ class ReplicationOption implements OptionInterface
             return $value ? $this->getDefault($options) : null;
         }
 
+        if ($value === 'sentinel') {
+            return function ($sentinels, $options) {
+                return new SentinelReplication($options->service, $sentinels, $options->connections);
+            };
+        }
+
         if (
             !is_object($value) &&
             null !== $asbool = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE)

+ 718 - 0
src/Connection/Aggregate/SentinelReplication.php

@@ -0,0 +1,718 @@
+<?php
+
+/*
+ * This file is part of the Predis package.
+ *
+ * (c) Daniele Alessandri <suppakilla@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Predis\Connection\Aggregate;
+
+use Predis\CommunicationException;
+use Predis\Command\CommandInterface;
+use Predis\Command\RawCommand;
+use Predis\Connection\ConnectionException;
+use Predis\Connection\FactoryInterface as ConnectionFactoryInterface;
+use Predis\Connection\NodeConnectionInterface;
+use Predis\Connection\Parameters;
+use Predis\Replication\ReplicationStrategy;
+use Predis\Replication\RoleException;
+use Predis\Response\ErrorInterface as ErrorResponseInterface;
+use Predis\Response\ServerException;
+
+/**
+ * @author Daniele Alessandri <suppakilla@gmail.com>
+ * @author Ville Mattila <ville@eventio.fi>
+ */
+class SentinelReplication implements ReplicationInterface
+{
+    /**
+     * @var NodeConnectionInterface
+     */
+    protected $master;
+
+    /**
+     * @var NodeConnectionInterface[]
+     */
+    protected $slaves = array();
+
+    /**
+     * @var NodeConnectionInterface
+     */
+    protected $current;
+
+    /**
+     * @var string
+     */
+    protected $service;
+
+    /**
+     * @var ConnectionFactoryInterface
+     */
+    protected $connectionFactory;
+
+    /**
+     * @var ReplicationStrategy
+     */
+    protected $strategy;
+
+    /**
+     * @var NodeConnectionInterface[]
+     */
+    protected $sentinels = array();
+
+    /**
+     * @var NodeConnectionInterface
+     */
+    protected $sentinelConnection;
+
+    /**
+     * @var float
+     */
+    protected $sentinelTimeout = 0.100;
+
+    /**
+     * Max number of automatic retries of commands upon server failure.
+     *
+     * -1 = unlimited retry attempts
+     *  0 = no retry attempts (fails immediatly)
+     *  n = fail only after n retry attempts
+     *
+     * @var int
+     */
+    protected $retryLimit = 20;
+
+    /**
+     * Time to wait in milliseconds before fetching a new configuration from one
+     * of the sentinel servers.
+     *
+     * @var int
+     */
+    protected $retryWait = 1000;
+
+    /**
+     * Flag for automatic fetching of available sentinels.
+     *
+     * @var bool
+     */
+    protected $updateSentinels = false;
+
+    /**
+     * @param string                     $service           Name of the service for autodiscovery.
+     * @param array                      $sentinels         Sentinel servers connection parameters.
+     * @param ConnectionFactoryInterface $connectionFactory Connection factory instance.
+     * @param ReplicationStrategy        $strategy          Replication strategy instance.
+     */
+    public function __construct(
+        $service,
+        array $sentinels,
+        ConnectionFactoryInterface $connectionFactory,
+        ReplicationStrategy $strategy = null
+    ) {
+        $this->sentinels = $sentinels;
+        $this->service = $service;
+        $this->connectionFactory = $connectionFactory;
+        $this->strategy = $strategy ?: new ReplicationStrategy();
+    }
+
+    /**
+     * Sets a default timeout for connections to sentinels.
+     *
+     * When "timeout" is present in the connection parameters of sentinels, its
+     * value overrides the default sentinel timeout.
+     *
+     * @param float $timeout Timeout value.
+     */
+    public function setSentinelTimeout($timeout)
+    {
+        $this->sentinelTimeout = (float) $timeout;
+    }
+
+    /**
+     * Sets the maximum number of retries for commands upon server failure.
+     *
+     * -1 = unlimited retry attempts
+     *  0 = no retry attempts (fails immediatly)
+     *  n = fail only after n retry attempts
+     *
+     * @param int $retry Number of retry attempts.
+     */
+    public function setRetryLimit($retry)
+    {
+        $this->retryLimit = (int) $retry;
+    }
+
+    /**
+     * Sets the time to wait (in seconds) before fetching a new configuration
+     * from one of the sentinels.
+     *
+     * @param float $seconds Time to wait before the next attempt.
+     */
+    public function setRetryWait($seconds)
+    {
+        $this->retryWait = (float) $seconds;
+    }
+
+    /**
+     * Set automatic fetching of available sentinels.
+     *
+     * @param bool $update Enable or disable automatic updates.
+     */
+    public function setUpdateSentinels($update)
+    {
+        $this->updateSentinels = (bool) $update;
+    }
+
+    /**
+     * Resets the current connection.
+     */
+    protected function reset()
+    {
+        $this->current = null;
+    }
+
+    /**
+     * Wipes the current list of master and slaves nodes.
+     */
+    protected function wipeServerList()
+    {
+        $this->reset();
+
+        $this->master = null;
+        $this->slaves = array();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function add(NodeConnectionInterface $connection)
+    {
+        $alias = $connection->getParameters()->alias;
+
+        if ($alias === 'master') {
+            $this->master = $connection;
+        } else {
+            $this->slaves[$alias ?: count($this->slaves)] = $connection;
+        }
+
+        $this->reset();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function remove(NodeConnectionInterface $connection)
+    {
+        if ($connection === $this->master) {
+            $this->master = null;
+            $this->reset();
+
+            return true;
+        }
+
+        if (false !== $id = array_search($connection, $this->slaves, true)) {
+            unset($this->slaves[$id]);
+            $this->reset();
+
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Creates a new connection to a sentinel server.
+     *
+     * @return NodeConnectionInterface
+     */
+    protected function createSentinelConnection($parameters)
+    {
+        if ($parameters instanceof NodeConnectionInterface) {
+            return $parameters;
+        }
+
+        if (is_string($parameters)) {
+            $parameters = Parameters::parse($parameters);
+        }
+
+        if (is_array($parameters)) {
+            $parameters += array(
+                'timeout' => $this->sentinelTimeout,
+
+                // We need to override password and database by setting them to
+                // NULL as they are not needed when connecting to sentinels.
+                'password' => null,
+                'database' => null,
+            );
+        }
+
+        $connection = $this->connectionFactory->create($parameters);
+
+        return $connection;
+    }
+
+    /**
+     * Returns the current sentinel connection.
+     *
+     * If there is no active sentinel connection, a new connection is created.
+     *
+     * @return NodeConnectionInterface
+     */
+    public function getSentinelConnection()
+    {
+        if (!$this->sentinelConnection) {
+            if (!$this->sentinels) {
+                throw new \Predis\ClientException('No sentinel server available for autodiscovery.');
+            }
+
+            $sentinel = array_shift($this->sentinels);
+            $this->sentinelConnection = $this->createSentinelConnection($sentinel);
+        }
+
+        return $this->sentinelConnection;
+    }
+
+    /**
+     * Fetches an updated list of sentinels from a sentinel.
+     */
+    public function updateSentinels()
+    {
+        SENTINEL_QUERY: {
+            $sentinel = $this->getSentinelConnection();
+
+            try {
+                $payload = $sentinel->executeCommand(
+                    RawCommand::create('SENTINEL', 'sentinels', $this->service)
+                );
+
+                $this->sentinels = array();
+                // NOTE: sentinel server does not return itself, so we add it back.
+                $this->sentinels[] = $sentinel->getParameters()->toArray();
+
+                foreach ($payload as $sentinel) {
+                    $this->sentinels[] = array(
+                        'host' => $sentinel[3],
+                        'port' => $sentinel[5],
+                    );
+                }
+            } catch (ConnectionException $exception) {
+                $this->sentinelConnection = null;
+
+                goto SENTINEL_QUERY;
+            }
+        }
+    }
+
+    /**
+     * Fetches the details for the master and slave servers from a sentinel.
+     */
+    public function querySentinel()
+    {
+        $this->wipeServerList();
+
+        $this->updateSentinels();
+        $this->getMaster();
+        $this->getSlaves();
+    }
+
+    /**
+     * Handles error responses returned by redis-sentinel.
+     *
+     * @param NodeConnectionInterface $sentinel Connection to a sentinel server.
+     * @param ErrorResponseInterface  $error    Error response.
+     */
+    private function handleSentinelErrorResponse(NodeConnectionInterface $sentinel, ErrorResponseInterface $error)
+    {
+        if ($error->getErrorType() === 'IDONTKNOW') {
+            throw new ConnectionException($sentinel, $error->getMessage());
+        } else {
+            throw new ServerException($error->getMessage());
+        }
+    }
+
+    /**
+     * Fetches the details for the master server from a sentinel.
+     *
+     * @param NodeConnectionInterface $sentinel Connection to a sentinel server.
+     * @param string                  $service  Name of the service.
+     *
+     * @return array
+     */
+    protected function querySentinelForMaster(NodeConnectionInterface $sentinel, $service)
+    {
+        $payload = $sentinel->executeCommand(
+            RawCommand::create('SENTINEL', 'get-master-addr-by-name', $service)
+        );
+
+        if ($payload === null) {
+            throw new ServerException('ERR No such master with that name');
+        }
+
+        if ($payload instanceof ErrorResponseInterface) {
+            $this->handleSentinelErrorResponse($sentinel, $payload);
+        }
+
+        return array(
+            'host' => $payload[0],
+            'port' => $payload[1],
+            'alias' => 'master',
+        );
+    }
+
+    /**
+     * Fetches the details for the slave servers from a sentinel.
+     *
+     * @param NodeConnectionInterface $sentinel Connection to a sentinel server.
+     * @param string                  $service  Name of the service.
+     *
+     * @return array
+     */
+    protected function querySentinelForSlaves(NodeConnectionInterface $sentinel, $service)
+    {
+        $slaves = array();
+
+        $payload = $sentinel->executeCommand(
+            RawCommand::create('SENTINEL', 'slaves', $service)
+        );
+
+        if ($payload instanceof ErrorResponseInterface) {
+            $this->handleSentinelErrorResponse($sentinel, $payload);
+        }
+
+        foreach ($payload as $slave) {
+            $flags = explode(',', $slave[9]);
+
+            if (array_intersect($flags, array('s_down', 'disconnected'))) {
+                continue;
+            }
+
+            $slaves[] = array(
+                'host' => $slave[3],
+                'port' => $slave[5],
+                'alias' => "slave-$slave[1]",
+            );
+        }
+
+        return $slaves;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getCurrent()
+    {
+        return $this->current;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getMaster()
+    {
+        if ($this->master) {
+            return $this->master;
+        }
+
+        if ($this->updateSentinels) {
+            $this->updateSentinels();
+        }
+
+        SENTINEL_QUERY: {
+            $sentinel = $this->getSentinelConnection();
+
+            try {
+                $masterParameters = $this->querySentinelForMaster($sentinel, $this->service);
+                $masterConnection = $this->connectionFactory->create($masterParameters);
+
+                $this->add($masterConnection);
+            } catch (ConnectionException $exception) {
+                $this->sentinelConnection = null;
+
+                goto SENTINEL_QUERY;
+            }
+        }
+
+        return $masterConnection;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getSlaves()
+    {
+        if ($this->slaves) {
+            return array_values($this->slaves);
+        }
+
+        if ($this->updateSentinels) {
+            $this->updateSentinels();
+        }
+
+        SENTINEL_QUERY: {
+            $sentinel = $this->getSentinelConnection();
+
+            try {
+                $slavesParameters = $this->querySentinelForSlaves($sentinel, $this->service);
+
+                foreach ($slavesParameters as $slaveParameters) {
+                    $this->add($this->connectionFactory->create($slaveParameters));
+                }
+            } catch (ConnectionException $exception) {
+                $this->sentinelConnection = null;
+
+                goto SENTINEL_QUERY;
+            }
+        }
+
+        return array_values($this->slaves ?: array());
+    }
+
+    /**
+     * Returns a random slave.
+     *
+     * @return NodeConnectionInterface
+     */
+    protected function pickSlave()
+    {
+        if ($slaves = $this->getSlaves()) {
+            return $slaves[rand(1, count($slaves)) - 1];
+        }
+    }
+
+    /**
+     * Returns the connection instance in charge for the given command.
+     *
+     * @param CommandInterface $command Command instance.
+     *
+     * @return NodeConnectionInterface
+     */
+    private function getConnectionInternal(CommandInterface $command)
+    {
+        if (!$this->current) {
+            if ($this->strategy->isReadOperation($command) && $slave = $this->pickSlave()) {
+                $this->current = $slave;
+            } else {
+                $this->current = $this->getMaster();
+            }
+
+            return $this->current;
+        }
+
+        if ($this->current === $this->master) {
+            return $this->current;
+        }
+
+        if (!$this->strategy->isReadOperation($command)) {
+            $this->current = $this->getMaster();
+        }
+
+        return $this->current;
+    }
+
+    /**
+     * Asserts that the specified connection matches an expected role.
+     *
+     * @param NodeConnectionInterface $sentinel Connection to a redis server.
+     * @param string                  $role     Expected role of the server ("master", "slave" or "sentinel").
+     */
+    protected function assertConnectionRole(NodeConnectionInterface $connection, $role)
+    {
+        $role = strtolower($role);
+        $actualRole = $connection->executeCommand(RawCommand::create('ROLE'));
+
+        if ($role !== $actualRole[0]) {
+            throw new RoleException($connection, "Expected $role but got $actualRole[0] [$connection]");
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getConnection(CommandInterface $command)
+    {
+        $connection = $this->getConnectionInternal($command);
+
+        if (!$connection->isConnected() && ($this->slaves || $this->master)) {
+            $this->assertConnectionRole(
+                $connection,
+                $this->strategy->isReadOperation($command) ? 'slave' : 'master'
+            );
+        }
+
+        return $connection;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function getConnectionById($connectionId)
+    {
+        if ($connectionId === 'master') {
+            return $this->getMaster();
+        }
+
+        $this->getSlaves();
+
+        if (isset($this->slaves[$connectionId])) {
+            return $this->slaves[$connectionId];
+        }
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function switchTo($connection)
+    {
+        if (!$connection instanceof NodeConnectionInterface) {
+            $connection = $this->getConnectionById($connection);
+        }
+
+        if ($connection && $connection === $this->current) {
+            return;
+        }
+
+        if ($connection !== $this->master && !in_array($connection, $this->slaves, true)) {
+            throw new \InvalidArgumentException('Invalid connection or connection not found.');
+        }
+
+        $connection->connect();
+
+        if ($this->current) {
+            $this->current->disconnect();
+        }
+
+        $this->current = $connection;
+    }
+
+    /**
+     * Switches to the master server.
+     */
+    public function switchToMaster()
+    {
+        $this->switchTo('master');
+    }
+
+    /**
+     * Switches to a random slave server.
+     */
+    public function switchToSlave()
+    {
+        $connection = $this->pickSlave();
+        $this->switchTo($connection);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function isConnected()
+    {
+        return $this->current ? $this->current->isConnected() : false;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function connect()
+    {
+        if (!$this->current) {
+            $this->current = $this->pickSlave();
+        }
+
+        $this->current->connect();
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function disconnect()
+    {
+        if ($this->master) {
+            $this->master->disconnect();
+        }
+
+        foreach ($this->slaves as $connection) {
+            $connection->disconnect();
+        }
+    }
+
+    /**
+     * Retries the execution of a command upon server failure after asking a new
+     * configuration to one of the sentinels.
+     *
+     * @param CommandInterface $command Command instance.
+     * @param string           $method  Actual method.
+     *
+     * @return mixed
+     */
+    private function retryCommandOnFailure(CommandInterface $command, $method)
+    {
+        $retries = 0;
+
+        SENTINEL_RETRY: {
+            try {
+                $response = $this->getConnection($command)->$method($command);
+            } catch (CommunicationException $exception) {
+                $this->wipeServerList();
+                $exception->getConnection()->disconnect();
+
+                if ($retries == $this->retryLimit) {
+                    throw $exception;
+                }
+
+                usleep($this->retryWait * 1000);
+
+                ++$retries;
+                goto SENTINEL_RETRY;
+            }
+        }
+
+        return $response;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function writeRequest(CommandInterface $command)
+    {
+        $this->retryCommandOnFailure($command, __FUNCTION__);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function readResponse(CommandInterface $command)
+    {
+        return $this->retryCommandOnFailure($command, __FUNCTION__);
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function executeCommand(CommandInterface $command)
+    {
+        return $this->retryCommandOnFailure($command, __FUNCTION__);
+    }
+
+    /**
+     * Returns the underlying replication strategy.
+     *
+     * @return ReplicationStrategy
+     */
+    public function getReplicationStrategy()
+    {
+        return $this->strategy;
+    }
+
+    /**
+     * {@inheritdoc}
+     */
+    public function __sleep()
+    {
+        return array(
+            'master', 'slaves', 'service', 'sentinels', 'connectionFactory', 'strategy',
+        );
+    }
+}

+ 24 - 0
src/Replication/RoleException.php

@@ -0,0 +1,24 @@
+<?php
+
+/*
+ * This file is part of the Predis package.
+ *
+ * (c) Daniele Alessandri <suppakilla@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Predis\Replication;
+
+use Predis\CommunicationException;
+
+/**
+ * Exception class that identifies a role mismatch when connecting to node
+ * managed by redis-sentinel.
+ *
+ * @author Daniele Alessandri <suppakilla@gmail.com>
+ */
+class RoleException extends CommunicationException
+{
+}

+ 1139 - 0
tests/Predis/Connection/Aggregate/SentinelReplicationTest.php

@@ -0,0 +1,1139 @@
+<?php
+
+/*
+ * This file is part of the Predis package.
+ *
+ * (c) Daniele Alessandri <suppakilla@gmail.com>
+ *
+ * For the full copyright and license information, please view the LICENSE
+ * file that was distributed with this source code.
+ */
+
+namespace Predis\Connection\Aggregate;
+
+use Predis\Command;
+use Predis\Connection;
+use Predis\Replication;
+use Predis\Response;
+use PredisTestCase;
+
+/**
+ *
+ */
+class SentinelReplicationTest extends PredisTestCase
+{
+    /**
+     * @group disconnected
+     * @expectedException Predis\ClientException
+     * @expectedExceptionMessage No sentinel server available for autodiscovery.
+     */
+    public function testMethodGetSentinelConnectionThrowsExceptionOnEmptySentinelsPool()
+    {
+        $replication = $this->getReplicationConnection('svc', array());
+        $replication->getSentinelConnection();
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodGetSentinelConnectionReturnsFirstAvailableSentinel()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?alias=sentinel2');
+        $sentinel3 = $this->getMockSentinelConnection('tcp://127.0.0.1:5383?alias=sentinel3');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1, $sentinel2, $sentinel3));
+
+        $this->assertSame($sentinel1, $replication->getSentinelConnection());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodAddAttachesMasterOrSlaveNodesToReplication()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave2 = $this->getMockConnection('tcp://127.0.0.1:6383?alias=slave2');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+        $replication->add($slave2);
+
+        $this->assertSame($master, $replication->getConnectionById('master'));
+        $this->assertSame($slave1, $replication->getConnectionById('slave1'));
+        $this->assertSame($slave2, $replication->getConnectionById('slave2'));
+
+        $this->assertSame($master, $replication->getMaster());
+        $this->assertSame(array($slave1, $slave2), $replication->getSlaves());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodRemoveDismissesMasterOrSlaveNodesFromReplication()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave2 = $this->getMockConnection('tcp://127.0.0.1:6383?alias=slave2');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+        $replication->add($slave2);
+
+        $this->assertTrue($replication->remove($slave1));
+        $this->assertFalse($replication->remove($sentinel1));
+
+        $this->assertSame('127.0.0.1:6381', (string) $replication->getMaster());
+        $this->assertCount(1, $slaves = $replication->getSlaves());
+        $this->assertSame('127.0.0.1:6383', (string) $slaves[0]);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodUpdateSentinelsFetchesSentinelNodes()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->once())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('sentinels', 'svc')
+                  ))
+                  ->will($this->returnValue(
+                      array(
+                          array(
+                              "name", "127.0.0.1:5382",
+                              "ip", "127.0.0.1",
+                              "port", "5382",
+                              "runid", "a113aa7a0d4870a85bb22b4b605fd26eb93ed40e",
+                              "flags", "sentinel",
+                          ),
+                          array(
+                              "name", "127.0.0.1:5383",
+                              "ip", "127.0.0.1",
+                              "port", "5383",
+                              "runid", "f53b52d281be5cdd4873700c94846af8dbe47209",
+                              "flags", "sentinel",
+                          ),
+                      )
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->updateSentinels();
+
+        // TODO: sorry for the smell...
+        $reflection = new \ReflectionProperty($replication, 'sentinels');
+        $reflection->setAccessible(true);
+
+        $expected = array(
+            array('host' => '127.0.0.1', 'port' => '5381'),
+            array('host' => '127.0.0.1', 'port' => '5382'),
+            array('host' => '127.0.0.1', 'port' => '5383'),
+        );
+
+        $this->assertSame($sentinel1, $replication->getSentinelConnection());
+        $this->assertSame($expected, array_intersect_key($expected, $reflection->getValue($replication)));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodUpdateSentinelsRemovesCurrentSentinelAndRetriesNextOneOnFailure()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->once())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('sentinels', 'svc')
+                  ))
+                  ->will($this->throwException(
+                     new Connection\ConnectionException($sentinel1, "Unknown connection error [127.0.0.1:5381]")
+                  ));
+
+        $sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?alias=sentinel2');
+        $sentinel2->expects($this->once())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('sentinels', 'svc')
+                  ))
+                  ->will($this->returnValue(
+                      array(
+                          array(
+                              "name", "127.0.0.1:5383",
+                              "ip", "127.0.0.1",
+                              "port", "5383",
+                              "runid", "f53b52d281be5cdd4873700c94846af8dbe47209",
+                              "flags", "sentinel",
+                          ),
+                      )
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1, $sentinel2));
+
+        $replication->updateSentinels();
+
+        // TODO: sorry for the smell...
+        $reflection = new \ReflectionProperty($replication, 'sentinels');
+        $reflection->setAccessible(true);
+
+        $expected = array(
+            array('host' => '127.0.0.1', 'port' => '5382'),
+            array('host' => '127.0.0.1', 'port' => '5383'),
+        );
+
+        $this->assertSame($sentinel2, $replication->getSentinelConnection());
+        $this->assertSame($expected, array_intersect_key($expected, $reflection->getValue($replication)));
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\ClientException
+     * @expectedExceptionMessage No sentinel server available for autodiscovery.
+     */
+    public function testMethodUpdateSentinelsThrowsExceptionOnNoAvailableSentinel()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->once())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('sentinels', 'svc')
+                  ))
+                  ->will($this->throwException(
+                     new Connection\ConnectionException($sentinel1, "Unknown connection error [127.0.0.1:5381]")
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->updateSentinels();
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodQuerySentinelFetchesMasterNodeSlaveNodesAndSentinelNodes()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->exactly(3))
+                  ->method('executeCommand')
+                  ->withConsecutive(
+                      $this->isRedisCommand('SENTINEL', array('sentinels', 'svc')),
+                      $this->isRedisCommand('SENTINEL', array('get-master-addr-by-name', 'svc')),
+                      $this->isRedisCommand('SENTINEL', array('slaves', 'svc'))
+                  )
+                  ->will($this->onConsecutiveCalls(
+                      // SENTINEL sentinels svc
+                      array(
+                          array(
+                              "name", "127.0.0.1:5382",
+                              "ip", "127.0.0.1",
+                              "port", "5382",
+                              "runid", "a113aa7a0d4870a85bb22b4b605fd26eb93ed40e",
+                              "flags", "sentinel",
+                          ),
+                      ),
+
+                      // SENTINEL get-master-addr-by-name svc
+                      array('127.0.0.1', '6381'),
+
+                      // SENTINEL slaves svc
+                      array(
+                          array(
+                              "name", "127.0.0.1:6382",
+                              "ip", "127.0.0.1",
+                              "port", "6382",
+                              "runid", "112cdebd22924a7d962be496f3a1c4c7c9bad93f",
+                              "flags", "slave",
+                              "master-host", "127.0.0.1",
+                              "master-port", "6381"
+                          ),
+                          array(
+                              "name", "127.0.0.1:6383",
+                              "ip", "127.0.0.1",
+                              "port", "6383",
+                              "runid", "1c0bf1291797fbc5608c07a17da394147dc62817",
+                              "flags", "slave",
+                              "master-host", "127.0.0.1",
+                              "master-port", "6381"
+                          ),
+                      )
+                  ));
+
+        $sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?alias=sentinel2');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave2 = $this->getMockConnection('tcp://127.0.0.1:6383?alias=slave2');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+        $replication->querySentinel();
+
+        // TODO: sorry for the smell...
+        $reflection = new \ReflectionProperty($replication, 'sentinels');
+        $reflection->setAccessible(true);
+
+        $sentinels = array(
+            array('host' => '127.0.0.1', 'port' => '5381'),
+            array('host' => '127.0.0.1', 'port' => '5382'),
+        );
+
+        $this->assertSame($sentinel1, $replication->getSentinelConnection());
+        $this->assertSame($sentinels, array_intersect_key($sentinels, $reflection->getValue($replication)));
+
+        $master = $replication->getMaster();
+        $slaves = $replication->getSlaves();
+
+        $this->assertSame('127.0.0.1:6381', (string) $master);
+
+        $this->assertCount(2, $slaves);
+        $this->assertSame('127.0.0.1:6382', (string) $slaves[0]);
+        $this->assertSame('127.0.0.1:6383', (string) $slaves[1]);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodGetMasterAsksSentinelForMasterOnMasterNotSet()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->at(0))
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('get-master-addr-by-name', 'svc')
+                  ))
+                  ->will($this->returnValue(
+                      array('127.0.0.1', '6381')
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $this->assertSame('127.0.0.1:6381', (string) $replication->getMaster());
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\ClientException
+     * @expectedExceptionMessage No sentinel server available for autodiscovery.
+     */
+    public function testMethodGetMasterThrowsExceptionOnNoAvailableSentinels()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->any())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('get-master-addr-by-name', 'svc')
+                  ))
+                  ->will($this->throwException(
+                      new Connection\ConnectionException($sentinel1, "Unknown connection error [127.0.0.1:5381]")
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->getMaster();
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodGetSlavesOnEmptySlavePoolAsksSentinelForSlaves()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->at(0))
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('slaves', 'svc')
+                  ))
+                  ->will($this->returnValue(
+                      array(
+                          array(
+                              "name", "127.0.0.1:6382",
+                              "ip", "127.0.0.1",
+                              "port", "6382",
+                              "runid", "112cdebd22924a7d962be496f3a1c4c7c9bad93f",
+                              "flags", "slave",
+                              "master-host", "127.0.0.1",
+                              "master-port", "6381"
+                          ),
+                          array(
+                              "name", "127.0.0.1:6383",
+                              "ip", "127.0.0.1",
+                              "port", "6383",
+                              "runid", "1c0bf1291797fbc5608c07a17da394147dc62817",
+                              "flags", "slave",
+                              "master-host", "127.0.0.1",
+                              "master-port", "6381"
+                          ),
+                      )
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $slaves = $replication->getSlaves();
+
+        $this->assertSame('127.0.0.1:6382', (string) $slaves[0]);
+        $this->assertSame('127.0.0.1:6383', (string) $slaves[1]);
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\ClientException
+     * @expectedExceptionMessage No sentinel server available for autodiscovery.
+     */
+    public function testMethodGetSlavesThrowsExceptionOnNoAvailableSentinels()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->any())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('slaves', 'svc')
+                  ))
+                  ->will($this->throwException(
+                      new Connection\ConnectionException($sentinel1, "Unknown connection error [127.0.0.1:5381]")
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->getSlaves();
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\ClientException
+     * @expectedExceptionMessage No sentinel server available for autodiscovery.
+     */
+    public function testMethodConnectThrowsExceptionOnConnectWithEmptySentinelsPool()
+    {
+        $replication = $this->getReplicationConnection('svc', array());
+        $replication->connect();
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodConnectForcesConnectionToSlave()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->never())
+               ->method('connect');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->once())
+               ->method('connect');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $replication->connect();
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodConnectOnEmptySlavePoolAsksSentinelForSlavesAndForcesConnectionToSlave()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->any())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('slaves', 'svc')
+                  ))
+                  ->will($this->returnValue(
+                      array(
+                          array(
+                              "name", "127.0.0.1:6382",
+                              "ip", "127.0.0.1",
+                              "port", "6382",
+                              "runid", "112cdebd22924a7d962be496f3a1c4c7c9bad93f",
+                              "flags", "slave",
+                              "master-host", "127.0.0.1",
+                              "master-port", "6381",
+                          ),
+                      )
+                  ));
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->never())
+               ->method('connect');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->once())
+               ->method('connect');
+
+        $factory = $this->getMock('Predis\Connection\FactoryInterface');
+        $factory->expects($this->once())
+                 ->method('create')
+                 ->with(array(
+                    'host' => '127.0.0.1',
+                    'port' => '6382',
+                    'alias' => 'slave-127.0.0.1:6382',
+                  ))
+                 ->will($this->returnValue($slave1));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1), $factory);
+
+        $replication->add($master);
+
+        $replication->connect();
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodDisconnectForcesDisconnectionOnAllConnectionsInPool()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->never())->method('disconnect');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->once())->method('disconnect');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->once())->method('disconnect');
+
+        $slave2 = $this->getMockConnection('tcp://127.0.0.1:6383?alias=slave2');
+        $slave2->expects($this->once())->method('disconnect');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+        $replication->add($slave2);
+
+        $replication->disconnect();
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodIsConnectedReturnConnectionStatusOfCurrentConnection()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->exactly(2))
+               ->method('isConnected')
+               ->will($this->onConsecutiveCalls(true, false));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($slave1);
+
+        $this->assertFalse($replication->isConnected());
+        $replication->connect();
+        $this->assertTrue($replication->isConnected());
+        $replication->getConnectionById('slave1')->disconnect();
+        $this->assertFalse($replication->isConnected());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodGetConnectionByIdReturnsConnectionWhenFound()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $this->assertSame($master, $replication->getConnectionById('master'));
+        $this->assertSame($slave1, $replication->getConnectionById('slave1'));
+        $this->assertNull($replication->getConnectionById('unknown'));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodSwitchToSelectsCurrentConnectionByConnectionAlias()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->once())->method('connect');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->never())->method('connect');
+
+        $slave2 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave2');
+        $slave2->expects($this->once())->method('connect');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+        $replication->add($slave2);
+
+        $replication->switchTo('master');
+        $this->assertSame($master, $replication->getCurrent());
+
+        $replication->switchTo('slave2');
+        $this->assertSame($slave2, $replication->getCurrent());
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException InvalidArgumentException
+     * @expectedExceptionMessage Invalid connection or connection not found.
+     */
+    public function testMethodSwitchToThrowsExceptionOnConnectionNotFound()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $replication->switchTo('unknown');
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodSwitchToMasterSelectsCurrentConnectionToMaster()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->once())->method('connect');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->never())->method('connect');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $replication->switchToMaster();
+
+        $this->assertSame($master, $replication->getCurrent());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodSwitchToSlaveSelectsCurrentConnectionToRandomSlave()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->never())->method('connect');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->once())->method('connect');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $replication->switchToSlave();
+
+        $this->assertSame($slave1, $replication->getCurrent());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetConnectionReturnsMasterForWriteCommands()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->exactly(2))
+               ->method('isConnected')
+               ->will($this->onConsecutiveCalls(false, true));
+        $master->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('ROLE'))
+               ->will($this->returnValue(array(
+                   "master", 3129659, array( array("127.0.0.1", 6382, 3129242) ),
+               )));
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $this->assertSame($master, $replication->getConnection(
+            Command\RawCommand::create('set', 'key', 'value')
+        ));
+
+        $this->assertSame($master, $replication->getConnection(
+            Command\RawCommand::create('del', 'key')
+        ));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetConnectionReturnsSlaveForReadOnlyCommands()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->exactly(2))
+               ->method('isConnected')
+               ->will($this->onConsecutiveCalls(false, true));
+
+        $slave1->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('ROLE'))
+               ->will($this->returnValue(array(
+                  "slave", "127.0.0.1", 9000, "connected", 3167038
+               )));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $this->assertSame($slave1, $replication->getConnection(
+            Command\RawCommand::create('get', 'key')
+        ));
+
+        $this->assertSame($slave1, $replication->getConnection(
+            Command\RawCommand::create('exists', 'key')
+        ));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetConnectionSwitchesToMasterAfterWriteCommand()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->exactly(2))
+               ->method('isConnected')
+               ->will($this->onConsecutiveCalls(false, true));
+        $master->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('ROLE'))
+               ->will($this->returnValue(array(
+                   "master", 3129659, array( array("127.0.0.1", 6382, 3129242) ),
+               )));
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->exactly(1))
+               ->method('isConnected')
+               ->will($this->onConsecutiveCalls(false));
+        $slave1->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('ROLE'))
+               ->will($this->returnValue(array(
+                  "slave", "127.0.0.1", 9000, "connected", 3167038
+               )));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $this->assertSame($slave1, $replication->getConnection(
+            Command\RawCommand::create('exists', 'key')
+        ));
+
+        $this->assertSame($master, $replication->getConnection(
+            Command\RawCommand::create('set', 'key', 'value')
+        ));
+
+        $this->assertSame($master, $replication->getConnection(
+            Command\RawCommand::create('get', 'key')
+        ));
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\Replication\RoleException
+     * @expectedExceptionMessage Expected master but got slave [127.0.0.1:6381]
+     */
+    public function testGetConnectionThrowsExceptionOnNodeRoleMismatch()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->once())
+               ->method('isConnected')
+               ->will($this->returnValue(false));
+        $master->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('ROLE'))
+               ->will($this->returnValue(array(
+                   "slave", "127.0.0.1", 9000, "connected", 3167038
+               )));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+
+        $replication->getConnection(Command\RawCommand::create('del', 'key'));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodExecuteCommandSendsCommandToNodeAndReturnsResponse()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $cmdGet = Command\RawCommand::create('get', 'key');
+        $cmdGetResponse = 'value';
+
+        $cmdSet = Command\RawCommand::create('set', 'key', 'value');
+        $cmdSetResponse = Response\Status::get('OK');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->any())
+               ->method('isConnected')
+               ->will($this->returnValue(true));
+        $master->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('SET', array('key', $cmdGetResponse)))
+               ->will($this->returnValue($cmdSetResponse));
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->any())
+               ->method('isConnected')
+               ->will($this->returnValue(true));
+        $slave1->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('GET', array('key')))
+               ->will($this->returnValue($cmdGetResponse));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $this->assertSame($cmdGetResponse, $replication->executeCommand($cmdGet));
+        $this->assertSame($cmdSetResponse, $replication->executeCommand($cmdSet));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodExecuteCommandRetriesReadOnlyCommandOnNextSlaveOnFailure()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->any())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('slaves', 'svc')
+                  ))
+                  ->will($this->returnValue(
+                      array(
+                          array(
+                              "name", "127.0.0.1:6383",
+                              "ip", "127.0.0.1",
+                              "port", "6383",
+                              "runid", "1c0bf1291797fbc5608c07a17da394147dc62817",
+                              "flags", "slave",
+                              "master-host", "127.0.0.1",
+                              "master-port", "6381"
+                          ),
+                      )
+                  ));
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->any())
+               ->method('isConnected')
+               ->will($this->returnValue(true));
+
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave1->expects($this->any())
+               ->method('isConnected')
+               ->will($this->returnValue(true));
+        $slave1->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('GET', array('key')))
+               ->will($this->throwException(
+                  new Connection\ConnectionException($slave1, "Unknown connection error [127.0.0.1:6382]")
+               ));
+
+        $slave2 = $this->getMockConnection('tcp://127.0.0.1:6383?alias=slave2');
+        $slave2->expects($this->any())
+               ->method('isConnected')
+               ->will($this->returnValue(true));
+        $slave2->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('GET', array('key')))
+               ->will($this->returnValue('value'));
+
+        $factory = $this->getMock('Predis\Connection\FactoryInterface');
+        $factory->expects($this->once())
+                 ->method('create')
+                 ->with(array(
+                    'host' => '127.0.0.1',
+                    'port' => '6383',
+                    'alias' => 'slave-127.0.0.1:6383',
+                  ))
+                 ->will($this->returnValue($slave2));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1), $factory);
+
+        $replication->add($master);
+        $replication->add($slave1);
+
+        $this->assertSame('value', $replication->executeCommand(
+            Command\RawCommand::create('get', 'key')
+        ));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodExecuteCommandRetriesWriteCommandOnNewMasterOnFailure()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->any())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('get-master-addr-by-name', 'svc')
+                  ))
+                  ->will($this->returnValue(
+                      array('127.0.0.1', '6391')
+                  ));
+
+        $masterOld = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $masterOld->expects($this->any())
+                  ->method('isConnected')
+                  ->will($this->returnValue(true));
+        $masterOld->expects($this->at(2))
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand('DEL', array('key')))
+                  ->will($this->throwException(
+                      new Connection\ConnectionException($masterOld, "Unknown connection error [127.0.0.1:6381]")
+                  ));
+
+        $masterNew = $this->getMockConnection('tcp://127.0.0.1:6391?alias=master');
+        $masterNew->expects($this->any())
+                  ->method('isConnected')
+                  ->will($this->returnValue(true));
+        $masterNew->expects($this->at(2))
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand('DEL', array('key')))
+                  ->will($this->returnValue(1));
+
+        $factory = $this->getMock('Predis\Connection\FactoryInterface');
+        $factory->expects($this->once())
+                 ->method('create')
+                 ->with(array(
+                    'host' => '127.0.0.1',
+                    'port' => '6391',
+                    'alias' => 'master',
+                  ))
+                 ->will($this->returnValue($masterNew));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1), $factory);
+
+        $replication->add($masterOld);
+
+        $this->assertSame(1, $replication->executeCommand(
+            Command\RawCommand::create('del', 'key')
+        ));
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\Response\ServerException
+     * @expectedExceptionMessage ERR No such master with that name
+     */
+    public function testMethodExecuteCommandThrowsExceptionOnUnknownServiceName()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->any())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('get-master-addr-by-name', 'svc')
+                  ))
+                  ->will($this->returnValue(null));
+
+        $masterOld = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $masterOld->expects($this->any())
+                  ->method('isConnected')
+                  ->will($this->returnValue(true));
+        $masterOld->expects($this->at(2))
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand('DEL', array('key')))
+                  ->will($this->throwException(
+                      new Connection\ConnectionException($masterOld, "Unknown connection error [127.0.0.1:6381]")
+                  ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($masterOld);
+
+        $replication->executeCommand(
+            Command\RawCommand::create('del', 'key')
+        );
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException Predis\ClientException
+     * @expectedExceptionMessage No sentinel server available for autodiscovery.
+     */
+    public function testMethodExecuteCommandThrowsExceptionOnConnectionFailureAndNoAvailableSentinels()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+        $sentinel1->expects($this->any())
+                  ->method('executeCommand')
+                  ->with($this->isRedisCommand(
+                      'SENTINEL', array('get-master-addr-by-name', 'svc')
+                  ))
+                  ->will($this->throwException(
+                      new Connection\ConnectionException($sentinel1, "Unknown connection error [127.0.0.1:5381]")
+                  ));
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $master->expects($this->any())
+               ->method('isConnected')
+               ->will($this->returnValue(true));
+        $master->expects($this->at(2))
+               ->method('executeCommand')
+               ->with($this->isRedisCommand('DEL', array('key')))
+               ->will($this->throwException(
+                   new Connection\ConnectionException($master, "Unknown connection error [127.0.0.1:6381]")
+               ));
+
+        $replication = $this->getReplicationConnection('svc', array($sentinel1));
+
+        $replication->add($master);
+
+        $replication->executeCommand(
+            Command\RawCommand::create('del', 'key')
+        );
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodGetReplicationStrategyReturnsInstance()
+    {
+        $strategy = new Replication\ReplicationStrategy();
+        $factory = new Connection\Factory();
+
+        $replication = new SentinelReplication(
+            'svc', array('tcp://127.0.0.1:5381?alias=sentinel1'), $factory, $strategy
+        );
+
+        $this->assertSame($strategy, $replication->getReplicationStrategy());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testMethodSerializeCanSerializeWholeObject()
+    {
+        $sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?alias=sentinel1');
+
+        $master = $this->getMockConnection('tcp://127.0.0.1:6381?alias=master');
+        $slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?alias=slave1');
+        $slave2 = $this->getMockConnection('tcp://127.0.0.1:6383?alias=slave2');
+
+        $strategy = new Replication\ReplicationStrategy();
+        $factory = new Connection\Factory();
+
+        $replication = new SentinelReplication('svc', array($sentinel1), $factory, $strategy);
+
+        $replication->add($master);
+        $replication->add($slave1);
+        $replication->add($slave2);
+
+        $unserialized = unserialize(serialize($replication));
+
+        $this->assertEquals($master, $unserialized->getConnectionById('master'));
+        $this->assertEquals($slave1, $unserialized->getConnectionById('slave1'));
+        $this->assertEquals($master, $unserialized->getConnectionById('slave2'));
+        $this->assertEquals($strategy, $unserialized->getReplicationStrategy());
+    }
+
+    // ******************************************************************** //
+    // ---- HELPER METHODS ------------------------------------------------ //
+    // ******************************************************************** //
+
+    /**
+     * Creates a new instance of replication connection.
+     *
+     * @param string                          $service   Name of the service
+     * @param array                           $sentinels Array of sentinels
+     * @param ConnectionFactoryInterface|null $factory   Optional connection factory instance.
+     *
+     * @return SentinelReplication
+     */
+    protected function getReplicationConnection($service, $sentinels, Connection\FactoryInterface $factory = null)
+    {
+        $factory = $factory ?: new Connection\Factory();
+
+        $replication = new SentinelReplication($service, $sentinels, $factory);
+        $replication->setRetryWait(0);
+
+        return $replication;
+    }
+
+    /**
+     * Returns a base mocked connection from Predis\Connection\NodeConnectionInterface.
+     *
+     * @param mixed $parameters Optional parameters.
+     *
+     * @return mixed
+     */
+    protected function getMockSentinelConnection($parameters = null)
+    {
+        $connection = $this->getMockConnection($parameters);
+
+        return $connection;
+    }
+
+    /**
+     * Returns a base mocked connection from Predis\Connection\NodeConnectionInterface.
+     *
+     * @param mixed $parameters Optional parameters.
+     *
+     * @return mixed
+     */
+    protected function getMockConnection($parameters = null)
+    {
+        $connection = $this->getMock('Predis\Connection\NodeConnectionInterface');
+
+        if ($parameters) {
+            $parameters = Connection\Parameters::create($parameters);
+            $hash = "{$parameters->host}:{$parameters->port}";
+
+            $connection->expects($this->any())
+                       ->method('getParameters')
+                       ->will($this->returnValue($parameters));
+            $connection->expects($this->any())
+                       ->method('__toString')
+                       ->will($this->returnValue($hash));
+        }
+
+        return $connection;
+    }
+}