Compare commits

...

3 Commits

Author SHA1 Message Date
Daniele Alessandri 6e8e23279e [tests] Update Predis\Connection\Aggregate\SentinelReplication tests.
Updated to verify that new sentinels retrieved from "SENTINEL" response
have their role automatically set to "sentinel". Also applied some minor
changes by dropping useless alias.
2020-09-12 19:44:56 +02:00
Daniele Alessandri 0392208520 Update README and CHANGELOG for role-specific default parameters. 2020-09-12 19:44:40 +02:00
Daniele Alessandri d9de9b5df3 Implement role-specific default parameters.
Until now users could specify a set of default parameters applied to all
connections created by the connection factory when not explicitly set in
the user-supplied set of parameters of each single node connection. This
is definitely handy, but it has some limits especially when dealing with
sentinel nodes since there are times when it is better to use different
defaults (e.g. "timeout") and they do not support certain parameters.

This commit adds the ability to specify role-specific default parameters
that gets applied only to connections targeting specific roles. This is
mostly useful for sentinels as they usually require lower timeouts than
normal Redis nodes and may also have a different password.

Role-specific parameters are passed as part of the "parameters" client
option in the form of named sub-arrays and take precedence over global
parameters, but they still do not override parameters explicitly set by
the user for single nodes.

Supported keys are "role.sentinel", "role.master" and "role.slave". In
regards to "role.sentinel", please note that:

  - sentinels do not support ACL authentication or database selection so
    so "username" and "database" are always stripped off.
  - "password" is never inherited from global defaults because users can
    have password-protected Redis nodes but unprotected Redis sentinels.
    In such cases, users must explicitly set a password either for each
    sentinel connection or just once in "role.sentinel".

