From b7150d979071775c66dcaa62e8d9228bd20ece72 Mon Sep 17 00:00:00 2001 From: sartor Date: Tue, 12 Jul 2022 01:09:39 +0300 Subject: [PATCH 1/5] Sentinel connection client alpha version (working) --- src/SentinelClient.php | 92 ++++++++++++++++++++++++++++++++++++++++++ tests/SentinelTest.php | 69 +++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/SentinelClient.php create mode 100644 tests/SentinelTest.php diff --git a/src/SentinelClient.php b/src/SentinelClient.php new file mode 100644 index 0000000..87a2745 --- /dev/null +++ b/src/SentinelClient.php @@ -0,0 +1,92 @@ + */ + private $urls; + + /** @var string */ + private $masterName; + + /** @var Factory */ + private $factory; + + /** @var StreamingClient */ + private $masterClient; + + /** + * @param array $urls list of sentinel addresses + * @param string $masterName sentinel master name + * @param ?ConnectorInterface $connector + * @param ?LoopInterface $loop + */ + public function __construct(array $urls, string $masterName, ConnectorInterface $connector = null, LoopInterface $loop = null) + { + $this->urls = $urls; + $this->masterName = $masterName; + $this->factory = new Factory($loop ?: Loop::get(), $connector); + } + + public function masterUrl(): PromiseInterface + { + $chain = reject(); + foreach ($this->urls as $url) { + $chain = $chain->then(function ($masterUrl) { + return $masterUrl; + }, function () use ($url) { + return $this->onError($url); + }); + } + + return $chain; + } + + public function masterConnection(): PromiseInterface + { + if (isset($this->masterClient)) { + return resolve($this->masterClient); + } + + return $this + ->masterUrl() + ->then(function (string $masterUrl) { + return $this->factory->createClient($masterUrl); + }) + ->then(function (StreamingClient $client) { + $this->masterClient = $client; + return $client->role(); + }) + ->then(function (array $role) { + $isRealMaster = ($role[0] ?? '') === 'master'; + return $isRealMaster ? $this->masterClient : reject(new \RuntimeException("Invalid master role: {$role[0]}")); + }); + } + + private function onError(string $nextUrl): PromiseInterface + { + return $this->factory + ->createClient($nextUrl) + ->then(function (StreamingClient $client) { + return $client->sentinel('get-master-addr-by-name', $this->masterName); + }) + ->then(function (array $response) { + return $response[0] . ':' . $response[1]; // ip:port + }); + } +} diff --git a/tests/SentinelTest.php b/tests/SentinelTest.php new file mode 100644 index 0000000..85f8e71 --- /dev/null +++ b/tests/SentinelTest.php @@ -0,0 +1,69 @@ +masterUri = getenv('REDIS_URI') ?: ''; + if ($this->masterUri === '') { + $this->markTestSkipped('No REDIS_URI environment variable given for Sentinel tests'); + } + + $uris = getenv('REDIS_URIS') ?: ''; + if ($uris === '') { + $this->markTestSkipped('No REDIS_URIS environment variable given for Sentinel tests'); + } + $this->uris = array_map('trim', explode(',', $uris)); + + $this->masterName = getenv('REDIS_SENTINEL_MASTER') ?: ''; + if ($this->masterName === '') { + $this->markTestSkipped('No REDIS_SENTINEL_MASTER environment variable given for Sentinel tests'); + } + + $this->loop = new StreamSelectLoop(); + } + + public function testMasterUrl() + { + $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $masterUrlPromise = $redis->masterUrl(); + $this->assertInstanceOf(PromiseInterface::class, $masterUrlPromise); + + $masterUrl = await($masterUrlPromise, $this->loop); + + $this->assertEquals($this->masterUri, $masterUrl); + } + + public function testMasterConnection() + { + $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $masterConnectionPromise = $redis->masterConnection(); + $this->assertInstanceOf(PromiseInterface::class, $masterConnectionPromise); + + $masterConnection = await($masterConnectionPromise, $this->loop); + + $this->assertInstanceOf(StreamingClient::class, $masterConnection); + } +} From 54266a3666a2d7a8661ff11b827e9bc88f195658 Mon Sep 17 00:00:00 2001 From: Sartor Date: Tue, 12 Jul 2022 12:52:31 +0300 Subject: [PATCH 2/5] redis sentinel tests, ci changes --- .github/workflows/ci.yml | 5 +-- CHANGELOG.md | 5 +++ src/SentinelClient.php | 11 +++--- ...entinelTest.php => SentinelClientTest.php} | 36 +++++++++++++------ 4 files changed, 39 insertions(+), 18 deletions(-) rename tests/{SentinelTest.php => SentinelClientTest.php} (53%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ff52770..07d842e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,8 @@ jobs: coverage: xdebug - run: composer install - run: docker run --net=host -d redis - - run: REDIS_URI=localhost:6379 vendor/bin/phpunit --coverage-text + - run: docker run --net=host -d -e REDIS_MASTER_HOST=localhost bitnami/redis-sentinel + - run: REDIS_URI=localhost:6379 REDIS_URIS=localhost:26379 REDIS_SENTINEL_MASTER=mymaster vendor/bin/phpunit --coverage-text if: ${{ matrix.php >= 7.3 }} - - run: REDIS_URI=localhost:6379 vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy + - run: REDIS_URI=localhost:6379 REDIS_URIS=localhost:26379 REDIS_SENTINEL_MASTER=mymaster vendor/bin/phpunit --coverage-text -c phpunit.xml.legacy if: ${{ matrix.php < 7.3 }} diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e8e66..98856e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.7.0 (TBA) + +* Feature: Support Redis Sentinel auto master discovery (alpha). + (@sartor) + ## 2.6.0 (2022-05-09) * Feature: Support PHP 8.1 release. diff --git a/src/SentinelClient.php b/src/SentinelClient.php index 87a2745..5c01d36 100644 --- a/src/SentinelClient.php +++ b/src/SentinelClient.php @@ -43,7 +43,7 @@ public function __construct(array $urls, string $masterName, ConnectorInterface $this->factory = new Factory($loop ?: Loop::get(), $connector); } - public function masterUrl(): PromiseInterface + public function masterAddress(): PromiseInterface { $chain = reject(); foreach ($this->urls as $url) { @@ -57,16 +57,17 @@ public function masterUrl(): PromiseInterface return $chain; } - public function masterConnection(): PromiseInterface + public function masterConnection(string $masterUriPath = '', array $masterUriParams = []): PromiseInterface { if (isset($this->masterClient)) { return resolve($this->masterClient); } return $this - ->masterUrl() - ->then(function (string $masterUrl) { - return $this->factory->createClient($masterUrl); + ->masterAddress() + ->then(function (string $masterUrl) use ($masterUriPath, $masterUriParams) { + $query = $masterUriParams ? '?' . http_build_query($masterUriParams) : ''; + return $this->factory->createClient($masterUrl . $masterUriPath . $query); }) ->then(function (StreamingClient $client) { $this->masterClient = $client; diff --git a/tests/SentinelTest.php b/tests/SentinelClientTest.php similarity index 53% rename from tests/SentinelTest.php rename to tests/SentinelClientTest.php index 85f8e71..3abe1c8 100644 --- a/tests/SentinelTest.php +++ b/tests/SentinelClientTest.php @@ -7,10 +7,9 @@ use Clue\React\Redis\Io\StreamingClient; use Clue\React\Redis\SentinelClient; use React\EventLoop\StreamSelectLoop; -use React\Promise\PromiseInterface; use function Clue\React\Block\await; -class SentinelTest extends TestCase +class SentinelClientTest extends TestCase { /** @var StreamSelectLoop */ private $loop; @@ -45,25 +44,40 @@ public function setUp(): void $this->loop = new StreamSelectLoop(); } - public function testMasterUrl() + public function testMasterAddress() { $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); - $masterUrlPromise = $redis->masterUrl(); - $this->assertInstanceOf(PromiseInterface::class, $masterUrlPromise); + $masterAddressPromise = $redis->masterAddress(); + $masterAddress = await($masterAddressPromise, $this->loop); + $this->assertEquals(str_replace('localhost', '127.0.0.1', $this->masterUri), $masterAddress); + } - $masterUrl = await($masterUrlPromise, $this->loop); + public function testMasterConnectionWithParams() + { + $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $masterConnectionPromise = $redis->masterConnection('/1', ['timeout' => 0.5]); + $masterConnection = await($masterConnectionPromise, $this->loop); + $this->assertInstanceOf(StreamingClient::class, $masterConnection); - $this->assertEquals($this->masterUri, $masterUrl); + $pong = await($masterConnection->ping(), $this->loop); + $this->assertEquals('PONG', $pong); } - public function testMasterConnection() + public function testConnectionFail() { - $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $redis = new SentinelClient(['128.128.0.1:26379?timeout=0.1'], $this->masterName, null, $this->loop); $masterConnectionPromise = $redis->masterConnection(); - $this->assertInstanceOf(PromiseInterface::class, $masterConnectionPromise); - $masterConnection = await($masterConnectionPromise, $this->loop); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection to redis://128.128.0.1:26379?timeout=0.1 timed out after 0.1 seconds'); + await($masterConnectionPromise, $this->loop); + } + public function testConnectionSkipInvalid() + { + $redis = new SentinelClient(array_merge(['128.128.0.1:26379?timeout=0.1'], $this->uris), $this->masterName, null, $this->loop); + $masterConnectionPromise = $redis->masterConnection('/1', ['timeout' => 5]); + $masterConnection = await($masterConnectionPromise, $this->loop); $this->assertInstanceOf(StreamingClient::class, $masterConnection); } } From d5bb3c168edcee334cb7b0a31e993df9c669f0a2 Mon Sep 17 00:00:00 2001 From: sartor Date: Tue, 12 Jul 2022 01:09:39 +0300 Subject: [PATCH 3/5] Sentinel connection client alpha version (working) --- src/SentinelClient.php | 92 ++++++++++++++++++++++++++++++++++++++++++ tests/SentinelTest.php | 69 +++++++++++++++++++++++++++++++ 2 files changed, 161 insertions(+) create mode 100644 src/SentinelClient.php create mode 100644 tests/SentinelTest.php diff --git a/src/SentinelClient.php b/src/SentinelClient.php new file mode 100644 index 0000000..87a2745 --- /dev/null +++ b/src/SentinelClient.php @@ -0,0 +1,92 @@ + */ + private $urls; + + /** @var string */ + private $masterName; + + /** @var Factory */ + private $factory; + + /** @var StreamingClient */ + private $masterClient; + + /** + * @param array $urls list of sentinel addresses + * @param string $masterName sentinel master name + * @param ?ConnectorInterface $connector + * @param ?LoopInterface $loop + */ + public function __construct(array $urls, string $masterName, ConnectorInterface $connector = null, LoopInterface $loop = null) + { + $this->urls = $urls; + $this->masterName = $masterName; + $this->factory = new Factory($loop ?: Loop::get(), $connector); + } + + public function masterUrl(): PromiseInterface + { + $chain = reject(); + foreach ($this->urls as $url) { + $chain = $chain->then(function ($masterUrl) { + return $masterUrl; + }, function () use ($url) { + return $this->onError($url); + }); + } + + return $chain; + } + + public function masterConnection(): PromiseInterface + { + if (isset($this->masterClient)) { + return resolve($this->masterClient); + } + + return $this + ->masterUrl() + ->then(function (string $masterUrl) { + return $this->factory->createClient($masterUrl); + }) + ->then(function (StreamingClient $client) { + $this->masterClient = $client; + return $client->role(); + }) + ->then(function (array $role) { + $isRealMaster = ($role[0] ?? '') === 'master'; + return $isRealMaster ? $this->masterClient : reject(new \RuntimeException("Invalid master role: {$role[0]}")); + }); + } + + private function onError(string $nextUrl): PromiseInterface + { + return $this->factory + ->createClient($nextUrl) + ->then(function (StreamingClient $client) { + return $client->sentinel('get-master-addr-by-name', $this->masterName); + }) + ->then(function (array $response) { + return $response[0] . ':' . $response[1]; // ip:port + }); + } +} diff --git a/tests/SentinelTest.php b/tests/SentinelTest.php new file mode 100644 index 0000000..85f8e71 --- /dev/null +++ b/tests/SentinelTest.php @@ -0,0 +1,69 @@ +masterUri = getenv('REDIS_URI') ?: ''; + if ($this->masterUri === '') { + $this->markTestSkipped('No REDIS_URI environment variable given for Sentinel tests'); + } + + $uris = getenv('REDIS_URIS') ?: ''; + if ($uris === '') { + $this->markTestSkipped('No REDIS_URIS environment variable given for Sentinel tests'); + } + $this->uris = array_map('trim', explode(',', $uris)); + + $this->masterName = getenv('REDIS_SENTINEL_MASTER') ?: ''; + if ($this->masterName === '') { + $this->markTestSkipped('No REDIS_SENTINEL_MASTER environment variable given for Sentinel tests'); + } + + $this->loop = new StreamSelectLoop(); + } + + public function testMasterUrl() + { + $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $masterUrlPromise = $redis->masterUrl(); + $this->assertInstanceOf(PromiseInterface::class, $masterUrlPromise); + + $masterUrl = await($masterUrlPromise, $this->loop); + + $this->assertEquals($this->masterUri, $masterUrl); + } + + public function testMasterConnection() + { + $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $masterConnectionPromise = $redis->masterConnection(); + $this->assertInstanceOf(PromiseInterface::class, $masterConnectionPromise); + + $masterConnection = await($masterConnectionPromise, $this->loop); + + $this->assertInstanceOf(StreamingClient::class, $masterConnection); + } +} From 1fd78b659a2dd31fa6e9124c331e8dbd33e21fd8 Mon Sep 17 00:00:00 2001 From: Sartor Date: Tue, 12 Jul 2022 12:52:31 +0300 Subject: [PATCH 4/5] redis sentinel tests, ci changes --- .github/workflows/ci.yml | 5 +-- CHANGELOG.md | 5 +++ src/SentinelClient.php | 11 +++--- ...entinelTest.php => SentinelClientTest.php} | 36 +++++++++++++------ 4 files changed, 39 insertions(+), 18 deletions(-) rename tests/{SentinelTest.php => SentinelClientTest.php} (53%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 21293f7..a25cc09 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,9 +27,10 @@ jobs: ini-file: development - run: composer install - run: docker run --net=host -d redis - - run: REDIS_URI=localhost:6379 vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml + run: docker run --net=host -d -e REDIS_MASTER_HOST=localhost bitnami/redis-sentinel + - run: REDIS_URI=localhost:6379 REDIS_URIS=localhost:26379 REDIS_SENTINEL_MASTER=mymaster vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml if: ${{ matrix.php >= 7.3 }} - - run: REDIS_URI=localhost:6379 vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml -c phpunit.xml.legacy + - run: REDIS_URI=localhost:6379 REDIS_URIS=localhost:26379 REDIS_SENTINEL_MASTER=mymaster vendor/bin/phpunit --coverage-text --coverage-clover=clover.xml -c phpunit.xml.legacy if: ${{ matrix.php < 7.3 }} - name: Check 100% code coverage shell: php {0} diff --git a/CHANGELOG.md b/CHANGELOG.md index c5e8e66..98856e8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 2.7.0 (TBA) + +* Feature: Support Redis Sentinel auto master discovery (alpha). + (@sartor) + ## 2.6.0 (2022-05-09) * Feature: Support PHP 8.1 release. diff --git a/src/SentinelClient.php b/src/SentinelClient.php index 87a2745..5c01d36 100644 --- a/src/SentinelClient.php +++ b/src/SentinelClient.php @@ -43,7 +43,7 @@ public function __construct(array $urls, string $masterName, ConnectorInterface $this->factory = new Factory($loop ?: Loop::get(), $connector); } - public function masterUrl(): PromiseInterface + public function masterAddress(): PromiseInterface { $chain = reject(); foreach ($this->urls as $url) { @@ -57,16 +57,17 @@ public function masterUrl(): PromiseInterface return $chain; } - public function masterConnection(): PromiseInterface + public function masterConnection(string $masterUriPath = '', array $masterUriParams = []): PromiseInterface { if (isset($this->masterClient)) { return resolve($this->masterClient); } return $this - ->masterUrl() - ->then(function (string $masterUrl) { - return $this->factory->createClient($masterUrl); + ->masterAddress() + ->then(function (string $masterUrl) use ($masterUriPath, $masterUriParams) { + $query = $masterUriParams ? '?' . http_build_query($masterUriParams) : ''; + return $this->factory->createClient($masterUrl . $masterUriPath . $query); }) ->then(function (StreamingClient $client) { $this->masterClient = $client; diff --git a/tests/SentinelTest.php b/tests/SentinelClientTest.php similarity index 53% rename from tests/SentinelTest.php rename to tests/SentinelClientTest.php index 85f8e71..3abe1c8 100644 --- a/tests/SentinelTest.php +++ b/tests/SentinelClientTest.php @@ -7,10 +7,9 @@ use Clue\React\Redis\Io\StreamingClient; use Clue\React\Redis\SentinelClient; use React\EventLoop\StreamSelectLoop; -use React\Promise\PromiseInterface; use function Clue\React\Block\await; -class SentinelTest extends TestCase +class SentinelClientTest extends TestCase { /** @var StreamSelectLoop */ private $loop; @@ -45,25 +44,40 @@ public function setUp(): void $this->loop = new StreamSelectLoop(); } - public function testMasterUrl() + public function testMasterAddress() { $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); - $masterUrlPromise = $redis->masterUrl(); - $this->assertInstanceOf(PromiseInterface::class, $masterUrlPromise); + $masterAddressPromise = $redis->masterAddress(); + $masterAddress = await($masterAddressPromise, $this->loop); + $this->assertEquals(str_replace('localhost', '127.0.0.1', $this->masterUri), $masterAddress); + } - $masterUrl = await($masterUrlPromise, $this->loop); + public function testMasterConnectionWithParams() + { + $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $masterConnectionPromise = $redis->masterConnection('/1', ['timeout' => 0.5]); + $masterConnection = await($masterConnectionPromise, $this->loop); + $this->assertInstanceOf(StreamingClient::class, $masterConnection); - $this->assertEquals($this->masterUri, $masterUrl); + $pong = await($masterConnection->ping(), $this->loop); + $this->assertEquals('PONG', $pong); } - public function testMasterConnection() + public function testConnectionFail() { - $redis = new SentinelClient($this->uris, $this->masterName, null, $this->loop); + $redis = new SentinelClient(['128.128.0.1:26379?timeout=0.1'], $this->masterName, null, $this->loop); $masterConnectionPromise = $redis->masterConnection(); - $this->assertInstanceOf(PromiseInterface::class, $masterConnectionPromise); - $masterConnection = await($masterConnectionPromise, $this->loop); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Connection to redis://128.128.0.1:26379?timeout=0.1 timed out after 0.1 seconds'); + await($masterConnectionPromise, $this->loop); + } + public function testConnectionSkipInvalid() + { + $redis = new SentinelClient(array_merge(['128.128.0.1:26379?timeout=0.1'], $this->uris), $this->masterName, null, $this->loop); + $masterConnectionPromise = $redis->masterConnection('/1', ['timeout' => 5]); + $masterConnection = await($masterConnectionPromise, $this->loop); $this->assertInstanceOf(StreamingClient::class, $masterConnection); } } From fc8494f2f3d9fe884bf0dce7a9839c65d4fb47d3 Mon Sep 17 00:00:00 2001 From: Sartor Date: Wed, 16 Aug 2023 15:02:43 +0300 Subject: [PATCH 5/5] Exception added to react reject function (new version) --- src/SentinelClient.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/SentinelClient.php b/src/SentinelClient.php index 5c01d36..78a67de 100644 --- a/src/SentinelClient.php +++ b/src/SentinelClient.php @@ -45,7 +45,7 @@ public function __construct(array $urls, string $masterName, ConnectorInterface public function masterAddress(): PromiseInterface { - $chain = reject(); + $chain = reject(new \RuntimeException('Initial reject promise')); foreach ($this->urls as $url) { $chain = $chain->then(function ($masterUrl) { return $masterUrl;