Browse Source

Extract slot map logic to separate class.

In addition to that, the methods askSlotsMap() and buildSlotsMap() of
the redis-cluster connection backend have been renamed respectively
to askSlotMap() and buildSlotMap().
Daniele Alessandri 8 years ago
parent
commit
69e40dab2b

+ 195 - 0
src/Cluster/SlotMap.php

@@ -0,0 +1,195 @@
+<?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\Cluster;
+
+/**
+ * Slot map for redis-cluster.
+ */
+class SlotMap implements \ArrayAccess, \IteratorAggregate, \Countable
+{
+    private $slots = array();
+
+    /**
+     * Checks if the given slot is valid.
+     *
+     * @param int $first Slot index.
+     *
+     * @return bool
+     */
+    public static function isValid($slot)
+    {
+        return $slot >= 0x0000 && $slot <= 0x3FFF;
+    }
+
+    /**
+     * Checks if the given slot range is valid.
+     *
+     * @param int $first Initial slot of the range.
+     * @param int $last  Last slot of the range.
+     *
+     * @return bool
+     */
+    public static function isValidRange($first, $last)
+    {
+        return $first >= 0x0000 && $first <= 0x3FFF && $last >= 0x0000 && $last <= 0x3FFF && $first <= $last;
+    }
+
+    /**
+     * Resets the slot map.
+     */
+    public function reset()
+    {
+        $this->slots = array();
+    }
+
+    /**
+     * Checks if the slot map is empty.
+     *
+     * @return bool
+     */
+    public function isEmpty()
+    {
+        return empty($this->slots);
+    }
+
+    /**
+     * Returns the current slot map as a dictionary of $slot => $node.
+     *
+     * The order of the slots in the dictionary is not guaranteed.
+     *
+     * @return array
+     */
+    public function toArray()
+    {
+        return $this->slots;
+    }
+
+    /**
+     * Returns the list of unique nodes in the slot map.
+     *
+     * @return array
+     */
+    public function getNodes()
+    {
+        return array_keys(array_flip($this->slots));
+    }
+
+    /**
+     * Assigns the specified slot range to a node.
+     *
+     * @param int                            $first      Initial slot of the range.
+     * @param int                            $last       Last slot of the range.
+     * @param NodeConnectionInterface|string $connection ID or connection instance.
+     *
+     * @throws \OutOfBoundsException
+     */
+    public function setSlots($first, $last, $connection)
+    {
+        if (!static::isValidRange($first, $last)) {
+            throw new \OutOfBoundsException("Invalid slot range $first-$last for `$connection`");
+        }
+
+        $this->slots += array_fill($first, $last - $first + 1, (string) $connection);
+    }
+
+    /**
+     * Returns the specified slot range.
+     *
+     * @param int $first      Initial slot of the range.
+     * @param int $last       Last slot of the range.
+     *
+     * @return array
+     */
+    public function getSlots($first, $last)
+    {
+        if (!static::isValidRange($first, $last)) {
+            throw new \OutOfBoundsException("Invalid slot range $first-$last");
+        }
+
+        return array_intersect_key($this->slots, array_fill($first, $last - $first + 1, null));
+    }
+
+    /**
+     * Checks if the specified slot is assigned.
+     *
+     * @param int $slot Slot index.
+     *
+     * @return bool
+     */
+    public function offsetExists($slot)
+    {
+        return isset($this->slots[$slot]);
+    }
+
+    /**
+     * Returns the node assigned to the specified slot
+     *
+     * @param int $slot Slot index.
+     *
+     * @return string
+     */
+    public function offsetGet($slot)
+    {
+        if (isset($this->slots[$slot])) {
+            return $this->slots[$slot];
+        }
+    }
+
+    /**
+     * Assigns the specified slot to a node.
+     *
+     * @param int                            $slot       Slot index.
+     * @param NodeConnectionInterface|string $connection ID or connection instance.
+     *
+     * @return string
+     */
+    public function offsetSet($slot, $connection)
+    {
+        if (!static::isValid($slot)) {
+            throw new \OutOfBoundsException("Invalid slot $slot for `$connection`");
+        }
+
+        $this->slots[(int) $slot] = (string) $connection;
+    }
+
+    /**
+     * Returns the node assigned to the specified slot
+     *
+     * @param int $slot Slot index.
+     *
+     * @return string
+     */
+    public function offsetUnset($slot)
+    {
+        unset($this->slots[$slot]);
+    }
+
+    /**
+     * Returns the current number of assigned slots.
+     *
+     * @return int
+     */
+    public function count()
+    {
+        return count($this->slots);
+    }
+
+    /**
+     * Returns an iterator over the slot map.
+     *
+     * @return \ArrayIterator
+     */
+    public function getIterator()
+    {
+        return new \ArrayIterator($this->slots);
+    }
+}