Here is a brief example showing how to configure Predis for replication
supervised by redis-sentinel using different timeout and password values
for sentinel nodes compared to normal master and replica nodes.

  $client = new Predis\Client($arrayOfSentinels, [
    'replication' => 'sentinel',
    'service' => $sentinelService,

    'parameters' => [
      // Set of global default parameters, applied to *any* connection:
      'scheme' => true,
      'tcp_nodelay' => true,
      'timeout' => 5,
      'username' => $redisUsername, // Won't be inherited by sentinels.
      'password' => $redisPassword, // Won't be inherited by sentinels.

      // Set of sentinels-specific default parameters:
      'role.sentinel' => [
        // For sentinels, "scheme" and "tcp_nodelay" are inherited from
        // default parameters and "timeout" is overridden. On the other
        // hand both "username" and "password" are never inherited but
        // still explicitly set a password for sentinels because, in our
        // example, sentinels are indeed password-protected.
        'timeout' => 0.200,
        'password' => $sentinelPassword,
      ],
  ]);
2020-09-12 17:27:32 +02:00
6 changed files with 473 additions and 38 deletions
+8
View File
@@ -48,6 +48,14 @@ v2.0.0 (202x-xx-xx)
get a single connection from the pool by using its ID. It is also possible to
retrive a connection by role using the method getConnectionByRole().
- It is possible to set `role`-specific default parameters via the `parameters`
client option by passing named arrays to the following keys: `role.sentinel`,
`role.master` and `role.slave`. These parameters take precedence over global
default parameters but, as usual, they do not override parameters explicitly
set by users for each single node connection. For sentinels, "username" and
"database" are always stripped because they are not supported while "password"
is never inherited from global default parameters and must be set explicitly.
- The concept of connection ID (ip:port pair) and connection alias (the `alias`
parameter) in `Predis\Connection\Cluster\PredisCluster` has been separated.
This change does not affect distribution and it is safe for existing clusters.
+57 -7
View File
@@ -169,6 +169,40 @@ Users can also provide custom options with values or callable objects (for lazy
are stored in the options container for later use through the library.
### Global and role-specific default connection parameters ###
While the `parameters` client option is useful to apply a set of default parameters and their values
to connections created by the underlying connection factory, sometimes it is useful to set different
values depending on the actual role of the target node (just to make an example, for sentinel nodes
it is common to use a lower connect() timeout compared to the default value for normal Redis nodes).
To make this possible `parameters` allows passing role-specific default values as named arrays using
three special keys: `role.sentinel`, `role.master` and `role.slave`. These role-specific parameters
take precedence over global default parameters passed at the root level of `parameters` but they do
not override parameters explicitly set by the user for each single node just like global defaults.
```php
$options = [
'parameters' => [
// Root level is for global default parameters.
'database' => 10,
'password' => $redisSecretPassword,
// ...
'role.master' => [
// Sub-key for default parameters targeting master Redis nodes.
],
'role.slave' => [
// Sub-key for default parameters targeting replica Redis nodes.
],
'role.sentinel' => [
// Sub-key for default parameters targeting Redis Sentinel nodes.
],
],
];
```
### Aggregate connections ###
Aggregate connections are the foundation upon which Predis implements clustering and replication and
@@ -234,22 +268,38 @@ the `service` option set to the name of the service:
```php
$sentinels = ['tcp://10.0.0.1', 'tcp://10.0.0.2', 'tcp://10.0.0.3'];
$options = ['replication' => 'sentinel', 'service' => 'mymaster'];
$options = ['replication' => 'sentinel', 'service' => 'myservice'];
$client = new Predis\Client($sentinels, $options);
```
If the master and slave nodes are configured to require an authentication from clients, a password
must be provided via the global `parameters` client option. This option can also be used to specify
a different database index. The client options array would then look like this:
When master and replica nodes are configured to require authentication from clients, users must pass
`password` (password-based authentication) or `username` and `password` (ACL-based authentication on
Redis >= 6.0) via the global `parameters` client option.
```php
$options = [
'replication' => 'sentinel',
'service' => 'mymaster',
'service' => 'myservice',
'parameters' => [
'password' => $secretpassword,
'database' => 10,
'password' => $secretRedisPassword,
],
];
```
For sentinels protected by a password (supported since Redis >= 5.0) its value is not inherited from
the global `parameters` client option so a `password` must be set using the `role.sentinel` sub-key:
```php
$options = [
'replication' => 'sentinel',
'service' => 'myservice',
'parameters' => [
'password' => $secretRedisPassword,
'role.sentinel' => [
'password' => $secretSentinelPassword
]
],
];
```
+64 -2
View File
@@ -11,6 +11,7 @@
namespace Predis\Connection;
use InvalidArgumentException;
use Predis\Command\RawCommand;
/**
@@ -119,6 +120,28 @@ class Factory implements FactoryInterface
*/
public function setDefaultParameters(array $parameters)
{
if (isset($parameters['role.master']) && !is_array($parameters['role.master'])) {
throw new InvalidArgumentException('Default parameters for `role.master` must be passed as a named array');
}
if (isset($parameters['role.slave']) && !is_array($parameters['role.slave'])) {
throw new InvalidArgumentException('Default parameters for `role.slave` must be passed as a named array');
}
if (isset($parameters['role.sentinel'])) {
if (!is_array($parameters['role.sentinel'])) {
throw new InvalidArgumentException('Default parameters for `role.sentinel` must be passed as a named array');
}
// NOTE: sentinels do not support "SELECT" and ACL "AUTH" commands
// so we must strip "database" and "username" from "role.sentinel"
// to prevent spurious commands from being sent to sentinel nodes.
unset(
$parameters['role.sentinel']['username'],
$parameters['role.sentinel']['database']
);
}
$this->defaults = $parameters;
}
@@ -132,6 +155,37 @@ class Factory implements FactoryInterface
return $this->defaults;
}
/**
* Applies default connection parameters to the user supplied parameters.
*
* @param array $parameters Input connection parameters
*
* @return array
*/
protected function applyDefaultParameters(array $parameters)
{
static $stripInternal = ['role.sentinel' => null, 'role.master' => null, 'role.slave' => null];
$stripAdditional = [];
if (isset($parameters['role'])) {
switch ($role = $parameters['role']) {
case 'sentinel':
// NOTE: we strip these from global defaults when dealing with sentinel nodes.
$stripAdditional = ['username' => null, 'password' => null, 'database' => null];
case 'master':
case 'slave':
if (isset($this->defaults["role.$role"])) {
$parameters += $this->defaults["role.$role"];
}
}
}
$parameters += array_diff_key($this->defaults, $stripInternal, $stripAdditional);
return $parameters;
}
/**
* Creates a connection parameters instance from the supplied argument.
*
@@ -144,11 +198,19 @@ class Factory implements FactoryInterface
if (is_string($parameters)) {
$parameters = Parameters::parse($parameters);
} else {
$parameters = $parameters ?: array();
$parameters = $parameters ?? [];
}
if (isset($parameters['role']) && $parameters['role'] === 'sentinel') {
// NOTE: sentinels do not support "SELECT" and ACL "AUTH" commands so we must strip
// "database" and "username" from input parameters to prevent spurious commands from
// being sent to sentinel nodes but they can still accept "password" when explicitly
// set (password-based authentication for sentinels is supported on Redis >= 5.0).
unset($parameters['username'], $parameters['database']);
}
if ($this->defaults) {
$parameters += $this->defaults;
$parameters = $this->applyDefaultParameters($parameters);
}
return new Parameters($parameters);
@@ -241,6 +241,8 @@ class SentinelReplication implements ReplicationInterface
/**
* Creates a new connection to a sentinel server.
*
* @param mixed $parameters Connection parameters or connection instance
*
* @return NodeConnectionInterface
*/
protected function createSentinelConnection($parameters)
@@ -254,12 +256,10 @@ class SentinelReplication implements ReplicationInterface
}
if (is_array($parameters)) {
// Password authentication is fine now that Redis Sentinel supports
// password-protected sentinel instances, but we must explicitly set
// "database" and "username" to NULL so that no augmented AUTH (ACL)
// and SELECT command are sent by accident to the sentinels.
$parameters['database'] = null;
$parameters['username'] = null;
// NOTE: we enforce the "sentinel" role so that appropriate default
// parameters are applied when creating the new connection instance
// and blacklisted ones are stripped off from input parameters.
$parameters['role'] = 'sentinel';
if (!isset($parameters['timeout'])) {
$parameters['timeout'] = $this->sentinelTimeout;
+314 -2
View File
@@ -44,8 +44,108 @@ class FactoryTest extends PredisTestCase
));
$this->assertSame($defaults, $factory->getDefaultParameters());
}
$parameters = array('database' => 10, 'persistent' => true);
/**
* @group disconnected
*/
public function testSettingDefaultParametersForMasterRole(): void
{
$factory = new Factory();
$factory->setDefaultParameters($expected = array(
'role.master' => [
'username' => 'myusername',
'password' => 'secret',
'database' => 10,
]
));
$this->assertSame($expected, $factory->getDefaultParameters());
}
/**
* @group disconnected
*/
public function testSettingDefaultParametersForMasterRoleAcceptsArrayOnly(): void
{
$this->expectException('InvalidArgumentException');
$this->expectExceptionMessage('Default parameters for `role.master` must be passed as a named array');
$factory = new Factory();
$factory->setDefaultParameters(array(
'role.master' => 'invalid value',
));
}
/**
* @group disconnected
*/
public function testSettingDefaultParametersForSlaveRole(): void
{
$factory = new Factory();
$factory->setDefaultParameters($expected = array(
'role.slave' => [
'username' => 'myusername',
'password' => 'secret',
'database' => 10,
]
));
$this->assertSame($expected, $factory->getDefaultParameters());
}
/**
* @group disconnected
*/
public function testSettingDefaultParametersForSlaveRoleAcceptsArrayOnly(): void
{
$this->expectException('InvalidArgumentException');
$this->expectExceptionMessage('Default parameters for `role.slave` must be passed as a named array');
$factory = new Factory();
$factory->setDefaultParameters(array(
'role.slave' => 'invalid value',
));
}
/**
* @group disconnected
*/
public function testSettingDefaultParametersForSentinelRoleIgnoresUsernameAndPassword(): void
{
$factory = new Factory();
$factory->setDefaultParameters(array(
'role.sentinel' => [
'username' => 'myusername',
'password' => 'secret',
'database' => 10,
]
));
$expected = array(
'role.sentinel' => [
'password' => 'secret',
]
);
$this->assertSame($expected, $factory->getDefaultParameters());
}
/**
* @group disconnected
*/
public function testSettingDefaultParametersForSentinelRoleAcceptsArrayOnly(): void
{
$this->expectException('InvalidArgumentException');
$this->expectExceptionMessage('Default parameters for `role.sentinel` must be passed as a named array');
$factory = new Factory();
$factory->setDefaultParameters(array(
'role.sentinel' => 'invalid value',
));
}
/**
@@ -195,7 +295,7 @@ class FactoryTest extends PredisTestCase
/**
* @group disconnected
*/
public function testCreateConnectionWithArrayParametersAndDefaults(): void
public function testCreateConnectionWithDefaultParametersDoNotOverrideExplicitInputParameters(): void
{
$factory = new Factory();
@@ -223,6 +323,218 @@ class FactoryTest extends PredisTestCase
$this->assertNull($parameters->path);
}
/**
* @group disconnected
*/
public function testCreateConnectionForSentinelRoleIgnoresUsernameAndDatabase(): void
{
$factory = new Factory();
$connection = $factory->create($inputParams = array(
'role' => 'sentinel',
'username' => 'myusername',
'password' => 'mypassword',
'database' => 10,
));
$parameters = $connection->getParameters();
$this->assertInstanceOf('Predis\Connection\NodeConnectionInterface', $connection);
$this->assertEquals($inputParams['role'], $parameters->role);
$this->assertEquals($inputParams['password'], $parameters->password);
$this->assertNull($parameters->username);
$this->assertNull($parameters->database);
}
/**
* @group disconnected
*/
public function testCreateConnectionForSentinelRoleDoesNotInheritPasswordFromGlobalDefaultParameters(): void
{
$factory = new Factory();
$factory->setDefaultParameters($defaultParams = array(
'password' => 'pwd.default',
));
$connectionSentinelRole = $factory->create($inputParamsSentinelRole = array(
'role' => 'sentinel',
));
$parameters = $connectionSentinelRole->getParameters();
$this->assertNull($parameters->password);
}
/**
* @group disconnected
*/
public function testCreateConnectionForSentinelRoleDoesNotInheritUsernameFromGlobalDefaultParameters(): void
{
$factory = new Factory();
$factory->setDefaultParameters($defaultParams = array(
'username' => 'usr.default',
));
$connectionSentinelRole = $factory->create($inputParamsSentinelRole = array(
'role' => 'sentinel',
));
$parameters = $connectionSentinelRole->getParameters();
$this->assertNull($parameters->username);
}
/**
* @group disconnected
*/
public function testCreateConnectionForSentinelRoleDoesNotInheritDatabaseFromGlobalDefaultParameters(): void
{
$factory = new Factory();
$factory->setDefaultParameters($defaultParams = array(
'database' => 15,
));
$connectionSentinelRole = $factory->create($inputParamsSentinelRole = array(
'role' => 'sentinel',
));
$parameters = $connectionSentinelRole->getParameters();
$this->assertNull($parameters->database);
}
/**
* @group disconnected
*/
public function testCreateConnectionWithDefaultRoleParametersDoNotOverrideExplicitInputParameters(): void
{
$factory = new Factory();
$factory->setDefaultParameters($defaultParams = array(
'timeout' => 20,
'password' => 'pwd.default.norole',
'role.master' => [
'password' => 'pwd.role.master',
'timeout' => 10,
],
'role.slave' => [
'password' => 'pwd.role.slave',
'timeout' => 5,
],
'role.sentinel' => [
'password' => 'pwd.role.sentinel',
'timeout' => 1,
],
));
// NO ROLE
$connectionNoRole = $factory->create($inputParamsNoRole = array(
'password' => 'pwd.local.norole',
'timeout' => 30,
));
$parameters = $connectionNoRole->getParameters();
$this->assertEquals('pwd.local.norole', $parameters->password);
$this->assertEquals(30, $parameters->timeout);
// ROLE MASTER
$connectionMasterRole = $factory->create($inputParamsMasterRole = array(
'role' => 'master',
'password' => 'pwd.local.master',
'timeout' => 30,
));
$parameters = $connectionMasterRole->getParameters();
$this->assertEquals('pwd.local.master', $parameters->password);
$this->assertEquals(30, $parameters->timeout);
// ROLE SLAVE
$connectionSlaveRole = $factory->create($inputParamsSlaveRole = array(
'role' => 'slave',
'password' => 'pwd.local.slave',
'timeout' => 30,
));
$parameters = $connectionSlaveRole->getParameters();
$this->assertEquals('pwd.local.slave', $parameters->password);
$this->assertEquals(30, $parameters->timeout);
// ROLE SENTINEL
$connectionSentinelRole = $factory->create($inputParamsSentinelRole = array(
'role' => 'slave',
'password' => 'pwd.local.sentinel',
'timeout' => 30,
));
$parameters = $connectionSentinelRole->getParameters();
$this->assertEquals('pwd.local.sentinel', $parameters->password);
$this->assertEquals(30, $parameters->timeout);
}
/**
* @group disconnected
*/
public function testCreateConnectionWithDefaultRoleParametersOverridesDefaultGlobalParameters(): void
{
$factory = new Factory();
$factory->setDefaultParameters($defaultParams = array(
'timeout' => 20,
'password' => 'pwd.default.norole',
'role.master' => [
'password' => 'pwd.role.master',
'timeout' => 10,
],
'role.slave' => [
'password' => 'pwd.role.slave',
'timeout' => 5,
],
'role.sentinel' => [
'password' => 'pwd.role.sentinel',
'timeout' => 1,
],
));
// NO ROLE
$connectionNoRole = $factory->create($inputParamsNoRole = array(
// EMPTY
));
$parameters = $connectionNoRole->getParameters();
$this->assertEquals('pwd.default.norole', $parameters->password);
$this->assertEquals(20, $parameters->timeout);
// ROLE MASTER
$connectionMasterRole = $factory->create($inputParamsMasterRole = array(
'role' => 'master',
));
$parameters = $connectionMasterRole->getParameters();
$this->assertEquals('pwd.role.master', $parameters->password);
$this->assertEquals(10, $parameters->timeout);
// ROLE SLAVE
$connectionSlaveRole = $factory->create($inputParamsSlaveRole = array(
'role' => 'slave',
));
$parameters = $connectionSlaveRole->getParameters();
$this->assertEquals('pwd.role.slave', $parameters->password);
$this->assertEquals(5, $parameters->timeout);
// ROLE SENTINEL
$connectionSentinelRole = $factory->create($inputParamsSentinelRole = array(
'role' => 'sentinel',
));
$parameters = $connectionSentinelRole->getParameters();
$this->assertEquals('pwd.role.sentinel', $parameters->password);
$this->assertEquals(1, $parameters->timeout);
}
/**
* @group disconnected
*/
@@ -41,7 +41,7 @@ class SentinelReplicationTest extends PredisTestCase
public function testParametersForSentinelConnectionShouldUsePasswordForAuthentication(): void
{
$replication = $this->getReplicationConnection('svc', array(
'tcp://127.0.0.1:5381?alias=sentinel1&password=secret',
'tcp://127.0.0.1:5381?password=secret',
));
$parameters = $replication->getSentinelConnection()->getParameters()->toArray();
@@ -122,9 +122,9 @@ class SentinelReplicationTest extends PredisTestCase
*/
public function testMethodGetSentinelConnectionReturnsFirstAvailableSentinel(): void
{
$sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?role=sentinel&alias=sentinel1');
$sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?role=sentinel&alias=sentinel2');
$sentinel3 = $this->getMockSentinelConnection('tcp://127.0.0.1:5383?role=sentinel&alias=sentinel3');
$sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?role=sentinel');
$sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?role=sentinel');
$sentinel3 = $this->getMockSentinelConnection('tcp://127.0.0.1:5383?role=sentinel');
$replication = $this->getReplicationConnection('svc', array($sentinel1, $sentinel2, $sentinel3));
@@ -305,15 +305,16 @@ class SentinelReplicationTest extends PredisTestCase
// TODO: sorry for the smell...
$reflection = new \ReflectionProperty($replication, 'sentinels');
$reflection->setAccessible(true);
$retrievedSentinels = $reflection->getValue($replication);
$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'),
$expectedSentinels = array(
array('scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => '5381', 'role' => 'sentinel'),
array('host' => '127.0.0.1', 'port' => '5382', 'role' => 'sentinel'),
array('host' => '127.0.0.1', 'port' => '5383', 'role' => 'sentinel'),
);
$this->assertSame($sentinel1, $replication->getSentinelConnection());
$this->assertSame($expected, array_intersect_key($expected, $reflection->getValue($replication)));
$this->assertEquals($expectedSentinels, $retrievedSentinels);
}
/**
@@ -321,7 +322,7 @@ class SentinelReplicationTest extends PredisTestCase
*/
public function testMethodUpdateSentinelsRemovesCurrentSentinelAndRetriesNextOneOnFailure(): void
{
$sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?role=sentinel&alias=sentinel1');
$sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?role=sentinel');
$sentinel1
->expects($this->once())
->method('executeCommand')
@@ -332,7 +333,7 @@ class SentinelReplicationTest extends PredisTestCase
new Connection\ConnectionException($sentinel1, 'Unknown connection error [127.0.0.1:5381]')
);
$sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?role=sentinel&alias=sentinel2');
$sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?role=sentinel');
$sentinel2
->expects($this->once())
->method('executeCommand')
@@ -357,14 +358,15 @@ class SentinelReplicationTest extends PredisTestCase
// TODO: sorry for the smell...
$reflection = new \ReflectionProperty($replication, 'sentinels');
$reflection->setAccessible(true);
$retrievedSentinels = $reflection->getValue($replication);
$expected = array(
array('host' => '127.0.0.1', 'port' => '5382'),
array('host' => '127.0.0.1', 'port' => '5383'),
$expectedSentinels = array(
array('scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => '5382', 'role' => 'sentinel'),
array('host' => '127.0.0.1', 'port' => '5383', 'role' => 'sentinel'),
);
$this->assertSame($sentinel2, $replication->getSentinelConnection());
$this->assertSame($expected, array_intersect_key($expected, $reflection->getValue($replication)));
$this->assertEquals($expectedSentinels, $retrievedSentinels);
}
/**
@@ -395,7 +397,7 @@ class SentinelReplicationTest extends PredisTestCase
*/
public function testMethodQuerySentinelFetchesMasterNodeSlaveNodesAndSentinelNodes(): void
{
$sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?role=sentinel&alias=sentinel1');
$sentinel1 = $this->getMockSentinelConnection('tcp://127.0.0.1:5381?role=sentinel');
$sentinel1
->expects($this->exactly(3))
->method('executeCommand')
@@ -442,7 +444,7 @@ class SentinelReplicationTest extends PredisTestCase
)
);
$sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?role=sentinel&alias=sentinel2');
$sentinel2 = $this->getMockSentinelConnection('tcp://127.0.0.1:5382?role=sentinel');
$master = $this->getMockConnection('tcp://127.0.0.1:6381?role=master');
$slave1 = $this->getMockConnection('tcp://127.0.0.1:6382?role=slave');
@@ -454,14 +456,15 @@ class SentinelReplicationTest extends PredisTestCase
// TODO: sorry for the smell...
$reflection = new \ReflectionProperty($replication, 'sentinels');
$reflection->setAccessible(true);
$retrievedSentinels = $reflection->getValue($replication);
$sentinels = array(
array('host' => '127.0.0.1', 'port' => '5381'),
array('host' => '127.0.0.1', 'port' => '5382'),
$expectedSentinels = array(
array('scheme' => 'tcp', 'host' => '127.0.0.1', 'port' => '5381', 'role' => 'sentinel'),
array('host' => '127.0.0.1', 'port' => '5382', 'role' => 'sentinel'),
);
$this->assertSame($sentinel1, $replication->getSentinelConnection());
$this->assertSame($sentinels, array_intersect_key($sentinels, $reflection->getValue($replication)));
$this->assertEquals($expectedSentinels, $retrievedSentinels);
$master = $replication->getMaster();
$slaves = $replication->getSlaves();