+ 41 - 90
src/Connection/Cluster/RedisCluster.php

@@ -13,6 +13,7 @@ namespace Predis\Connection\Cluster;
 
 use Predis\ClientException;
 use Predis\Cluster\RedisStrategy as RedisClusterStrategy;
+use Predis\Cluster\SlotMap;
 use Predis\Cluster\StrategyInterface;
 use Predis\Command\CommandInterface;
 use Predis\Command\RawCommand;
@@ -49,7 +50,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
     private $useClusterSlots = true;
     private $pool = array();
     private $slots = array();
-    private $slotsMap;
+    private $slotmap;
     private $strategy;
     private $connections;
     private $retryLimit = 5;
@@ -64,6 +65,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
     ) {
         $this->connections = $connections;
         $this->strategy = $strategy ?: new RedisClusterStrategy();
+        $this->slotmap = new SlotMap();
     }
 
     /**
@@ -120,7 +122,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
     public function add(NodeConnectionInterface $connection)
     {
         $this->pool[(string) $connection] = $connection;
-        unset($this->slotsMap);
+        $this->slotmap->reset();
     }
 
     /**
@@ -129,12 +131,9 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
     public function remove(NodeConnectionInterface $connection)
     {
         if (false !== $id = array_search($connection, $this->pool, true)) {
-            unset(
-                $this->pool[$id],
-                $this->slotsMap
-            );
-
+            $this->slotmap->reset();
             $this->slots = array_diff($this->slots, array($connection));
+            unset($this->pool[$id]);
 
             return true;
         }
@@ -152,10 +151,9 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
     public function removeById($connectionID)
     {
         if (isset($this->pool[$connectionID])) {
-            unset(
-                $this->pool[$connectionID],
-                $this->slotsMap
-            );
+            $this->slotmap->reset();
+            $this->slots = array_diff($this->slots, array($connectionID));
+            unset($this->pool[$connectionID]);
 
             return true;
         }
@@ -171,12 +169,10 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
      * cluster, so it is most effective when all of the connections supplied on
      * initialization have the "slots" parameter properly set accordingly to the
      * current cluster configuration.
-     *
-     * @return array
      */
-    public function buildSlotsMap()
+    public function buildSlotMap()
     {
-        $this->slotsMap = array();
+        $this->slotmap->reset();
 
         foreach ($this->pool as $connectionID => $connection) {
             $parameters = $connection->getParameters();
@@ -192,11 +188,9 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
                     $slots[1] = $slots[0];
                 }
 
-                $this->setSlots($slots[0], $slots[1], $connectionID);
+                $this->slotmap->setSlots($slots[0], $slots[1], $connectionID);
             }
         }
-
-        return $this->slotsMap;
     }
 
     /**
@@ -210,7 +204,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
      *
      * @return mixed
      */
-    private function queryClusterNodeForSlotsMap(NodeConnectionInterface $connection)
+    private function queryClusterNodeForSlotMap(NodeConnectionInterface $connection)
     {
         $retries = 0;
         $command = RawCommand::create('CLUSTER', 'SLOTS');
@@ -246,18 +240,16 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
      * the pool.
      *
      * @param NodeConnectionInterface $connection Optional connection instance.
-     *
-     * @return array
      */
-    public function askSlotsMap(NodeConnectionInterface $connection = null)
+    public function askSlotMap(NodeConnectionInterface $connection = null)
     {
         if (!$connection && !$connection = $this->getRandomConnection()) {
-            return array();
+            return;
         }
 
-        $this->resetSlotsMap();
+        $this->slotmap->reset();
 
-        $response = $this->queryClusterNodeForSlotsMap($connection);
+        $response = $this->queryClusterNodeForSlotMap($connection);
 
         foreach ($response as $slots) {
             // We only support master servers for now, so we ignore subsequent
@@ -265,61 +257,11 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
             list($start, $end, $master) = $slots;
 
             if ($master[0] === '') {
-                $this->setSlots($start, $end, (string) $connection);
+                $this->slotmap->setSlots($start, $end, (string) $connection);
             } else {
-                $this->setSlots($start, $end, "{$master[0]}:{$master[1]}");
+                $this->slotmap->setSlots($start, $end, "{$master[0]}:{$master[1]}");
             }
         }
-
-        return $this->slotsMap;
-    }
-
-    /**
-     * Resets the slots map cache.
-     */
-    public function resetSlotsMap()
-    {
-        $this->slotsMap = array();
-    }
-
-    /**
-     * Returns the current slots map for the cluster.
-     *
-     * The order of the returned $slot => $server dictionary is not guaranteed.
-     *
-     * @return array
-     */
-    public function getSlotsMap()
-    {
-        if (!isset($this->slotsMap)) {
-            $this->slotsMap = array();
-        }
-
-        return $this->slotsMap;
-    }
-
-    /**
-     * Pre-associates a connection to a slots range to avoid runtime guessing.
-     *
-     * @param int                            $first      Initial slot of the range.
-     * @param int                            $last       Last slot of the range.
-     * @param NodeConnectionInterface|string $connection ID or connection instance.
-     *
-     * @throws \OutOfBoundsException
-     */
-    public function setSlots($first, $last, $connection)
-    {
-        if ($first < 0x0000 || $first > 0x3FFF ||
-            $last < 0x0000 || $last > 0x3FFF ||
-            $last < $first
-        ) {
-            throw new \OutOfBoundsException(
-                "Invalid slot range for $connection: [$first-$last]."
-            );
-        }
-
-        $slots = array_fill($first, $last - $first + 1, (string) $connection);
-        $this->slotsMap = $this->getSlotsMap() + $slots;
     }
 
     /**
@@ -337,12 +279,12 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
             throw new ClientException('No connections available in the pool');
         }
 
-        if (!isset($this->slotsMap)) {
-            $this->buildSlotsMap();
+        if ($this->slotmap->isEmpty()) {
+            $this->buildSlotMap();
         }
 
-        if (isset($this->slotsMap[$slot])) {
-            return $this->slotsMap[$slot];
+        if ($node = $this->slotmap[$slot]) {
+            return $node;
         }
 
         $count = count($this->pool);
@@ -400,7 +342,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
      */
     public function getConnectionBySlot($slot)
     {
-        if ($slot < 0x0000 || $slot > 0x3FFF) {
+        if (!SlotMap::isValid($slot)) {
             throw new \OutOfBoundsException("Invalid slot [$slot].");
         }
 
@@ -451,6 +393,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
     {
         $this->pool[(string) $connection] = $connection;
         $this->slots[(int) $slot] = $connection;
+        $this->slotmap[(int) $slot] = $connection;
     }
 
     /**
@@ -495,7 +438,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
         }
 
         if ($this->useClusterSlots) {
-            $this->askSlotsMap($connection);
+            $this->askSlotMap($connection);
         }
 
         $this->move($connection, $slot);
@@ -557,7 +500,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
                 if ($failure) {
                     throw $exception;
                 } elseif ($this->useClusterSlots) {
-                    $this->askSlotsMap();
+                    $this->askSlotMap();
                 }
 
                 $failure = true;
@@ -612,15 +555,13 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
      */
     public function getIterator()
     {
-        if ($this->useClusterSlots) {
-            $slotsmap = $this->getSlotsMap() ?: $this->askSlotsMap();
-        } else {
-            $slotsmap = $this->getSlotsMap() ?: $this->buildSlotsMap();
+        if ($this->slotmap->isEmpty()) {
+            $this->useClusterSlots ? $this->askSlotMap() : $this->buildSlotMap();
         }
 
         $connections = array();
 
-        foreach (array_unique($slotsmap) as $node) {
+        foreach ($this->slotmap->getNodes() as $node) {
             if (!$connection = $this->getConnectionById($node)) {
                 $this->add($connection = $this->createConnection($node));
             }
@@ -631,6 +572,16 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
         return new \ArrayIterator($connections);
     }
 
+    /**
+     * Returns the underlying slot map.
+     *
+     * @return SlotMap
+     */
+    public function getSlotMap()
+    {
+        return $this->slotmap;
+    }
+
     /**
      * Returns the underlying command hash strategy used to hash commands by
      * using keys found in their arguments.
@@ -661,7 +612,7 @@ class RedisCluster implements ClusterInterface, \IteratorAggregate, \Countable
      * procedure, mostly when targeting many keys that would end up in a lot of
      * redirections.
      *
-     * The slots map can still be manually fetched using the askSlotsMap()
+     * The slots map can still be manually fetched using the askSlotMap()
      * method whether or not this option is enabled.
      *
      * @param bool $value Enable or disable the use of CLUSTER SLOTS.

+ 471 - 0
tests/Predis/Cluster/SlotMapTest.php

@@ -0,0 +1,471 @@
+<?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\Cluster;
+
+use PredisTestCase;
+
+/**
+ *
+ */
+class SlotMapTest extends PredisTestCase
+{
+    /**
+     * @group disconnected
+     */
+    public function testIsValidReturnsTrueOnValidSlot()
+    {
+        $this->assertTrue(SlotMap::isValid(0));
+        $this->assertTrue(SlotMap::isValid(16383));
+
+        $this->assertTrue(SlotMap::isValid(5000));
+        $this->assertTrue(SlotMap::isValid('5000'));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsValidReturnsFalseOnInvalidSlot()
+    {
+        $this->assertFalse(SlotMap::isValid(-1));
+        $this->assertFalse(SlotMap::isValid(16384));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsValidRangeReturnsTrueOnValidSlotRange()
+    {
+        $this->assertTrue(SlotMap::isValidRange(0, 16383));
+        $this->assertTrue(SlotMap::isValidRange(2000, 2999));
+        $this->assertTrue(SlotMap::isValidRange(3000, 3000));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsValidRangeReturnsFalseOnInvalidSlotRange()
+    {
+        $this->assertFalse(SlotMap::isValidRange(0, 16384));
+        $this->assertFalse(SlotMap::isValidRange(-1, 16383));
+        $this->assertFalse(SlotMap::isValidRange(-1, 16384));
+        $this->assertFalse(SlotMap::isValidRange(2999, 2000));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testToArrayReturnsEmptyArrayOnEmptySlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $this->assertEmpty($slotmap->toArray());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testSetSlotsAssignsSpecifiedNodeToSlotRange()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $slotmap->setSlots(5461, 10921, '127.0.0.1:6380');
+        $slotmap->setSlots(10922, 16383, '127.0.0.1:6381');
+
+        $expectedMap = array_merge(
+            array_fill(0, 5461, '127.0.0.1:6379'),
+            array_fill(5460, 5461, '127.0.0.1:6380'),
+            array_fill(10921, 5462, '127.0.0.1:6381')
+        );
+
+        $this->assertSame($expectedMap, $slotmap->toArray());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testSetSlotsOverwritesSlotRange()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $slotmap->setSlots(1000, 2000, '127.0.0.1:6380');
+
+        $expectedMap =
+            array_fill(0, 5461, '127.0.0.1:6379') +
+            array_fill(1000, 2000, '127.0.0.1:6380');
+
+        $this->assertSame($expectedMap, $slotmap->toArray());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testSetSlotsAssignsSingleSlotWhenFirstAndLastSlotMatch()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(10, 10, '127.0.0.1:6379');
+
+        $this->assertSame(array(10 => '127.0.0.1:6379'), $slotmap->toArray());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testSetSlotsCastsValueToString()
+    {
+        $slotmap = new SlotMap();
+
+        $connection = $this->getMockConnection();
+        $connection
+            ->expects($this->once())
+            ->method('__toString')
+            ->will($this->returnValue('127.0.0.1:6379'));
+
+        $slotmap->setSlots(10, 10, $connection);
+
+        $this->assertSame(array(10 => '127.0.0.1:6379'), $slotmap->toArray());
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException \OutOfBoundsException
+     * @expectedExceptionMessage Invalid slot range 0-16384 for `127.0.0.1:6379`
+     */
+    public function testSetSlotsThrowsExceptionOnInvalidSlotRange()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 16384, '127.0.0.1:6379');
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetSlotsReturnsEmptyArrayOnEmptySlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $this->assertEmpty($slotmap->getSlots(3, 11));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetSlotsReturnsDictionaryOfSlotsWithAssignedNodes()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5, '127.0.0.1:6379');
+        $slotmap->setSlots(10, 13, '127.0.0.1:6380');
+
+        $expectedMap = array(
+            3 => '127.0.0.1:6379',
+            4 => '127.0.0.1:6379',
+            5 => '127.0.0.1:6379',
+            10 => '127.0.0.1:6380',
+            11 => '127.0.0.1:6380',
+        );
+
+        $this->assertSame($expectedMap, $slotmap->getSlots(3, 11));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetSlotsReturnsEmptyArrayOnEmptySlotRange()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5, '127.0.0.1:6379');
+        $slotmap->setSlots(10, 13, '127.0.0.1:6380');
+
+        $this->assertEmpty($slotmap->getSlots(100, 200));
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException \OutOfBoundsException
+     * @expectedExceptionMessage Invalid slot range 0-16384
+     */
+    public function testGetSlotsThrowsExceptionOnInvalidSlotRange()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->getSlots(0, 16384);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsEmptyReturnsTrueOnEmptySlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $this->assertTrue($slotmap->isEmpty());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testIsEmptyReturnsFalseOnNonEmptySlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertFalse($slotmap->isEmpty());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testCountReturnsZeroOnEmptySlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $this->assertSame(0, count($slotmap));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testCountReturnsAssignedSlotsInSlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $this->assertSame(5461, count($slotmap));
+
+        $slotmap->setSlots(5461, 10921, '127.0.0.1:6380');
+        $this->assertSame(10922, count($slotmap));
+
+        $slotmap->setSlots(10922, 16383, '127.0.0.1:6381');
+        $this->assertSame(16384, count($slotmap));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testResetEmptiesSlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $slotmap->setSlots(5461, 10921, '127.0.0.1:6380');
+        $slotmap->setSlots(10922, 16383, '127.0.0.1:6381');
+
+        $this->assertFalse($slotmap->isEmpty());
+
+        $slotmap->reset();
+
+        $this->assertTrue($slotmap->isEmpty());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetNodesReturnsEmptyArrayOnEmptySlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $this->assertEmpty($slotmap->getNodes());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetNodesReturnsArrayOfNodesInSlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $slotmap->setSlots(5461, 10921, '127.0.0.1:6380');
+        $slotmap->setSlots(10922, 16383, '127.0.0.1:6381');
+
+        $this->assertSame(array('127.0.0.1:6379', '127.0.0.1:6380', '127.0.0.1:6381'), $slotmap->getNodes());
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetExistsReturnsTrueOnAssignedSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertTrue(isset($slotmap[0]));
+        $this->assertTrue(isset($slotmap[2000]));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetExistsReturnsFalseOnAssignedSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertFalse(isset($slotmap[6000]));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetExistsReturnsFalseOnInvalidSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertFalse(isset($slotmap[-100]));
+        $this->assertFalse(isset($slotmap[16384]));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetGetReturnsNodeOfAssignedSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $slotmap->setSlots(5461, 10921, '127.0.0.1:6380');
+        $slotmap->setSlots(10922, 16383, '127.0.0.1:6381');
+
+        $this->assertSame('127.0.0.1:6379', $slotmap[0]);
+        $this->assertSame('127.0.0.1:6380', $slotmap[5461]);
+        $this->assertSame('127.0.0.1:6381', $slotmap[10922]);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetGetReturnsNullOnUnassignedSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertNull($slotmap[5461]);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetGetReturnsNullOnInvalidSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertNull($slotmap[-100]);
+        $this->assertNull($slotmap[16384]);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetUnsetRemovesSlotAssignment()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertTrue(isset($slotmap[100]));
+        unset($slotmap[100]);
+        $this->assertFalse(isset($slotmap[100]));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetUnsetDoesNotDoAnythingOnUnassignedSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertFalse(isset($slotmap[5461]));
+        unset($slotmap[5461]);
+        $this->assertFalse(isset($slotmap[5461]));
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetSetAssignsNodeToSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertSame('127.0.0.1:6380', $slotmap[100] = '127.0.0.1:6380');
+        $this->assertSame('127.0.0.1:6380', $slotmap[100]);
+
+        $this->assertNull($slotmap[5461]);
+        $this->assertSame('127.0.0.1:6380', $slotmap[5461] = '127.0.0.1:6380');
+        $this->assertSame('127.0.0.1:6380', $slotmap[5461]);
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testOffsetSetCastsValueToString()
+    {
+        $slotmap = new SlotMap();
+
+        $connection = $this->getMockConnection();
+        $connection
+            ->expects($this->once())
+            ->method('__toString')
+            ->will($this->returnValue('127.0.0.1:6379'));
+
+        $this->assertSame($connection, $slotmap[0] = $connection);
+        $this->assertSame('127.0.0.1:6379', $slotmap[0]);
+    }
+
+    /**
+     * @group disconnected
+     * @expectedException \OutOfBoundsException
+     * @expectedExceptionMessage Invalid slot 16384 for `127.0.0.1:6379`
+     */
+    public function testOffsetSetThrowsExceptionOnInvalidSlot()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap[16384] = '127.0.0.1:6379';
+    }
+
+    /**
+     * @group disconnected
+     */
+    public function testGetIteratorReturnsIteratorOverSlotMap()
+    {
+        $slotmap = new SlotMap();
+
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $slotmap->setSlots(5461, 10921, '127.0.0.1:6380');
+        $slotmap->setSlots(10922, 16383, '127.0.0.1:6381');
+
+        $expectedMap = array_merge(
+            array_fill(0, 5461, '127.0.0.1:6379'),
+            array_fill(5460, 5461, '127.0.0.1:6380'),
+            array_fill(10921, 5462, '127.0.0.1:6381')
+        );
+
+        $this->assertSame($expectedMap, iterator_to_array($slotmap));
+    }
+}

+ 34 - 53
tests/Predis/Connection/Cluster/RedisClusterTest.php

@@ -348,34 +348,6 @@ class RedisClusterTest extends PredisTestCase
         $this->assertSame($connection4, $connections[2]);
     }
 
-    /**
-     * @group disconnected
-     */
-    public function testCanAssignConnectionsToCustomSlots()
-    {
-        $connection1 = $this->getMockConnection('tcp://127.0.0.1:6379');
-        $connection2 = $this->getMockConnection('tcp://127.0.0.1:6380');
-        $connection3 = $this->getMockConnection('tcp://127.0.0.1:6381');
-
-        $cluster = new RedisCluster(new Connection\Factory());
-
-        $cluster->add($connection1);
-        $cluster->add($connection2);
-        $cluster->add($connection3);
-
-        $cluster->setSlots(0, 1364, '127.0.0.1:6379');
-        $cluster->setSlots(1365, 2729, '127.0.0.1:6380');
-        $cluster->setSlots(2730, 4095, '127.0.0.1:6381');
-
-        $expectedMap = array_merge(
-            array_fill(0, 1365, '127.0.0.1:6379'),
-            array_fill(1364, 1365, '127.0.0.1:6380'),
-            array_fill(2729, 1366, '127.0.0.1:6381')
-        );
-
-        $this->assertSame($expectedMap, $cluster->getSlotsMap());
-    }
-
     /**
      * @group disconnected
      */
@@ -388,12 +360,14 @@ class RedisClusterTest extends PredisTestCase
 
         $cluster->add($connection1);
 
-        $cluster->setSlots(0, 4095, '127.0.0.1:6379');
-        $this->assertSame(array_fill(0, 4096, '127.0.0.1:6379'), $cluster->getSlotsMap());
+        $slotmap = $cluster->getSlotMap();
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+
+        $this->assertSame(array_fill(0, 5461, '127.0.0.1:6379'), $slotmap->toArray());
 
         $cluster->add($connection2);
 
-        $this->assertEmpty($cluster->getSlotsMap());
+        $this->assertCount(0, $slotmap);
     }
 
     /**
@@ -409,18 +383,20 @@ class RedisClusterTest extends PredisTestCase
         $cluster->add($connection1);
         $cluster->add($connection2);
 
-        $cluster->setSlots(0, 2047, '127.0.0.1:6379');
-        $cluster->setSlots(2048, 4095, '127.0.0.1:6380');
+        $slotmap = $cluster->getSlotMap();
+        $slotmap->setSlots(0, 5460, '127.0.0.1:6379');
+        $slotmap->setSlots(5461, 10921, '127.0.0.1:6380');
 
         $expectedMap = array_merge(
-            array_fill(0, 2048, '127.0.0.1:6379'),
-            array_fill(2048, 2048, '127.0.0.1:6380')
+            array_fill(0, 5461, '127.0.0.1:6379'),
+            array_fill(5460, 5461, '127.0.0.1:6380')
         );
 
-        $this->assertSame($expectedMap, $cluster->getSlotsMap());
+        $this->assertSame($expectedMap, $slotmap->toArray());
 
         $cluster->remove($connection1);
-        $this->assertEmpty($cluster->getSlotsMap());
+
+        $this->assertCount(0, $slotmap);
     }
 
     /**
@@ -438,7 +414,7 @@ class RedisClusterTest extends PredisTestCase
         $cluster->add($connection2);
         $cluster->add($connection3);
 
-        $cluster->buildSlotsMap();
+        $cluster->buildSlotMap();
 
         $expectedMap = array_merge(
             array_fill(0, 5461, '127.0.0.1:6379'),
@@ -446,7 +422,8 @@ class RedisClusterTest extends PredisTestCase
             array_fill(10921, 5462, '127.0.0.1:6381')
         );
 
-        $actualMap = $cluster->getSlotsMap();
+        $actualMap = $cluster->getSlotMap()->toArray();
+
         ksort($actualMap);
 
         $this->assertSame($expectedMap, $actualMap);
@@ -467,7 +444,7 @@ class RedisClusterTest extends PredisTestCase
         $cluster->add($connection2);
         $cluster->add($connection3);
 
-        $cluster->buildSlotsMap();
+        $cluster->buildSlotMap();
 
         $expectedMap = array_merge(
             array_fill(0, 5461, '127.0.0.1:6379'),
@@ -479,7 +456,8 @@ class RedisClusterTest extends PredisTestCase
             array_fill(11000, 5383, '127.0.0.1:6381')
         );
 
-        $actualMap = $cluster->getSlotsMap();
+        $actualMap = $cluster->getSlotMap()->toArray();
+
         ksort($actualMap);
 
         $this->assertSame($expectedMap, $actualMap);
@@ -504,7 +482,7 @@ class RedisClusterTest extends PredisTestCase
         $this->assertSame($connection2, $cluster->getConnectionBySlot(5461));
         $this->assertSame($connection3, $cluster->getConnectionBySlot(10922));
 
-        $cluster->setSlots(5461, 7096, '127.0.0.1:6380');
+        $cluster->getSlotMap()->setSlots(5461, 7096, '127.0.0.1:6380');
         $this->assertSame($connection2, $cluster->getConnectionBySlot(5461));
     }
 
@@ -679,7 +657,7 @@ class RedisClusterTest extends PredisTestCase
     /**
      * @group disconnected
      */
-    public function testRetriesExecutingCommandOnConnectionFailureButDoNotAskSlotsMapWhenDisabled()
+    public function testRetriesExecutingCommandOnConnectionFailureButDoNotAskSlotMapWhenDisabled()
     {
         $connection1 = $this->getMockConnection('tcp://127.0.0.1:6381?slots=0-5500');
         $connection1
@@ -766,7 +744,7 @@ class RedisClusterTest extends PredisTestCase
     /**
      * @group disconnected
      */
-    public function testAskSlotsMapReturnEmptyArrayOnEmptyConnectionsPool()
+    public function testAskSlotMapReturnEmptyArrayOnEmptyConnectionsPool()
     {
         $factory = $this->getMock('Predis\Connection\FactoryInterface');
         $factory
@@ -774,14 +752,15 @@ class RedisClusterTest extends PredisTestCase
             ->method('create');
 
         $cluster = new RedisCluster($factory);
+        $cluster->askSlotMap();
 
-        $this->assertEmpty($cluster->askSlotsMap());
+        $this->assertCount(0, $cluster->getSlotMap());
     }
 
     /**
      * @group disconnected
      */
-    public function testAskSlotsMapRetriesOnDifferentNodeOnConnectionFailure()
+    public function testAskSlotMapRetriesOnDifferentNodeOnConnectionFailure()
     {
         $slotsmap = array(
             array(0, 5460, array('127.0.0.1', 9381), array()),
@@ -836,7 +815,9 @@ class RedisClusterTest extends PredisTestCase
         $cluster->add($connection2);
         $cluster->add($connection3);
 
-        $this->assertCount(16384, $cluster->askSlotsMap());
+        $cluster->askSlotMap();
+
+        $this->assertCount(16384, $cluster->getSlotMap());
     }
 
     /**
@@ -844,7 +825,7 @@ class RedisClusterTest extends PredisTestCase
      * @expectedException \Predis\Connection\ConnectionException
      * @expectedExceptionMessage Unknown connection error [127.0.0.1:6382]
      */
-    public function testAskSlotsMapHonorsRetryLimitOnMultipleConnectionFailures()
+    public function testAskSlotMapHonorsRetryLimitOnMultipleConnectionFailures()
     {
         $slotsmap = array(
             array(0, 5460, array('127.0.0.1', 9381), array()),
@@ -897,7 +878,7 @@ class RedisClusterTest extends PredisTestCase
 
         $cluster->setRetryLimit(1);
 
-        $cluster->askSlotsMap();
+        $cluster->askSlotMap();
     }
 
     /**
@@ -1190,7 +1171,7 @@ class RedisClusterTest extends PredisTestCase
 
         $cluster->add($connection1);
 
-        $cluster->askSlotsMap();
+        $cluster->askSlotMap();
 
         $this->assertSame($cluster->getConnectionBySlot('6144'), $connection1);
     }
@@ -1198,7 +1179,7 @@ class RedisClusterTest extends PredisTestCase
     /**
      * @group disconnected
      */
-    public function testAskSlotsMapToRedisClusterOnMovedResponseByDefault()
+    public function testAskSlotMapToRedisClusterOnMovedResponseByDefault()
     {
         $cmdGET = Command\RawCommand::create('GET', 'node:1001');
         $rspMOVED = new Response\Error('MOVED 1970 127.0.0.1:6380');
@@ -1223,7 +1204,7 @@ class RedisClusterTest extends PredisTestCase
             ))
             ->will($this->returnValue($rspSlotsArray));
         $connection2
-            ->expects($this->at(2))
+            ->expects($this->at(3))
             ->method('executeCommand')
             ->with($cmdGET)
             ->will($this->returnValue('foobar'));
@@ -1278,7 +1259,7 @@ class RedisClusterTest extends PredisTestCase
         $cluster->add($connection2);
         $cluster->add($connection3);
 
-        $cluster->buildSlotsMap();
+        $cluster->buildSlotMap();
 
         $unserialized = unserialize(serialize($cluster));